Browse Source

feat(dashboard): Add customizable login extensions API (#3704)

David Höck 6 months ago
parent
commit
866afcb42c

+ 80 - 45
packages/dashboard/src/lib/components/login/login-form.tsx

@@ -10,8 +10,10 @@ import { useForm } from 'react-hook-form';
 import { toast } from 'sonner';
 import { uiConfig } from 'virtual:vendure-ui-config';
 import { z } from 'zod';
+import { useLoginExtensions } from '../../framework/extension-api/use-login-extensions.js';
 import { LogoMark } from '../shared/logo-mark.js';
 import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../ui/form.js';
+import { Separator } from '../ui/separator.js';
 
 export interface LoginFormProps extends React.ComponentProps<'div'> {
     loginError?: string;
@@ -19,7 +21,7 @@ export interface LoginFormProps extends React.ComponentProps<'div'> {
     onFormSubmit?: (username: string, password: string) => void;
 }
 
-type RemoteLoginImage = {
+export type RemoteLoginImage = {
     urls: { regular: string };
     location: { name: string };
     user: { name: string; links: { html: string } };
@@ -32,6 +34,7 @@ const formSchema = z.object({
 
 export function LoginForm({ className, onFormSubmit, isVerifying, loginError, ...props }: LoginFormProps) {
     const [remoteLoginImage, setRemoteLoginImage] = React.useState<RemoteLoginImage | null>(null);
+    const loginExtensions = useLoginExtensions();
 
     React.useEffect(() => {
         if (!uiConfig.loginImageUrl) {
@@ -66,17 +69,39 @@ export function LoginForm({ className, onFormSubmit, isVerifying, loginError, ..
                         >
                             <div className="flex flex-col gap-6">
                                 <div className="flex flex-col items-start  space-y-4">
-                                    {!uiConfig.hideVendureBranding && (
-                                        <LogoMark className="text-vendure-brand h-6 w-auto" />
+                                    {loginExtensions.logo ? (
+                                        <>
+                                            <loginExtensions.logo.component />
+                                            {loginExtensions.beforeForm && (
+                                                <>
+                                                    <loginExtensions.beforeForm.component />
+                                                    <Separator className="w-full" />
+                                                </>
+                                            )}
+                                        </>
+                                    ) : (
+                                        <>
+                                            {!uiConfig.hideVendureBranding && (
+                                                <LogoMark className="text-vendure-brand h-6 w-auto" />
+                                            )}
+                                            <div>
+                                                <h1 className="text-2xl font-medium">
+                                                    <Trans>Welcome back!</Trans>
+                                                </h1>
+                                                <p className="text-muted-foreground text-balance">
+                                                    Login to your Vendure store
+                                                </p>
+                                            </div>
+                                            {loginExtensions.beforeForm && (
+                                                <>
+                                                    <Separator className="w-full" />
+                                                    <div className="w-full">
+                                                        <loginExtensions.beforeForm.component />
+                                                    </div>
+                                                </>
+                                            )}
+                                        </>
                                     )}
-                                    <div>
-                                        <h1 className="text-2xl font-medium">
-                                            <Trans>Welcome back!</Trans>
-                                        </h1>
-                                        <p className="text-muted-foreground text-balance">
-                                            Login to your Vendure store
-                                        </p>
-                                    </div>
                                 </div>
                                 <FormField
                                     control={form.control}
@@ -117,7 +142,6 @@ export function LoginForm({ className, onFormSubmit, isVerifying, loginError, ..
                                         </FormItem>
                                     )}
                                 />
-
                                 <Button type="submit" disabled={isVerifying}>
                                     {isVerifying && (
                                         <>
@@ -128,44 +152,55 @@ export function LoginForm({ className, onFormSubmit, isVerifying, loginError, ..
                                     {!isVerifying && <span>Login</span>}
                                 </Button>
                             </div>
+                            {loginExtensions.afterForm && (
+                                <>
+                                    <Separator className="w-full my-4" />
+
+                                    <loginExtensions.afterForm.component />
+                                </>
+                            )}
                         </form>
                     </Form>
-                    <div className="bg-muted relative hidden md:block lg:min-h-[500px]">
-                        {remoteLoginImage && (
-                            <>
+                    {loginExtensions.loginImage ? (
+                        <loginExtensions.loginImage.component />
+                    ) : (
+                        <div className="bg-muted relative hidden md:block lg:min-h-[500px]">
+                            {remoteLoginImage && (
+                                <>
+                                    <img
+                                        src={remoteLoginImage.urls.regular}
+                                        alt="Image"
+                                        className="absolute inset-0 h-full w-full object-cover"
+                                    />
+                                    <div className="absolute h-full w-full top-0 left-0 flex items-end justify-start bg-gradient-to-b from-transparent to-black/80 p-4 ">
+                                        <div>
+                                            <p className="text-lg font-medium text-white">
+                                                {remoteLoginImage.location.name}
+                                            </p>
+                                            <p className="text-sm text-white/80">
+                                                By
+                                                <a
+                                                    className="mx-1 underline"
+                                                    href={remoteLoginImage.user.links.html}
+                                                    target="_blank"
+                                                >
+                                                    {remoteLoginImage.user.name}
+                                                </a>
+                                                on Unsplash
+                                            </p>
+                                        </div>
+                                    </div>
+                                </>
+                            )}
+                            {uiConfig.loginImageUrl && (
                                 <img
-                                    src={remoteLoginImage.urls.regular}
-                                    alt="Image"
+                                    src={uiConfig.loginImageUrl}
+                                    alt="Login image"
                                     className="absolute inset-0 h-full w-full object-cover"
                                 />
-                                <div className="absolute h-full w-full top-0 left-0 flex items-end justify-start bg-gradient-to-b from-transparent to-black/80 p-4 ">
-                                    <div>
-                                        <p className="text-lg font-medium text-white">
-                                            {remoteLoginImage.location.name}
-                                        </p>
-                                        <p className="text-sm text-white/80">
-                                            By
-                                            <a
-                                                className="mx-1 underline"
-                                                href={remoteLoginImage.user.links.html}
-                                                target="_blank"
-                                            >
-                                                {remoteLoginImage.user.name}
-                                            </a>
-                                            on Unsplash
-                                        </p>
-                                    </div>
-                                </div>
-                            </>
-                        )}
-                        {uiConfig.loginImageUrl && (
-                            <img
-                                src={uiConfig.loginImageUrl}
-                                alt="Login image"
-                                className="absolute inset-0 h-full w-full object-cover"
-                            />
-                        )}
-                    </div>
+                            )}
+                        </div>
+                    )}
                 </CardContent>
             </Card>
         </div>

+ 4 - 0
packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts

@@ -7,6 +7,7 @@ import {
     registerDetailFormExtensions,
     registerFormComponentExtensions,
     registerLayoutExtensions,
+    registerLoginExtensions,
     registerNavigationExtensions,
     registerWidgetExtensions,
 } from './logic/index.js';
@@ -57,6 +58,9 @@ export function defineDashboardExtension(extension: DashboardExtension) {
         // Register alert extensions
         registerAlertExtensions(extension.alerts);
 
+        // Register login extensions
+        registerLoginExtensions(extension.login);
+
         // Execute extension source change callbacks
         const callbacks = globalRegistry.get('extensionSourceChangeCallbacks');
         if (callbacks.size) {

+ 6 - 0
packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts

@@ -5,6 +5,7 @@ import {
     DashboardCustomFormComponents,
     DashboardDataTableExtensionDefinition,
     DashboardDetailFormExtensionDefinition,
+    DashboardLoginExtensions,
     DashboardNavSectionDefinition,
     DashboardPageBlockDefinition,
     DashboardRouteDefinition,
@@ -64,4 +65,9 @@ export interface DashboardExtension {
      */
     dataTables?: DashboardDataTableExtensionDefinition[];
     detailForms?: DashboardDetailFormExtensionDefinition[];
+    /**
+     * @description
+     * Allows you to customize the login page with custom components.
+     */
+    login?: DashboardLoginExtensions;
 }

+ 1 - 0
packages/dashboard/src/lib/framework/extension-api/logic/index.ts

@@ -4,5 +4,6 @@ export * from './data-table.js';
 export * from './detail-forms.js';
 export * from './form-components.js';
 export * from './layout.js';
+export * from './login.js';
 export * from './navigation.js';
 export * from './widgets.js';

+ 17 - 0
packages/dashboard/src/lib/framework/extension-api/logic/login.ts

@@ -0,0 +1,17 @@
+import { globalRegistry } from '../../registry/global-registry.js';
+import { DashboardLoginExtensions } from '../types/login.js';
+
+export function registerLoginExtensions(loginExtensions?: DashboardLoginExtensions) {
+    if (!loginExtensions) {
+        return;
+    }
+
+    const registryKey = 'loginExtensions';
+
+    globalRegistry.set(registryKey, (oldValue: DashboardLoginExtensions) => {
+        return {
+            ...oldValue,
+            ...loginExtensions,
+        };
+    });
+}

+ 1 - 0
packages/dashboard/src/lib/framework/extension-api/types/index.ts

@@ -4,5 +4,6 @@ export * from './data-table.js';
 export * from './detail-forms.js';
 export * from './form-components.js';
 export * from './layout.js';
+export * from './login.js';
 export * from './navigation.js';
 export * from './widgets.js';

+ 101 - 0
packages/dashboard/src/lib/framework/extension-api/types/login.ts

@@ -0,0 +1,101 @@
+import type React from 'react';
+
+/**
+ * @description
+ * **Status: Developer Preview**
+ *
+ * Defines a custom logo component for the login page.
+ *
+ * @docsCategory extensions
+ * @since 3.3.0
+ */
+export interface LoginLogoExtension {
+    /**
+     * @description
+     * A React component that will replace the default Vendure logo.
+     */
+    component: React.ComponentType;
+}
+
+/**
+ * @description
+ * **Status: Developer Preview**
+ *
+ * Defines content to display before the login form.
+ *
+ * @docsCategory extensions
+ * @since 3.3.0
+ */
+export interface LoginBeforeFormExtension {
+    /**
+     * @description
+     * A React component that will be rendered before the login form.
+     */
+    component: React.ComponentType;
+}
+
+/**
+ * @description
+ * **Status: Developer Preview**
+ *
+ * Defines content to display after the login form.
+ *
+ * @docsCategory extensions
+ * @since 3.3.0
+ */
+export interface LoginAfterFormExtension {
+    /**
+     * @description
+     * A React component that will be rendered after the login form.
+     */
+    component: React.ComponentType;
+}
+
+/**
+ * @description
+ * **Status: Developer Preview**
+ *
+ * Defines a custom login image component that replaces the default image panel.
+ *
+ * @docsCategory extensions
+ * @since 3.3.0
+ */
+export interface LoginImageExtension {
+    /**
+     * @description
+     * A React component that will replace the default login image panel.
+     */
+    component: React.ComponentType;
+}
+
+/**
+ * @description
+ * **Status: Developer Preview**
+ *
+ * Defines all available login page extensions.
+ *
+ * @docsCategory extensions
+ * @since 3.3.0
+ */
+export interface DashboardLoginExtensions {
+    /**
+     * @description
+     * Custom logo component to replace the default Vendure logo.
+     */
+    logo?: LoginLogoExtension;
+    /**
+     * @description
+     * Component to render before the login form.
+     */
+    beforeForm?: LoginBeforeFormExtension;
+    /**
+     * @description
+     * Component to render after the login form.
+     */
+    afterForm?: LoginAfterFormExtension;
+    /**
+     * @description
+     * Custom login image component to replace the default image panel.
+     */
+    loginImage?: LoginImageExtension;
+}

+ 26 - 0
packages/dashboard/src/lib/framework/extension-api/use-login-extensions.ts

@@ -0,0 +1,26 @@
+import { useEffect, useState } from 'react';
+
+import { globalRegistry } from '../registry/global-registry.js';
+
+import { onExtensionSourceChange } from './define-dashboard-extension.js';
+import { DashboardLoginExtensions } from './types/login.js';
+
+export function useLoginExtensions(): DashboardLoginExtensions {
+    const [extensions, setExtensions] = useState<DashboardLoginExtensions>(() => {
+        return globalRegistry.get('loginExtensions') || {};
+    });
+
+    useEffect(() => {
+        const updateExtensions = () => {
+            setExtensions(globalRegistry.get('loginExtensions') || {});
+        };
+
+        // Subscribe to extension changes
+        onExtensionSourceChange(updateExtensions);
+
+        // Update immediately in case extensions were registered before this hook was called
+        updateExtensions();
+    }, []);
+
+    return extensions;
+}

+ 4 - 0
packages/dashboard/src/lib/framework/registry/global-registry.ts

@@ -36,6 +36,10 @@ class GlobalRegistry {
         const oldValue = this.get(key);
         this.registry.set(key, updater(oldValue));
     }
+
+    public has(key: string): boolean {
+        return this.registry.has(key);
+    }
 }
 
 export type GlobalRegistryKey = keyof GlobalRegistryContents;

+ 2 - 0
packages/dashboard/src/lib/framework/registry/registry-types.ts

@@ -1,6 +1,7 @@
 import {
     BulkAction,
     DashboardActionBarItem,
+    DashboardLoginExtensions,
     DashboardPageBlockDefinition,
     DashboardWidgetDefinition,
 } from '@/vdb/framework/extension-api/types/index.js';
@@ -26,4 +27,5 @@ export interface GlobalRegistryContents {
     bulkActionsRegistry: Map<string, BulkAction[]>;
     listQueryDocumentRegistry: Map<string, DocumentNode[]>;
     detailQueryDocumentRegistry: Map<string, DocumentNode[]>;
+    loginExtensions: DashboardLoginExtensions;
 }

+ 2 - 0
packages/dashboard/src/lib/index.ts

@@ -180,9 +180,11 @@ export * from './framework/extension-api/types/data-table.js';
 export * from './framework/extension-api/types/detail-forms.js';
 export * from './framework/extension-api/types/form-components.js';
 export * from './framework/extension-api/types/layout.js';
+export * from './framework/extension-api/types/login.js';
 export * from './framework/extension-api/types/navigation.js';
 export * from './framework/extension-api/types/widgets.js';
 export * from './framework/extension-api/use-dashboard-extensions.js';
+export * from './framework/extension-api/use-login-extensions.js';
 export * from './framework/form-engine/custom-form-component-extensions.js';
 export * from './framework/form-engine/custom-form-component.js';
 export * from './framework/form-engine/form-schema-tools.js';

+ 32 - 1
packages/dev-server/test-plugins/reviews/dashboard/index.tsx

@@ -1,4 +1,10 @@
-import { Button, DataTableBulkActionItem, defineDashboardExtension, usePage } from '@vendure/dashboard';
+import {
+    Button,
+    DataTableBulkActionItem,
+    defineDashboardExtension,
+    LogoMark,
+    usePage,
+} from '@vendure/dashboard';
 import { InfoIcon } from 'lucide-react';
 import { toast } from 'sonner';
 
@@ -15,6 +21,31 @@ import { reviewDetail } from './review-detail';
 import { reviewList } from './review-list';
 
 defineDashboardExtension({
+    login: {
+        logo: {
+            component: () => (
+                <div className="text-red-500 italic">
+                    <LogoMark className="text-red-500 h-6 w-auto" />
+                </div>
+            ),
+        },
+        afterForm: {
+            component: () => (
+                <div>
+                    <Button variant="secondary" className="w-full">
+                        Login with Vendure ID
+                    </Button>
+                </div>
+            ),
+        },
+        loginImage: {
+            component: () => (
+                <div className="h-full w-full bg-red-500 flex items-center justify-center text-white text-2xl font-bold">
+                    Custom Login Image
+                </div>
+            ),
+        },
+    },
     routes: [reviewList, reviewDetail],
     widgets: [
         {