From 5c7bfeab8f6a5bbfb7cfbe2a3799a2b69d244b25 Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 9 Jan 2025 10:20:28 +0100 Subject: [PATCH] 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. --- app/build.ts | 7 +- app/package.json | 5 + app/src/auth/auth-schema.ts | 1 + app/src/ui/elements/index.ts | 1 + app/src/ui/layouts/AppShell/Header.tsx | 16 +++- app/src/ui/modules/auth/AuthForm.tsx | 128 +++++++++++++++++++++++++ app/src/ui/modules/auth/AuthScreen.tsx | 41 ++++++++ app/src/ui/modules/auth/LoginForm.tsx | 65 ------------- app/src/ui/modules/auth/SocialLink.tsx | 33 +++++++ app/src/ui/modules/auth/index.ts | 9 ++ app/src/ui/routes/auth/auth.login.tsx | 77 +-------------- 11 files changed, 237 insertions(+), 146 deletions(-) create mode 100644 app/src/ui/elements/index.ts create mode 100644 app/src/ui/modules/auth/AuthForm.tsx create mode 100644 app/src/ui/modules/auth/AuthScreen.tsx delete mode 100644 app/src/ui/modules/auth/LoginForm.tsx create mode 100644 app/src/ui/modules/auth/SocialLink.tsx create mode 100644 app/src/ui/modules/auth/index.ts diff --git a/app/build.ts b/app/build.ts index 6511124..37394a7 100644 --- a/app/build.ts +++ b/app/build.ts @@ -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, diff --git a/app/package.json b/app/package.json index 352ad90..d2c52b3 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/auth/auth-schema.ts b/app/src/auth/auth-schema.ts index 202e0b4..84882b5 100644 --- a/app/src/auth/auth-schema.ts +++ b/app/src/auth/auth-schema.ts @@ -33,6 +33,7 @@ const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => { const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject)); export type AppAuthStrategies = Static; export type AppAuthOAuthStrategy = Static; +export type AppAuthCustomOAuthStrategy = Static; const guardConfigSchema = Type.Object({ enabled: Type.Optional(Type.Boolean({ default: false })) diff --git a/app/src/ui/elements/index.ts b/app/src/ui/elements/index.ts new file mode 100644 index 0000000..0e3c055 --- /dev/null +++ b/app/src/ui/elements/index.ts @@ -0,0 +1 @@ +export { Auth } from "ui/modules/auth/index"; diff --git a/app/src/ui/layouts/AppShell/Header.tsx b/app/src/ui/layouts/AppShell/Header.tsx index 0d1c701..6343772 100644 --- a/app/src/ui/layouts/AppShell/Header.tsx +++ b/app/src/ui/layouts/AppShell/Header.tsx @@ -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) { diff --git a/app/src/ui/modules/auth/AuthForm.tsx b/app/src/ui/modules/auth/AuthForm.tsx new file mode 100644 index 0000000..fa864ef --- /dev/null +++ b/app/src/ui/modules/auth/AuthForm.tsx @@ -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, "onSubmit" | "action"> & { + className?: string; + formData?: any; + action: "login" | "register"; + method?: "POST" | "GET"; + auth?: Partial>; + buttonLabel?: string; +}; + +class TypeboxValidator implements Validator { + 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; + const has_oauth = Object.keys(oauth).length > 0; + + return ( +
+ {has_oauth && ( + <> +
+ {Object.entries(oauth)?.map(([name, oauth], key) => ( + + ))} +
+ + + )} +
+ {({ errors, submitting }) => ( + <> + + + + + + + + + + + + )} +
+
+ ); +} + +const Or = () => ( +
+
+
+
+
or
+
+
+
+
+); diff --git a/app/src/ui/modules/auth/AuthScreen.tsx b/app/src/ui/modules/auth/AuthScreen.tsx new file mode 100644 index 0000000..3ac60e1 --- /dev/null +++ b/app/src/ui/modules/auth/AuthScreen.tsx @@ -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 ( +
+ {!loading && ( +
+ {typeof logo !== "undefined" ? ( + logo + ) : ( + + + + )} + {typeof intro !== "undefined" ? ( + intro + ) : ( +
+

Sign in to your admin panel

+

Enter your credentials below to get access.

+
+ )} + +
+ )} +
+ ); +} diff --git a/app/src/ui/modules/auth/LoginForm.tsx b/app/src/ui/modules/auth/LoginForm.tsx deleted file mode 100644 index c20206b..0000000 --- a/app/src/ui/modules/auth/LoginForm.tsx +++ /dev/null @@ -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, "onSubmit"> & { - className?: string; - formData?: any; -}; - -class TypeboxValidator implements Validator { - 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 ( -
- {({ errors, submitting }) => ( - <> -
{JSON.stringify(errors, null, 2)}
- - Email address - - - - Password - - - - - - )} -
- ); -} diff --git a/app/src/ui/modules/auth/SocialLink.tsx b/app/src/ui/modules/auth/SocialLink.tsx new file mode 100644 index 0000000..e116bb4 --- /dev/null +++ b/app/src/ui/modules/auth/SocialLink.tsx @@ -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 ( +
+ + {children} +
+ ); +} diff --git a/app/src/ui/modules/auth/index.ts b/app/src/ui/modules/auth/index.ts new file mode 100644 index 0000000..f3940d7 --- /dev/null +++ b/app/src/ui/modules/auth/index.ts @@ -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 +}; diff --git a/app/src/ui/routes/auth/auth.login.tsx b/app/src/ui/routes/auth/auth.login.tsx index 9a05b76..b9bf183 100644 --- a/app/src/ui/routes/auth/auth.login.tsx +++ b/app/src/ui/routes/auth/auth.login.tsx @@ -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; - - return ( - - - {!loading && ( -
- - - -
-

Sign in to your admin panel

-

Enter your credentials below to get access.

-
-
- {Object.keys(oauth).length > 0 && ( - <> - {Object.entries(oauth)?.map(([name, oauth], key) => ( -
- -
- ))} - -
-
-
-
-
or
-
-
-
-
- - )} - - - {/*Logout*/} -
-
- )} - - - ); + return ; }