mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 21:06:04 +00:00
Merge pull request #98 from bknd-io/feat/auth-ui-improvements
feat/auth-ui-improvements
This commit is contained in:
@@ -63,14 +63,14 @@
|
|||||||
"@aws-sdk/client-s3": "^3.613.0",
|
"@aws-sdk/client-s3": "^3.613.0",
|
||||||
"@bluwy/giget-core": "^0.1.2",
|
"@bluwy/giget-core": "^0.1.2",
|
||||||
"@dagrejs/dagre": "^1.1.4",
|
"@dagrejs/dagre": "^1.1.4",
|
||||||
|
"@mantine/modals": "^7.13.4",
|
||||||
|
"@mantine/notifications": "^7.13.4",
|
||||||
"@hono/typebox-validator": "^0.2.6",
|
"@hono/typebox-validator": "^0.2.6",
|
||||||
"@hono/vite-dev-server": "^0.17.0",
|
"@hono/vite-dev-server": "^0.17.0",
|
||||||
"@hono/zod-validator": "^0.4.1",
|
"@hono/zod-validator": "^0.4.1",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@libsql/kysely-libsql": "^0.4.1",
|
"@libsql/kysely-libsql": "^0.4.1",
|
||||||
"@mantine/modals": "^7.13.4",
|
"@rjsf/core": "5.22.2",
|
||||||
"@mantine/notifications": "^7.13.4",
|
|
||||||
"@rjsf/core": "^5.22.2",
|
|
||||||
"@tabler/icons-react": "3.18.0",
|
"@tabler/icons-react": "3.18.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
|
import type { AppAuthSchema } from "auth/auth-schema";
|
||||||
import { useBknd } from "ui/client/bknd";
|
import { useBknd } from "ui/client/bknd";
|
||||||
|
import { routes } from "ui/lib/routes";
|
||||||
|
|
||||||
export function useBkndAuth() {
|
export function useBkndAuth() {
|
||||||
const { config, schema, actions: bkndActions } = useBknd();
|
const { config, schema, actions: bkndActions, app } = useBknd();
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
|
config: {
|
||||||
|
set: async (data: Partial<AppAuthSchema>) => {
|
||||||
|
console.log("--set", data);
|
||||||
|
if (await bkndActions.set("auth", data, true)) {
|
||||||
|
await bkndActions.reload();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
roles: {
|
roles: {
|
||||||
add: async (name: string, data: any = {}) => {
|
add: async (name: string, data: any = {}) => {
|
||||||
console.log("add role", name, data);
|
console.log("add role", name, data);
|
||||||
@@ -22,7 +34,29 @@ export function useBkndAuth() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const $auth = {};
|
|
||||||
|
const minimum_permissions = [
|
||||||
|
"system.access.admin",
|
||||||
|
"system.access.api",
|
||||||
|
"system.config.read",
|
||||||
|
"system.config.read.secrets",
|
||||||
|
"system.build"
|
||||||
|
];
|
||||||
|
const $auth = {
|
||||||
|
roles: {
|
||||||
|
none: Object.keys(config.auth.roles ?? {}).length === 0,
|
||||||
|
minimum_permissions,
|
||||||
|
has_admin: Object.entries(config.auth.roles ?? {}).some(
|
||||||
|
([name, role]) =>
|
||||||
|
role.implicit_allow ||
|
||||||
|
minimum_permissions.every((p) => role.permissions?.includes(p))
|
||||||
|
)
|
||||||
|
},
|
||||||
|
routes: {
|
||||||
|
settings: app.getSettingsPath(["auth"]),
|
||||||
|
listUsers: app.getAbsolutePath("/data/" + routes.data.entity.list(config.auth.entity_name))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
$auth,
|
$auth,
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ const Base: React.FC<AlertProps> = ({
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{title && <b className="mr-2">{title}:</b>}
|
<p>
|
||||||
{message || children}
|
{title && <b>{title}: </b>}
|
||||||
|
{message || children}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
|||||||
53
app/src/ui/components/display/ErrorBoundary.tsx
Normal file
53
app/src/ui/components/display/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React, { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?:
|
||||||
|
| (({ error, resetError }: { error: Error; resetError: () => void }) => ReactNode)
|
||||||
|
| ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetError = () => {
|
||||||
|
this.setState({ hasError: false, error: undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return this.props.fallback ? (
|
||||||
|
typeof this.props.fallback === "function" ? (
|
||||||
|
this.props.fallback({ error: this.state.error!, resetError: this.resetError })
|
||||||
|
) : (
|
||||||
|
this.props.fallback
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<h2>Something went wrong.</h2>
|
||||||
|
<button onClick={this.resetError}>Try Again</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
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,20 @@ 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,
|
||||||
|
props.disabled && "opacity-50 !cursor-not-allowed"
|
||||||
|
)}
|
||||||
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 +226,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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { getLabel, getMultiSchemaMatched } from "./utils";
|
|||||||
|
|
||||||
export type AnyOfFieldRootProps = {
|
export type AnyOfFieldRootProps = {
|
||||||
path?: string;
|
path?: string;
|
||||||
schema?: JsonSchema;
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,14 +33,14 @@ export const useAnyOfContext = () => {
|
|||||||
|
|
||||||
const selectedAtom = atom<number | null>(null);
|
const selectedAtom = atom<number | null>(null);
|
||||||
|
|
||||||
const Root = ({ path = "", schema: _schema, children }: AnyOfFieldRootProps) => {
|
const Root = ({ path = "", children }: AnyOfFieldRootProps) => {
|
||||||
const {
|
const {
|
||||||
setValue,
|
setValue,
|
||||||
lib,
|
lib,
|
||||||
pointer,
|
pointer,
|
||||||
value: { matchedIndex, schemas },
|
value: { matchedIndex, schemas },
|
||||||
schema
|
schema
|
||||||
} = useDerivedFieldContext(path, _schema, (ctx) => {
|
} = useDerivedFieldContext(path, (ctx) => {
|
||||||
const [matchedIndex, schemas = []] = getMultiSchemaMatched(ctx.schema, ctx.value);
|
const [matchedIndex, schemas = []] = getMultiSchemaMatched(ctx.schema, ctx.value);
|
||||||
return { matchedIndex, schemas };
|
return { matchedIndex, schemas };
|
||||||
});
|
});
|
||||||
@@ -115,7 +114,7 @@ const Select = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// @todo: add local validation for AnyOf fields
|
// @todo: add local validation for AnyOf fields
|
||||||
const Field = ({ name, label, schema, ...props }: Partial<FormFieldProps>) => {
|
const Field = ({ name, label, ...props }: Partial<FormFieldProps>) => {
|
||||||
const { selected, selectedSchema, path, errors } = useAnyOfContext();
|
const { selected, selectedSchema, path, errors } = useAnyOfContext();
|
||||||
if (selected === null) return null;
|
if (selected === null) return null;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,12 +10,8 @@ import { FieldWrapper } from "./FieldWrapper";
|
|||||||
import { useDerivedFieldContext, useFormValue } from "./Form";
|
import { useDerivedFieldContext, useFormValue } from "./Form";
|
||||||
import { coerce, getMultiSchema, getMultiSchemaMatched, isEqual, suffixPath } from "./utils";
|
import { coerce, getMultiSchema, getMultiSchemaMatched, isEqual, suffixPath } from "./utils";
|
||||||
|
|
||||||
export const ArrayField = ({
|
export const ArrayField = ({ path = "" }: { path?: string }) => {
|
||||||
path = "",
|
const { setValue, pointer, required, schema, ...ctx } = useDerivedFieldContext(path);
|
||||||
schema: _schema
|
|
||||||
}: { path?: string; schema?: JsonSchema }) => {
|
|
||||||
const { setValue, pointer, required, ...ctx } = useDerivedFieldContext(path, _schema);
|
|
||||||
const schema = _schema ?? ctx.schema;
|
|
||||||
if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`;
|
if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`;
|
||||||
|
|
||||||
// if unique items with enum
|
// if unique items with enum
|
||||||
@@ -55,7 +51,7 @@ export const ArrayField = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ArrayItem = memo(({ path, index, schema }: any) => {
|
const ArrayItem = memo(({ path, index, schema }: any) => {
|
||||||
const { value, ...ctx } = useDerivedFieldContext(path, schema, (ctx) => {
|
const { value, ...ctx } = useDerivedFieldContext(path, (ctx) => {
|
||||||
return ctx.value?.[index];
|
return ctx.value?.[index];
|
||||||
});
|
});
|
||||||
const itemPath = suffixPath(path, index);
|
const itemPath = suffixPath(path, index);
|
||||||
@@ -107,7 +103,7 @@ const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
|
|||||||
setValue,
|
setValue,
|
||||||
value: { currentIndex },
|
value: { currentIndex },
|
||||||
...ctx
|
...ctx
|
||||||
} = useDerivedFieldContext(path, schema, (ctx) => {
|
} = useDerivedFieldContext(path, (ctx) => {
|
||||||
return { currentIndex: ctx.value?.length ?? 0 };
|
return { currentIndex: ctx.value?.length ?? 0 };
|
||||||
});
|
});
|
||||||
const itemsMultiSchema = getMultiSchema(schema.items);
|
const itemsMultiSchema = getMultiSchema(schema.items);
|
||||||
|
|||||||
@@ -1,24 +1,40 @@
|
|||||||
import type { JsonSchema } from "json-schema-library";
|
import type { JsonSchema } from "json-schema-library";
|
||||||
import type { ChangeEvent, ComponentPropsWithoutRef } from "react";
|
import type { ChangeEvent, ComponentPropsWithoutRef } from "react";
|
||||||
|
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
||||||
import * as Formy from "ui/components/form/Formy";
|
import * as Formy from "ui/components/form/Formy";
|
||||||
import { useEvent } from "ui/hooks/use-event";
|
import { useEvent } from "ui/hooks/use-event";
|
||||||
import { ArrayField } from "./ArrayField";
|
import { ArrayField } from "./ArrayField";
|
||||||
import { FieldWrapper } from "./FieldWrapper";
|
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
|
||||||
import { useDerivedFieldContext, useFormValue } from "./Form";
|
import { useDerivedFieldContext, useFormValue } from "./Form";
|
||||||
import { ObjectField } from "./ObjectField";
|
import { ObjectField } from "./ObjectField";
|
||||||
import { coerce, isType, isTypeSchema } from "./utils";
|
import { coerce, isType, isTypeSchema } from "./utils";
|
||||||
|
|
||||||
export type FieldProps = {
|
export type FieldProps = {
|
||||||
name: string;
|
|
||||||
schema?: JsonSchema;
|
|
||||||
onChange?: (e: ChangeEvent<any>) => void;
|
onChange?: (e: ChangeEvent<any>) => void;
|
||||||
label?: string | false;
|
placeholder?: string;
|
||||||
hidden?: boolean;
|
disabled?: boolean;
|
||||||
|
} & Omit<FieldwrapperProps, "children" | "schema">;
|
||||||
|
|
||||||
|
export const Field = (props: FieldProps) => {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary fallback={fieldErrorBoundary(props)}>
|
||||||
|
<FieldImpl {...props} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }: FieldProps) => {
|
const fieldErrorBoundary =
|
||||||
const { path, setValue, required, ...ctx } = useDerivedFieldContext(name, _schema);
|
({ name }: FieldProps) =>
|
||||||
const schema = _schema ?? ctx.schema;
|
({ error }: { error: Error }) => (
|
||||||
|
<Pre>
|
||||||
|
Field "{name}" error: {error.message}
|
||||||
|
</Pre>
|
||||||
|
);
|
||||||
|
|
||||||
|
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))
|
if (!isTypeSchema(schema))
|
||||||
return (
|
return (
|
||||||
<Pre>
|
<Pre>
|
||||||
@@ -27,14 +43,14 @@ export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (isType(schema.type, "object")) {
|
if (isType(schema.type, "object")) {
|
||||||
return <ObjectField path={name} schema={schema} />;
|
return <ObjectField path={name} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isType(schema.type, "array")) {
|
if (isType(schema.type, "array")) {
|
||||||
return <ArrayField path={name} schema={schema} />;
|
return <ArrayField path={name} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const disabled = schema.readOnly ?? "const" in schema ?? false;
|
const disabled = props.disabled ?? schema.readOnly ?? "const" in schema ?? false;
|
||||||
|
|
||||||
const handleChange = useEvent((e: ChangeEvent<HTMLInputElement>) => {
|
const handleChange = useEvent((e: ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = coerce(e.target.value, schema as any, { required });
|
const value = coerce(e.target.value, schema as any, { required });
|
||||||
@@ -46,12 +62,13 @@ export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldWrapper name={name} label={_label} required={required} schema={schema} hidden={hidden}>
|
<FieldWrapper name={name} required={required} schema={schema} {...props}>
|
||||||
<FieldComponent
|
<FieldComponent
|
||||||
schema={schema}
|
schema={schema}
|
||||||
name={name}
|
name={name}
|
||||||
required={required}
|
required={required}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
placeholder={placeholder}
|
||||||
onChange={onChange ?? handleChange}
|
onChange={onChange ?? handleChange}
|
||||||
/>
|
/>
|
||||||
</FieldWrapper>
|
</FieldWrapper>
|
||||||
@@ -73,7 +90,9 @@ export const FieldComponent = ({
|
|||||||
const props = {
|
const props = {
|
||||||
..._props,
|
..._props,
|
||||||
// allow override
|
// allow override
|
||||||
value: typeof _props.value !== "undefined" ? _props.value : value
|
value: typeof _props.value !== "undefined" ? _props.value : value,
|
||||||
|
placeholder:
|
||||||
|
(_props.placeholder ?? typeof schema.default !== "undefined") ? String(schema.default) : ""
|
||||||
};
|
};
|
||||||
|
|
||||||
if (schema.enum) {
|
if (schema.enum) {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -22,6 +22,8 @@ export type FieldwrapperProps = {
|
|||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
children: ReactElement | ReactNode;
|
children: ReactElement | ReactNode;
|
||||||
errorPlacement?: "top" | "bottom";
|
errorPlacement?: "top" | "bottom";
|
||||||
|
description?: string;
|
||||||
|
descriptionPlacement?: "top" | "bottom";
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FieldWrapper({
|
export function FieldWrapper({
|
||||||
@@ -32,18 +34,26 @@ export function FieldWrapper({
|
|||||||
wrapper,
|
wrapper,
|
||||||
hidden,
|
hidden,
|
||||||
errorPlacement = "bottom",
|
errorPlacement = "bottom",
|
||||||
children
|
descriptionPlacement = "bottom",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
}: FieldwrapperProps) {
|
}: FieldwrapperProps) {
|
||||||
const errors = useFormError(name, { strict: true });
|
const errors = useFormError(name, { strict: true });
|
||||||
const examples = schema?.examples || [];
|
const examples = schema?.examples || [];
|
||||||
const examplesId = `${name}-examples`;
|
const examplesId = `${name}-examples`;
|
||||||
const description = schema?.description;
|
const description = props?.description ?? schema?.description;
|
||||||
const label = typeof _label !== "undefined" ? _label : schema ? getLabel(name, schema) : name;
|
const label = typeof _label !== "undefined" ? _label : schema ? getLabel(name, schema) : name;
|
||||||
|
|
||||||
const Errors = errors.length > 0 && (
|
const Errors = errors.length > 0 && (
|
||||||
<Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage>
|
<Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const Description = description && (
|
||||||
|
<Formy.Help className={descriptionPlacement === "top" ? "-mt-1 mb-1" : "mb-2"}>
|
||||||
|
{description}
|
||||||
|
</Formy.Help>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formy.Group
|
<Formy.Group
|
||||||
error={errors.length > 0}
|
error={errors.length > 0}
|
||||||
@@ -62,6 +72,7 @@ export function FieldWrapper({
|
|||||||
{label} {required && <span className="font-medium opacity-30">*</span>}
|
{label} {required && <span className="font-medium opacity-30">*</span>}
|
||||||
</Formy.Label>
|
</Formy.Label>
|
||||||
)}
|
)}
|
||||||
|
{descriptionPlacement === "top" && Description}
|
||||||
|
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<div className="flex flex-1 flex-col gap-3">
|
<div className="flex flex-1 flex-col gap-3">
|
||||||
@@ -80,7 +91,7 @@ export function FieldWrapper({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{description && <Formy.Help>{description}</Formy.Help>}
|
{descriptionPlacement === "bottom" && Description}
|
||||||
{errorPlacement === "bottom" && Errors}
|
{errorPlacement === "bottom" && Errors}
|
||||||
</Formy.Group>
|
</Formy.Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +300,6 @@ type SelectorFn<Ctx = any, Refined = any> = (state: Ctx) => Refined;
|
|||||||
|
|
||||||
export function useDerivedFieldContext<Data = any, Reduced = undefined>(
|
export function useDerivedFieldContext<Data = any, Reduced = undefined>(
|
||||||
path,
|
path,
|
||||||
_schema?: LibJsonSchema,
|
|
||||||
deriveFn?: SelectorFn<
|
deriveFn?: SelectorFn<
|
||||||
FormContext<Data> & {
|
FormContext<Data> & {
|
||||||
pointer: string;
|
pointer: string;
|
||||||
@@ -307,7 +308,8 @@ 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;
|
||||||
@@ -324,9 +326,6 @@ export function useDerivedFieldContext<Data = any, Reduced = undefined>(
|
|||||||
const prefixedName = prefixPath(path, root);
|
const prefixedName = prefixPath(path, root);
|
||||||
const prefixedPointer = pathToPointer(prefixedName);
|
const prefixedPointer = pathToPointer(prefixedName);
|
||||||
const value = getPath(state.data, prefixedName);
|
const value = getPath(state.data, prefixedName);
|
||||||
/*const errors = state.errors.filter((error) =>
|
|
||||||
error.data.pointer.startsWith(prefixedPointer)
|
|
||||||
);*/
|
|
||||||
const fieldSchema =
|
const fieldSchema =
|
||||||
pointer === "#/"
|
pointer === "#/"
|
||||||
? (schema as LibJsonSchema)
|
? (schema as LibJsonSchema)
|
||||||
|
|||||||
@@ -1,27 +1,19 @@
|
|||||||
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;
|
||||||
schema?: Exclude<JSONSchema, boolean>;
|
|
||||||
label?: string | false;
|
label?: string | false;
|
||||||
wrapperProps?: Partial<FieldwrapperProps>;
|
wrapperProps?: Partial<FieldwrapperProps>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ObjectField = ({
|
export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: ObjectFieldProps) => {
|
||||||
path = "",
|
const { schema, ...ctx } = useDerivedFieldContext(path);
|
||||||
schema: _schema,
|
|
||||||
label: _label,
|
|
||||||
wrapperProps = {}
|
|
||||||
}: ObjectFieldProps) => {
|
|
||||||
const ctx = useDerivedFieldContext(path, _schema);
|
|
||||||
const schema = _schema ?? ctx.schema;
|
|
||||||
if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`;
|
if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`;
|
||||||
const properties = schema.properties ?? {};
|
const properties = Object.entries(schema.properties ?? {}) as [string, JSONSchema][];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldWrapper
|
<FieldWrapper
|
||||||
@@ -31,17 +23,20 @@ export const ObjectField = ({
|
|||||||
errorPlacement="top"
|
errorPlacement="top"
|
||||||
{...wrapperProps}
|
{...wrapperProps}
|
||||||
>
|
>
|
||||||
{Object.keys(properties).map((prop) => {
|
{properties.length === 0 ? (
|
||||||
const schema = properties[prop];
|
<i className="opacity-50">No properties</i>
|
||||||
const name = [path, prop].filter(Boolean).join(".");
|
) : (
|
||||||
if (typeof schema === "undefined" || typeof schema === "boolean") return;
|
properties.map(([prop, schema]) => {
|
||||||
|
const name = [path, prop].filter(Boolean).join(".");
|
||||||
|
if (typeof schema === "undefined" || typeof schema === "boolean") return;
|
||||||
|
|
||||||
if (schema.anyOf || schema.oneOf) {
|
if (schema.anyOf || schema.oneOf) {
|
||||||
return <AnyOfField key={name} path={name} />;
|
return <AnyOfField key={name} path={name} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Field key={name} name={name} />;
|
return <Field key={name} name={name} />;
|
||||||
})}
|
})
|
||||||
|
)}
|
||||||
</FieldWrapper>
|
</FieldWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const Modal2 = forwardRef<Modal2Ref, Modal2Props>(
|
|||||||
|
|
||||||
export const ModalTitle = ({ path, onClose }: { path: string[]; onClose: () => void }) => {
|
export const ModalTitle = ({ path, onClose }: { path: string[]; onClose: () => void }) => {
|
||||||
return (
|
return (
|
||||||
<div className="py-3 px-5 font-bold bg-primary/5 flex flex-row justify-between items-center sticky top-0 left-0 right-0 z-10 border-b border-b-muted">
|
<div className="py-3 px-5 font-bold bg-lightest flex flex-row justify-between items-center sticky top-0 left-0 right-0 z-10 border-none">
|
||||||
<div className="flex flex-row gap-1">
|
<div className="flex flex-row gap-1">
|
||||||
{path.map((p, i) => {
|
{path.map((p, i) => {
|
||||||
const last = i + 1 === path.length;
|
const last = i + 1 === path.length;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Select,
|
Select,
|
||||||
Switch,
|
Switch,
|
||||||
|
Tabs,
|
||||||
TagsInput,
|
TagsInput,
|
||||||
TextInput,
|
TextInput,
|
||||||
Textarea,
|
Textarea,
|
||||||
@@ -29,7 +30,7 @@ export function createMantineTheme(scheme: "light" | "dark"): {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const input =
|
const input =
|
||||||
"bg-muted/40 border-transparent disabled:bg-muted/50 disabled:text-primary/50 focus:border-zinc-500";
|
"!bg-muted/40 border-transparent disabled:bg-muted/50 disabled:text-primary/50 focus:border-zinc-500";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme: createTheme({
|
theme: createTheme({
|
||||||
@@ -81,7 +82,6 @@ export function createMantineTheme(scheme: "light" | "dark"): {
|
|||||||
TextInput: TextInput.extend({
|
TextInput: TextInput.extend({
|
||||||
classNames: (theme, props) => ({
|
classNames: (theme, props) => ({
|
||||||
wrapper: "leading-none",
|
wrapper: "leading-none",
|
||||||
//input: "focus:border-primary/50 bg-transparent disabled:text-primary"
|
|
||||||
input
|
input
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -100,9 +100,14 @@ export function createMantineTheme(scheme: "light" | "dark"): {
|
|||||||
Modal: Modal.extend({
|
Modal: Modal.extend({
|
||||||
classNames: (theme, props) => ({
|
classNames: (theme, props) => ({
|
||||||
...props.classNames,
|
...props.classNames,
|
||||||
root: `bknd-admin ${scheme} ${props.className ?? ""} `,
|
root: `bknd-admin ${scheme} ${props.className ?? ""}`,
|
||||||
content: "bg-lightest border border-primary/10",
|
content: "!bg-background !rounded-lg !select-none",
|
||||||
overlay: "backdrop-blur"
|
overlay: "!backdrop-blur-sm"
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
Tabs: Tabs.extend({
|
||||||
|
classNames: (theme, props) => ({
|
||||||
|
tab: "data-[active=true]:border-primary"
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
Menu: Menu.extend({
|
Menu: Menu.extend({
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// REGISTER ERROR OVERLAY
|
// REGISTER ERROR OVERLAY
|
||||||
if (process.env.NODE_ENV !== "production") {
|
const showOverlay = true;
|
||||||
|
if (process.env.NODE_ENV !== "production" && showOverlay) {
|
||||||
const showErrorOverlay = (err) => {
|
const showErrorOverlay = (err) => {
|
||||||
// must be within function call because that's when the element is defined for sure.
|
// must be within function call because that's when the element is defined for sure.
|
||||||
const ErrorOverlay = customElements.get("vite-error-overlay");
|
const ErrorOverlay = customElements.get("vite-error-overlay");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type ModalProps, Tabs } from "@mantine/core";
|
import { type ModalProps, Tabs } from "@mantine/core";
|
||||||
import type { ContextModalProps } from "@mantine/modals";
|
import type { ContextModalProps } from "@mantine/modals";
|
||||||
|
import clsx from "clsx";
|
||||||
import { transformObject } from "core/utils";
|
import { transformObject } from "core/utils";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
import { JsonViewer } from "../../components/code/JsonViewer";
|
import { JsonViewer } from "../../components/code/JsonViewer";
|
||||||
@@ -29,8 +30,8 @@ export function DebugModal({ innerProps }: ContextModalProps<DebugProps>) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const count = Object.keys(tabs).length;
|
const count = Object.keys(tabs).length;
|
||||||
function renderTab({ value, label, ...props }: (typeof tabs)[keyof typeof tabs]) {
|
function renderTab({ value, label, className, ...props }: (typeof tabs)[keyof typeof tabs]) {
|
||||||
return <JsonViewer json={value as any} {...props} />;
|
return <JsonViewer json={value as any} className={clsx("text-sm", className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function SchemaFormModal({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{error && <Alert.Exception message={error} />}
|
{error && <Alert.Exception message={error} />}
|
||||||
<div className="pt-3 pb-3 px-3 gap-4 flex flex-col">
|
<div className="pt-3 pb-3 px-4 gap-4 flex flex-col">
|
||||||
<JsonSchemaForm
|
<JsonSchemaForm
|
||||||
tagName="form"
|
tagName="form"
|
||||||
ref={formRef}
|
ref={formRef}
|
||||||
@@ -84,10 +84,10 @@ export function SchemaFormModal({
|
|||||||
|
|
||||||
SchemaFormModal.defaultTitle = "JSON Schema Form Modal";
|
SchemaFormModal.defaultTitle = "JSON Schema Form Modal";
|
||||||
SchemaFormModal.modalProps = {
|
SchemaFormModal.modalProps = {
|
||||||
|
size: "md",
|
||||||
classNames: {
|
classNames: {
|
||||||
size: "md",
|
|
||||||
root: "bknd-admin",
|
root: "bknd-admin",
|
||||||
header: "!bg-primary/5 border-b border-b-muted !py-3 px-5 !h-auto !min-h-px",
|
header: "!bg-lightest !py-3 px-5 !h-auto !min-h-px",
|
||||||
content: "rounded-lg select-none",
|
content: "rounded-lg select-none",
|
||||||
title: "!font-bold !text-md",
|
title: "!font-bold !text-md",
|
||||||
body: "!p-0"
|
body: "!p-0"
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { IconFingerprint } from "@tabler/icons-react";
|
import { IconFingerprint } from "@tabler/icons-react";
|
||||||
import { TbSettings } from "react-icons/tb";
|
import { TbSettings } from "react-icons/tb";
|
||||||
import { useBknd } from "ui/client/bknd";
|
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
import { Empty } from "ui/components/display/Empty";
|
import { Empty } from "ui/components/display/Empty";
|
||||||
|
import { Icon } from "ui/components/display/Icon";
|
||||||
import { Link } from "ui/components/wouter/Link";
|
import { Link } from "ui/components/wouter/Link";
|
||||||
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 { routes, useNavigate } from "ui/lib/routes";
|
import { routes } from "ui/lib/routes";
|
||||||
|
|
||||||
export function AuthRoot({ children }) {
|
export function AuthRoot({ children }) {
|
||||||
const { app, config } = useBknd();
|
const { config, $auth } = useBkndAuth();
|
||||||
const users_entity = config.auth.entity_name;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppShell.Sidebar>
|
<AppShell.Sidebar>
|
||||||
<AppShell.SectionHeader
|
<AppShell.SectionHeader
|
||||||
right={
|
right={
|
||||||
<Link href={app.getSettingsPath(["auth"])}>
|
<Link href={$auth.routes.settings}>
|
||||||
<IconButton Icon={TbSettings} />
|
<IconButton Icon={TbSettings} />
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
@@ -32,23 +32,42 @@ export function AuthRoot({ children }) {
|
|||||||
</AppShell.SidebarLink>
|
</AppShell.SidebarLink>
|
||||||
<AppShell.SidebarLink
|
<AppShell.SidebarLink
|
||||||
as={Link}
|
as={Link}
|
||||||
href={app.getAbsolutePath("/data/" + routes.data.entity.list(users_entity))}
|
href={$auth.routes.listUsers}
|
||||||
disabled={!config.auth.enabled}
|
disabled={!config.enabled}
|
||||||
|
className="justify-between"
|
||||||
>
|
>
|
||||||
Users
|
Users
|
||||||
|
{!config.enabled && <AuthWarning title="Auth is not enabled." />}
|
||||||
</AppShell.SidebarLink>
|
</AppShell.SidebarLink>
|
||||||
<AppShell.SidebarLink
|
<AppShell.SidebarLink
|
||||||
as={Link}
|
as={Link}
|
||||||
href={routes.auth.roles.list()}
|
href={routes.auth.roles.list()}
|
||||||
disabled={!config.auth.enabled}
|
disabled={!config.enabled}
|
||||||
|
className="justify-between"
|
||||||
>
|
>
|
||||||
Roles & Permissions
|
Roles & Permissions
|
||||||
|
{!config.enabled ? (
|
||||||
|
<AuthWarning title="Auth is not enabled." />
|
||||||
|
) : $auth.roles.none ? (
|
||||||
|
<AuthWarning title="No roles defined." />
|
||||||
|
) : !$auth.roles.has_admin ? (
|
||||||
|
<AuthWarning title="No admin role defined." />
|
||||||
|
) : null}
|
||||||
</AppShell.SidebarLink>
|
</AppShell.SidebarLink>
|
||||||
<AppShell.SidebarLink as={Link} href={routes.auth.strategies()} disabled>
|
<AppShell.SidebarLink
|
||||||
|
as={Link}
|
||||||
|
href={routes.auth.strategies()}
|
||||||
|
disabled={!config.enabled}
|
||||||
|
className="justify-between"
|
||||||
|
>
|
||||||
Strategies
|
Strategies
|
||||||
|
{!config.enabled && <AuthWarning title="Auth is not enabled." />}
|
||||||
</AppShell.SidebarLink>
|
</AppShell.SidebarLink>
|
||||||
{/*<AppShell.SidebarLink as={Link} href={routes.auth.settings()}>*/}
|
<AppShell.SidebarLink
|
||||||
<AppShell.SidebarLink as={Link} href={app.getSettingsPath(["auth"])}>
|
as={Link}
|
||||||
|
href={routes.auth.settings()}
|
||||||
|
className="justify-between"
|
||||||
|
>
|
||||||
Settings
|
Settings
|
||||||
</AppShell.SidebarLink>
|
</AppShell.SidebarLink>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -60,6 +79,10 @@ export function AuthRoot({ children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AuthWarning = ({ title }) => (
|
||||||
|
<Icon.Warning title={title} className="size-5 pointer-events-auto" />
|
||||||
|
);
|
||||||
|
|
||||||
export function AuthEmpty() {
|
export function AuthEmpty() {
|
||||||
useBrowserTitle(["Auth"]);
|
useBrowserTitle(["Auth"]);
|
||||||
return <Empty Icon={IconFingerprint} title="Not implemented yet" />;
|
return <Empty Icon={IconFingerprint} title="Not implemented yet" />;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import { TbArrowRight, TbCircle, TbCircleCheckFilled, TbFingerprint } from "react-icons/tb";
|
||||||
import { useApiQuery } from "ui/client";
|
import { useApiQuery } from "ui/client";
|
||||||
import { useBknd } from "ui/client/bknd";
|
import { useBknd } from "ui/client/bknd";
|
||||||
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||||
import { ButtonLink, type ButtonLinkProps } from "ui/components/buttons/Button";
|
import { ButtonLink, type ButtonLinkProps } from "ui/components/buttons/Button";
|
||||||
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
import { Alert } from "ui/components/display/Alert";
|
import { Alert } from "ui/components/display/Alert";
|
||||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
import { routes } from "ui/lib/routes";
|
import { routes, useNavigate } from "ui/lib/routes";
|
||||||
|
|
||||||
export function AuthIndex() {
|
export function AuthIndex() {
|
||||||
const { app } = useBknd();
|
const { app } = useBknd();
|
||||||
@@ -21,7 +24,7 @@ export function AuthIndex() {
|
|||||||
|
|
||||||
const usersLink = app.getAbsolutePath("/data/" + routes.data.entity.list(users_entity));
|
const usersLink = app.getAbsolutePath("/data/" + routes.data.entity.list(users_entity));
|
||||||
const rolesLink = routes.auth.roles.list();
|
const rolesLink = routes.auth.roles.list();
|
||||||
const strategiesLink = app.getSettingsPath(["auth", "strategies"]);
|
const strategiesLink = routes.auth.strategies();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -32,8 +35,22 @@ export function AuthIndex() {
|
|||||||
title="Auth not enabled"
|
title="Auth not enabled"
|
||||||
message="To use authentication features, please enable it in the settings."
|
message="To use authentication features, please enable it in the settings."
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col flex-grow p-3 gap-3">
|
<div className="flex flex-col md:flex-row flex-1 p-3 gap-3">
|
||||||
<div className="grid xs:grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-5">
|
<div className="flex flex-col border border-primary/20 self-stretch md:self-start">
|
||||||
|
<div className="flex flex-row gap-3 py-3 px-4 border-b border-b-muted font-medium bg-muted items-center justify-between">
|
||||||
|
Getting started
|
||||||
|
<TbFingerprint className="size-5" />
|
||||||
|
</div>
|
||||||
|
<Item title="Enable authentication" done={enabled} to={routes.auth.settings()} />
|
||||||
|
<Item title="Create Roles" done={rolesTotal > 0} to={rolesLink} />
|
||||||
|
<Item title="Create an user" done={usersTotal > 0} to={usersLink} />
|
||||||
|
<Item
|
||||||
|
title="Enable a second strategy"
|
||||||
|
done={strategiesTotal > 1}
|
||||||
|
to={strategiesLink}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid xs:grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-5 flex-grow">
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title="Users registered"
|
title="Users registered"
|
||||||
value={!enabled ? 0 : usersTotal}
|
value={!enabled ? 0 : usersTotal}
|
||||||
@@ -50,7 +67,7 @@ export function AuthIndex() {
|
|||||||
value={!enabled ? 0 : rolesTotal}
|
value={!enabled ? 0 : rolesTotal}
|
||||||
actions={[
|
actions={[
|
||||||
{ label: "View all", href: rolesLink },
|
{ label: "View all", href: rolesLink },
|
||||||
{ label: "Add new", variant: "default", href: rolesLink }
|
{ label: "Manage", variant: "default", href: rolesLink }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
@@ -58,7 +75,7 @@ export function AuthIndex() {
|
|||||||
value={!enabled ? 0 : strategiesTotal}
|
value={!enabled ? 0 : strategiesTotal}
|
||||||
actions={[
|
actions={[
|
||||||
{ label: "View all", href: strategiesLink },
|
{ label: "View all", href: strategiesLink },
|
||||||
{ label: "Add new", variant: "default", href: strategiesLink }
|
{ label: "Manage", variant: "default", href: strategiesLink }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,7 +92,7 @@ type KpiCardProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const KpiCard: React.FC<KpiCardProps> = ({ title, value, actions }) => (
|
const KpiCard: React.FC<KpiCardProps> = ({ title, value, actions }) => (
|
||||||
<div className="flex flex-col border border-muted">
|
<div className="flex flex-col border border-muted h-auto self-start">
|
||||||
<div className="flex flex-col gap-2 px-5 pt-3.5 pb-4 border-b border-b-muted">
|
<div className="flex flex-col gap-2 px-5 pt-3.5 pb-4 border-b border-b-muted">
|
||||||
<div>
|
<div>
|
||||||
<span className="opacity-50">{title}</span>
|
<span className="opacity-50">{title}</span>
|
||||||
@@ -92,3 +109,25 @@ const KpiCard: React.FC<KpiCardProps> = ({ title, value, actions }) => (
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const Item = ({ title, done = false, to }: { title: string; done?: boolean; to?: string }) => {
|
||||||
|
const [navigate] = useNavigate();
|
||||||
|
return (
|
||||||
|
<div className="flex border-b border-b-muted">
|
||||||
|
<div className={clsx("flex flex-1 flex-row gap-3 py-3 px-4", done && "opacity-50")}>
|
||||||
|
<div className="flex flex-row flex-1 gap-3 items-center">
|
||||||
|
{done ? <TbCircleCheckFilled className="size-5" /> : <TbCircle className="size-5" />}
|
||||||
|
<p
|
||||||
|
className={clsx(
|
||||||
|
"font-medium text-primary/80 leading-none",
|
||||||
|
done ? "line-through" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{to && <IconButton Icon={TbArrowRight} onClick={() => navigate(to)} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,111 +1,210 @@
|
|||||||
import { cloneDeep, omit } from "lodash-es";
|
import clsx from "clsx";
|
||||||
import { useEffect, useRef } from "react";
|
import { isDebug } from "core";
|
||||||
import { useBknd } from "ui/client/bknd";
|
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 { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
|
||||||
import { Button } from "ui/components/buttons/Button";
|
import { Button } from "ui/components/buttons/Button";
|
||||||
import { Alert } from "ui/components/display/Alert";
|
import { Icon } from "ui/components/display/Icon";
|
||||||
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
|
import { Message } from "ui/components/display/Message";
|
||||||
|
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 * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
import { useNavigate } from "ui/lib/routes";
|
import { create } from "zustand";
|
||||||
import { extractSchema } from "../settings/utils/schema";
|
import { combine } from "zustand/middleware";
|
||||||
|
|
||||||
// @todo: improve the inline editing expierence, for now redirect to settings
|
const useAuthSettingsStore = create(
|
||||||
export function AuthSettingsList() {
|
combine(
|
||||||
const { app } = useBknd();
|
{
|
||||||
const [navigate] = useNavigate();
|
advanced: [] as string[]
|
||||||
useEffect(() => {
|
},
|
||||||
navigate(app.getSettingsPath(["auth"]));
|
(set) => ({
|
||||||
}, []);
|
toggleAdvanced: (which: string) =>
|
||||||
|
set((state) => ({
|
||||||
|
advanced: state.advanced.includes(which)
|
||||||
|
? state.advanced.filter((w) => w !== which)
|
||||||
|
: [...state.advanced, which]
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return null;
|
export function AuthSettings(props) {
|
||||||
|
useBrowserTitle(["Auth", "Settings"]);
|
||||||
|
|
||||||
/*useBknd({ withSecrets: true });
|
const { hasSecrets } = useBknd({ withSecrets: true });
|
||||||
return <AuthSettingsListInternal />;*/
|
if (!hasSecrets) {
|
||||||
|
return <Message.MissingPermission what="Auth Settings" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthSettingsInternal {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uiSchema = {
|
const formConfig = {
|
||||||
jwt: {
|
ignoreKeys: ["roles", "strategies"],
|
||||||
fields: {
|
options: { keepEmpty: true, debug: isDebug() }
|
||||||
"ui:options": {
|
|
||||||
orderable: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function AuthSettingsListInternal() {
|
function AuthSettingsInternal() {
|
||||||
const $auth = useBkndAuth();
|
const { config, schema: _schema, actions, $auth } = useBkndAuth();
|
||||||
const { entities } = useBkndData();
|
const schema = JSON.parse(JSON.stringify(_schema));
|
||||||
const formRef = useRef<JsonSchemaFormRef>(null);
|
|
||||||
const config = $auth.config;
|
|
||||||
const schema = cloneDeep(omit($auth.schema, ["title"]));
|
|
||||||
const [generalSchema, generalConfig, extracted] = extractSchema(schema as any, config, [
|
|
||||||
"jwt",
|
|
||||||
"roles",
|
|
||||||
"guard",
|
|
||||||
"strategies"
|
|
||||||
]);
|
|
||||||
try {
|
|
||||||
const user_entity = config.entity_name ?? "users";
|
|
||||||
const user_fields = Object.entries(entities[user_entity]?.fields ?? {})
|
|
||||||
.map(([name, field]) => (!field.config?.virtual ? name : undefined))
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
if (user_fields) {
|
schema.properties.jwt.required = ["alg"];
|
||||||
console.log("user_fields", user_fields);
|
|
||||||
extracted.jwt.schema.properties.fields.items.enum = user_fields;
|
|
||||||
extracted.jwt.schema.properties.fields.uniqueItems = true;
|
|
||||||
uiSchema.jwt.fields["ui:widget"] = "checkboxes";
|
|
||||||
} else {
|
|
||||||
uiSchema.jwt.fields["ui:widget"] = "hidden";
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function onSubmit(data: any) {
|
||||||
console.log(formRef.current?.validateForm(), formRef.current?.formData());
|
await actions.config.set(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Form schema={schema} initialValues={config as any} onSubmit={onSubmit} {...formConfig}>
|
||||||
<AppShell.SectionHeader
|
<Subscribe
|
||||||
right={
|
selector={(state) => ({
|
||||||
<Button variant="primary" onClick={handleSubmit}>
|
dirty: state.dirty,
|
||||||
Update
|
errors: state.errors.length > 0,
|
||||||
</Button>
|
submitting: state.submitting
|
||||||
}
|
})}
|
||||||
>
|
>
|
||||||
Settings
|
{({ dirty, errors, submitting }) => (
|
||||||
</AppShell.SectionHeader>
|
<AppShell.SectionHeader
|
||||||
|
className="pl-4"
|
||||||
|
right={
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={!dirty || errors || submitting}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</AppShell.SectionHeader>
|
||||||
|
)}
|
||||||
|
</Subscribe>
|
||||||
<AppShell.Scrollable>
|
<AppShell.Scrollable>
|
||||||
<Alert.Warning
|
<Section className="pt-4 pl-0 pb-0">
|
||||||
visible={!config.enabled}
|
<div className="pl-4">
|
||||||
title="Auth not enabled"
|
<AuthField
|
||||||
message="Enable it by toggling the switch below. Please also make sure set a secure secret to sign JWT tokens."
|
name="enabled"
|
||||||
/>
|
label="Authentication Enabled"
|
||||||
<div className="flex flex-col flex-grow px-5 py-4 gap-8">
|
description="Only after enabling authentication, all settings below will take effect."
|
||||||
<div>
|
descriptionPlacement="top"
|
||||||
<JsonSchemaForm
|
|
||||||
schema={generalSchema}
|
|
||||||
className="legacy hide-required-mark fieldset-alternative mute-root"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-6 relative pl-4 pb-2">
|
||||||
<div className="flex flex-col gap-3">
|
<Overlay />
|
||||||
<h3 className="font-bold">JWT Settings</h3>
|
<AuthField
|
||||||
<JsonSchemaForm
|
name="guard.enabled"
|
||||||
ref={formRef}
|
label={
|
||||||
schema={extracted.jwt.schema}
|
<div className="flex flex-row gap-2 items-center">
|
||||||
uiSchema={uiSchema.jwt}
|
<span>Guard Enabled</span>
|
||||||
className="legacy hide-required-mark fieldset-alternative mute-root"
|
{!$auth.roles.has_admin && (
|
||||||
|
<Icon.Warning title="No admin roles defined. Enabling the guard will likely block all requests." />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
disabled={$auth.roles.none}
|
||||||
|
description="When enabled, enforces permissions on all routes. Make sure to create roles first."
|
||||||
|
descriptionPlacement="top"
|
||||||
|
/>
|
||||||
|
<AuthField
|
||||||
|
name="allow_register"
|
||||||
|
label="Allow User Registration"
|
||||||
|
description="When enabled, allows users to register autonomously. New users use the default role."
|
||||||
|
descriptionPlacement="top"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</Section>
|
||||||
|
<div className="flex flex-col gap-3 relative mt-3 pb-4">
|
||||||
|
<Overlay />
|
||||||
|
<AppShell.Separator />
|
||||||
|
<Section title="JWT">
|
||||||
|
<AuthField name="jwt.issuer" />
|
||||||
|
<AuthField
|
||||||
|
name="jwt.secret"
|
||||||
|
description="The secret used to sign the JWT token. If not set, a random key will be generated after enabling authentication."
|
||||||
|
advanced="jwt"
|
||||||
|
/>
|
||||||
|
<AuthField name="jwt.alg" advanced="jwt" />
|
||||||
|
<AuthField name="jwt.expires" advanced="jwt" />
|
||||||
|
<ToggleAdvanced which="jwt" />
|
||||||
|
</Section>
|
||||||
|
<AppShell.Separator />
|
||||||
|
<Section title="Cookie">
|
||||||
|
<AuthField name="cookie.path" advanced="cookie" />
|
||||||
|
<AuthField name="cookie.sameSite" advanced="cookie" />
|
||||||
|
<AuthField name="cookie.secure" advanced="cookie" />
|
||||||
|
<AuthField name="cookie.expires" advanced="cookie" />
|
||||||
|
<AuthField
|
||||||
|
name="cookie.renew"
|
||||||
|
label="Renew Cookie"
|
||||||
|
description="Automatically renew users cookie on every request."
|
||||||
|
descriptionPlacement="top"
|
||||||
|
/>
|
||||||
|
<AuthField name="cookie.pathSuccess" advanced="cookie" />
|
||||||
|
<AuthField name="cookie.pathLoggedOut" />
|
||||||
|
<ToggleAdvanced which="cookie" />
|
||||||
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
|
<FormDebug />
|
||||||
</AppShell.Scrollable>
|
</AppShell.Scrollable>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToggleAdvanced = ({ which }: { which: string }) => {
|
||||||
|
const { advanced, toggleAdvanced } = useAuthSettingsStore();
|
||||||
|
const show = advanced.includes(which);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
IconLeft={show ? TbChevronUp : TbChevronDown}
|
||||||
|
onClick={() => toggleAdvanced(which)}
|
||||||
|
variant={show ? "default" : "ghost"}
|
||||||
|
className="self-start"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{show ? "Hide advanced settings" : "Show advanced settings"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
//const Overlay = () => null;
|
||||||
|
const Overlay = () => (
|
||||||
|
<Subscribe selector={(state) => ({ enabled: state.data.enabled })}>
|
||||||
|
{({ enabled }) =>
|
||||||
|
!enabled && (
|
||||||
|
<div className="absolute w-full h-full z-50 inset-0 bg-background opacity-90" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Subscribe>
|
||||||
|
);
|
||||||
|
|
||||||
|
function Section(props: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
first?: boolean;
|
||||||
|
}) {
|
||||||
|
const { children, title, className } = props;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={clsx("flex flex-col gap-6 px-4", title && "pt-0", className)}>
|
||||||
|
{title && <h3 className="text-lg font-bold">{title}</h3>}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AuthJwtSettings() {}
|
function AuthField(props: FieldProps & { advanced?: string }) {
|
||||||
|
const { advanced, ...rest } = props;
|
||||||
|
const showAdvanced = useAuthSettingsStore((state) => state.advanced);
|
||||||
|
if (advanced && !showAdvanced.includes(advanced)) return null;
|
||||||
|
|
||||||
|
return <Field {...rest} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { AuthRoot } from "./_auth.root";
|
|||||||
import { AuthIndex } from "./auth.index";
|
import { AuthIndex } from "./auth.index";
|
||||||
import { AuthRolesList } from "./auth.roles";
|
import { AuthRolesList } from "./auth.roles";
|
||||||
import { AuthRolesEdit } from "./auth.roles.edit.$role";
|
import { AuthRolesEdit } from "./auth.roles.edit.$role";
|
||||||
import { AuthSettingsList } from "./auth.settings";
|
import { AuthSettings } from "./auth.settings";
|
||||||
import { AuthStrategiesList } from "./auth.strategies";
|
import { AuthStrategiesList } from "./auth.strategies";
|
||||||
import { AuthUsersList } from "./auth.users";
|
import { AuthUsersList } from "./auth.users";
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ export default function AuthRoutes() {
|
|||||||
<Route path="/roles" component={AuthRolesList} />
|
<Route path="/roles" component={AuthRolesList} />
|
||||||
<Route path="/roles/edit/:role" component={AuthRolesEdit} />
|
<Route path="/roles/edit/:role" component={AuthRolesEdit} />
|
||||||
<Route path="/strategies" component={AuthStrategiesList} />
|
<Route path="/strategies" component={AuthStrategiesList} />
|
||||||
<Route path="/settings" component={AuthSettingsList} />
|
<Route path="/settings" component={AuthSettings} />
|
||||||
</AuthRoot>
|
</AuthRoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
Subscribe,
|
Subscribe,
|
||||||
useFormError
|
useFormError
|
||||||
} from "ui/components/form/json-schema-form";
|
} from "ui/components/form/json-schema-form";
|
||||||
import { Media } from "ui/elements";
|
|
||||||
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";
|
||||||
|
|
||||||
@@ -87,7 +86,7 @@ function MediaSettingsInternal() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AppShell.Separator />
|
<AppShell.Separator />
|
||||||
<div className="flex flex-col gap-3 p-3">
|
<div className="flex flex-col gap-3 p-3 relative">
|
||||||
<Overlay />
|
<Overlay />
|
||||||
<AnyOf.Root path="adapter">
|
<AnyOf.Root path="adapter">
|
||||||
<Adapters />
|
<Adapters />
|
||||||
@@ -177,7 +176,7 @@ const Overlay = () => (
|
|||||||
<Subscribe selector={(state) => ({ enabled: state.data.enabled })}>
|
<Subscribe selector={(state) => ({ enabled: state.data.enabled })}>
|
||||||
{({ enabled }) =>
|
{({ enabled }) =>
|
||||||
!enabled && (
|
!enabled && (
|
||||||
<div className="absolute w-full h-full z-50 bg-background opacity-70 pointer-events-none" />
|
<div className="absolute w-full h-full z-50 inset-0 bg-background opacity-90" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</Subscribe>
|
</Subscribe>
|
||||||
|
|||||||
@@ -32,6 +32,33 @@ const schema2 = {
|
|||||||
required: ["age"]
|
required: ["age"]
|
||||||
} as const satisfies JSONSchema;
|
} as const satisfies JSONSchema;
|
||||||
|
|
||||||
|
const authSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
what: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
jwt: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
fields: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as const satisfies JSONSchema;
|
||||||
|
|
||||||
|
const formOptions = {
|
||||||
|
debug: true
|
||||||
|
};
|
||||||
|
|
||||||
export default function JsonSchemaForm3() {
|
export default function JsonSchemaForm3() {
|
||||||
const { schema: _schema, config } = useBknd();
|
const { schema: _schema, config } = useBknd();
|
||||||
const schema = JSON.parse(JSON.stringify(_schema));
|
const schema = JSON.parse(JSON.stringify(_schema));
|
||||||
@@ -46,6 +73,8 @@ export default function JsonSchemaForm3() {
|
|||||||
return (
|
return (
|
||||||
<Scrollable>
|
<Scrollable>
|
||||||
<div className="flex flex-col p-3">
|
<div className="flex flex-col p-3">
|
||||||
|
<Form schema={_schema.auth} options={formOptions} />
|
||||||
|
|
||||||
{/*<Form
|
{/*<Form
|
||||||
onChange={(data) => console.log("change", data)}
|
onChange={(data) => console.log("change", data)}
|
||||||
onSubmit={(data) => console.log("submit", data)}
|
onSubmit={(data) => console.log("submit", data)}
|
||||||
@@ -249,13 +278,13 @@ export default function JsonSchemaForm3() {
|
|||||||
</Form>*/}
|
</Form>*/}
|
||||||
|
|
||||||
{/*<CustomMediaForm />*/}
|
{/*<CustomMediaForm />*/}
|
||||||
<Form
|
|
||||||
|
{/*<Form
|
||||||
schema={schema.media}
|
schema={schema.media}
|
||||||
initialValues={config.media as any}
|
initialValues={config.media as any}
|
||||||
onSubmit={console.log}
|
onSubmit={console.log}
|
||||||
options={{ debug: true }}
|
options={{ debug: true }}
|
||||||
/*validateOn="change"*/
|
/>*/}
|
||||||
/>
|
|
||||||
|
|
||||||
{/*<Form
|
{/*<Form
|
||||||
schema={removeKeyRecursively(schema.media, "pattern") as any}
|
schema={removeKeyRecursively(schema.media, "pattern") as any}
|
||||||
@@ -333,7 +362,7 @@ function CustomMediaForm() {
|
|||||||
<AnyOf.Root path="adapter">
|
<AnyOf.Root path="adapter">
|
||||||
<CustomMediaFormAdapter />
|
<CustomMediaFormAdapter />
|
||||||
</AnyOf.Root>
|
</AnyOf.Root>
|
||||||
{/*<FormDebug force />*/}
|
<FormDebug force />
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user