feat: Add customer authentication

This commit is contained in:
Alessandro Casazza 2021-08-06 17:33:00 +02:00
parent 42a02d40ff
commit 0f381d4cc6
No known key found for this signature in database
GPG Key ID: 3AF41B06C6495D3D
38 changed files with 147 additions and 460 deletions

View File

@ -1,6 +1,6 @@
import { FC, useEffect, useState, useCallback } from 'react'
import { Logo, Button, Input } from '@components/ui'
import useLogin from '@framework/auth/use-login'
import useLogin from '@commerce/auth/use-login'
import { useUI } from '@components/ui/context'
import { validate } from 'email-validator'
@ -18,7 +18,6 @@ const LoginView: React.FC = () => {
const handleLogin = async (e: React.SyntheticEvent<EventTarget>) => {
e.preventDefault()
if (!dirty && !disabled) {
setDirty(true)
handleValidation()

View File

@ -17,7 +17,7 @@ const loginEndpoint: GetAPISchema<
) {
return
}
debugger
try {
const body = req.body ?? {}
return await handlers['login']({ ...ctx, body })

View File

@ -146,6 +146,7 @@ export const createEndpoint =
options?: API['schema']['endpoint']['options']
}
): NextApiHandler => {
debugger
return getEndpoint(commerce, { ...endpoint, ...context })
}

View File

@ -1,5 +1 @@
COMMERCE_PROVIDER=commercelayer
COMMERCELAYER_CLIENT_ID=
COMMERCELAYER_ENDPOINT=
COMMERCELAYER_MARKET_SCOPE=

View File

@ -1,23 +1 @@
# Commerce Layer Provider
⚠️ This provider is still a work in progress.
Before getting started, you should do the following:
- Create a Commerce Layer [developer account](https://commercelayer.io).
- Create a new [organization](https://commercelayer.io/docs/data-model/users-and-organizations/) for your business.
- Create an application with `sales_channel` kind.
Next, copy the `.env.template` file in this directory to `.env.local` in the main directory (which will be ignored by Git):
```bash
cp framework/commercelayer/.env.template .env.local
```
Next, add the application credentials from your organization application dashboard in `.env.local`.
## Contribute
Our commitment to Open Source can be found [here](https://vercel.com/oss).
If you find an issue with the provider or want a new feature, feel free to open a PR or [create a new issue](https://github.com/vercel/commerce/issues).
# Next.js Local Provider

View File

@ -1,18 +1,35 @@
import { GetAPISchema, createEndpoint } from '@commerce/api'
import loginEndpoint from '@commerce/api/endpoints/login'
import type { LoginSchema } from '../../../types/login'
import type { CommercelayerAPI } from '../..'
import login from './login'
import { GetAPISchema } from '@commerce/api'
import { CommerceAPIError } from '@commerce/api/utils/errors'
import isAllowedOperation from '@commerce/api/utils/is-allowed-operation'
import { LoginSchema } from '@commerce/types/login'
export type LoginAPI = GetAPISchema<CommercelayerAPI, LoginSchema>
export type LoginEndpoint = LoginAPI['endpoint']
export const handlers: LoginEndpoint['handlers'] = { login }
const loginApi = createEndpoint<LoginAPI>({
handler: loginEndpoint,
handlers,
const loginEndpoint: GetAPISchema<
any,
LoginSchema<any>
>['endpoint']['handler'] = async (ctx) => {
const { req, res, handlers } = ctx
debugger
if (
!isAllowedOperation(req, res, {
POST: handlers['login'],
})
) {
return
}
debugger
try {
const body = req.body ?? {}
return await handlers['login']({ ...ctx, body })
} catch (error) {
console.error(error)
export default loginApi
const message =
error instanceof CommerceAPIError
? 'An unexpected error ocurred with the Commerce API'
: 'An unexpected error ocurred'
res.status(500).json({ data: null, errors: [{ message }] })
}
}
export default loginEndpoint

View File

@ -1,47 +0,0 @@
import { FetcherError } from '@commerce/utils/errors'
import { getAccessToken } from '../../index'
import type { LoginEndpoint } from '.'
const login: LoginEndpoint['handlers']['login'] = async ({
res,
body: { email, password },
config,
commerce,
}) => {
if (!(email && password)) {
return res.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
try {
const user = { email, password };
const getToken = await getAccessToken(user);
await config.apiFetch(`/api/customers/${getToken.customerId}`, {
method: 'GET'
})
} catch (error) {
if (error instanceof FetcherError &&
/invalid credentials/i.test(error.message)) {
return res.status(401).json({
data: null,
errors: [
{
message:
'Cannot find an account that matches the provided credentials',
code: 'INVALID_CREDENTIALS',
},
],
})
}
throw error
}
await commerce.login({ variables: { email, password }, config, res })
res.status(200).json({ data: null })
}
export default login

View File

@ -1,18 +1 @@
import { GetAPISchema, createEndpoint } from '@commerce/api'
import signupEndpoint from '@commerce/api/endpoints/signup'
import type { SignupSchema } from '../../../types/signup'
import type { CommercelayerAPI } from '../..'
import signup from './signup'
export type SignupAPI = GetAPISchema<CommercelayerAPI, SignupSchema>
export type SignupEndpoint = SignupAPI['endpoint']
export const handlers: SignupEndpoint['handlers'] = { signup }
const singupApi = createEndpoint<SignupAPI>({
handler: signupEndpoint,
handlers,
})
export default singupApi
export default function noopApi(...args: any[]): void {}

View File

@ -1,55 +0,0 @@
import { CommercelayerApiError } from '../../utils/errors'
import type { SignupEndpoint } from '.'
const signup: SignupEndpoint['handlers']['signup'] = async ({
res,
body: { email, password },
config,
commerce,
}) => {
if (!(email && password)) {
return res.status(400).json({
data: null,
errors: [{ message: 'Invalid request' }],
})
}
try {
await config.apiFetch('/api/customers', {
method: 'POST',
body: {
data: {
type: 'customers',
attributes: {
email: email,
password: password,
},
},
},
})
} catch (error) {
if (error instanceof CommercelayerApiError && error.status === 422) {
const inputEmail = error.data?.errors.meta.value
if ('code' in error.data?.errors) {
return res.status(400).json({
data: null,
errors: [
{
message: `A user already exists with ${inputEmail}`,
code: 'USER_EXISTS',
},
],
})
}
}
throw error
}
await commerce.login({ variables: { email, password }, res, config })
res.status(200).json({ data: null })
}
export default signup

View File

@ -1,7 +1,6 @@
import type { CommerceAPI, CommerceAPIConfig } from '@commerce/api'
import type { RequestInit, Response, Fetch } from '@vercel/fetch'
import { getCommerceApi as commerceApi } from '@commerce/api'
import createFetcher from './utils/fetch-api'
import createFetcher from './utils/fetch-local'
import getAllPages from './operations/get-all-pages'
import getPage from './operations/get-page'
@ -11,63 +10,14 @@ import getAllProductPaths from './operations/get-all-product-paths'
import getAllProducts from './operations/get-all-products'
import getProduct from './operations/get-product'
import { getToken } from './utils/get-token'
export interface CommercelayerConfig extends Omit<CommerceAPIConfig, 'fetch'> {
apiClientId: string
apiToken: string
apiFetch(
query: string,
endpoint: string,
fetchOptions?: RequestInit,
user?: UserCredentials
): Promise<{ data: any; res: Response }>
}
export type UserCredentials = {
email: string
password: string
}
const CLIENT_ID = process.env.COMMERCELAYER_CLIENT_ID
const ENDPOINT = process.env.COMMERCELAYER_ENDPOINT
const MARKET_SCOPE = process.env.COMMERCELAYER_MARKET_SCOPE
if (!CLIENT_ID) {
throw new Error(
`The environment variable COMMERCELAYER_CLIENT_ID is missing and it's required.`
)
}
if (!ENDPOINT) {
throw new Error(
`The environment variable COMMERCELAYER_ENDPOINT is missing and it's required.`
)
}
if (!MARKET_SCOPE) {
throw new Error(
`The environment variable COMMERCELAYER_MARKET_SCOPE is missing and it's required.`
)
}
export async function getAccessToken(user?: UserCredentials) {
return await getToken({
clientId: CLIENT_ID,
endpoint: ENDPOINT,
scope: MARKET_SCOPE,
user,
})
}
const config: CommercelayerConfig = {
commerceUrl: ENDPOINT,
apiClientId: CLIENT_ID,
export interface LocalConfig extends CommerceAPIConfig {}
const config: LocalConfig = {
commerceUrl: '',
apiToken: '',
cartCookie: '',
customerCookie: '',
cartCookieMaxAge: 2592000,
apiToken: '',
apiFetch: createFetcher(() => getCommerceApi().getConfig()),
fetch: createFetcher(() => getCommerceApi().getConfig()),
}
const operations = {
@ -83,12 +33,10 @@ const operations = {
export const provider = { config, operations }
export type Provider = typeof provider
export type CommercelayerAPI<P extends Provider = Provider> = CommerceAPI<
P | any
>
export type LocalAPI<P extends Provider = Provider> = CommerceAPI<P | any>
export function getCommerceApi<P extends Provider>(
customProvider: P = provider as any
): CommercelayerAPI<P> {
): LocalAPI<P> {
return commerceApi(customProvider as any)
}

View File

@ -1,6 +1,6 @@
export type Page = { url: string }
export type GetAllPagesResult = { pages: Page[] }
import type { CommercelayerConfig } from '../index'
import type { LocalConfig } from '../index'
export default function getAllPagesOperation() {
function getAllPages({
@ -8,7 +8,7 @@ export default function getAllPagesOperation() {
preview,
}: {
url?: string
config?: Partial<CommercelayerConfig>
config?: Partial<LocalConfig>
preview?: boolean
}): Promise<GetAllPagesResult> {
return Promise.resolve({

View File

@ -1,7 +1,7 @@
import { Product } from '@commerce/types/product'
import { GetAllProductsOperation } from '@commerce/types/product'
import type { OperationContext } from '@commerce/api/operations'
import type { CommercelayerConfig, Provider } from '../index'
import type { LocalConfig, Provider } from '../index'
import data from '../../data.json'
export default function getAllProductsOperation({
@ -14,7 +14,7 @@ export default function getAllProductsOperation({
}: {
query?: string
variables?: T['variables']
config?: Partial<CommercelayerConfig>
config?: Partial<LocalConfig>
preview?: boolean
} = {}): Promise<{ products: Product[] | any[] }> {
return {

View File

@ -1,4 +1,4 @@
import type { CommercelayerConfig } from '../index'
import type { LocalConfig } from '../index'
import { Product } from '@commerce/types/product'
import { GetProductOperation } from '@commerce/types/product'
import data from '../../data.json'
@ -14,7 +14,7 @@ export default function getProductOperation({
}: {
query?: string
variables?: T['variables']
config?: Partial<CommercelayerConfig>
config?: Partial<LocalConfig>
preview?: boolean
} = {}): Promise<Product | {} | any> {
return {

View File

@ -1,6 +1,6 @@
import { OperationContext } from '@commerce/api/operations'
import { Category } from '@commerce/types/site'
import { CommercelayerConfig } from '../index'
import { LocalConfig } from '../index'
export type GetSiteInfoResult<
T extends { categories: any[]; brands: any[] } = {
@ -17,7 +17,7 @@ export default function getSiteInfoOperation({}: OperationContext<any>) {
}: {
query?: string
variables?: any
config?: Partial<CommercelayerConfig>
config?: Partial<LocalConfig>
preview?: boolean
} = {}): Promise<GetSiteInfoResult> {
return Promise.resolve({

View File

@ -0,0 +1,15 @@
import Cookies, { CookieAttributes } from 'js-cookie'
const setCookie = (
name: string,
token?: string,
options?: CookieAttributes
) => {
if (!token) {
Cookies.remove(name)
} else {
Cookies.set(name, token, options)
}
}
export default setCookie

View File

@ -1,11 +0,0 @@
// Email must start with and contain an alphanumeric character, contain a @ character, and . character
export const validateEmail = (email: string) => {
const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(String(email).toLowerCase());
}
// Passwords must be at least eight characters and must contain at least one uppercase letter, one lowercase letter, one number and one special character
export const validatePassword = (password: string) => {
const re = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
return re.test(String(password).toLowerCase());
}

View File

@ -1,22 +0,0 @@
import type { Response } from '@vercel/fetch'
export class CommercelayerApiError extends Error {
status: number
res: Response
data: any
constructor(msg: string, res: Response, data?: any) {
super(msg)
this.name = 'CommercelayerApiError'
this.status = res.status
this.res = res
this.data = data
}
}
export class CommercelayerNetworkError extends Error {
constructor(msg: string) {
super(msg)
this.name = 'CommercelayerNetworkError'
}
}

View File

@ -1,40 +0,0 @@
import type { RequestInit } from '@vercel/fetch'
import { FetcherError } from '@commerce/utils/errors'
import { CommercelayerConfig, getAccessToken, UserCredentials } from '../index'
import fetch from './fetch'
const fetchApi =
(getConfig: () => CommercelayerConfig) =>
async (
query: string,
endpoint: string,
fetchOptions?: RequestInit,
user?: UserCredentials
) => {
const config = getConfig()
const getToken = await getAccessToken(user)
const token = getToken?.accessToken
const res = await fetch(config.commerceUrl + endpoint, {
...fetchOptions,
headers: {
...fetchOptions?.headers,
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify([
query,
]),
})
const json = await res.json()
if (json.errors) {
throw new FetcherError({
errors: json.errors ?? [{ message: 'Failed to fetch Commerce Layer API' }],
status: res.status,
})
}
return { data: json.data, res }
}
export default fetchApi

View File

@ -0,0 +1,35 @@
import { FetcherError } from '@commerce/utils/errors'
import type { GraphQLFetcher } from '@commerce/api'
import type { LocalConfig } from '../index'
import fetch from './fetch'
const fetchGraphqlApi: (getConfig: () => LocalConfig) => GraphQLFetcher =
(getConfig) =>
async (query: string, { variables, preview } = {}, fetchOptions) => {
debugger
const config = getConfig()
const res = await fetch(config.commerceUrl, {
...fetchOptions,
method: 'POST',
headers: {
...fetchOptions?.headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables,
}),
})
const json = await res.json()
if (json.errors) {
throw new FetcherError({
errors: json.errors ?? [{ message: 'Failed to fetch for API' }],
status: res.status,
})
}
return { data: json.data, res }
}
export default fetchGraphqlApi

View File

@ -1,40 +0,0 @@
import Cookies from 'js-cookie'
import { getSalesChannelToken } from '@commercelayer/js-auth'
type GetTokenObj = {
clientId?: string
endpoint?: string
scope?: string
user?: any
}
export async function getToken({
clientId,
endpoint,
scope = 'market:all',
user,
}: GetTokenObj) {
const getCookieToken = Cookies.get('clAccessToken')
if (!getCookieToken && clientId && endpoint) {
const auth = await getSalesChannelToken(
{
clientId,
endpoint,
scope,
},
user
)
Cookies.set('clAccessToken', auth?.accessToken as string, {
// @ts-ignore
expires: auth?.expires,
})
return auth
? {
accessToken: auth.accessToken,
customerId: auth.data.owner_id,
...auth.data,
}
: null
}
return { accessToken: getCookieToken }
}

View File

@ -1,40 +1,40 @@
import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import { MutationHook } from '@commerce/utils/types'
import useLogin, { UseLogin } from '@commerce/auth/use-login'
import type { LoginHook } from '../types/login'
import useCustomer from '../customer/use-customer'
import { CommerceError } from '@commerce/utils/errors'
import { getCustomerToken } from '@commercelayer/js-auth'
import setCookie from '@framework/api/utils/cookies'
export default useLogin as UseLogin<typeof handler>
export const handler: MutationHook<LoginHook> = {
export const handler: MutationHook<any> = {
fetchOptions: {
url: '/api/customers',
method: 'GET',
// query: 'login',
url: '/customer',
},
async fetcher({ input: { email, password }, options, fetch }) {
if (!(email && password)) {
throw new CommerceError({
message:
'An email address and password are required to login',
message: 'An email and password are required to login',
})
}
return fetch({
...options,
body: { email, password },
})
const token = await getCustomerToken(
{
endpoint: process.env.NEXT_PUBLIC_COMMERCELAYER_ENDPOINT as string,
clientId: process.env.NEXT_PUBLIC_COMMERCELAYER_CLIENT_ID as string,
scope: process.env.NEXT_PUBLIC_COMMERCELAYER_MARKET_SCOPE as string,
},
useHook: ({ fetch }) => () => {
const { revalidate } = useCustomer()
return useCallback(
async function login(input) {
const data = await fetch({ input })
await revalidate()
return data
},
[fetch, revalidate]
{ username: email, password }
)
token &&
setCookie('CL_TOKEN', token.accessToken, { expires: token.expires })
return token
},
useHook:
({ fetch }) =>
() => {
return async function login(input) {
const data = await fetch({ input })
return data
}
},
}

View File

@ -1,44 +1,19 @@
import { useCallback } from 'react'
import type { MutationHook } from '@commerce/utils/types'
import { CommerceError } from '@commerce/utils/errors'
import useSignup, { UseSignup } from '@commerce/auth/use-signup'
import type { SignupHook } from '../types/signup'
import useCustomer from '../customer/use-customer'
import { MutationHook } from '@commerce/utils/types'
import useSignup, { UseSignup } from '@commerce/auth/use-signup'
export default useSignup as UseSignup<typeof handler>
export const handler: MutationHook<SignupHook> = {
export const handler: MutationHook<any> = {
fetchOptions: {
url: '/api/customers',
method: 'POST',
},
async fetcher({
input: { email, password },
options,
fetch,
}) {
if (!(email && password)) {
throw new CommerceError({
message:
'An email address and password are required to signup',
})
}
return fetch({
...options,
body: { email, password },
})
},
useHook: ({ fetch }) => () => {
const { revalidate } = useCustomer()
return useCallback(
async function signup(input) {
const data = await fetch({ input })
await revalidate()
return data
},
[fetch, revalidate]
)
query: '',
},
async fetcher() {
return null
},
useHook:
({ fetch }) =>
() =>
() => {},
}

View File

@ -2,6 +2,7 @@ import { Fetcher } from '@commerce/utils/types'
export const fetcher: Fetcher = async () => {
console.log('FETCHER')
debugger
const res = await fetch('./data.json')
if (res.ok) {
const { data } = await res.json()

View File

@ -1,13 +1,13 @@
import * as React from 'react'
import { ReactNode } from 'react'
import { commerceLayerProvider } from './provider'
import { localProvider } from './provider'
import {
CommerceConfig,
CommerceProvider as CoreCommerceProvider,
useCommerce as useCoreCommerce,
} from '@commerce'
export const commerceLayerConfig: CommerceConfig = {
export const localConfig: CommerceConfig = {
locale: 'en-us',
cartCookie: 'session',
}
@ -21,8 +21,8 @@ export function CommerceProvider({
} & Partial<CommerceConfig>) {
return (
<CoreCommerceProvider
provider={commerceLayerProvider}
config={{ ...commerceLayerConfig, ...config }}
provider={localProvider}
config={{ ...localConfig, ...config }}
>
{children}
</CoreCommerceProvider>

View File

@ -9,8 +9,8 @@ import { handler as useLogin } from './auth/use-login'
import { handler as useLogout } from './auth/use-logout'
import { handler as useSignup } from './auth/use-signup'
export type Provider = typeof commerceLayerProvider
export const commerceLayerProvider = {
export type Provider = typeof localProvider
export const localProvider = {
locale: 'en-us',
cartCookie: 'session',
fetcher: fetcher,

View File

@ -1 +0,0 @@
export * from '@commerce/types/cart'

View File

@ -1 +0,0 @@
export * from '@commerce/types/checkout'

View File

@ -1 +0,0 @@
export * from '@commerce/types/common'

View File

@ -1 +0,0 @@
export * from '@commerce/types/customer'

View File

@ -1,25 +0,0 @@
import * as Cart from './cart'
import * as Checkout from './checkout'
import * as Common from './common'
import * as Customer from './customer'
import * as Login from './login'
import * as Logout from './logout'
import * as Page from './page'
import * as Product from './product'
import * as Signup from './signup'
import * as Site from './site'
import * as Wishlist from './wishlist'
export type {
Cart,
Checkout,
Common,
Customer,
Login,
Logout,
Page,
Product,
Signup,
Site,
Wishlist,
}

View File

@ -1,11 +0,0 @@
import * as Core from '@commerce/types/login'
import { LoginBody, LoginTypes } from '@commerce/types/login'
export * from '@commerce/types/login'
export type LoginHook<T extends LoginTypes = LoginTypes> = {
data: null
actionInput: LoginBody
fetcherInput: LoginBody
body: T['body']
}

View File

@ -1 +0,0 @@
export * from '@commerce/types/logout'

View File

@ -1 +0,0 @@
export * from '@commerce/types/page'

View File

@ -1 +0,0 @@
export * from '@commerce/types/product'

View File

@ -1 +0,0 @@
export * from '@commerce/types/signup'

View File

@ -1 +0,0 @@
export * from '@commerce/types/site'

View File

@ -1 +0,0 @@
export * from '@commerce/types/wishlist'

View File

@ -21,7 +21,7 @@
"node": ">=14.x"
},
"dependencies": {
"@commercelayer/js-auth": "^2.0.6",
"@commercelayer/js-auth": "^2.0.7",
"@commercelayer/js-sdk": "^4.1.3",
"@chec/commerce.js": "^2.8.0",
"@react-spring/web": "^9.4.1",