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:
dswbx
2025-01-09 10:20:28 +01:00
parent fcab042e88
commit 5c7bfeab8f
11 changed files with 237 additions and 146 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { Auth } from "ui/modules/auth/index";

View File

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

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

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

View File

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

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

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

View File

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