Przeglądaj źródła

refactor(dashboard): Simplify login screen and add password visibility toggle (#3863)

David Höck 3 miesięcy temu
rodzic
commit
6c332e783e

+ 0 - 1
package-lock.json

@@ -5080,7 +5080,6 @@
     },
     "node_modules/@clack/prompts/node_modules/is-unicode-supported": {
       "version": "1.3.0",
-      "extraneous": true,
       "inBundle": true,
       "license": "MIT",
       "engines": {

+ 2 - 2
packages/dashboard/src/app/routes/login.tsx

@@ -36,8 +36,8 @@ function LoginPage() {
     const isVerifying = isLoading || auth.status === 'verifying';
 
     return (
-        <div className="flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
-            <div className="w-full max-w-sm md:max-w-4xl">
+        <div className="flex min-h-svh flex-col items-center justify-center p-6 md:p-10 bg-sidebar">
+            <div className="w-full max-w-sm md:max-w-md">
                 <LoginForm
                     onFormSubmit={onFormSubmit}
                     isVerifying={isVerifying}

+ 46 - 123
packages/dashboard/src/lib/components/login/login-form.tsx

@@ -1,31 +1,27 @@
 import { Button } from '@/vdb/components/ui/button.js';
 import { Card, CardContent } from '@/vdb/components/ui/card.js';
 import { Input } from '@/vdb/components/ui/input.js';
-import { Trans } from '@lingui/react/macro';
+import { PasswordInput } from '@/vdb/components/ui/password-input.js';
 import { cn } from '@/vdb/lib/utils.js';
 import { zodResolver } from '@hookform/resolvers/zod';
+import { Trans, useLingui } from '@lingui/react/macro';
 import { Loader2 } from 'lucide-react';
 import * as React from 'react';
 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 { Form, FormControl, FormField, FormItem, FormMessage } from '../ui/form.js';
 import { Separator } from '../ui/separator.js';
 
-export interface LoginFormProps extends React.ComponentProps<'div'> {
-    loginError?: string;
-    isVerifying?: boolean;
-    onFormSubmit?: (username: string, password: string) => void;
-}
-
-export type RemoteLoginImage = {
-    urls: { regular: string };
-    location: { name: string };
-    user: { name: string; links: { html: string } };
-};
+export type LoginFormProps = Readonly<
+    {
+        loginError?: string;
+        isVerifying?: boolean;
+        onFormSubmit?: (username: string, password: string) => void;
+    } & React.ComponentProps<'div'>
+>;
 
 const formSchema = z.object({
     username: z.string().min(1),
@@ -33,16 +29,8 @@ 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) {
-            fetch('https://login-image.vendure.io')
-                .then(res => res.json())
-                .then(data => setRemoteLoginImage(data));
-        }
-    }, []);
+    const { t } = useLingui();
 
     React.useEffect(() => {
         if (loginError && !isVerifying) {
@@ -59,60 +47,40 @@ export function LoginForm({ className, onFormSubmit, isVerifying, loginError, ..
     });
 
     return (
-        <div className={cn('flex flex-col gap-6 bg-sidebar', className)} {...props}>
-            <Card className="overflow-hidden">
-                <CardContent className="grid p-0 md:grid-cols-2">
+        <div className={cn('flex flex-col items-center gap-6', className)} {...props}>
+            {loginExtensions.logo ? (
+                <loginExtensions.logo.component />
+            ) : (
+                <LogoMark className="text-primary h-8 w-auto" />
+            )}
+            <Card className="w-full">
+                <CardContent className="pt-6">
                     <Form {...form}>
                         <form
-                            className="p-6 md:p-8 flex flex-col items-stretch justify-center"
+                            className="flex flex-col items-center gap-6"
                             onSubmit={form.handleSubmit(data => onFormSubmit?.(data.username, data.password))}
                         >
-                            <div className="flex flex-col gap-6">
-                                <div className="flex flex-col items-start  space-y-4">
-                                    {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 className="flex flex-col items-center text-center gap-2">
+                                <h1 className="text-2xl font-semibold tracking-tight">
+                                    <Trans>Welcome to Vendure</Trans>
+                                </h1>
+                                <p className="text-sm text-muted-foreground">
+                                    <Trans>Sign in to access the admin dashboard</Trans>
+                                </p>
+                            </div>
+                            {loginExtensions.beforeForm && (
+                                <div className="w-full">
+                                    <loginExtensions.beforeForm.component />
                                 </div>
+                            )}
+                            <div className="grid gap-4 w-full">
                                 <FormField
                                     control={form.control}
                                     name="username"
                                     render={({ field }) => (
                                         <FormItem>
-                                            <FormLabel htmlFor="email" asChild>
-                                                <Trans>Username</Trans>
-                                            </FormLabel>
                                             <FormControl>
-                                                <Input {...field} placeholder="Username or email address" />
+                                                <Input {...field} placeholder={t`Email`} />
                                             </FormControl>
                                             <FormMessage />
                                         </FormItem>
@@ -123,84 +91,39 @@ export function LoginForm({ className, onFormSubmit, isVerifying, loginError, ..
                                     name="password"
                                     render={({ field }) => (
                                         <FormItem>
-                                            <div className="flex items-center">
-                                                <FormLabel htmlFor="password" asChild>
-                                                    <Trans>Password</Trans>
-                                                </FormLabel>
-                                                <a
-                                                    tabIndex={-1}
-                                                    href="#"
-                                                    className="ml-auto text-sm underline-offset-2 hover:underline"
-                                                >
-                                                    Forgot your password?
-                                                </a>
-                                            </div>
                                             <FormControl>
-                                                <Input {...field} type="password" />
+                                                <PasswordInput {...field} placeholder={t`Password`} />
                                             </FormControl>
                                             <FormMessage />
                                         </FormItem>
                                     )}
                                 />
-                                <Button type="submit" disabled={isVerifying}>
+                                <Button type="submit" className="w-full" disabled={isVerifying}>
                                     {isVerifying && (
                                         <>
                                             <Loader2 className="animate-spin" />
                                             Please wait
                                         </>
                                     )}
-                                    {!isVerifying && <span>Login</span>}
+                                    {!isVerifying && <Trans>Sign in</Trans>}
                                 </Button>
+                                <div className="text-center text-sm">
+                                    <Trans>
+                                        <span className="text-muted-foreground mr-0.5">Forgot password?</span>
+                                        <a tabIndex={-1} href="#" className="text-primary hover:underline">
+                                            Request reset
+                                        </a>
+                                    </Trans>
+                                </div>
                             </div>
                             {loginExtensions.afterForm && (
                                 <>
-                                    <Separator className="w-full my-4" />
-
+                                    <Separator className="w-full" />
                                     <loginExtensions.afterForm.component />
                                 </>
                             )}
                         </form>
                     </Form>
-                    {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={uiConfig.loginImageUrl}
-                                    alt="Login image"
-                                    className="absolute inset-0 h-full w-full object-cover"
-                                />
-                            )}
-                        </div>
-                    )}
                 </CardContent>
             </Card>
         </div>

+ 148 - 0
packages/dashboard/src/lib/components/ui/input-group.tsx

@@ -0,0 +1,148 @@
+import { cva, type VariantProps } from 'class-variance-authority';
+import * as React from 'react';
+
+import { Button } from '@/vdb/components/ui/button.js';
+import { Input } from '@/vdb/components/ui/input.js';
+import { Textarea } from '@/vdb/components/ui/textarea.js';
+import { cn } from '@/vdb/lib/utils.js';
+
+function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
+    return (
+        <div
+            data-slot="input-group"
+            role="group"
+            className={cn(
+                'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
+                'h-9 has-[>textarea]:h-auto',
+
+                // Variants based on alignment.
+                'has-[>[data-align=inline-start]]:[&>input]:pl-2',
+                'has-[>[data-align=inline-end]]:[&>input]:pr-2',
+                'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
+                'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
+
+                // Focus state.
+                'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
+
+                // Error state.
+                'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
+
+                className,
+            )}
+            {...props}
+        />
+    );
+}
+
+const inputGroupAddonVariants = cva(
+    "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
+    {
+        variants: {
+            align: {
+                'inline-start': 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
+                'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
+                'block-start':
+                    'order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5',
+                'block-end':
+                    'order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5',
+            },
+        },
+        defaultVariants: {
+            align: 'inline-start',
+        },
+    },
+);
+
+function InputGroupAddon({
+    className,
+    align = 'inline-start',
+    ...props
+}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
+    return (
+        <div
+            role="group"
+            data-slot="input-group-addon"
+            data-align={align}
+            className={cn(inputGroupAddonVariants({ align }), className)}
+            onClick={e => {
+                if ((e.target as HTMLElement).closest('button')) {
+                    return;
+                }
+                e.currentTarget.parentElement?.querySelector('input')?.focus();
+            }}
+            {...props}
+        />
+    );
+}
+
+const inputGroupButtonVariants = cva('text-sm shadow-none flex gap-2 items-center', {
+    variants: {
+        size: {
+            xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
+            sm: 'h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5',
+            'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
+            'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
+        },
+    },
+    defaultVariants: {
+        size: 'xs',
+    },
+});
+
+function InputGroupButton({
+    className,
+    type = 'button',
+    variant = 'ghost',
+    size = 'xs',
+    ...props
+}: Omit<React.ComponentProps<typeof Button>, 'size'> & VariantProps<typeof inputGroupButtonVariants>) {
+    return (
+        <Button
+            type={type}
+            data-size={size}
+            variant={variant}
+            className={cn(inputGroupButtonVariants({ size }), className)}
+            {...props}
+        />
+    );
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
+    return (
+        <span
+            className={cn(
+                "text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
+                className,
+            )}
+            {...props}
+        />
+    );
+}
+
+function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
+    return (
+        <Input
+            data-slot="input-group-control"
+            className={cn(
+                'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
+                className,
+            )}
+            {...props}
+        />
+    );
+}
+
+function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
+    return (
+        <Textarea
+            data-slot="input-group-control"
+            className={cn(
+                'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
+                className,
+            )}
+            {...props}
+        />
+    );
+}
+
+export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, InputGroupTextarea };

+ 33 - 0
packages/dashboard/src/lib/components/ui/password-input.tsx

@@ -0,0 +1,33 @@
+import { t } from '@lingui/react/macro';
+import { Eye, EyeOff } from 'lucide-react';
+import * as React from 'react';
+
+import {
+    InputGroup,
+    InputGroupAddon,
+    InputGroupButton,
+    InputGroupInput,
+} from './input-group.js';
+
+type PasswordInputProps = Readonly<Omit<React.ComponentProps<'input'>, 'type'>>;
+
+function PasswordInput({ ...props }: PasswordInputProps) {
+    const [showPassword, setShowPassword] = React.useState(false);
+
+    return (
+        <InputGroup>
+            <InputGroupInput type={showPassword ? 'text' : 'password'} {...props} />
+            <InputGroupAddon align="inline-end">
+                <InputGroupButton
+                    size="icon-xs"
+                    onClick={() => setShowPassword(!showPassword)}
+                    aria-label={showPassword ? t`Hide password` : t`Show password`}
+                >
+                    {showPassword ? <EyeOff /> : <Eye />}
+                </InputGroupButton>
+            </InputGroupAddon>
+        </InputGroup>
+    );
+}
+
+export { PasswordInput };

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

@@ -48,22 +48,6 @@ export interface LoginAfterFormExtension {
     component: React.ComponentType;
 }
 
-/**
- * @description
- * Defines a custom login image component that replaces the default image panel.
- *
- * @docsCategory extensions-api
- * @docsPage Login
- * @since 3.4.0
- */
-export interface LoginImageExtension {
-    /**
-     * @description
-     * A React component that will replace the default login image panel.
-     */
-    component: React.ComponentType;
-}
-
 /**
  * @description
  * Defines all available login page extensions.
@@ -89,9 +73,4 @@ export interface DashboardLoginExtensions {
      * Component to render after the login form.
      */
     afterForm?: LoginAfterFormExtension;
-    /**
-     * @description
-     * Custom login image component to replace the default image panel.
-     */
-    loginImage?: LoginImageExtension;
 }

Plik diff jest za duży
+ 5 - 1
packages/dev-server/graphql/graphql-env.d.ts


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

@@ -1,10 +1,4 @@
-import {
-    Button,
-    DataTableBulkActionItem,
-    defineDashboardExtension,
-    LogoMark,
-    usePage,
-} from '@vendure/dashboard';
+import { Button, DataTableBulkActionItem, defineDashboardExtension, usePage } from '@vendure/dashboard';
 import { InfoIcon } from 'lucide-react';
 import { toast } from 'sonner';
 
@@ -24,13 +18,6 @@ import { routeWithoutAuth } from './route-without-auth';
 
 defineDashboardExtension({
     login: {
-        logo: {
-            component: () => (
-                <div className="text-red-500 italic">
-                    <LogoMark className="text-red-500 h-6 w-auto" />
-                </div>
-            ),
-        },
         afterForm: {
             component: () => (
                 <div>
@@ -40,13 +27,6 @@ defineDashboardExtension({
                 </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, routeWithoutAuth],
     widgets: [

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików