diff --git a/framework/kibocommerce/api/endpoints/customer/customer.ts b/framework/kibocommerce/api/endpoints/customer/customer.ts new file mode 100644 index 000000000..2ae29d2bf --- /dev/null +++ b/framework/kibocommerce/api/endpoints/customer/customer.ts @@ -0,0 +1,39 @@ +import type { CustomerEndpoint } from '.' +import { getCustomerAccountQuery } from '../../queries/get-customer-account-query' + +const getLoggedInCustomer: CustomerEndpoint['handlers']['getLoggedInCustomer'] = async ({ + req, + res, + config, +}) => { + const token = req.cookies[config.customerCookie] + + const { accessToken } = JSON.parse(token); + + if (accessToken) { + const { data } = await config.fetch( + getCustomerAccountQuery, + undefined, + { + headers: { + 'x-vol-user-claims': accessToken + }, + } + ) + + const customer = data?.customerAccount; + + if (!customer) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Customer not found', code: 'not_found' }], + }) + } + + return res.status(200).json({ data: { customer } }) + } + + res.status(200).json({ data: null }) +} + +export default getLoggedInCustomer diff --git a/framework/kibocommerce/api/endpoints/customer/index.ts b/framework/kibocommerce/api/endpoints/customer/index.ts index 491bf0ac9..c32bcfa91 100644 --- a/framework/kibocommerce/api/endpoints/customer/index.ts +++ b/framework/kibocommerce/api/endpoints/customer/index.ts @@ -1 +1,18 @@ -export default function noopApi(...args: any[]): void {} +import { GetAPISchema, createEndpoint } from '@commerce/api' +import customerEndpoint from '@commerce/api/endpoints/customer' +import type { CustomerSchema } from '../../../types/customer' +import type { KiboCommerceAPI } from '../..' +import getLoggedInCustomer from './customer' + +export type CustomerAPI = GetAPISchema + +export type CustomerEndpoint = CustomerAPI['endpoint'] + +export const handlers: CustomerEndpoint['handlers'] = { getLoggedInCustomer } + +const customerApi = createEndpoint({ + handler: customerEndpoint, + handlers, +}) + +export default customerApi diff --git a/framework/kibocommerce/api/endpoints/login/index.ts b/framework/kibocommerce/api/endpoints/login/index.ts index 491bf0ac9..0671c98bd 100644 --- a/framework/kibocommerce/api/endpoints/login/index.ts +++ b/framework/kibocommerce/api/endpoints/login/index.ts @@ -1 +1,22 @@ -export default function noopApi(...args: any[]): void {} +// export default function noopApi(...args: any[]): void {} + +import { GetAPISchema, createEndpoint } from '@commerce/api' +import loginEndpoint from '@commerce/api/endpoints/login' +import type { LoginSchema } from '../../../types/login' +import type { KiboCommerceAPI } 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/framework/kibocommerce/api/endpoints/login/login.ts b/framework/kibocommerce/api/endpoints/login/login.ts new file mode 100644 index 000000000..d5b81c731 --- /dev/null +++ b/framework/kibocommerce/api/endpoints/login/login.ts @@ -0,0 +1,66 @@ +import { FetcherError } from '@commerce/utils/errors' +import type { LoginEndpoint } from '.' +import { loginMutation } from '../../mutations/login-mutation' +import { prepareSetCookie } from '../../../lib/prepareSetCookie'; +import { setCookies } from '../../../lib/setCookie' +import { getCookieExpirationDate } from '../../../lib/getCookieExpirationDate' + +const invalidCredentials = /invalid credentials/i + +const login: LoginEndpoint['handlers']['login'] = async ({ + req, + res, + body: { email, password }, + config, + commerce, +}) => { + + if (!(email && password)) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + let response; + try { + + const variables = { loginInput : { username: email, password }}; + response = await config.fetch(loginMutation, { variables }) + const { account: token } = response.data; + + // Set Cookie + const cookieExpirationDate = getCookieExpirationDate(config.customerCookieMaxAgeInDays) + + const authCookie = prepareSetCookie( + config.customerCookie, + JSON.stringify(token), + token.accessTokenExpiration ? { expires: cookieExpirationDate }: {}, + ) + setCookies(res, [authCookie]) + + } catch (error) { + // Check if the email and password didn't match an existing account + if ( + error instanceof FetcherError && + invalidCredentials.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 + } + + res.status(200).json({ data: response }) +} + +export default login \ No newline at end of file diff --git a/framework/kibocommerce/api/endpoints/logout/index.ts b/framework/kibocommerce/api/endpoints/logout/index.ts index 491bf0ac9..ec4ded011 100644 --- a/framework/kibocommerce/api/endpoints/logout/index.ts +++ b/framework/kibocommerce/api/endpoints/logout/index.ts @@ -1 +1,18 @@ -export default function noopApi(...args: any[]): void {} +import { GetAPISchema, createEndpoint } from '@commerce/api' +import logoutEndpoint from '@commerce/api/endpoints/logout' +import type { LogoutSchema } from '../../../types/logout' +import type { KiboCommerceAPI } from '../..' +import logout from './logout' + +export type LogoutAPI = GetAPISchema + +export type LogoutEndpoint = LogoutAPI['endpoint'] + +export const handlers: LogoutEndpoint['handlers'] = { logout } + +const logoutApi = createEndpoint({ + handler: logoutEndpoint, + handlers, +}) + +export default logoutApi diff --git a/framework/kibocommerce/api/endpoints/logout/logout.ts b/framework/kibocommerce/api/endpoints/logout/logout.ts new file mode 100644 index 000000000..242899910 --- /dev/null +++ b/framework/kibocommerce/api/endpoints/logout/logout.ts @@ -0,0 +1,22 @@ +import type { LogoutEndpoint } from '.' +import {prepareSetCookie} from '../../../lib/prepareSetCookie'; +import {setCookies} from '../../../lib/setCookie' + +const logout: LogoutEndpoint['handlers']['logout'] = async ({ + res, + body: { redirectTo }, + config, +}) => { + // Remove the cookie + const authCookie = prepareSetCookie(config.customerCookie,'',{ maxAge: -1, path: '/' }) + setCookies(res, [authCookie]) + + // Only allow redirects to a relative URL + if (redirectTo?.startsWith('/')) { + res.redirect(redirectTo) + } else { + res.status(200).json({ data: null }) + } +} + +export default logout diff --git a/framework/kibocommerce/api/endpoints/signup/index.ts b/framework/kibocommerce/api/endpoints/signup/index.ts index 491bf0ac9..3eda94d06 100644 --- a/framework/kibocommerce/api/endpoints/signup/index.ts +++ b/framework/kibocommerce/api/endpoints/signup/index.ts @@ -1 +1,18 @@ -export default function noopApi(...args: any[]): void {} +import { GetAPISchema, createEndpoint } from '@commerce/api' +import signupEndpoint from '@commerce/api/endpoints/signup' +import type { SignupSchema } from '../../../types/signup' +import type { KiboCommerceAPI } from '../..' +import signup from './signup' + +export type SignupAPI = GetAPISchema + +export type SignupEndpoint = SignupAPI['endpoint'] + +export const handlers: SignupEndpoint['handlers'] = { signup } + +const singupApi = createEndpoint({ + handler: signupEndpoint, + handlers, +}) + +export default singupApi diff --git a/framework/kibocommerce/api/endpoints/signup/signup.ts b/framework/kibocommerce/api/endpoints/signup/signup.ts new file mode 100644 index 000000000..031e22c94 --- /dev/null +++ b/framework/kibocommerce/api/endpoints/signup/signup.ts @@ -0,0 +1,91 @@ +import { FetcherError } from '@commerce/utils/errors' +import type { SignupEndpoint } from '.' +import { registerUserMutation, registerUserLoginMutation } from '../../mutations/signup-mutation' +import { prepareSetCookie } from '../../../lib/prepareSetCookie'; +import { setCookies } from '../../../lib/setCookie' +import { getCookieExpirationDate } from '../../../lib/getCookieExpirationDate' + +const invalidCredentials = /invalid credentials/i + +const signup: SignupEndpoint['handlers']['signup'] = async ({ + req, + res, + body: { email, password, firstName, lastName }, + config, + commerce, +}) => { + + if (!(email && password)) { + return res.status(400).json({ + data: null, + errors: [{ message: 'Invalid request' }], + }) + } + + let response; + try { + + // Register user + const registerUserVariables = { + customerAccountInput: { + emailAddress: email, + firstName: firstName, + lastName: lastName, + acceptsMarketing: true, + id: 0 + } + } + + const registerUserResponse = await config.fetch(registerUserMutation, { variables: registerUserVariables}) + const accountId = registerUserResponse.data?.account?.id; + + // Login user + const registerUserLoginVairables = { + accountId: accountId, + customerLoginInfoInput: { + emailAddress: email, + username: email, + password: password, + isImport: false + } + } + + response = await config.fetch(registerUserLoginMutation, { variables: registerUserLoginVairables}) + const { account: token } = response.data; + + // Set Cookie + const cookieExpirationDate = getCookieExpirationDate(config.customerCookieMaxAgeInDays) + + const authCookie = prepareSetCookie( + config.customerCookie, + JSON.stringify(token), + token.accessTokenExpiration ? { expires: cookieExpirationDate }: {}, + ) + + setCookies(res, [authCookie]) + + } catch (error) { + // Check if the email and password didn't match an existing account + if ( + error instanceof FetcherError && + invalidCredentials.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 + } + + res.status(200).json({ data: response }) +} + +export default signup \ No newline at end of file diff --git a/framework/kibocommerce/api/index.ts b/framework/kibocommerce/api/index.ts index 6ed9a8209..52c2e20dc 100644 --- a/framework/kibocommerce/api/index.ts +++ b/framework/kibocommerce/api/index.ts @@ -17,6 +17,7 @@ export interface KiboCommerceConfig extends CommerceAPIConfig { clientId?: string sharedSecret?: string storeApiFetch(endpoint: string, options?: RequestInit): Promise + customerCookieMaxAgeInDays: number } const config: KiboCommerceConfig = { @@ -31,6 +32,7 @@ const config: KiboCommerceConfig = { clientId: process.env.KIBO_CLIENT_ID || '', sharedSecret: process.env.KIBO_SHARED_SECRET || '', storeApiFetch: createFetchStoreApi(() => getCommerceApi().getConfig()), + customerCookieMaxAgeInDays: 30, } const operations = { diff --git a/framework/kibocommerce/api/mutations/login-mutation.ts b/framework/kibocommerce/api/mutations/login-mutation.ts new file mode 100644 index 000000000..730adeda1 --- /dev/null +++ b/framework/kibocommerce/api/mutations/login-mutation.ts @@ -0,0 +1,20 @@ + +export const loginMutation = /* GraphQL */` +mutation login($loginInput:CustomerUserAuthInfoInput!) { + account:createCustomerAuthTicket(customerUserAuthInfoInput:$loginInput) { + accessToken + userId + refreshToken + refreshTokenExpiration + accessTokenExpiration + customerAccount { + id + firstName + lastName + emailAddress + userName + } + } + } +` + diff --git a/framework/kibocommerce/api/mutations/signup-mutation.ts b/framework/kibocommerce/api/mutations/signup-mutation.ts new file mode 100644 index 000000000..bb25534ab --- /dev/null +++ b/framework/kibocommerce/api/mutations/signup-mutation.ts @@ -0,0 +1,41 @@ + +const registerUserMutation = /* GraphQL */` +mutation registerUser($customerAccountInput: CustomerAccountInput!) { + account:createCustomerAccount(customerAccountInput:$customerAccountInput) { + emailAddress + userName + firstName + lastName + localeCode + userId + id + isAnonymous + attributes { + values + fullyQualifiedName + } + } +}`; + +const registerUserLoginMutation = /* GraphQL */` +mutation registerUserLogin($accountId: Int!, $customerLoginInfoInput: CustomerLoginInfoInput!) { + account:createCustomerAccountLogin(accountId:$accountId, customerLoginInfoInput:$customerLoginInfoInput) { + accessToken + accessTokenExpiration + refreshToken + refreshTokenExpiration + userId + customerAccount { + id + emailAddress + firstName + userName + } + } +}`; + +export { + registerUserMutation, + registerUserLoginMutation +}; + diff --git a/framework/kibocommerce/api/queries/get-customer-account-query.ts b/framework/kibocommerce/api/queries/get-customer-account-query.ts new file mode 100644 index 000000000..9528b8467 --- /dev/null +++ b/framework/kibocommerce/api/queries/get-customer-account-query.ts @@ -0,0 +1,12 @@ +export const getCustomerAccountQuery = /* GraphQL */` +query getUser { + customerAccount:getCurrentAccount { + id + firstName + lastName + emailAddress + userName + isAnonymous + } +} +` \ No newline at end of file diff --git a/framework/kibocommerce/api/utils/fetch-local.ts b/framework/kibocommerce/api/utils/fetch-local.ts index e6ad35ba2..2612188a9 100644 --- a/framework/kibocommerce/api/utils/fetch-local.ts +++ b/framework/kibocommerce/api/utils/fetch-local.ts @@ -8,9 +8,11 @@ const fetchGraphqlApi: (getConfig: () => KiboCommerceConfig) => GraphQLFetcher = async (query: string, { variables, preview } = {}, fetchOptions) => { const config = getConfig() const res = await fetch(config.commerceUrl, { + //const res = await fetch(config.commerceUrl + (preview ? '/preview' : ''), { ...fetchOptions, method: 'POST', headers: { + Authorization: `Bearer ${config.apiToken}`, ...fetchOptions?.headers, 'Content-Type': 'application/json', }, @@ -23,7 +25,7 @@ const fetchGraphqlApi: (getConfig: () => KiboCommerceConfig) => GraphQLFetcher = const json = await res.json() if (json.errors) { throw new FetcherError({ - errors: json.errors ?? [{ message: 'Failed to fetch for API' }], + errors: json.errors ?? [{ message: 'Failed to fetch KiboCommerce API' }], status: res.status, }) } diff --git a/framework/kibocommerce/auth/use-login.tsx b/framework/kibocommerce/auth/use-login.tsx index 28351dc7f..a23a444b5 100644 --- a/framework/kibocommerce/auth/use-login.tsx +++ b/framework/kibocommerce/auth/use-login.tsx @@ -1,16 +1,40 @@ import { MutationHook } from '@commerce/utils/types' import useLogin, { UseLogin } from '@commerce/auth/use-login' +import { useCallback } from 'react' +import { CommerceError } from '@commerce/utils/errors' +import type { LoginHook } from '../types/login' +import useCustomer from '../customer/use-customer' export default useLogin as UseLogin -export const handler: MutationHook = { +export const handler: MutationHook = { fetchOptions: { - query: '', + url: '/api/login', + method: 'POST' }, - async fetcher() { - return null + async fetcher({ input: { email, password }, options, fetch }) { + if (!(email && password)) { + throw new CommerceError({ + message: + 'An email and password are required to login', + }) + } + + return fetch({ + ...options, + body: { email, password }, + }) }, - useHook: () => () => { - return async function () {} + useHook: ({ fetch }) => () => { + const { revalidate } = useCustomer() + + return useCallback( + async function login(input) { + const data = await fetch({ input }) + await revalidate() + return data + }, + [fetch, revalidate] + ) }, } diff --git a/framework/kibocommerce/auth/use-logout.tsx b/framework/kibocommerce/auth/use-logout.tsx index 9b3fc3e44..e75563e04 100644 --- a/framework/kibocommerce/auth/use-logout.tsx +++ b/framework/kibocommerce/auth/use-logout.tsx @@ -1,17 +1,26 @@ -import { MutationHook } from '@commerce/utils/types' +import { useCallback } from 'react' +import type { MutationHook } from '@commerce/utils/types' import useLogout, { UseLogout } from '@commerce/auth/use-logout' +import type { LogoutHook } from '../types/logout' +import useCustomer from '../customer/use-customer' export default useLogout as UseLogout -export const handler: MutationHook = { +export const handler: MutationHook = { fetchOptions: { - query: '', + url: '/api/logout', + method: 'GET', }, - async fetcher() { - return null + useHook: ({ fetch }) => () => { + const { mutate } = useCustomer() + + return useCallback( + async function logout() { + const data = await fetch() + await mutate(null, false) + return data + }, + [fetch, mutate] + ) }, - useHook: - ({ fetch }) => - () => - async () => {}, } diff --git a/framework/kibocommerce/auth/use-signup.tsx b/framework/kibocommerce/auth/use-signup.tsx index e9ad13458..da06fd3eb 100644 --- a/framework/kibocommerce/auth/use-signup.tsx +++ b/framework/kibocommerce/auth/use-signup.tsx @@ -1,19 +1,44 @@ import { useCallback } from 'react' -import useCustomer from '../customer/use-customer' -import { MutationHook } from '@commerce/utils/types' +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' export default useSignup as UseSignup -export const handler: MutationHook = { +export const handler: MutationHook = { fetchOptions: { - query: '', + url: '/api/signup', + method: 'POST', }, - async fetcher() { - return null + async fetcher({ + input: { firstName, lastName, email, password }, + options, + fetch, + }) { + if (!(firstName && lastName && email && password)) { + throw new CommerceError({ + message: + 'A first name, last name, email and password are required to signup', + }) + } + + return fetch({ + ...options, + body: { firstName, lastName, email, password }, + }) + }, + useHook: ({ fetch }) => () => { + const { revalidate } = useCustomer() + + return useCallback( + async function signup(input) { + const data = await fetch({ input }) + await revalidate() + return data + }, + [fetch, revalidate] + ) }, - useHook: - ({ fetch }) => - () => - () => {}, } diff --git a/framework/kibocommerce/commerce.config.json b/framework/kibocommerce/commerce.config.json index 7b96ac1c2..cd58f1e29 100644 --- a/framework/kibocommerce/commerce.config.json +++ b/framework/kibocommerce/commerce.config.json @@ -6,4 +6,4 @@ "search": true, "customerAuth": true } -} +} \ No newline at end of file diff --git a/framework/kibocommerce/customer/use-customer.tsx b/framework/kibocommerce/customer/use-customer.tsx index 41757cd0d..238b1229b 100644 --- a/framework/kibocommerce/customer/use-customer.tsx +++ b/framework/kibocommerce/customer/use-customer.tsx @@ -1,15 +1,24 @@ import { SWRHook } from '@commerce/utils/types' import useCustomer, { UseCustomer } from '@commerce/customer/use-customer' +import type { CustomerHook } from '../types/customer' export default useCustomer as UseCustomer -export const handler: SWRHook = { + +export const handler: SWRHook = { fetchOptions: { - query: '', + url: '/api/customer', + method: 'GET', }, - async fetcher({ input, options, fetch }) {}, - useHook: () => () => { - return async function addItem() { - return {} - } + async fetcher({ options, fetch }) { + const data = await fetch(options) + return data?.customer ?? null + }, + useHook: ({ useData }) => (input) => { + return useData({ + swrOptions: { + revalidateOnFocus: false, + ...input?.swrOptions, + }, + }) }, } diff --git a/framework/kibocommerce/lib/getCookieExpirationDate.ts b/framework/kibocommerce/lib/getCookieExpirationDate.ts new file mode 100644 index 000000000..89fd24504 --- /dev/null +++ b/framework/kibocommerce/lib/getCookieExpirationDate.ts @@ -0,0 +1,8 @@ +export function getCookieExpirationDate(maxAgeInDays: number){ + const today = new Date(); + const expirationDate = new Date(); + + const cookieExpirationDate = new Date ( expirationDate.setDate(today.getDate() + maxAgeInDays) ) + + return cookieExpirationDate; +} \ No newline at end of file diff --git a/framework/kibocommerce/lib/prepareSetCookie.ts b/framework/kibocommerce/lib/prepareSetCookie.ts new file mode 100644 index 000000000..3d9b3380a --- /dev/null +++ b/framework/kibocommerce/lib/prepareSetCookie.ts @@ -0,0 +1,13 @@ +export function prepareSetCookie(name: string, value: string, options: any = {}): string { + const cookieValue = [`${name}=${value}`]; + + if (options.maxAge) { + cookieValue.push(`Max-Age=${options.maxAge}`); + } + + if (options.expires && !options.maxAge) { + cookieValue.push(`Expires=${options.expires.toUTCString()}`); + } + + return cookieValue.join('; '); +} \ No newline at end of file diff --git a/framework/kibocommerce/lib/setCookie.ts b/framework/kibocommerce/lib/setCookie.ts new file mode 100644 index 000000000..2c194c921 --- /dev/null +++ b/framework/kibocommerce/lib/setCookie.ts @@ -0,0 +1,3 @@ +export function setCookies(res: any, cookies: string[]): void { + res.setHeader('Set-Cookie', cookies); +} \ No newline at end of file diff --git a/framework/kibocommerce/types/customer.ts b/framework/kibocommerce/types/customer.ts new file mode 100644 index 000000000..427bc0b03 --- /dev/null +++ b/framework/kibocommerce/types/customer.ts @@ -0,0 +1,5 @@ +import * as Core from '@commerce/types/customer' + +export * from '@commerce/types/customer' + +export type CustomerSchema = Core.CustomerSchema diff --git a/framework/kibocommerce/types/login.ts b/framework/kibocommerce/types/login.ts new file mode 100644 index 000000000..78b246191 --- /dev/null +++ b/framework/kibocommerce/types/login.ts @@ -0,0 +1,8 @@ +import * as Core from '@commerce/types/login' +import type { CustomerUserAuthInfoInput } from '../schema' + +export * from '@commerce/types/login' + +export type LoginOperation = Core.LoginOperation & { + variables: CustomerUserAuthInfoInput +} diff --git a/framework/kibocommerce/types/logout.ts b/framework/kibocommerce/types/logout.ts new file mode 100644 index 000000000..9f0a466af --- /dev/null +++ b/framework/kibocommerce/types/logout.ts @@ -0,0 +1 @@ +export * from '@commerce/types/logout' diff --git a/framework/kibocommerce/types/signup.ts b/framework/kibocommerce/types/signup.ts new file mode 100644 index 000000000..58543c6f6 --- /dev/null +++ b/framework/kibocommerce/types/signup.ts @@ -0,0 +1 @@ +export * from '@commerce/types/signup'