mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 12:56:05 +00:00
Refactor authentication components for modularity
Replaced `LoginForm` with a more extensible `AuthForm` and `AuthScreen`, enabling support for multiple auth strategies (e.g., OAuth) and improved reusability. Updated imports, routes, and configurations accordingly.
This commit is contained in:
@@ -138,7 +138,12 @@ await tsup.build({
|
|||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch,
|
||||||
entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css"],
|
entry: [
|
||||||
|
"src/ui/index.ts",
|
||||||
|
"src/ui/client/index.ts",
|
||||||
|
"src/ui/elements/index.ts",
|
||||||
|
"src/ui/main.css"
|
||||||
|
],
|
||||||
outDir: "dist/ui",
|
outDir: "dist/ui",
|
||||||
external: ["bun:test", "react", "react-dom", "use-sync-external-store"],
|
external: ["bun:test", "react", "react-dom", "use-sync-external-store"],
|
||||||
metafile: true,
|
metafile: true,
|
||||||
|
|||||||
@@ -104,6 +104,11 @@
|
|||||||
"import": "./dist/ui/index.js",
|
"import": "./dist/ui/index.js",
|
||||||
"require": "./dist/ui/index.cjs"
|
"require": "./dist/ui/index.cjs"
|
||||||
},
|
},
|
||||||
|
"./elements": {
|
||||||
|
"types": "./dist/types/ui/elements/index.d.ts",
|
||||||
|
"import": "./dist/ui/elements/index.js",
|
||||||
|
"require": "./dist/ui/elements/index.cjs"
|
||||||
|
},
|
||||||
"./client": {
|
"./client": {
|
||||||
"types": "./dist/types/ui/client/index.d.ts",
|
"types": "./dist/types/ui/client/index.d.ts",
|
||||||
"import": "./dist/ui/client/index.js",
|
"import": "./dist/ui/client/index.js",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => {
|
|||||||
const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject));
|
const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject));
|
||||||
export type AppAuthStrategies = Static<typeof strategiesSchema>;
|
export type AppAuthStrategies = Static<typeof strategiesSchema>;
|
||||||
export type AppAuthOAuthStrategy = Static<typeof STRATEGIES.oauth.schema>;
|
export type AppAuthOAuthStrategy = Static<typeof STRATEGIES.oauth.schema>;
|
||||||
|
export type AppAuthCustomOAuthStrategy = Static<typeof STRATEGIES.custom_oauth.schema>;
|
||||||
|
|
||||||
const guardConfigSchema = Type.Object({
|
const guardConfigSchema = Type.Object({
|
||||||
enabled: Type.Optional(Type.Boolean({ default: false }))
|
enabled: Type.Optional(Type.Boolean({ default: false }))
|
||||||
|
|||||||
1
app/src/ui/elements/index.ts
Normal file
1
app/src/ui/elements/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { Auth } from "ui/modules/auth/index";
|
||||||
@@ -144,7 +144,7 @@ export function Header({ hasSidebar = true }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UserMenu() {
|
function UserMenu() {
|
||||||
const { adminOverride } = useBknd();
|
const { adminOverride, config } = useBknd();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const [navigate] = useNavigate();
|
const [navigate] = useNavigate();
|
||||||
const { logout_route } = useBkndWindowContext();
|
const { logout_route } = useBkndWindowContext();
|
||||||
@@ -163,10 +163,16 @@ function UserMenu() {
|
|||||||
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings }
|
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (config.auth.enabled) {
|
||||||
if (!auth.user) {
|
if (!auth.user) {
|
||||||
items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
|
items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
|
||||||
} else {
|
} else {
|
||||||
items.push({ label: `Logout ${auth.user.email}`, onClick: handleLogout, icon: IconKeyOff });
|
items.push({
|
||||||
|
label: `Logout ${auth.user.email}`,
|
||||||
|
onClick: handleLogout,
|
||||||
|
icon: IconKeyOff
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!adminOverride) {
|
if (!adminOverride) {
|
||||||
|
|||||||
128
app/src/ui/modules/auth/AuthForm.tsx
Normal file
128
app/src/ui/modules/auth/AuthForm.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import type { ValueError } from "@sinclair/typebox/value";
|
||||||
|
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
|
||||||
|
import { type TSchema, Type, Value } from "core/utils";
|
||||||
|
import { Form, type Validator } from "json-schema-form-react";
|
||||||
|
import { transform } from "lodash-es";
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { Button } from "ui/components/buttons/Button";
|
||||||
|
import { Group, Input, Label } from "ui/components/form/Formy";
|
||||||
|
import { SocialLink } from "ui/modules/auth/SocialLink";
|
||||||
|
|
||||||
|
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
|
||||||
|
className?: string;
|
||||||
|
formData?: any;
|
||||||
|
action: "login" | "register";
|
||||||
|
method?: "POST" | "GET";
|
||||||
|
auth?: Partial<Pick<AppAuthSchema, "basepath" | "strategies">>;
|
||||||
|
buttonLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class TypeboxValidator implements Validator<ValueError> {
|
||||||
|
async validate(schema: TSchema, data: any) {
|
||||||
|
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validator = new TypeboxValidator();
|
||||||
|
|
||||||
|
const schema = Type.Object({
|
||||||
|
email: Type.String({
|
||||||
|
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
|
||||||
|
}),
|
||||||
|
password: Type.String({
|
||||||
|
minLength: 8 // @todo: this should be configurable
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AuthForm({
|
||||||
|
formData,
|
||||||
|
className,
|
||||||
|
method = "POST",
|
||||||
|
action,
|
||||||
|
auth,
|
||||||
|
buttonLabel = action === "login" ? "Sign in" : "Sign up",
|
||||||
|
...props
|
||||||
|
}: LoginFormProps) {
|
||||||
|
const basepath = auth?.basepath ?? "/api/auth";
|
||||||
|
const password = {
|
||||||
|
action: `${basepath}/password/${action}`,
|
||||||
|
strategy: auth?.strategies?.password ?? ({ type: "password" } as const)
|
||||||
|
};
|
||||||
|
|
||||||
|
const oauth = transform(
|
||||||
|
auth?.strategies ?? {},
|
||||||
|
(result, value, key) => {
|
||||||
|
if (value.type !== "password") {
|
||||||
|
result[key] = value.config;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
) as Record<string, AppAuthOAuthStrategy>;
|
||||||
|
const has_oauth = Object.keys(oauth).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 w-full">
|
||||||
|
{has_oauth && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
{Object.entries(oauth)?.map(([name, oauth], key) => (
|
||||||
|
<SocialLink
|
||||||
|
provider={name}
|
||||||
|
method={method}
|
||||||
|
basepath={basepath}
|
||||||
|
key={key}
|
||||||
|
action={action}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Or />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Form
|
||||||
|
method={method}
|
||||||
|
action={password.action}
|
||||||
|
{...props}
|
||||||
|
schema={schema}
|
||||||
|
validator={validator}
|
||||||
|
validationMode="change"
|
||||||
|
className={twMerge("flex flex-col gap-3 w-full", className)}
|
||||||
|
>
|
||||||
|
{({ errors, submitting }) => (
|
||||||
|
<>
|
||||||
|
<Group>
|
||||||
|
<Label htmlFor="email">Email address</Label>
|
||||||
|
<Input type="email" name="email" />
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input type="password" name="password" />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="large"
|
||||||
|
className="w-full mt-2 justify-center"
|
||||||
|
disabled={errors.length > 0 || submitting}
|
||||||
|
>
|
||||||
|
{buttonLabel}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Or = () => (
|
||||||
|
<div className="w-full flex flex-row items-center">
|
||||||
|
<div className="relative flex grow">
|
||||||
|
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
|
||||||
|
</div>
|
||||||
|
<div className="mx-5">or</div>
|
||||||
|
<div className="relative flex grow">
|
||||||
|
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
41
app/src/ui/modules/auth/AuthScreen.tsx
Normal file
41
app/src/ui/modules/auth/AuthScreen.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useAuthStrategies } from "ui/client/schema/auth/use-auth";
|
||||||
|
import { Logo } from "ui/components/display/Logo";
|
||||||
|
import { Link } from "ui/components/wouter/Link";
|
||||||
|
import { AuthForm } from "ui/modules/auth/AuthForm";
|
||||||
|
|
||||||
|
export type AuthScreenProps = {
|
||||||
|
method?: "POST" | "GET";
|
||||||
|
action?: "login" | "register";
|
||||||
|
logo?: ReactNode;
|
||||||
|
intro?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthScreen({ method = "POST", action = "login", logo, intro }: AuthScreenProps) {
|
||||||
|
const { strategies, basepath, loading } = useAuthStrategies();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col select-none h-dvh w-dvw justify-center items-center bknd-admin">
|
||||||
|
{!loading && (
|
||||||
|
<div className="flex flex-col gap-4 items-center w-96 px-6 py-7">
|
||||||
|
{typeof logo !== "undefined" ? (
|
||||||
|
logo
|
||||||
|
) : (
|
||||||
|
<Link href={"/"} className="link">
|
||||||
|
<Logo scale={0.25} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{typeof intro !== "undefined" ? (
|
||||||
|
intro
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<h1 className="text-xl font-bold">Sign in to your admin panel</h1>
|
||||||
|
<p className="text-primary/50">Enter your credentials below to get access.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AuthForm auth={{ basepath, strategies }} method={method} action={action} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import type { ValueError } from "@sinclair/typebox/value";
|
|
||||||
import { type TSchema, Type, Value } from "core/utils";
|
|
||||||
import { Form, type Validator } from "json-schema-form-react";
|
|
||||||
import type { ComponentPropsWithoutRef } from "react";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
import { Button } from "ui/components/buttons/Button";
|
|
||||||
import * as Formy from "ui/components/form/Formy";
|
|
||||||
|
|
||||||
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit"> & {
|
|
||||||
className?: string;
|
|
||||||
formData?: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
class TypeboxValidator implements Validator<ValueError> {
|
|
||||||
async validate(schema: TSchema, data: any) {
|
|
||||||
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const validator = new TypeboxValidator();
|
|
||||||
|
|
||||||
const schema = Type.Object({
|
|
||||||
email: Type.String({
|
|
||||||
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
|
|
||||||
}),
|
|
||||||
password: Type.String({
|
|
||||||
minLength: 8 // @todo: this should be configurable
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
export function LoginForm({ formData, className, ...props }: LoginFormProps) {
|
|
||||||
return (
|
|
||||||
<Form
|
|
||||||
method="POST"
|
|
||||||
{...props}
|
|
||||||
schema={schema}
|
|
||||||
validator={validator}
|
|
||||||
validationMode="change"
|
|
||||||
className={twMerge("flex flex-col gap-3 w-full", className)}
|
|
||||||
>
|
|
||||||
{({ errors, submitting }) => (
|
|
||||||
<>
|
|
||||||
<pre>{JSON.stringify(errors, null, 2)}</pre>
|
|
||||||
<Formy.Group>
|
|
||||||
<Formy.Label htmlFor="email">Email address</Formy.Label>
|
|
||||||
<Formy.Input type="email" name="email" />
|
|
||||||
</Formy.Group>
|
|
||||||
<Formy.Group>
|
|
||||||
<Formy.Label htmlFor="password">Password</Formy.Label>
|
|
||||||
<Formy.Input type="password" name="password" />
|
|
||||||
</Formy.Group>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
size="large"
|
|
||||||
className="w-full mt-2 justify-center"
|
|
||||||
disabled={errors.length > 0 || submitting}
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
33
app/src/ui/modules/auth/SocialLink.tsx
Normal file
33
app/src/ui/modules/auth/SocialLink.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Button } from "ui/components/buttons/Button";
|
||||||
|
import type { IconType } from "ui/components/buttons/IconButton";
|
||||||
|
|
||||||
|
export type SocialLinkProps = {
|
||||||
|
label?: string;
|
||||||
|
provider: string;
|
||||||
|
icon?: IconType;
|
||||||
|
action: "login" | "register";
|
||||||
|
method?: "GET" | "POST";
|
||||||
|
basepath?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SocialLink({
|
||||||
|
label,
|
||||||
|
provider,
|
||||||
|
icon,
|
||||||
|
action,
|
||||||
|
method = "POST",
|
||||||
|
basepath = "/api/auth",
|
||||||
|
children
|
||||||
|
}: SocialLinkProps) {
|
||||||
|
return (
|
||||||
|
<form method={method} action={[basepath, name, action].join("/")} className="w-full">
|
||||||
|
<Button type="submit" size="large" variant="outline" className="justify-center w-full">
|
||||||
|
Continue with {label ?? ucFirstAllSnakeToPascalWithSpaces(provider)}
|
||||||
|
</Button>
|
||||||
|
{children}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
app/src/ui/modules/auth/index.ts
Normal file
9
app/src/ui/modules/auth/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { AuthForm } from "ui/modules/auth/AuthForm";
|
||||||
|
import { AuthScreen } from "ui/modules/auth/AuthScreen";
|
||||||
|
import { SocialLink } from "ui/modules/auth/SocialLink";
|
||||||
|
|
||||||
|
export const Auth = {
|
||||||
|
Screen: AuthScreen,
|
||||||
|
Form: AuthForm,
|
||||||
|
SocialLink: SocialLink
|
||||||
|
};
|
||||||
@@ -1,80 +1,7 @@
|
|||||||
import type { AppAuthOAuthStrategy } from "auth/auth-schema";
|
|
||||||
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
|
||||||
import { transform } from "lodash-es";
|
|
||||||
import { useAuthStrategies } from "ui/client/schema/auth/use-auth";
|
|
||||||
import { Button } from "ui/components/buttons/Button";
|
|
||||||
import { Logo } from "ui/components/display/Logo";
|
|
||||||
import { Link } from "ui/components/wouter/Link";
|
|
||||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||||
import { LoginForm } from "ui/modules/auth/LoginForm";
|
import { AuthScreen } from "ui/modules/auth/AuthScreen";
|
||||||
import * as AppShell from "../../layouts/AppShell/AppShell";
|
|
||||||
|
|
||||||
export function AuthLogin() {
|
export function AuthLogin() {
|
||||||
useBrowserTitle(["Login"]);
|
useBrowserTitle(["Login"]);
|
||||||
const { strategies, basepath, loading } = useAuthStrategies();
|
return <AuthScreen action="login" />;
|
||||||
|
|
||||||
const oauth = transform(
|
|
||||||
strategies ?? {},
|
|
||||||
(result, value, key) => {
|
|
||||||
if (value.type !== "password") {
|
|
||||||
result[key] = value.config;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
) as Record<string, AppAuthOAuthStrategy>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppShell.Root>
|
|
||||||
<AppShell.Content center>
|
|
||||||
{!loading && (
|
|
||||||
<div className="flex flex-col gap-4 items-center w-96 px-6 py-7">
|
|
||||||
<Link href={"/"} className="link">
|
|
||||||
<Logo scale={0.25} />
|
|
||||||
</Link>
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<h1 className="text-xl font-bold">Sign in to your admin panel</h1>
|
|
||||||
<p className="text-primary/50">Enter your credentials below to get access.</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 w-full">
|
|
||||||
{Object.keys(oauth).length > 0 && (
|
|
||||||
<>
|
|
||||||
{Object.entries(oauth)?.map(([name, oauth], key) => (
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action={`${basepath}/${name}/login`}
|
|
||||||
key={key}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
key={key}
|
|
||||||
type="submit"
|
|
||||||
size="large"
|
|
||||||
variant="outline"
|
|
||||||
className="justify-center w-full"
|
|
||||||
>
|
|
||||||
Continue with {ucFirstAllSnakeToPascalWithSpaces(oauth.name)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="w-full flex flex-row items-center">
|
|
||||||
<div className="relative flex grow">
|
|
||||||
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
|
|
||||||
</div>
|
|
||||||
<div className="mx-5">or</div>
|
|
||||||
<div className="relative flex grow">
|
|
||||||
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<LoginForm action="/api/auth/password/login" />
|
|
||||||
{/*<a href="/auth/logout">Logout</a>*/}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AppShell.Content>
|
|
||||||
</AppShell.Root>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user