mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +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,
|
||||
sourcemap,
|
||||
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",
|
||||
external: ["bun:test", "react", "react-dom", "use-sync-external-store"],
|
||||
metafile: true,
|
||||
|
||||
@@ -104,6 +104,11 @@
|
||||
"import": "./dist/ui/index.js",
|
||||
"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": {
|
||||
"types": "./dist/types/ui/client/index.d.ts",
|
||||
"import": "./dist/ui/client/index.js",
|
||||
|
||||
@@ -33,6 +33,7 @@ const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => {
|
||||
const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject));
|
||||
export type AppAuthStrategies = Static<typeof strategiesSchema>;
|
||||
export type AppAuthOAuthStrategy = Static<typeof STRATEGIES.oauth.schema>;
|
||||
export type AppAuthCustomOAuthStrategy = Static<typeof STRATEGIES.custom_oauth.schema>;
|
||||
|
||||
const guardConfigSchema = Type.Object({
|
||||
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() {
|
||||
const { adminOverride } = useBknd();
|
||||
const { adminOverride, config } = useBknd();
|
||||
const auth = useAuth();
|
||||
const [navigate] = useNavigate();
|
||||
const { logout_route } = useBkndWindowContext();
|
||||
@@ -163,10 +163,16 @@ function UserMenu() {
|
||||
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings }
|
||||
];
|
||||
|
||||
if (!auth.user) {
|
||||
items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
|
||||
} else {
|
||||
items.push({ label: `Logout ${auth.user.email}`, onClick: handleLogout, icon: IconKeyOff });
|
||||
if (config.auth.enabled) {
|
||||
if (!auth.user) {
|
||||
items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
|
||||
} else {
|
||||
items.push({
|
||||
label: `Logout ${auth.user.email}`,
|
||||
onClick: handleLogout,
|
||||
icon: IconKeyOff
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 { LoginForm } from "ui/modules/auth/LoginForm";
|
||||
import * as AppShell from "../../layouts/AppShell/AppShell";
|
||||
import { AuthScreen } from "ui/modules/auth/AuthScreen";
|
||||
|
||||
export function AuthLogin() {
|
||||
useBrowserTitle(["Login"]);
|
||||
const { strategies, basepath, loading } = useAuthStrategies();
|
||||
|
||||
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>
|
||||
);
|
||||
return <AuthScreen action="login" />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user