mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
257 lines
7.9 KiB
TypeScript
257 lines
7.9 KiB
TypeScript
import { isDebug } from "core";
|
|
import { autoFormatString } from "core/utils";
|
|
import type { ChangeEvent } from "react";
|
|
import {
|
|
TbAt,
|
|
TbBrandAppleFilled,
|
|
TbBrandDiscordFilled,
|
|
TbBrandFacebookFilled,
|
|
TbBrandGithubFilled,
|
|
TbBrandGoogleFilled,
|
|
TbBrandInstagram,
|
|
TbBrandOauth,
|
|
TbBrandX,
|
|
TbSettings,
|
|
} from "react-icons/tb";
|
|
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,
|
|
HiddenField,
|
|
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 { CollapsibleList } from "ui/components/list/CollapsibleList";
|
|
import { useRoutePathState } from "ui/hooks/use-route-path-state";
|
|
|
|
export function AuthStrategiesList(props) {
|
|
useBrowserTitle(["Auth", "Strategies"]);
|
|
|
|
const {
|
|
hasSecrets,
|
|
config: {
|
|
auth: { enabled },
|
|
},
|
|
} = useBknd({ withSecrets: true });
|
|
if (!hasSecrets) {
|
|
return <Message.MissingPermission what="Auth Strategies" />;
|
|
} else if (!enabled) {
|
|
return <Message.NotEnabled description="Enable Auth first." />;
|
|
}
|
|
|
|
return <AuthStrategiesListInternal {...props} />;
|
|
}
|
|
|
|
const formOptions = {
|
|
keepEmpty: true,
|
|
debug: isDebug(),
|
|
};
|
|
|
|
function AuthStrategiesListInternal() {
|
|
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,
|
|
]),
|
|
);
|
|
|
|
async function handleSubmit(data: any) {
|
|
await $auth.actions.config.set({ strategies: data });
|
|
}
|
|
|
|
return (
|
|
<Form schema={schema} initialValues={config} onSubmit={handleSubmit} options={formOptions}>
|
|
<Subscribe
|
|
selector={(state) => ({
|
|
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>
|
|
<div className="flex flex-col p-4 gap-4">
|
|
<p className="opacity-70">
|
|
Allow users to sign in or sign up using different strategies.
|
|
</p>
|
|
<CollapsibleList.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 />
|
|
</CollapsibleList.Root>
|
|
</div>
|
|
<FormDebug />
|
|
</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 { active, toggle } = useRoutePathState("/strategies/:strategy?", name);
|
|
|
|
if (!schema) return null;
|
|
|
|
return (
|
|
<FormContextOverride schema={schema} prefix={name}>
|
|
<CollapsibleList.Item
|
|
hasError={errors.length > 0}
|
|
className={
|
|
unavailable ? "opacity-20 pointer-events-none cursor-not-allowed" : undefined
|
|
}
|
|
>
|
|
<CollapsibleList.Preview
|
|
left={<StrategyIcon type={type} provider={name} />}
|
|
right={
|
|
<>
|
|
<StrategyToggle type={type} />
|
|
<IconButton
|
|
Icon={TbSettings}
|
|
size="lg"
|
|
iconProps={{ strokeWidth: 1.5 }}
|
|
variant={active ? "primary" : "ghost"}
|
|
onClick={() => toggle(!active)}
|
|
/>
|
|
</>
|
|
}
|
|
>
|
|
<span className="leading-none">{autoFormatString(name)}</span>
|
|
</CollapsibleList.Preview>
|
|
<CollapsibleList.Detail open={active}>
|
|
<StrategyForm type={type} name={name} />
|
|
</CollapsibleList.Detail>
|
|
</CollapsibleList.Item>
|
|
</FormContextOverride>
|
|
);
|
|
};
|
|
|
|
const StrategyToggle = ({ type }: { type: StrategyProps["type"] }) => {
|
|
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, name }: Pick<StrategyProps, "type" | "name">) => {
|
|
let Component = (p: any) => (
|
|
<ObjectField path="" wrapperProps={{ wrapper: "group", label: false }} />
|
|
);
|
|
switch (type) {
|
|
case "password":
|
|
Component = StrategyPasswordForm;
|
|
break;
|
|
case "oauth":
|
|
Component = StrategyOAuthForm;
|
|
break;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<HiddenField name="type" inputProps={{ disabled: true, defaultValue: type }} />
|
|
<Component type={type} name={name} />
|
|
</>
|
|
);
|
|
};
|
|
|
|
const StrategyPasswordForm = () => {
|
|
return <ObjectField path="config" wrapperProps={{ wrapper: "group", label: false }} />;
|
|
};
|
|
|
|
const StrategyOAuthForm = ({ type, name }: Pick<StrategyProps, "type" | "name">) => {
|
|
return (
|
|
<>
|
|
<HiddenField name="config.name" inputProps={{ disabled: true, defaultValue: name }} />
|
|
<Field name="config.client.client_id" required inputProps={{ type: "password" }} />
|
|
<Field name="config.client.client_secret" required inputProps={{ type: "password" }} />
|
|
</>
|
|
);
|
|
};
|