diff --git a/packages/ordercloud/src/api/endpoints/index.ts b/packages/ordercloud/src/api/endpoints/index.ts index 9bcb44b43..0fcb3f609 100644 --- a/packages/ordercloud/src/api/endpoints/index.ts +++ b/packages/ordercloud/src/api/endpoints/index.ts @@ -4,19 +4,21 @@ import createEndpoints from '@vercel/commerce/api/endpoints' import cart from './cart' import checkout from './checkout' +import login from './login' +import logout from './logout' +import signup from './signup' import products from './catalog/products' import customer from './customer' import customerCard from './customer/card' import customerAddress from './customer/address' -import signup from './signup' -import logout from './logout' const endpoints = { cart, checkout, - logout: logout, - signup: signup, - customer: customer, + login, + logout, + signup, + customer, 'customer/card': customerCard, 'customer/address': customerAddress, 'catalog/products': products, diff --git a/packages/ordercloud/src/api/endpoints/login/index.ts b/packages/ordercloud/src/api/endpoints/login/index.ts new file mode 100644 index 000000000..5558cda66 --- /dev/null +++ b/packages/ordercloud/src/api/endpoints/login/index.ts @@ -0,0 +1,18 @@ +import { GetAPISchema, createEndpoint } from '@vercel/commerce/api' +import loginEndpoint from '@vercel/commerce/api/endpoints/login' +import type { LoginSchema } from '@vercel/commerce/types/login' +import type { OrdercloudAPI } from '../..' +import login from './login' + +export type LoginAPI = GetAPISchema + +export type LoginEndpoint = LoginAPI['endpoint'] + +export const handlers: LoginEndpoint['handlers'] = { login } + +const loginApi = createEndpoint({ + handler: loginEndpoint, + handlers, +}) + +export default loginApi diff --git a/packages/ordercloud/src/api/endpoints/login/login.ts b/packages/ordercloud/src/api/endpoints/login/login.ts new file mode 100644 index 000000000..75abe01f2 --- /dev/null +++ b/packages/ordercloud/src/api/endpoints/login/login.ts @@ -0,0 +1,54 @@ +import type { LoginEndpoint } from '.' + +import { FetcherError } from '@vercel/commerce/utils/errors' +import { CommerceAPIError } from '@vercel/commerce/api/utils/errors' +import { getFetchConfig, getToken } from '../..//utils/fetch-rest' +import { serialize } from 'cookie' + +const invalidCredentials = /Invalid username or password/i + +const login: LoginEndpoint['handlers']['login'] = async ({ + body: { email, password }, + config, +}) => { + try { + const token = await getToken({ + grantType: 'password', + username: email, + password: password, + ...getFetchConfig(config), + }) + + if (!token.access_token) { + throw new CommerceAPIError('Failed to retrieve access token', { + status: 401, + }) + } + + return { + headers: { + 'Set-Cookie': serialize(config.tokenCookie, token.access_token, { + expires: new Date(Date.now() + token.expires_in * 1000), + secure: process.env.NODE_ENV === 'production', + path: '/', + sameSite: 'lax', + }), + }, + data: null, + } + } catch (error) { + // Check if the email and password didn't match an existing account + if (error instanceof FetcherError) { + throw new CommerceAPIError( + error.errors.some((e) => invalidCredentials.test(e.message)) + ? 'Cannot find an account that matches the provided credentials' + : error.message, + { status: error.status || 401 } + ) + } else { + throw error + } + } +} + +export default login diff --git a/packages/ordercloud/src/api/utils/fetch-rest.ts b/packages/ordercloud/src/api/utils/fetch-rest.ts index b767bfab5..be15b893f 100644 --- a/packages/ordercloud/src/api/utils/fetch-rest.ts +++ b/packages/ordercloud/src/api/utils/fetch-rest.ts @@ -1,31 +1,39 @@ import { FetcherError } from '@vercel/commerce/utils/errors' import { OrdercloudConfig } from '../index' -export let token: string | null = null - -// Get token util -async function getToken({ - baseUrl, - clientId, - clientSecret, -}: { +export type GetTokenParams = ( + | { + grantType: 'password' + username: string + password: string + } + | { grantType: 'client_credentials' } +) & { baseUrl: string clientId: string clientSecret?: string -}): Promise<{ +} + +// Get token util +export async function getToken(params: GetTokenParams): Promise<{ access_token: string expires_in: number refresh_token: string token_type: string }> { + let body = `client_id=${params.clientId}&client_secret=${params.clientSecret}&grant_type=${params.grantType}` + if (params.grantType === 'password') { + body += `&username=${params.username}&password=${params.password}` + } + // If not, get a new one and store it - const authResponse = await fetch(`${baseUrl}/oauth/token`, { + const authResponse = await fetch(`${params.baseUrl}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, - body: `client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`, + body, }) // If something failed getting the auth response @@ -37,8 +45,18 @@ async function getToken({ // And return an error throw new FetcherError({ - errors: [{ message: error.error_description.Code }], - status: error.error_description.HttpStatus, + errors: [ + { + message: + typeof error.error_description === 'object' + ? error.error_description.Code + : error.error_description, + }, + ], + status: + typeof error.error_description === 'object' + ? error.error_description.HttpStatus + : undefined, }) } @@ -102,6 +120,11 @@ export async function fetchData(opts: { } } +export const getFetchConfig = (config: OrdercloudConfig) => ({ + baseUrl: config.commerceUrl, + clientId: process.env.ORDERCLOUD_BUYER_CLIENT_ID as string, +}) + export const createBuyerFetcher: ( getConfig: () => OrdercloudConfig ) => ( @@ -117,6 +140,8 @@ export const createBuyerFetcher: ( body?: Record, fetchOptions?: Record ) => { + let token: string | null = null + if (fetchOptions?.token) { token = fetchOptions?.token } @@ -128,8 +153,8 @@ export const createBuyerFetcher: ( if (!token) { const newToken = await getToken({ - baseUrl: config.commerceUrl, - clientId: process.env.ORDERCLOUD_BUYER_CLIENT_ID as string, + grantType: 'client_credentials', + ...getFetchConfig(config), }) token = newToken.access_token meta.token = newToken diff --git a/packages/ordercloud/src/fetcher.ts b/packages/ordercloud/src/fetcher.ts index 1da35718e..93af554e1 100644 --- a/packages/ordercloud/src/fetcher.ts +++ b/packages/ordercloud/src/fetcher.ts @@ -1,17 +1,16 @@ import { Fetcher } from '@vercel/commerce/utils/types' +import { handleFetchResponse } from './utils' const clientFetcher: Fetcher = async ({ method, url, body }) => { - const response = await fetch(url!, { - method, - body: body ? JSON.stringify(body) : undefined, - headers: { - 'Content-Type': 'application/json', - }, - }) - .then((response) => response.json()) - .then((response) => response.data) - - return response + return handleFetchResponse( + await fetch(url!, { + method, + body: body ? JSON.stringify(body) : undefined, + headers: { + 'Content-Type': 'application/json', + }, + }) + ) } export default clientFetcher diff --git a/packages/ordercloud/src/utils/handle-fetch-response.ts b/packages/ordercloud/src/utils/handle-fetch-response.ts new file mode 100644 index 000000000..4fcbb160a --- /dev/null +++ b/packages/ordercloud/src/utils/handle-fetch-response.ts @@ -0,0 +1,25 @@ +import { FetcherError } from '@vercel/commerce/utils/errors' + +export function getError(errors: any[] | null, status: number) { + errors = errors ?? [{ message: 'Failed to fetch OrderCloud API' }] + return new FetcherError({ errors, status }) +} + +export async function getAsyncError(res: Response) { + const data = await res.json() + return getError(data.errors, res.status) +} + +export const handleFetchResponse = async (res: Response) => { + if (res.ok) { + const { data, errors } = await res.json() + + if (errors && errors.length) { + throw getError(errors, res.status) + } + + return data + } + + throw await getAsyncError(res) +} diff --git a/packages/ordercloud/src/utils/index.ts b/packages/ordercloud/src/utils/index.ts new file mode 100644 index 000000000..bb3d2e199 --- /dev/null +++ b/packages/ordercloud/src/utils/index.ts @@ -0,0 +1 @@ +export * from './handle-fetch-response'