以WooGraphQL建造无头商店:第五章第四章

banner

抬头: 此教程涵盖较早的 WooGraphQL 发布, 并且不再更新 。 重写版本正在展开,

https://www.youtube.com/embed/nhlexP-KtSo (中文(简体) ).

目录

  1. 为您的无头电子商务应用程序设置文字通讯
  2. 创建和设置您的电子商务应用促进发展
  3. 使用 WooGraphQL 创建商店列表页面
  4. 创建用户登录和导航
  5. 创建产品页面和墨盒选项

欢迎来到我们教程系列的下一个分期, 在那里,我们将丰富我们的eCommerce商店 通过包含用户导航和登录页面。 本章的内容是增强用户界面和互动能力,从而创造出更具接触性和无缝用户体验.

创建用户登录和导航

eCommerce网站的核心功能之一是用户能够浏览网站的不同部分,并有能力登录和管理其账户。 顶端通航栏作为通航的中心枢纽,使用户能够方便而高效地访问网站的各个部分. 在顶端导航栏上添加用户导航不仅会提高可用性,而且会帮助用户无心地访问其账户和推车细节.

此外,我们将带领您通过在您的eCommerce商店中添加登录页面的过程. 一个设计良好的登录页面对于任何在线商店都至关重要,因为它是用户体验个性化的网关,允许用户跟踪订单,保存自己最喜爱的产品,并加快取出过程.

为了让这笔交易更贴切,我们将探索一个能节省你时间和精力的巧妙伎俩. 我们会告诉你如何利用会话的下放链接 通过终端用户的会话 从我们的Next.js应用程序到WordPress的安装 托管我们的WooCommerce商店。 这种方法的好处是,它使我们能够绕过在我们的应用程序中创建某些网页的需要,特别是那些可能花费时间来构建的网页,或者那些依赖于我们前端应用程序中不易提供的功能的网页.

先决条件

在开始之前,我们应该更新我们 .env.local 中包含一些对本章至关重要的变量。

SESSION_TOKEN_LS_KEY=[SESSION_TOKEN_LS_KEY]
REFRESH_TOKEN_LS_KEY=[REFRESH_TOKEN_LS_KEY]
AUTH_TOKEN_SS_KEY=[AUTH_TOKEN_SS_KEY]
AUTH_TOKEN_EXPIRY_SS_KEY=[AUTH_TOKEN_EXPIRY_SS_KEY]
CLIENT_CREDENTIALS_LS_KEY=[CLIENT_CREDENTIALS_LS_KEY]
CLIENT_SESSION_SS_KEY=[CLIENT_SESSION_SS_KEY]
CLIENT_SESSION_EXP_SS_KEY=[CLIENT_SESSION_EXP_SS_KEY]
NONCE_KEY=[NONCE_KEY]
NONCE_SALT=[NONCE_SALT]

值可以是随机的,但 NONCE_KEYNONCE_SALT 必须在 WordPress 中匹配它们的等同值。 您通常可以通过检查 WordPress 安装的值来找到此值 wp-config.php 文档。 记得更新你的 next.config.js 还有

...

const nextConfig = {
  ...
  
  },
  env: {
    ...
    SESSION_TOKEN_LS_KEY: process.env.SESSION_TOKEN_LS_KEY,
    REFRESH_TOKEN_LS_KEY: process.env.REFRESH_TOKEN_LS_KEY,
    AUTH_TOKEN_SS_KEY: process.env.AUTH_TOKEN_SS_KEY,
    AUTH_TOKEN_EXP_SS_KEY: process.env.AUTH_TOKEN_EXP_SS_KEY,
    CLIENT_CREDENTIALS_LS_KEY: process.env.CLIENT_CREDENTIALS_LS_KEY,
    CLIENT_SESSION_SS_KEY: process.env.CLIENT_SESSION_SS_KEY,
    CLIENT_SESSION_EXP_SS_KEY: process.env.CLIENT_SESSION_EXP_SS_KEY,
    NONCE_KEY: process.env.NONCE_KEY,
    NONCE_SALT: process.env.NONCE_SALT,
  },
}
...

到本教程结束后,您将获得改善用户导航的知识,加入一个方便用户的登录页面,并实现会话下放链接,将您的eCommerce商店带入下一层用户交互和功能. 让我们潜入并开始!

第一部分 创建登录页面和用户Nav

1. 联合国 安装 zod, (中文(简体) ). react-hook-form,以及 @hookform/resolvers 软件包 :

npm install zod react-hook-form @hookform/resolvers

我们将使用这个软件包来管理和验证登录表.

2. 联合国 创建 /app/login/page.tsx数字 :

// 应用/登录/page.tsx
import { Login } from '@/client/Login';

export default function LoginPage() {
  return (
    <div className="min-h-screen">
      <Login/>
    </div>
  );
}

很简单 前进

3个 创建 /client/Login数字 :

// 客户端/ Login/ Login.tsx
import { useEffect } from 'react';
import * as z from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';

import { useSession } from '@/client/SessionProvider';
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/ui/form';
import { Input } from '@/ui/input';
import { Button } from '@/ui/button';
import { LoadingSpinner } from '@/ui/LoadingSpinner';

export const LoginSchema = z.object({
  username: z.string().min(4, {
    message: 'Username must be at least 4 characters long',
  }),
  password: z.string().min(1, {
    message: 'Password must enter a password',
  })
});

export function Login() {
  const { login, isAuthenticated, fetching } = useSession();
  const router = useRouter();
  const form = useForm<z.infer<typeof LoginSchema>>({
    resolver: zodResolver(LoginSchema),
    defaultValues: {
      username: '',
      password: '',
    },
  });

  useEffect(() => {
    if (isAuthenticated) {
      router.push('/');
    }
  }, [isAuthenticated]);

  const onSubmit = (data: z.infer<typeof LoginSchema>) => {
    login(data.username, data.password);
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 w-full max-w-screen-lg mx-auto px-4">
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder="Enter your username or e-mail associate with your account." {...field}/>
              </FormControl>
              <FormMessage/>
            </FormItem>
          )}
/>
        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Password</FormLabel>
              <FormControl>
                <Input type="password" placeholder="Enter your password." {...field}/>
              </FormControl>
              <FormMessage/>
            </FormItem>
          )}
/>
        <Button
          type="submit"
          disabled={fetching}
          className="flex gap-x-2 items-center"
>
          Submit
          {fetching && <LoadingSpinner noText/>}
        </Button>
      </form>
    </Form>
  );
}

如果你熟悉的话 react-hook-form 这对你来说不应该是不正常的 我想说的是

const { login, isAuthenticated, fetching } = useSession();

useSession 是钩子 SessionProvider 我们还没有执行, 所以不要指望 这样做马上。 我们很快就会搞定的

4.四. 创建 /client/UserNav数字 :

import { useRouter } from 'next/navigation';

import { cn } from '@/utils/ui';
import { deleteClientSessionId } from '@woographql/utils/client';
import { useSession } from '@/client/SessionProvider';
import { NavLink, linkClassName } from '@/ui/NavLink';
import { Button } from '@/ui/button';

export function UserNav() {
  const { push } = useRouter();
  const {
    cart,
    customer,
    cartUrl,
    checkoutUrl,
    accountUrl,
    logout: killSession,
    isAuthenticated,
    fetching,
  } = useSession();
  
  const goToCartPage = () => {
    deleteClientSessionId();
    console.log('Go to cart page');
    window.location.href = cartUrl;
  };
  const goToCheckoutPage = () => {
    deleteClientSessionId();
    window.location.href = checkoutUrl;
  };
  const goToAccountPage = () => {
    deleteClientSessionId();
    window.location.href = accountUrl;
  };

  const logout = () => {
    killSession(`Goodbye, ${customer?.firstName}`);
  };

  return (
    <>
      <li className="group">
        <Button
          className={cn(
            'flex flex-row gap-x-2 items-center p-0 hover:no-underline text-base font-normal',
            linkClassName,
          )}
          disabled={fetching}
          variant='link'
          onClick={goToCartPage}
>
          <span>{cart?.contents?.itemCount || 0}</span>
          Cart
        </Button>
      </li>
      <li className="group w-auto">
        <Button
          className={cn(
            'p-0 hover:no-underline text-base font-normal',
            linkClassName,
          )}
          disabled={fetching}
          variant='link'
          onClick={goToCheckoutPage}
>
          Checkout
        </Button>
      </li>
      {isAuthenticated ? (
        <>
          <li className="group">
            <Button
              className={cn(
                'p-0 hover:no-underline text-base font-normal',
                linkClassName,
              )}
              disabled={fetching}
              variant='link'
              onClick={goToAccountPage}
>
              Account
            </Button>
          </li>
          <li className="group">
            <Button
              className={cn(
                'p-0 hover:no-underline text-base font-normal',
                linkClassName,
              )}
              disabled={fetching}
              variant='link'
              onClick={logout}
>
              Logout
            </Button>
          </li>
        </>
      ) : (
        <li className="group">
          <NavLink href="/login">
            Login
          </NavLink>
        </li>
      )}
    </>
  )
}

这个 UserNav 组件负责显示基于从 SessionProvider。 。 。 。

const {
    cart, // Cart data object.
    customer, // Customer data object.
    goToCartPage, // Callback for sending the user to the Cart Page.
    goToAccountPage, // Callback for sending the user to the Account Page.
    goToCheckoutPage, // Callback for sending the user to the Checkout Page.
    logout,  // Callback for deleting end-user's session.
    isAuthenticated, // Flag determining end-user's login status
    refetchUrls, // Callback to begin process of generating new session drop-off urls.
    fetching, // Flag determining session handler fetcher status.
} = useSession();

我们再加这个 /server/TopNav 最后移动到 SessionProvider。 。 。 。

5 (韩语). 更新 /server/TopNav/TopNav.tsx数字 :

// 服务器/TopNav/TopNav.tsx
...

import { UserNav } from '@/client/UserNav';
...

export function TopNav({ menu }: TopNavProps) {
  return (
    <nav className="w-full bg-white min-h-24 py-4 px-4">
      <ul className="max-w-screen-lg m-auto w-full flex flex-row gap-x-4 justify-end items-center">
        ...
        <UserNav/>
      </ul>
    </nav>
  )
}

第二部分:创建会话

1. 联合国 创建 /client/SessionProvider数字 :

// 客户端/会议提供/会议提供.tsx
import {
  createContext,
  PropsWithChildren,
  useContext,
  useReducer,
  useEffect,
} from 'react';

import {
  Customer,
  Cart,
} from '@/graphql';

import { useToast } from '@/ui/use-toast';

export interface SessionContext {
  isAuthenticated: boolean;
  hasCredentials: boolean;
  cart: Cart|null;
  customer: Customer|null;
  cartUrl: string;
  checkoutUrl: string;
  accountUrl: string;
  fetching: boolean;
  logout: (message?: string) => void;
  login: (username: string, password: string, successMessage?: string) => Promise<boolean>;
  refetch: () => Promise<boolean>;
}

const initialContext: SessionContext = {
  isAuthenticated: false,
  hasCredentials: false,
  cart: null,
  customer: null,
  cartUrl: '',
  checkoutUrl: '',
  accountUrl: '',
  fetching: false,
  logout: (message?: string) => null,
  login: (username: string, password: string) => new Promise((resolve) => { resolve(false); }),
  refetch: () => new Promise((resolve) => { resolve(false); }),
};

export const sessionContext = createContext<SessionContext>(initialContext);

type SessionAction = {
  type: 'UPDATE_STATE';
  payload: SessionContext;
} | {
  type: 'LOGOUT';
};

const reducer = (state: SessionContext, action: SessionAction): SessionContext => {
  switch (action.type) {
    case 'UPDATE_STATE':
      return {
        ...state,
        ...action.payload,
      };
    case 'LOGOUT':
      return {
        ...state,
        customer: null,
        cart: null,
      };
    default:
      throw new Error('Invalid action dispatched to session data reducer');
  }
};

const { Provider } = sessionContext;

export function SessionProvider({ children }: PropsWithChildren) {
  const { toast } = useToast();
  const [state, dispatch] = useReducer(reducer, initialContext);

  // 删除登录状态和本地的证书存储 。
  const logout = () => {
    dispatch({ type: 'LOGOUT' });
  };
      

  const store: SessionContext = {
    ...state,
    isAuthenticated: !!state.customer?.id && 'guest' !== state.customer.id,
    hasCredentials: false,
    logout,
  };
  return (
    <Provider value={store}>{children}</Provider>
  );
}

export const useSession = () => useContext(sessionContext);

现在把它加进去 /app/layout.tsx

2. 联合国 更新 /app/layout.tsx数字 :

// 应用程序/layout.tsx
...

import { SessionProvider } from '@/client/SessionProvider';
...

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  ...

  return (
    <html lang="en">
      <body>
        <SessionProvider>
          <TopNav menu={menu}/>
          <main className="w-full">
            {children}
          </main>
        </SessionProvider>
        <Toaster/>
      </body>
    </html>
  )
}

有了这个应用程序,将成功构建和运行,但是我们引入的组件都没有实际工作. 那些有敏锐眼光的人会注意到,我们还没有执行任何逻辑来与我们的终点进行交流.

这是因为,在处理最终用户的安全问题时,需要对会议全权证书进行精密管理并限制GraphQL终点的能见度。 需要注意的是,尽管至今已经创建了各种功能,但是在显示GraphQL端点的活应用程序中还是有动作发生. 别相信 打开您的浏览器开发工具并检查 Network 选项卡。 刷新页面并单击周围, 做任何事情, 但是您不会看到对您的 GraphQL 端点的可见请求 。

直到现在为止,所有的GraphQL请求都是在服务器级别上提出的,只在页面构建时间的每次运行中,但我们必须提出一些客户端请求,以应对终端用户的会话,如果我们直接运行GraphQL查询到客户端,就会暴露端点.

所以我们不会在客户端上运行它们 而是利用下13路的网页 我们将制作几个路由页面, 以运行客户端的 GraphQL 请求 。 在此之前,我们将需要创建一些效用文件,以定义与这些路由页面沟通的逻辑,并管理时间敏感的会话权限. 我们开始吧

第3部分:创建工具文件.

1. 联合国 安装 crypto-jsjwt-decode 软件包 :

npm install crypto-js jwt-decode

这些库可以重新创建WordPress的散列功能.

2. 联合国 创建 /utils/nonce.ts数字 :

// 倾斜/非倾斜
import HmacMD5 from 'crypto-js/hmac-md5';
import jwtDecode from 'jwt-decode';

export const MINUTE_IN_SECONDS = 60;
export const HOUR_IN_SECONDS = 60 * MINUTE_IN_SECONDS;
export const DAY_IN_SECONDS = 24 * HOUR_IN_SECONDS;

export function time() {
  return Math.floor(new Date().getTime() / 1000);
}

export function nonceTick() {
  const nonceLife = DAY_IN_SECONDS;
  return Math.ceil(time() / (nonceLife / 2));
}

export function wpNonceHash(data: string) {
  const nonceSalt = process.env.NONCE_KEY as string + process.env.NONCE_SALT as string;
  const hash = HmacMD5(data, nonceSalt).toString();

  return hash;
}

export function createNonce(action: string, uId:string|number, token:string) {
  const i = nonceTick();

  const nonce = wpNonceHash(`${i}|${action}|${uId}|${token}`).slice(-12, -2);

  return nonce;
}

export enum ActionTypes {
  Cart = 'cart',
  Checkout = 'checkout',
  Account = 'account',
}

function getAction(action: ActionTypes, uId: string|number) {
  switch (action) {
    case ActionTypes.Cart:
      return `load-cart_${uId}`;
    case ActionTypes.Checkout:
      return `load-checkout_${uId}`;
    case ActionTypes.Account:
      return `load-account_${uId}`;
    default:
      throw new Error('Invalid nonce action provided.');
  }
}

function getNonceParam(action: ActionTypes) {
  switch (action) {
    case ActionTypes.Cart:
      return '_wc_cart';
    case ActionTypes.Checkout:
      return '_wc_checkout';
    case ActionTypes.Account:
      return '_wc_account';
    default:
      throw new Error('Invalid nonce action provided.');
  }
}

type DecodedToken = {
  data: { customer_id: string };
}
export function getUidFromToken(sessionToken: string) {
  const decodedToken = jwtDecode<DecodedToken>(sessionToken);
  if (!decodedToken?.data?.customer_id) {
    throw new Error('Failed to decode session token');
  }
  return decodedToken.data.customer_id;
}

export function generateUrl(sessionToken:string, clientSessionId:string, actionType: ActionTypes) {
  const uId = getUidFromToken(sessionToken);
  const action = getAction(actionType, uId);

  // 创建非ce
  const nonce = createNonce(action, uId, clientSessionId);

  // 创建 URL.
  const param = getNonceParam(actionType);
  let url = `${process.env.BACKEND_URL}/wp/transfer-session?session_id=${uId}&${param}=${nonce}`;

  return url;
}

我不打算在文件上谈很多细节 但主要是JS的克隆人 几个PHP/WP的功能/constants 至于我们 session 我们关心的公用事业 time 函数和 MINUTE_IN_SECONDS 常数。 前进

3个 创建 /utils/client.ts数字 :

// 用户/客户
import {
  wpNonceHash,
  time,
  HOUR_IN_SECONDS,
  MINUTE_IN_SECONDS,
  DAY_IN_SECONDS,
} from '@/utils/nonce';

type Creds = {
  userAgent: string;
  ip: string;
  issued: number;
}

async function createClientSessionId() {
  const encodedCredentials = localStorage.getItem(process.env.CLIENT_CREDENTIALS_LS_KEY as string);
  let credentials: null|Creds = encodedCredentials ? JSON.parse(encodedCredentials) : null;
  if (!credentials || time()> credentials.issued + (14 * DAY_IN_SECONDS)) {
    // 与用户代理创建证书对象 。
    credentials = {
      userAgent: window?.navigator?.userAgent || '',
      ip: '',
      issued: 0,
    };

    // 获取IP。
    const response = await fetch('https://api.ipify.org/?format=json');
    const { data } = await response.json();
    credentials.ip = data?.ip || '';
  }

  // 更新时间戳,以确保每次生成新的非切
  // 终端用户启动该应用程序。
  credentials.issued = time();
  localStorage.setItem(process.env.CLIENT_CREDENTIALS_LS_KEY as string, JSON.stringify(credentials));

  // 生成客户端会话编号 。
  const clientSessionId = wpNonceHash(JSON.stringify(credentials));
  const timeout = `${credentials.issued + HOUR_IN_SECONDS}`;

  // 保存客户端会话编号 。
  sessionStorage.setItem(process.env.CLIENT_SESSION_SS_KEY as string, clientSessionId);
  sessionStorage.setItem(process.env.CLIENT_SESSION_EXP_SS_KEY as string, timeout);

  // 返回客户端会话编号 。
  return { clientSessionId, timeout };
}

function hasSessionToken() {
  const sessionToken = localStorage.getItem(process.env.SESSION_TOKEN_LS_KEY as string);

  return !!sessionToken;
}

let clientSetter: ReturnType<typeof setInterval>;

/**
 * Creates timed fetcher for renewing client credentials and client session id.
 * 
 * @returns {void}
 */
function setClientFetcher() {
  if (clientSetter) {
    clearInterval(clientSetter);
  }
  clientSetter = setInterval(
    async () => {
      if (!hasSessionToken()) {
        clearInterval(clientSetter);
        return;
      }
      createClientSessionId();
    },
    Number(45 * MINUTE_IN_SECONDS),
  );
}

export async function getClientSessionId() {
  let clientSessionId = sessionStorage.getItem(process.env.CLIENT_SESSION_SS_KEY as string);
  let timeout = sessionStorage.getItem(process.env.CLIENT_SESSION_EXP_SS_KEY as string);
  if (!clientSessionId || !timeout || time()> Number(timeout)) {
    ({ clientSessionId, timeout } = await createClientSessionId());
    setClientFetcher();
  }

  return { clientSessionId, timeout };
}

export function deleteClientSessionId() {
  if (clientSetter) {
    clearInterval(clientSetter);
  }
  sessionStorage.removeItem(process.env.CLIENT_SESSION_SS_KEY as string);
  sessionStorage.removeItem(process.env.CLIENT_SESSION_EXP_SS_KEY as string);
}

export function deleteClientCredentials() {
  deleteClientSessionId();
  localStorage.removeItem(process.env.CLIENT_CREDENTIALS_LS_KEY as string);
}

这里的逻辑是建立 clientSessionId。这是为了将限制会话下放链接的使用与最终用户的机器连接起来。 我们会有更多关于它的用法 我们创建路线页。 现在需要注意的一件事是,它具有时间敏感性,这里的逻辑大多是致力于它的评估和更新.

4.四. 创建 /utils/session.ts数字 :

import { GraphQLError } from 'graphql/error';

import {
  Customer,
  Cart,
} from '@/graphql';
import { getClientSessionId } from '@/utils/client';
import { MINUTE_IN_SECONDS, time } from '@/utils/nonce';

type ResponseErrors = {
  errors?: {
    message: string;
    data?: unknown;
  }
}
async function apiCall<T>(url: string, input: globalThis.RequestInit) {
  const response = await fetch(
    url,
    {
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
      ...input,
    },
  );

  const json: T&ResponseErrors = await response.json();

  // 捕捉错误。
  if (json?.errors || response.status !== 200) {
    throw new Error(json.errors?.message || `Failed to fetch: ${url}`);
  }

  return json;
}

// 认证管理.
function saveCredentials(authToken: string, sessionToken?: string, refreshToken?: string) {
  sessionStorage.setItem(process.env.AUTH_TOKEN_SS_KEY as string, authToken);
  if (!!sessionToken) {
    localStorage.setItem(process.env.SESSION_TOKEN_LS_KEY as string, sessionToken);
  }
  if (refreshToken) {
    localStorage.setItem(process.env.REFRESH_TOKEN_LS_KEY as string, refreshToken);
  }
}

function saveSessionToken(sessionToken: string) {
  localStorage.setItem(process.env.SESSION_TOKEN_LS_KEY as string, sessionToken);
}

export function hasCredentials() {
  const sessionToken = localStorage.getItem(process.env.SESSION_TOKEN_LS_KEY as string);
  const authToken = sessionStorage.getItem(process.env.AUTH_TOKEN_SS_KEY as string);
  const refreshToken = localStorage.getItem(process.env.REFRESH_TOKEN_LS_KEY as string);

  if (!!sessionToken && !!authToken && !!refreshToken) {
    return true;
  }

  return false;
}

function setAuthTokenExpiry() {
  const authTimeout = time() + (15 * MINUTE_IN_SECONDS);
  sessionStorage.setItem(process.env.AUTH_TOKEN_EXPIRY_SS_KEY as string, `${authTimeout}`);
}

function authTokenIsExpired() {
  const authTimeout = sessionStorage.getItem(process.env.AUTH_TOKEN_EXPIRY_SS_KEY as string);
  if (!authTimeout || Number(authTimeout) < time()) {
    return true;
  }
}

type FetchAuthTokenResponse = {
  authToken: string;
  sessionToken: string;
}

async function fetchAuthToken() {
  const refreshToken = localStorage.getItem(process.env.REFRESH_TOKEN_LS_KEY as string);
  if (!refreshToken) {
    // eslint-disable-next-line 无控制台
    isDev() && console.error('Unauthorized');
    return null;
  }

  const json = await apiCall<FetchAuthTokenResponse>(
    '/api/auth',
    {
      method: 'POST',
      body: JSON.stringify({ refreshToken }),
    },
  );

  const { authToken, sessionToken } = json;
  saveCredentials(authToken, sessionToken);
  setAuthTokenExpiry();

  return authToken;
}

let tokenSetter: ReturnType<typeof setInterval>;
function setAutoFetcher() {
  if (tokenSetter) {
    clearInterval(tokenSetter);
  }
  tokenSetter = setInterval(
    async () => {
      if (!hasCredentials()) {
        clearInterval(tokenSetter);
        return;
      }
      fetchAuthToken();
    },
    Number(process.env.AUTH_KEY_TIMEOUT || 30000),
  );
}

type LoginResponse = {
  authToken: string
  refreshToken: string;
  sessionToken: string;
}
export async function login(username: string, password: string): Promise<boolean|string> {
  let json: LoginResponse;
  try {
    json = await apiCall<LoginResponse>(
      '/api/login',
      {
        method: 'POST',
        body: JSON.stringify({ username, password }),
      },
    );
  } catch (error) {
    return (error as GraphQLError)?.message || error as string;
  }

  const { authToken, refreshToken, sessionToken } = json;
  saveCredentials(authToken, sessionToken, refreshToken);
  setAutoFetcher();

  return true;
}

export async function getAuthToken() {
  let authToken = sessionStorage.getItem(process.env.AUTH_TOKEN_SS_KEY as string);
  if (!authToken || authTokenIsExpired()) {
    authToken = await fetchAuthToken();
  }

  if (authToken && !tokenSetter) {
    setAutoFetcher();
  }
  return authToken;
}

type FetchSessionTokenResponse = {
  sessionToken: string;
}

async function fetchSessionToken() {
  const json = await apiCall<FetchSessionTokenResponse>(
    '/api/auth',
    { method: 'GET' },
  );

  const { sessionToken } = json;

  sessionToken && saveSessionToken(sessionToken);
  return sessionToken;
}

async function getSessionToken() {
  let sessionToken = localStorage.getItem(process.env.SESSION_TOKEN_LS_KEY as string);
  if (!sessionToken) {
    sessionToken = await fetchSessionToken();
  }
  return sessionToken;
}

export function hasRefreshToken() {
  const refreshToken = localStorage.getItem(process.env.REFRESH_TOKEN_LS_KEY as string);

  return !!refreshToken;
}

export function hasAuthToken() {
  const authToken = sessionStorage.getItem(process.env.AUTH_TOKEN_SS_KEY as string);

  return !!authToken;
}

export type FetchSessionResponse = {
  customer: Customer;
  cart: Cart;
}
export async function getSession(): Promise<FetchSessionResponse|string> {
  const authToken = await getAuthToken();
  const sessionToken = await getSessionToken();
  let json: FetchSessionResponse;
  try {
    json = await apiCall<FetchSessionResponse>(
      '/api/session',
      {
        method: 'POST',
        body: JSON.stringify({
          sessionToken,
          authToken,
        }),
      },
    );
  } catch (error) {
    return (error as GraphQLError)?.message || error as string;
  }

  const { customer } = json;
  saveSessionToken(customer.sessionToken as string);

  return json;
}



export type FetchAuthURLResponse = {
  cartUrl: string;
  checkoutUrl: string;
  accountUrl: string
}
export async function fetchAuthURLs(): Promise<FetchAuthURLResponse|string> {
  const authToken = await getAuthToken();
  const sessionToken = await getSessionToken();
  const { clientSessionId, timeout } = await getClientSessionId();
  let json: FetchAuthURLResponse;
  try {
    json = await apiCall<FetchAuthURLResponse>(
      '/api/nonce',
      {
        method: 'POST',
        body: JSON.stringify({
          sessionToken,
          authToken,
          clientSessionId,
          timeout,
        }),
      },
    );
  } catch (error) {
    return (error as GraphQLError)?.message || error as string;
  }

  return json;
}

export function deleteCredentials() {
  if (tokenSetter) {
    clearInterval(tokenSetter);
  }
  localStorage.removeItem(process.env.SESSION_TOKEN_LS_KEY as string);
  sessionStorage.removeItem(process.env.AUTH_TOKEN_SS_KEY as string);
  localStorage.removeItem(process.env.REFRESH_TOKEN_LS_KEY as string);
}

虽然它有点牛肉 这相当直的向前。 login, (中文(简体) ). getSession,以及 fetchAuthURLs 是我们用回调 SessionProvider。 。 。 。

  • login: 向 /api/login 与最终用户的路径 usernamepassword 输入,如果成功和 authTokenrefreshToken 已返回。
  • getSession:首先从用户获取最终用户的会话全权证书。 localStorage 然后发送 POST 请求 /api/session 与最终用户的 authTokensessionToken 用来确认身份。
    • 如果最终用户有 refreshToken 无效 authToken 新建 authToken 通过将 POST 请求发送给 /api/authfetchAuthToken, (中文(简体) ).
    • 如果最终用户没有新的身份证明 sessionToken 通过发送 GET 请求到 /api/authfetchSessionToken
  • fetchAuthURLs: 工作完全一样 getSession 除非它发出 clientSessionIdclientSessionIdTimeout 以及会话下放 URL 生成。
    • 如果最终用户的无效 clientSessionId,生成了一个新的。

创建了所有的工具文件 。 让我们转到路面页面。

第4部分:创建路由页面

1. 联合国 创建 app/api/auth/route.ts数字 :

// 应用/api/auth/route.ts
import { NextResponse } from 'next/server';
import { print } from 'graphql';

import {
  GetSessionDocument,
  GetSessionQuery,
  RefreshAuthTokenDocument,
  RefreshAuthTokenMutation,
  getClient,
} from '@/graphql';

export async function GET(request: Request) {
  try {
    const client = getClient();

    const { data, headers } = await client.rawRequest<GetSessionQuery>(
      print(GetSessionDocument),
    );

    const cart = data?.cart;
    const customer = data?.customer;
    const sessionToken = headers.get('woocommerce-session');

    if (!cart || !customer || !sessionToken) {
      return NextResponse.json({ errors: { message: 'Failed to retrieve session credentials.' } }, { status: 500 });
    }

    return NextResponse.json({ sessionToken });
  } catch (err) {
    console.log(err);
    return NextResponse.json({ errors: { message: 'Sorry, something went wrong' } }, { status: 500 });
  }
}

export async function POST(request: Request) {
  try {
    const client = getClient();

    const body = await request.json();
    let authToken = body.authToken;
    const refreshToken = body.refreshToken;
    if (!authToken && !refreshToken) {
      return NextResponse.json({ errors: { message: 'No refresh token provided' } }, { status: 500 });
    }

    if (!authToken && refreshToken) {
      client.setHeaders({});
      const results = await client.request<RefreshAuthTokenMutation>(
        RefreshAuthTokenDocument,
        { refreshToken },
      );

      authToken = results?.refreshJwtAuthToken?.authToken;

      if (!authToken) {
        return NextResponse.json({ errors: { message: 'Failed to retrieve auth token.' } }, { status: 500 });
      }
    }

    client.setHeaders({ Authorization: `Bearer ${authToken}` });
    const { data: cartData, headers } = await client.rawRequest<GetSessionQuery>(
      print(GetSessionDocument),
    );

    const newSessionToken = cartData?.customer?.sessionToken;
    if (!newSessionToken) {
      return NextResponse.json({ errors: { message: 'Failed to validate auth token.' } }, { status: 500 });
    }

    const sessionToken = headers.get('woocommerce-session') || newSessionToken;

    return NextResponse.json({ authToken, sessionToken });
  } catch (err) {
    console.log(err);
    return NextResponse.json({ errors: { message: 'Sorry, something went wrong' } }, { status: 500 });
  }
}

虽然这条路线都有一条 GETPOST 召回,两个都尝试 同一个目标只是在不同的情况下。

  • POST 假设用户是返回的用户,其中之一为 refreshToken 或者说 authToken 如果成功返回,则返回 authTokensessionToken。 。 。 。
  • GET 假设用户是新人且没有证书,并返回新的 sessionToken 为来宾用户。

2. 联合国 创建 /app/api/login/route.ts数字 :

import { NextResponse } from 'next/server';
import { print } from 'graphql';

import {
  GetSessionDocument,
  GetSessionQuery,
  LoginDocument,
  LoginMutation,
  getClient,
} from '@/graphql';

type RequestBody = ({
  auth: string;
  username: undefined;
  password: undefined;
} | {
  auth: undefined;
  username: string;
  password: string;
})

export async function POST(request: Request) {
  try {
    const { username, password } = await request.json() as RequestBody;
    const graphQLClient = getClient();

    if (!username || !password) {
      return NextResponse.json({
        errors: {
          message: 'Proper credential must be provided for authentication',
        },
      }, { status: 500 });
    }

    const data = await graphQLClient.request<LoginMutation>(LoginDocument, { username, password });
    if (!data?.login) {
      return NextResponse.json({ errors: { message: 'Login failed.' } }, { status: 500 });
    }

    const { authToken, refreshToken } = data?.login;
    if (!authToken || !refreshToken) {
      return NextResponse.json({ errors: { message: 'Failed to retrieve credentials.' } }, { status: 500 });
    }

    graphQLClient.setHeader('Authorization', `Bearer ${data.login.authToken}`);
    const { data:_, headers } = await graphQLClient.rawRequest<GetSessionQuery>(print(GetSessionDocument));
    
    const sessionToken = headers.get('woocommerce-session');
    if (!sessionToken) {
      return NextResponse.json({ errors: { message: 'Failed to retrieve session token.' } }, { status: 500 });
    }


    return NextResponse.json({ authToken, refreshToken, sessionToken });
  } catch (err) {
    console.log(err);
    return NextResponse.json({ errors: { message: 'Login credentials invalid.' } }, { status: 500 });
  }
}

这个与上个相似,但比较简单. 这需要一个 usernamepassword 从输入中返回终端用户 authToken, (中文(简体) ). refreshToken,以及 sessionToken。 。 。 。

说明, sessionToken 正在从 HTTP 回复中检索 GetSession 查询 authToken 设置于 Authorization。这样做的理由是,在数据库中使最后一个会话与用户连接。 如果 sessionTokencustomer 使用查询代替此查询 。 login 每次旧会话都会被抹去 sessionToken 已使用。 该图案也被用于 POST 调用 api/auth 航道也一样 如果你注意到。

3个 创建 /app/api/session/route.ts数字 :

import { NextResponse } from 'next/server';
import {
  GetSessionDocument,
  GetSessionQuery,
  Cart,
  Customer,
  getClient,
} from '@/graphql';

type RequestBody = {
  sessionToken: string;
  authToken?: string;
}

type GraphQLRequestHeaders = {
  'woocommerce-session': string;
  Authorization?: string;
}

export async function POST(request: Request) {
  try {
    const { sessionToken, authToken } = await request.json() as RequestBody;

    if (!sessionToken) {
      return NextResponse.json({ errors: { message: 'Session not started' } }, { status: 500 });
    }
    const headers: GraphQLRequestHeaders = { 'woocommerce-session': `Session ${sessionToken}` };
    if (authToken) {
      headers.Authorization = `Bearer ${authToken}`;
    }
    const graphQLClient = getClient();
    graphQLClient.setHeaders(headers);

    const results = await graphQLClient.request<GetSessionQuery>(
      GetSessionDocument,
    );

    const customer = results?.customer as Customer;
    const cart = results?.cart as Cart;

    if (!customer) {
      return NextResponse.json({ errors: { message: 'Failed to retrieve customer data.' } }, { status: 500 });
    }

    if (!cart) {
      return NextResponse.json({ errors: { message: 'Failed to retrieve cart data.' } }, { status: 500 });
    }

    return NextResponse.json({ customer, cart });
  } catch (err) {
    console.log(err);
    return NextResponse.json({ errors: { message: 'Sorry, something went wrong' } }, { status: 500 });
  }
}

这一个期望 sessionToken 并且可能 authToken。如果获得适当的证书,则返回最终用户的证书。 customercart 数据来源 GetSession 查询。

4.四. 创建 /api/nonce/route.ts数字 :

import { NextResponse } from 'next/server';

import { UpdateSessionDocument, UpdateSessionMutation, getClient } from '@/graphql';
import { ActionTypes, generateUrl } from '@/utils/nonce';

type RequestBody = {
  sessionToken: string;
  authToken?: string;
  clientSessionId: string;
  timeout: number;
}

type GraphQLRequestHeaders = {
  'woocommerce-session': string;
  Authorization?: string;
}

export async function POST(request: Request) {
  try {
    const {
      sessionToken,
      authToken,
      clientSessionId,
      timeout,
    } = await request.json() as RequestBody;

    if (!sessionToken) {
      return NextResponse.json({ errors: { message: 'No session started.' } }, { status: 500 });
    }

    if (!clientSessionId || !timeout) {
      return NextResponse.json({ errors: { message: 'Client Session ID and expiration must be provided.' } }, { status: 500 });
    }

    const headers: GraphQLRequestHeaders = { 'woocommerce-session': `Session ${sessionToken}` };
    if (authToken) {
      headers.Authorization = `Bearer ${authToken}`;
    }
    const client = getClient();
    client.setHeaders(headers);

    const input = {
      sessionData: [
        {
          key: 'client_session_id',
          value: clientSessionId,
        },
        {
          key: 'client_session_id_expiration',
          value: timeout,
        },
      ],
    };
    const results = await client.request<UpdateSessionMutation>(
      UpdateSessionDocument,
      { input },
    );

    if (!results.updateSession) {
      const message = 'Failed to update session';
      return NextResponse.json({ errors: { message } }, { status: 500 });
    }

    const cartUrl = generateUrl(sessionToken, clientSessionId, ActionTypes.Cart);
    const checkoutUrl = generateUrl(sessionToken, clientSessionId, ActionTypes.Checkout);
    const accountUrl = generateUrl(sessionToken, clientSessionId, ActionTypes.Account);

    return NextResponse.json({ cartUrl, checkoutUrl, accountUrl });
  } catch (err) {
    console.log(err);
    return NextResponse.json({ errors: { message: 'Sorry, something went wrong' } }, { status: 500 });
  }
}

此路由与最后一个加终端用户的输入相同 clientSessionIdclientSessionIdTimeout当给出适当的输入时,它设置了最终用户的 client_session_idclient_session_id_expiration 在最终用户的WooCommerce会话中,服务器上的对象。 随后这些值被用来生成WP后端上的一系列非切,用于会话下放的URL.

现在我们可以在GraphQL端点上查询这些非Ces甚至整个会话下放的URL,然而,这些非Ces/URLs在中转中泄露的风险。 因此,我们利用了其中定义的其他功能。 utils/nonce.ts 在我们的路由页面中重新创建完全相同的非Ces。 generateUrl 生成一个为终端用户量身定制的会话投放 URL, 并绑定在该机上。 最后的路由页面已经创建,剩下的都是更新 SessionProvider 以使用工具文件和扩展路由页面。

第5编:完成 SessionProvider

1. 联合国 更新 /client/SessionProvider/SessionProvider.tsx数字 :

// 客户端/会议提供/会议提供.tsx
...

import {
  hasCredentials,
  deleteCredentials,
  getSession as getSessionApiCall,
  FetchSessionResponse as Session,
  FetchAuthURLResponse as AuthUrls,
  fetchAuthURLs as fetchAuthURLsApiCall,
  login as loginApiCall,
} from '@/utils/session';
import {
  deleteClientSessionId,
  deleteClientCredentials,
} from '@/utils/client';
import { useToast } from '@/ui/use-toast';
...

export function SessionProvider({ children }: PropsWithChildren) {
  ...

  // 删除登录状态和本地的证书存储 。
  const logout = () => {
    ...
    deleteCredentials();
    deleteClientCredentials();
    fetchSession();
  };

  // 进程会话获取请求响应 。
  const setSession = (session: Session|string, authUrls: AuthUrls|string) => {
    if (typeof session === 'string') {
      toast({
        title: 'Fetch Session Error',
        description: 'Failed to fetch session.',
        variant: 'destructive'
      });
    }
    if (typeof authUrls === 'string') {
      toast({
        title: 'Session Error',
        description: 'Failed to generate session URLs. Please refresh the page.',
        variant: 'destructive'
      });
    }

    if (typeof session === 'string' || typeof authUrls === 'string') {
      dispatch({
        type: 'UPDATE_STATE',
        payload: { fetching: false } as SessionContext,
      });
      return false;
    }

    dispatch({
      type: 'UPDATE_STATE',
      payload: {
        ...session,
        ...authUrls,
        fetching: false
      } as SessionContext,
    });

    return true;
  };

  const login = (username: string, password: string) => {
    dispatch({
      type: 'UPDATE_STATE',
      payload: { fetching: true } as SessionContext,
    });
    return loginApiCall(username, password)
      .then((success) => {
        if (typeof success === 'string') {
          toast({
            title: 'Login Error',
            description: success,
            variant: 'destructive'
          });
          dispatch({
            type: 'UPDATE_STATE',
            payload: { fetching: false } as SessionContext,
          });
          return false;
        }
        
        return fetchSession();
    });
  };

  // 获取客户数据 。
  const fetchSession = () => {
    dispatch({
      type: 'UPDATE_STATE',
      payload: { fetching: true } as SessionContext,
    });

    return getSessionApiCall()
      .then(async (sessionPayload) => {
        const authUrlPayload = await fetchAuthURLsApiCall();
        return setSession(sessionPayload, authUrlPayload);
      });
  };

  useEffect(() => {
    if (state.fetching) {
      return;
    }

    if (!state.customer || !state.cart) {
      fetchSession();
    }
  }, []);
      

  const store: SessionContext = {
    ...
    refetch: fetchSession,
    login,
  };
  return (
    <Provider value={store}>{children}</Provider>
  );
}

export const useSession = () => useContext(sessionContext);

在更新会话提供程序后,登录和其它用户导航选项应该如预期的那样工作,尽管您可能需要样式化您的WP安装,以看起来像您 下一个无缝体验的应用程序 。

结论

接下来的辅导中,我们将用单一的产品页和推车选项完成我们的应用. 希望这间教程能让你玩得开心 下次见

继续最后一章



Leave a Reply

Your email address will not be published. Required fields are marked *