Jelajahi Sumber

feat(dashboard): Set up api layer with login/logout

Michael Bromley 11 bulan lalu
induk
melakukan
d117c40cc1

File diff ditekan karena terlalu besar
+ 320 - 83
package-lock.json


+ 3 - 6
packages/dashboard/README.md

@@ -1,8 +1,5 @@
-# React + Vite
+# Vendure Dashboard
 
-This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+This is an admin dashboard for managing Vendure applications. It is designed to supersede the existing Admin UI.
 
-Currently, two official plugins are available:
-
-- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
-- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+Current status: early work in progress

+ 5 - 0
packages/dashboard/package.json

@@ -19,9 +19,13 @@
     "@radix-ui/react-slot": "^1.1.2",
     "@radix-ui/react-tooltip": "^1.1.8",
     "@tailwindcss/vite": "^4.0.6",
+    "@tanstack/react-query": "^5.66.7",
     "@tanstack/react-router": "^1.105.0",
+    "@types/node": "^22.13.4",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
+    "gql.tada": "^1.8.10",
+    "graphql-request": "^7.1.2",
     "lucide-react": "^0.475.0",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
@@ -31,6 +35,7 @@
   },
   "devDependencies": {
     "@eslint/js": "^9.19.0",
+    "@tanstack/eslint-plugin-query": "^5.66.1",
     "@tanstack/router-devtools": "^1.105.0",
     "@tanstack/router-plugin": "^1.105.0",
     "@types/react": "^19.0.8",

+ 95 - 32
packages/dashboard/src/auth.tsx

@@ -1,56 +1,119 @@
+import { api } from '@/graphql/api.js';
+import { graphql } from '@/graphql/graphql.js';
+import { useMutation, useQuery } from '@tanstack/react-query';
 import * as React from 'react';
 
-async function sleep(ms: number) {
-    return new Promise(resolve => setTimeout(resolve, ms));
-}
-
 export interface AuthContext {
+    status: 'authenticated' | 'verifying' | 'unauthenticated';
+    authenticationError?: string;
     isAuthenticated: boolean;
-    login: (username: string) => Promise<void>;
-    logout: () => Promise<void>;
+    login: (username: string, password: string, onSuccess?: () => void) => void;
+    logout: (onSuccess?: () => void) => Promise<void>;
     user: string | null;
 }
 
-const AuthContext = React.createContext<AuthContext | null>(null);
-
-const key = 'tanstack.auth.user';
+const LoginMutation = graphql(`
+    mutation Login($username: String!, $password: String!) {
+        login(username: $username, password: $password) {
+            __typename
+            ... on CurrentUser {
+                id
+                identifier
+            }
+            ... on ErrorResult {
+                message
+                errorCode
+            }
+        }
+    }
+`);
 
-function getStoredUser() {
-    return localStorage.getItem(key);
-}
+const LogOutMutation = graphql(`
+    mutation LogOut {
+        logout {
+            success
+        }
+    }
+`);
 
-function setStoredUser(user: string | null) {
-    if (user) {
-        localStorage.setItem(key, user);
-    } else {
-        localStorage.removeItem(key);
+const CurrentUserQuery = graphql(`
+    query CurrentUser {
+        me {
+            id
+            identifier
+        }
     }
-}
+`);
+
+const AuthContext = React.createContext<AuthContext | null>(null);
 
 export function AuthProvider({ children }: { children: React.ReactNode }) {
-    const [user, setUser] = React.useState<string | null>(getStoredUser());
-    const isAuthenticated = !!user;
+    const [status, setStatus] = React.useState<AuthContext['status']>('verifying');
+    const [user, setUser] = React.useState<string | null>(null);
+    const [authenticationError, setAuthenticationError] = React.useState<string | undefined>();
+    const onLoginSuccessFn = React.useRef<() => void>(() => {});
+    const onLogoutSuccessFn = React.useRef<() => void>(() => {});
+    const isAuthenticated = status === 'authenticated';
 
-    const logout = React.useCallback(async () => {
-        await sleep(250);
+    const { data, isLoading } = useQuery({
+        queryKey: ['currentUser'],
+        queryFn: () => api.query(CurrentUserQuery),
+        retry: false,
+    });
 
-        setStoredUser(null);
-        setUser(null);
-    }, []);
+    const loginMutation = useMutation({
+        mutationFn: api.mutate(LoginMutation),
+        onSuccess: async data => {
+            if (data?.login.__typename === 'CurrentUser') {
+                setStatus('authenticated');
+                onLoginSuccessFn.current();
+            } else {
+                setAuthenticationError(data?.login.message);
+                setStatus('unauthenticated');
+            }
+        },
+        onError: error => {
+            setAuthenticationError(error.message);
+            setStatus('unauthenticated');
+        },
+    });
 
-    const login = React.useCallback(async (username: string) => {
-        await sleep(500);
+    const logoutMutation = useMutation({
+        mutationFn: api.mutate(LogOutMutation),
+        onSuccess: async data => {
+            console.log(data);
+            if (data?.logout.success === true) {
+                setStatus('unauthenticated');
+                onLogoutSuccessFn.current();
+            }
+        },
+    });
 
-        setStoredUser(username);
-        setUser(username);
+    const logout = React.useCallback(async (onLogoutSuccess?: () => void) => {
+        logoutMutation.mutate({});
+        onLogoutSuccessFn.current = onLogoutSuccess || (() => {});
     }, []);
 
-    React.useEffect(() => {
-        setUser(getStoredUser());
+    const login = React.useCallback((username: string, password: string, onLoginSuccess?: () => void) => {
+        setStatus('verifying');
+        onLoginSuccessFn.current = onLoginSuccess || (() => {});
+        loginMutation.mutate({ username, password });
     }, []);
 
+    React.useEffect(() => {
+        if (!isLoading) {
+            if (data?.me?.id) {
+                setStatus('authenticated');
+            } else {
+                setStatus('unauthenticated');
+            }
+        } else {
+            setStatus('verifying');
+        }
+    }, [isLoading, data]);
+
     return (
-        <AuthContext.Provider value={{ isAuthenticated, user, login, logout }}>
+        <AuthContext.Provider value={{ isAuthenticated, authenticationError, status, user, login, logout }}>
             {children}
         </AuthContext.Provider>
     );

+ 38 - 48
packages/dashboard/src/components/login-form.tsx

@@ -1,24 +1,34 @@
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert.js';
 import { cn } from '@/lib/utils';
 import { Button } from '@/components/ui/button';
 import { Card, CardContent } from '@/components/ui/card';
 import { Input } from '@/components/ui/input';
 import { Label } from '@/components/ui/label';
+import { AlertCircle, Loader2 } from 'lucide-react';
+import * as React from 'react';
 
 export interface LoginFormProps extends React.ComponentProps<'div'> {
-    onFormSubmit?: (event: React.FormEvent<HTMLFormElement>) => void;
+    loginError?: string;
+    isVerifying?: boolean;
+    onFormSubmit?: (username: string, password: string) => void;
 }
 
-export function LoginForm({ className, onFormSubmit, ...props }: LoginFormProps) {
+export function LoginForm({ className, onFormSubmit, isVerifying, loginError, ...props }: LoginFormProps) {
+    function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
+        e.preventDefault();
+        const data = new FormData(e.currentTarget);
+        const fieldValue = data.get('username');
+        if (!fieldValue) return;
+        const username = fieldValue.toString();
+        const password = data.get('password')?.toString() || '';
+        onFormSubmit?.(username, password);
+    }
+
     return (
         <div className={cn('flex flex-col gap-6', className)} {...props}>
             <Card className="overflow-hidden">
                 <CardContent className="grid p-0 md:grid-cols-2">
-                    <form
-                        className="p-6 md:p-8"
-                        onSubmit={e => {
-                            onFormSubmit?.(e);
-                        }}
-                    >
+                    <form className="p-6 md:p-8" onSubmit={handleSubmit}>
                         <div className="flex flex-col gap-6">
                             <div className="flex flex-col items-center text-center">
                                 <h1 className="text-2xl font-bold">Welcome back</h1>
@@ -27,12 +37,12 @@ export function LoginForm({ className, onFormSubmit, ...props }: LoginFormProps)
                                 </p>
                             </div>
                             <div className="grid gap-3">
-                                <Label htmlFor="email">Email</Label>
+                                <Label htmlFor="email">User</Label>
                                 <Input
-                                    name="email"
-                                    id="email"
-                                    type="email"
-                                    placeholder="m@example.com"
+                                    name="username"
+                                    id="username"
+                                    type="username"
+                                    placeholder="Username or email address"
                                     required
                                 />
                             </div>
@@ -48,43 +58,23 @@ export function LoginForm({ className, onFormSubmit, ...props }: LoginFormProps)
                                 </div>
                                 <Input name="password" id="password" type="password" required />
                             </div>
-                            <Button type="submit" className="w-full">
-                                Login
-                            </Button>
-                            <div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
-                                <span className="bg-background text-muted-foreground relative z-10 px-2">
-                                    Or continue with
-                                </span>
-                            </div>
-                            <div className="grid grid-cols-3 gap-4">
-                                <Button variant="outline" type="button" className="w-full">
-                                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
-                                        <path
-                                            d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
-                                            fill="currentColor"
-                                        />
-                                    </svg>
-                                    <span className="sr-only">Login with Apple</span>
+                            {loginError && (
+                                <Alert variant="destructive">
+                                    <AlertCircle className="h-4 w-4" />
+                                    <AlertTitle>Error</AlertTitle>
+                                    <AlertDescription>{loginError}</AlertDescription>
+                                </Alert>
+                            )}
+                            {isVerifying ? (
+                                <Button disabled>
+                                    <Loader2 className="animate-spin" />
+                                    Please wait
                                 </Button>
-                                <Button variant="outline" type="button" className="w-full">
-                                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
-                                        <path
-                                            d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
-                                            fill="currentColor"
-                                        />
-                                    </svg>
-                                    <span className="sr-only">Login with Google</span>
+                            ) : (
+                                <Button type="submit" className="w-full">
+                                    Login
                                 </Button>
-                                <Button variant="outline" type="button" className="w-full">
-                                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
-                                        <path
-                                            d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"
-                                            fill="currentColor"
-                                        />
-                                    </svg>
-                                    <span className="sr-only">Login with Meta</span>
-                                </Button>
-                            </div>
+                            )}
                             <div className="text-center text-sm">
                                 Don&apos;t have an account?{' '}
                                 <a href="#" className="underline underline-offset-4">

+ 4 - 6
packages/dashboard/src/components/nav-user.tsx

@@ -32,13 +32,11 @@ export function NavUser({
     const auth = useAuth();
 
     const handleLogout = () => {
-        if (window.confirm('Are you sure you want to logout?')) {
-            auth.logout().then(() => {
-                router.invalidate().finally(() => {
-                    navigate({ to: '/' });
-                });
+        auth.logout(() => {
+            router.invalidate().finally(() => {
+                navigate({ to: '/login' });
             });
-        }
+        });
     };
 
     return (

+ 15 - 15
packages/dashboard/src/components/ui/input.tsx

@@ -1,19 +1,19 @@
-import * as React from "react"
+import * as React from 'react';
 
-import { cn } from "@/lib/utils"
+import { cn } from '@/lib/utils';
 
-function Input({ className, type, ...props }: React.ComponentProps<"input">) {
-  return (
-    <input
-      type={type}
-      data-slot="input"
-      className={cn(
-        "border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground aria-invalid:outline-destructive/60 aria-invalid:ring-destructive/20 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/50 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 aria-invalid:outline-destructive/60 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/40 aria-invalid:ring-destructive/20 aria-invalid:border-destructive/60 dark:aria-invalid:border-destructive flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-4 focus-visible:outline-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-[3px] aria-invalid:focus-visible:outline-none md:text-sm dark:aria-invalid:focus-visible:ring-4",
-        className
-      )}
-      {...props}
-    />
-  )
+function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
+    return (
+        <input
+            type={type}
+            data-slot="input"
+            className={cn(
+                'border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground aria-invalid:outline-destructive/60 aria-invalid:ring-destructive/20 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/50 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 aria-invalid:outline-destructive/60 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/40 aria-invalid:ring-destructive/20 aria-invalid:border-destructive/60 dark:aria-invalid:border-destructive flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-4 focus-visible:outline-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-[3px] aria-invalid:focus-visible:outline-none md:text-sm dark:aria-invalid:focus-visible:ring-4',
+                className,
+            )}
+            {...props}
+        />
+    );
 }
 
-export { Input }
+export { Input };

+ 56 - 0
packages/dashboard/src/graphql/api.ts

@@ -0,0 +1,56 @@
+import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
+import { GraphQLClient, RequestDocument } from 'graphql-request';
+import { request as graphqlRequest, Variables } from 'graphql-request';
+
+const API_URL = 'http://localhost:3000/admin-api';
+
+const client = new GraphQLClient(API_URL, {
+    credentials: 'include',
+    mode: 'cors',
+});
+
+export type VariablesAndRequestHeadersArgs<V extends Variables> =
+    V extends Record<any, never>
+        ? [variables?: V, requestHeaders?: HeadersInit]
+        : [variables: V, requestHeaders?: HeadersInit];
+
+function query<T, V extends Variables = Variables>(
+    document: RequestDocument | TypedDocumentNode<T, V>,
+    variables?: V,
+) {
+    return client.request<T>({
+        document,
+        variables,
+    });
+}
+
+function mutate<T, V extends Variables = Variables>(
+    document: RequestDocument | TypedDocumentNode<T, V>,
+): (variables: V) => Promise<T>;
+function mutate<T, V extends Variables = Variables>(
+    document: RequestDocument | TypedDocumentNode<T, V>,
+    variables: V,
+): Promise<T>;
+function mutate<T, V extends Variables = Variables>(
+    document: RequestDocument | TypedDocumentNode<T, V>,
+    maybeVariables?: V,
+) {
+    if (maybeVariables) {
+        return client.request<T>({
+            document,
+            variables: maybeVariables,
+        });
+    } else {
+        return (variables: V): Promise<T> => {
+            return client.request<T>({
+                document,
+                variables,
+            });
+        };
+    }
+}
+
+export const api = {
+    query,
+    mutate,
+};

File diff ditekan karena terlalu besar
+ 53 - 0
packages/dashboard/src/graphql/graphql-env.d.ts


+ 9 - 0
packages/dashboard/src/graphql/graphql.ts

@@ -0,0 +1,9 @@
+import { initGraphQLTada } from 'gql.tada';
+import type { introspection } from './graphql-env.d.ts';
+
+export const graphql = initGraphQLTada<{
+    introspection: introspection;
+}>();
+
+export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada';
+export { readFragment } from 'gql.tada';

+ 9 - 3
packages/dashboard/src/main.tsx

@@ -1,7 +1,9 @@
 import { AuthProvider, useAuth } from '@/auth.js';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import React from 'react';
 import ReactDOM from 'react-dom/client';
 import { RouterProvider, createRouter } from '@tanstack/react-router';
+
 import { routeTree } from './routeTree.gen';
 import './styles.css';
 
@@ -22,6 +24,8 @@ declare module '@tanstack/react-router' {
     }
 }
 
+const queryClient = new QueryClient();
+
 function InnerApp() {
     const auth = useAuth();
     return <RouterProvider router={router} context={{ auth }} />;
@@ -29,9 +33,11 @@ function InnerApp() {
 
 function App() {
     return (
-        <AuthProvider>
-            <InnerApp />
-        </AuthProvider>
+        <QueryClientProvider client={queryClient}>
+            <AuthProvider>
+                <InnerApp />
+            </AuthProvider>
+        </QueryClientProvider>
     );
 }
 

+ 3 - 18
packages/dashboard/src/routes/__root.tsx

@@ -1,18 +1,7 @@
-import { AuthContext, useAuth } from '@/auth.js';
-import { AppSidebar } from '@/components/app-sidebar.js';
-import {
-    Breadcrumb,
-    BreadcrumbItem,
-    BreadcrumbLink,
-    BreadcrumbList,
-    BreadcrumbPage,
-    BreadcrumbSeparator,
-} from '@/components/ui/breadcrumb.js';
-import { Separator } from '@/components/ui/separator.js';
-import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar.js';
-import * as React from 'react';
-import { Link, Outlet, createRootRoute, createRootRouteWithContext } from '@tanstack/react-router';
+import { AuthContext } from '@/auth.js';
+import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
 import { TanStackRouterDevtools } from '@tanstack/router-devtools';
+import * as React from 'react';
 
 interface MyRouterContext {
     auth: AuthContext;
@@ -23,10 +12,6 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
 });
 
 function RootComponent() {
-    // const auth = useAuth();
-    // if (!auth.isAuthenticated) {
-    //     return ;
-    // }
     return (
         <>
             <Outlet />

+ 1 - 17
packages/dashboard/src/routes/_authenticated.tsx

@@ -9,10 +9,8 @@ import {
 } from '@/components/ui/breadcrumb.js';
 import { Separator } from '@/components/ui/separator.js';
 import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar.js';
+import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
 import * as React from 'react';
-import { Link, Outlet, createFileRoute, redirect, useRouter } from '@tanstack/react-router';
-
-import { useAuth } from '../auth.js';
 
 export const Route = createFileRoute('/_authenticated')({
     beforeLoad: ({ context, location }) => {
@@ -29,20 +27,6 @@ export const Route = createFileRoute('/_authenticated')({
 });
 
 function AuthLayout() {
-    const router = useRouter();
-    const navigate = Route.useNavigate();
-    const auth = useAuth();
-
-    const handleLogout = () => {
-        if (window.confirm('Are you sure you want to logout?')) {
-            auth.logout().then(() => {
-                router.invalidate().finally(() => {
-                    navigate({ to: '/' });
-                });
-            });
-        }
-    };
-
     return (
         <SidebarProvider>
             <AppSidebar />

+ 16 - 32
packages/dashboard/src/routes/login.tsx

@@ -1,7 +1,7 @@
 import { useAuth } from '@/auth.js';
 import { LoginForm } from '@/components/login-form';
+import { createFileRoute, Navigate, redirect, useRouterState } from '@tanstack/react-router';
 import * as React from 'react';
-import { createFileRoute, redirect, useRouter, useRouterState } from '@tanstack/react-router';
 import { z } from 'zod';
 
 const fallback = '/dashboard' as const;
@@ -18,48 +18,32 @@ export const Route = createFileRoute('/login')({
     component: LoginPage,
 });
 
-async function sleep(ms: number) {
-    return new Promise(resolve => setTimeout(resolve, ms));
-}
-
 export default function LoginPage() {
     const auth = useAuth();
-    const router = useRouter();
     const isLoading = useRouterState({ select: s => s.isLoading });
     const navigate = Route.useNavigate();
-    const [isSubmitting, setIsSubmitting] = React.useState(false);
-
     const search = Route.useSearch();
 
-    const onFormSubmit = async (evt: React.FormEvent<HTMLFormElement>) => {
-        setIsSubmitting(true);
-        try {
-            evt.preventDefault();
-            const data = new FormData(evt.currentTarget);
-            const fieldValue = data.get('email');
-            if (!fieldValue) return;
-            const username = fieldValue.toString();
-            await auth.login(username);
-
-            await router.invalidate();
-
-            // This is just a hack being used to wait for the auth state to update
-            // in a real app, you'd want to use a more robust solution
-            await sleep(1);
-
-            await navigate({ to: search.redirect || fallback });
-        } catch (error) {
-            console.error('Error logging in: ', error);
-        } finally {
-            setIsSubmitting(false);
-        }
+    const onFormSubmit = (username: string, password: string) => {
+        auth.login(username, password, () => {
+            console.log(`Redirecting to ${search.redirect || fallback}`);
+            navigate({ to: search.redirect || fallback });
+        });
     };
 
-    const isLoggingIn = isLoading || isSubmitting;
+    if (auth.isAuthenticated) {
+        return <Navigate to={search.redirect || fallback} />;
+    }
+
+    const isVerifying = isLoading || auth.status === 'verifying';
     return (
         <div className="flex min-h-svh flex-col items-center justify-center bg-muted p-6 md:p-10">
             <div className="w-full max-w-sm md:max-w-3xl">
-                <LoginForm onFormSubmit={onFormSubmit} />
+                <LoginForm
+                    onFormSubmit={onFormSubmit}
+                    isVerifying={isVerifying}
+                    loginError={auth.authenticationError}
+                />
             </div>
         </div>
     );

+ 8 - 1
packages/dashboard/tsconfig.json

@@ -7,6 +7,13 @@
     "baseUrl": ".",
     "paths": {
       "@/*": ["./src/*"]
-    }
+    },
+    "plugins": [
+      {
+        "name": "gql.tada/ts-plugin",
+        "schema": "http://localhost:3000/admin-api",
+        "tadaOutputLocation": "./src/graphql/graphql-env.d.ts"
+      }
+    ]
   }
 }

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini