From 8f90b3ba306926151464e8a8808822caeef6189a Mon Sep 17 00:00:00 2001 From: rafinskipg Date: Fri, 8 Jan 2021 09:52:55 +0100 Subject: [PATCH 01/26] Add purchasing options --- .../content-form/content-form.tsx | 34 ++- .../content-form/purchasing-options-form.tsx | 270 ++++++++++++++++++ components/generic/content-buy-form/index.tsx | 33 +++ .../dynamic-field/fields/input-number.tsx | 6 +- components/layout/header/admin-sub-header.tsx | 10 +- edge.config.js | 136 ++++++++- lib/config/config.schema.js | 15 + lib/config/load-config.js | 14 + lib/types/contentTypeDefinition.ts | 15 +- pages/create/content/[type].tsx | 8 +- 10 files changed, 522 insertions(+), 19 deletions(-) create mode 100644 components/content/write-content/content-form/purchasing-options-form.tsx create mode 100644 components/generic/content-buy-form/index.tsx diff --git a/components/content/write-content/content-form/content-form.tsx b/components/content/write-content/content-form/content-form.tsx index da1f4963..91015595 100644 --- a/components/content/write-content/content-form/content-form.tsx +++ b/components/content/write-content/content-form/content-form.tsx @@ -1,6 +1,5 @@ -import React, { useEffect, useState, memo, useCallback } from 'react' -import { useGoogleReCaptcha } from 'react-google-recaptcha-v3' -import Link from 'next/link' +import PurchasingOptionsForm, { PurchashingOptionsType } from './purchasing-options-form' +import React, { memo, useCallback, useEffect, useState } from 'react' import API from '@lib/api/api-endpoints' import Button from '@components/generic/button/button' @@ -9,8 +8,11 @@ import ContentSummaryView from '../../read-content/content-summary-view/content- import DynamicField from '@components/generic/dynamic-field/dynamic-field-edit' import { FIELDS } from '@lib/constants' import GroupSummaryView from '@components/groups/read/group-summary-view/group-summary-view' +import Link from 'next/link' import Toggle from '@components/generic/toggle/toggle' import fetch from '@lib/fetcher' +import hasPermission from '@lib/permissions/has-permission' +import { useGoogleReCaptcha } from 'react-google-recaptcha-v3' function ContentForm(props) { // Saving states @@ -18,8 +20,18 @@ function ContentForm(props) { const [success, setSuccess] = useState(false) const [error, setError] = useState(false) + const defaultShoppingOptions: PurchashingOptionsType = { + multiple: false, + sku: '', + stock: 1, + options: [] + } + // used to store values - const [state, setState] = useState({}) + const [state, setState] = useState({ + draft: false, + purchasingOptions: defaultShoppingOptions + }) useEffect(() => { // Preload the form values @@ -30,12 +42,17 @@ function ContentForm(props) { const allowedKeys = props.permittedFields .map((f) => f.name) .concat('draft') + .concat('purchasingOptions') allowedKeys.map((k) => { filteredData[k] = props.content[k] }) - setState(filteredData) + setState({ + ...state, + ...filteredData, + purchasingOptions: filteredData['purchasingOptions'] || defaultShoppingOptions + }) } }, [props.content, props.type]) @@ -170,6 +187,13 @@ function ContentForm(props) { /> ))} + {hasPermission(props.currentUser, `content.${props.type.slug}.purchasing.sell`) &&
+ setState({ + ...state, + purchasingOptions: val + })} /> +
} +
+
+ + + ) +} + + +type PropTypes = { + value: PurchashingOptionsType, + onChange: (val: PurchashingOptionsType) => void +} + + +export default function PurchasingOptionsForm({ + value, + onChange +}: PropTypes) { + const [variants, setVariants] = useState([]) + + const addVariant = (ev: React.MouseEvent) => { + ev.preventDefault() + ev.stopPropagation() + + setVariants([...variants, { + name: '', + price: 0.0, + stock: 0, + default: false + }]) + } + + const onChangeVariant = (index, value: OptionsType) => { + setVariants(variants.map((item, i) => { + return index === i ? { + ...value + } : { + ...item, + default: value.default === true ? false: item.default + } + })) + } + + const onRemoveVariant = (index) => { + setVariants(variants.filter((item, i) => { + return i !== index + })) + } + + return ( + <> +
+

Purchashing Options

+ onChange({ + ...value, + multiple: val + })} + field={{ + name: 'multiple', + type: 'boolean', + label: 'Allow multiple items', + }} /> + onChange({ + ...value, + sku: val + })} + field={{ + name: 'sku', + type: 'text', + label: 'SKU', + }} /> + onChange({ + ...value, + stock: val + })} + field={{ + name: 'stock', + type: 'number', + label: 'stock', + min: 0 + }} /> + +
+
+ onChange({ + ...value, + price: val + })} + field={{ + name: 'price', + type: 'number', + label: 'price', + min: 0.1, + max: 10000 + }} /> +
+
+ onChange({ + ...value, + currency: val + })} + field={{ + name: 'currency', + type: 'select', + label: 'Currency', + options: [{ + value: 'euro', + label: '€' + }, { + value: 'dollar', + label: '$' + }, { + value: 'pound', + label: '£' + }], + description: 'Note: All values will be transformed to dollars at the moment of the payment.' + }} /> +
+
+ +
+

Product Variants

+

You can add diferent options for this product. For example: Sizes S,X,M,L with different prices and stock.

+ {variants.map((item, index) => { + return
+ onChangeVariant(index, val)} onClickRemove={() => onRemoveVariant(index)} /> +
+ })} + +
+ + + ) +} \ No newline at end of file diff --git a/components/generic/content-buy-form/index.tsx b/components/generic/content-buy-form/index.tsx new file mode 100644 index 00000000..8194fa3c --- /dev/null +++ b/components/generic/content-buy-form/index.tsx @@ -0,0 +1,33 @@ +import { ContentEntityType, ContentTypeDefinition } from '@lib/types' + +import Button from '../button/button' +import React from 'react' + +type PropTypes = { + contentType : ContentTypeDefinition, + content: ContentEntityType +} + +export default function ContentBuyForm({ + contentType, + content +}: PropTypes) { + return ( + <> +
+
+
+ +
+
+ + + ) +} \ No newline at end of file diff --git a/components/generic/dynamic-field/fields/input-number.tsx b/components/generic/dynamic-field/fields/input-number.tsx index 5efa1606..23f4584f 100644 --- a/components/generic/dynamic-field/fields/input-number.tsx +++ b/components/generic/dynamic-field/fields/input-number.tsx @@ -1,7 +1,6 @@ -import { memo } from 'react' - -import useOnChange from '@components/generic/dynamic-field/hooks/useOnChange' +import { memo } from 'react' import useFieldLength from '@components/generic/dynamic-field/hooks/useFieldLength' +import useOnChange from '@components/generic/dynamic-field/hooks/useOnChange' function InputNumber(props) { const { onChange, touched } = useOnChange({ onChangeHandler: props.onChange }) @@ -19,6 +18,7 @@ function InputNumber(props) { min={min} max={max} className={`${touched ? 'touched' : ''}`} + step="any" onChange={onChange} /> ) diff --git a/components/layout/header/admin-sub-header.tsx b/components/layout/header/admin-sub-header.tsx index 8ccd8822..ad3d32a9 100644 --- a/components/layout/header/admin-sub-header.tsx +++ b/components/layout/header/admin-sub-header.tsx @@ -56,7 +56,7 @@ export default function AdminSubHeader({ user }: PropTypes) { @@ -112,7 +112,7 @@ export default function AdminSubHeader({ user }: PropTypes) { -
  • + } + {hasPermission(user, 'admin.access') &&
  • @@ -170,8 +185,75 @@ export default function AdminSubHeader({ user }: PropTypes) { ) })} -
  • + } + {(hasPermission(user, 'purchasing.orders') || hasPermission(user, 'purchasing.sell')) &&
  • + + + + Orders + + +
  • } +
  • + + + + + + + Create + + +
  • @@ -208,6 +290,12 @@ export default function AdminSubHeader({ user }: PropTypes) { .admin-navigation li:hover .icon { fill: var(--accents-2); } + + .i { + font-size: 20px; + padding-right: 5px; + color: var(--accents-4); + } svg { margin-right: 4px; width: 20px; @@ -217,7 +305,7 @@ export default function AdminSubHeader({ user }: PropTypes) { transition: 0.35s ease; } .view-more{ - padding-right: 32px; + position: relative; user-select: none; } @@ -249,10 +337,6 @@ export default function AdminSubHeader({ user }: PropTypes) { max-width: 100px; } - .view-more { - padding-right: 80px; - } - svg{ box-sizing: content-box; margin: 0; diff --git a/components/layout/header/header.tsx b/components/layout/header/header.tsx index 6bfb3627..64132bf1 100644 --- a/components/layout/header/header.tsx +++ b/components/layout/header/header.tsx @@ -12,7 +12,7 @@ import { function Header() { const { user } = useUser() - const showAdminNav = hasPermission(user, `admin.access`) + const showAdminNav = hasPermission(user, `admin.access`) || hasPermission(user, 'purchasing.sell') || hasPermission(user, 'purchasing.orders') const [active, setActive] = useState(false) @@ -35,6 +35,7 @@ function Header() { {showAdminNav && } +
    diff --git a/edge.config.ts b/edge.config.ts index da68dd39..002056a5 100644 --- a/edge.config.ts +++ b/edge.config.ts @@ -249,6 +249,16 @@ export const getConfig = (config: any) => { web: false, }, + purchasing: { + enabled: true, + permissions: { + buy: [userRole], + sell: [shopRole, adminRole], + ship: [adminRole], + admin: [adminRole] + } + }, + comments: { enabled: true, permissions: { @@ -260,16 +270,6 @@ export const getConfig = (config: any) => { }, }, - purchasing: { - enabled: true, - permissions: { - buy: [userRole], - sell: [shopRole, adminRole], - ship: [adminRole], - admin: [adminRole] - } - }, - entityInteractions: [ { @@ -829,6 +829,17 @@ export const getConfig = (config: any) => { enabled: false, }, + // Purchasing + purchasing: { + enabled: true, + permissions: { + buy: [userRole], + sell: [shopRole, adminRole], + ship: [adminRole], + admin: [adminRole] + }, + }, + // super search configuration superSearch: { enabled: true, diff --git a/lib/client/hooks/use-content-types.js b/lib/client/hooks/use-content-types.ts similarity index 100% rename from lib/client/hooks/use-content-types.js rename to lib/client/hooks/use-content-types.ts diff --git a/lib/config/config.schema.ts b/lib/config/config.schema.ts index e5fdc774..c9e2ab03 100644 --- a/lib/config/config.schema.ts +++ b/lib/config/config.schema.ts @@ -163,7 +163,7 @@ export default object({ storage: object().required('Missing storage configuration'), database: object({ type: string().matches( - `(${DATABASES.FIREBASE}|${DATABASES.IN_MEMORY}|${DATABASES.MONGO})`, + `(${DATABASES.IN_MEMORY}|${DATABASES.MONGO})`, 'Invalid database type' ), }), @@ -221,6 +221,21 @@ export default object({ types: array(GroupTypeSchema), initialGroups: array(), }), + purchasing: object({ + enabled: boolean().default(false), + permissions: object({ + buy: array(string()), + sell: array(string()), + orders: array(string()), + admin: array(string()), + }) + .default({ + buy: [], + sell: [], + orders: [], + admin: [], + }), + }), superSearch: object({ enabled: boolean(), permissions: object(), diff --git a/lib/config/load-config.ts b/lib/config/load-config.ts index 469ccba4..ca446fad 100644 --- a/lib/config/load-config.ts +++ b/lib/config/load-config.ts @@ -122,6 +122,19 @@ export default function load() { newConfig.superSearch.permissions.read } + // Purchasing + if (newConfig.purchasing.enabled) { + availablePermissions[`purchasing.buy`] = + newConfig.purchasing.permissions.buy + availablePermissions[`purchasing.sell`] = + newConfig.purchasing.permissions.sell + availablePermissions[`purchasing.orders`] = + newConfig.purchasing.permissions.orders + availablePermissions[`purchasing.admin`] = + newConfig.purchasing.permissions.admin + } + + // interactions ;(newConfig.user.entityInteractions || []).forEach( ({ type: interactionType, permissions }) => { diff --git a/lib/permissions/index.ts b/lib/permissions/index.ts index f653196e..4fad662f 100644 --- a/lib/permissions/index.ts +++ b/lib/permissions/index.ts @@ -43,17 +43,23 @@ export const contentPermission = ( // Check permissions for a content purchasing actions export const purchasingPermission = ( user: UserType = publicUser, - entityType: string, action: string, + entityType?: string, content: ContentEntityType = null ) => { - const permission = [ + + const globalPermission = [ + `purchasing.${action}`, + `purchasing.admin`, + ] + + const contentTypePermission = [ `content.${entityType}.purchasing.${action}`, `content.${entityType}.purchasing.admin`, ] // If there is a content we need to check also if the content owner is the current user - const canAccess = hasPerm(user, permission) + const canAccess = entityType ? hasPerm(user, contentTypePermission) : hasPerm(user, globalPermission) if (content) { // Draft check diff --git a/package.json b/package.json index 1f5e1d1e..53fc54d5 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "formidable-serverless": "^1.0.3", "gray-matter": "^4.0.2", "isomorphic-unfetch": "^3.0.0", + "line-awesome": "^1.3.0", "markdown-it": "^11.0.0", "markdown-it-emoji": "^1.4.0", "mongodb": "^3.5.6", diff --git a/pages/_app.tsx b/pages/_app.tsx index e94fdbc6..1876b2be 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,4 +1,5 @@ import '../styles/index.scss' +import 'line-awesome/dist/line-awesome/css/line-awesome.css' import * as gtag from '../lib/client/gtag' diff --git a/pages/api/content/[type].ts b/pages/api/content/[type].ts index ae6845f5..662a3cf9 100644 --- a/pages/api/content/[type].ts +++ b/pages/api/content/[type].ts @@ -83,7 +83,7 @@ const createContent = async (req: Request, res) => { const { purchasingOptions, ...rest} = req.body // Only store purchasing options if the current user has permissions - const content = purchasingPermission(req.currentUser, type.slug, 'sell') ? { + const content = purchasingPermission(req.currentUser, 'sell', type.slug) ? { ...rest, purchasingOptions } : { diff --git a/pages/api/orders/index.ts b/pages/api/orders/index.ts index b38d8245..4e610770 100644 --- a/pages/api/orders/index.ts +++ b/pages/api/orders/index.ts @@ -36,6 +36,12 @@ import validateOrder from '@lib/validations/order' async function createOrder(req: Request, res) { + if (!purchasingPermission(req.currentUser, 'buy')) { + return res.status(401).json({ + error: 'Unauthorized' + }) + } + const orderObject: OrderType = { ...req.body, } @@ -60,27 +66,37 @@ async function createOrder(req: Request, res) { } -const getOrders = (filterParams, paginationParams) => (req: Request, res) => { - return findOrders(filterParams, paginationParams) - .then(result => { - return res.status(200).json(result) - }) -} - - -export default async (req: Request, res) => { +const getOrders = (req: Request, res) => { const { - query: { search, sortBy, sortOrder, from, limit }, + query: { sellerId, buyerId, sortBy, sortOrder, from, limit }, } = req - const filterParams = {} + const filterParams = { } + + if (sellerId) { + filterParams['sellerId'] = sellerId + } - if (search) { - filterParams['$or'] = [ - { to: { $regex: search } }, - { from: { $regex: search } }, - { text: { $regex: search } } - ] + if (buyerId) { + filterParams['buyerId'] = buyerId + } + + + // If there is a buyer ID + // We allow the current user to see their orders + // We allow order manager role to see orders + // We allow seller to see orders + const canAccessWithBuyerId = buyerId && (req.currentUser.id === buyerId || purchasingPermission(req.currentUser, 'orders') || (sellerId && req.currentUser.id === sellerId)) + const canAccessWithoutSellerId = !sellerId && purchasingPermission(req.currentUser, 'orders') + const canAccessSellerId = sellerId && (req.currentUser.id === sellerId || purchasingPermission(req.currentUser, 'orders')) + const canManageOrders = purchasingPermission(req.currentUser, 'orders') + + const canAccess = canAccessWithBuyerId || canAccessWithoutSellerId || canAccessSellerId || canManageOrders + + if (!canAccess) { + return res.status(401).json({ + error: 'Unauthorized' + }) } const paginationParams = { @@ -90,6 +106,16 @@ export default async (req: Request, res) => { limit, } + + return findOrders(filterParams, paginationParams) + .then(result => { + return res.status(200).json(result) + }) +} + + +export default async (req: Request, res) => { + try { // Connect to database await connect() @@ -108,15 +134,9 @@ export default async (req: Request, res) => { }) } - // TODO: We need to check the permissions of all the products in the order object - if (!purchasingPermission(req.currentUser, type, 'buy')) { - return res.status(401).json({ - error: 'Unauthorized' - }) - } await methods(req, res, { - get: getOrders(filterParams, paginationParams), + get: getOrders, post: createOrder, }) } diff --git a/yarn.lock b/yarn.lock index 5792aac0..c24e97d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6599,6 +6599,11 @@ levn@^0.3.0, levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +line-awesome@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/line-awesome/-/line-awesome-1.3.0.tgz#51d59fe311ed040d22d8e80d3aa0b9a4b57e6cd3" + integrity sha512-Y0YHksL37ixDsHz+ihCwOtF5jwJgCDxQ3q+zOVgaSW8VugHGTsZZXMacPYZB1/JULBi6BAuTCTek+4ZY/UIwcw== + line-column@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/line-column/-/line-column-1.0.2.tgz#d25af2936b6f4849172b312e4792d1d987bc34a2" From 9d7ec00c0143a2b8750a030f8660d41606b7229e Mon Sep 17 00:00:00 2001 From: rafinskipg Date: Sun, 17 Jan 2021 20:21:06 +0100 Subject: [PATCH 07/26] shopping carts API --- lib/api/entities/shopping-carts/index.ts | 69 +++++++++++++ lib/types/entities/shopping-cart.ts | 15 +++ lib/types/index.ts | 1 + pages/api/shopping-carts/index.ts | 120 +++++++++++++++++++++++ 4 files changed, 205 insertions(+) create mode 100644 lib/api/entities/shopping-carts/index.ts create mode 100644 lib/types/entities/shopping-cart.ts create mode 100644 pages/api/shopping-carts/index.ts diff --git a/lib/api/entities/shopping-carts/index.ts b/lib/api/entities/shopping-carts/index.ts new file mode 100644 index 00000000..d2d9f39c --- /dev/null +++ b/lib/api/entities/shopping-carts/index.ts @@ -0,0 +1,69 @@ +import { ANY_OBJECT, ShoppingCartType } from '@lib/types' + +import { getDB } from '@lib/api/db' + +type ShoppingCartsResponseType = { + total: number, + from: number, + limit: number, + results: ShoppingCartType[] +} + +export function findOneShoppingCart(options) { + return getDB() + .collection('shopping-carts') + .findOne(options) +} + +export function addShoppingCart(data: ShoppingCartType) { + return getDB() + .collection('shopping-carts') + .add(data) +} + +export function updateOneShoppingCart(id, data:ShoppingCartType) { + return getDB() + .collection('shopping-carts') + .doc(id) + .set(data) +} + +export async function findShoppingCarts( + options = {}, + paginationOptions: ANY_OBJECT = {} +): Promise { + const { from = 0, limit = 15, sortBy, sortShoppingCart = 'DESC' } = paginationOptions + + const total = await getDB() + .collection('shopping-carts') + .count(options) + + return getDB() + .collection('shopping-carts') + .limit(limit) + .start(from) + .find(options, { + sortBy, + sortShoppingCart, + }) + .then(async (data) => { + return { + results: data, + from, + limit, + total, + } + }) +} + +export function deleteShoppingCarts(options) { + return getDB() + .collection('shopping-carts') + .remove(options) +} + +export function deleteOneShoppingCart(options) { + return getDB() + .collection('shopping-carts') + .remove(options, true) +} diff --git a/lib/types/entities/shopping-cart.ts b/lib/types/entities/shopping-cart.ts new file mode 100644 index 00000000..658c5258 --- /dev/null +++ b/lib/types/entities/shopping-cart.ts @@ -0,0 +1,15 @@ +import { TDate } from "timeago.js"; + +export type ShoppingCartProductType = { + productId: string, + productContentType: string, + sellerId: string, + amount: number, + variant: string +} + +export type ShoppingCartType = { + userId: string, + products: ShoppingCartProductType[] + createdAt: number +} \ No newline at end of file diff --git a/lib/types/index.ts b/lib/types/index.ts index 628bbebf..ca37ccf5 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -15,6 +15,7 @@ export * from './userTypeDefinition' export * from './entities/email' export * from './statistic' export * from './entities/orders' +export * from './entities/shopping-cart' export type ANY_OBJECT = { [key: string]: any diff --git a/pages/api/shopping-carts/index.ts b/pages/api/shopping-carts/index.ts new file mode 100644 index 00000000..cd53b6d6 --- /dev/null +++ b/pages/api/shopping-carts/index.ts @@ -0,0 +1,120 @@ +import { + addShoppingCart, + findShoppingCarts, +} from '@lib/api/entities/shopping-carts' + +import { Request } from '@lib/types' +import { ShoppingCartType } from '@lib/types/entities/shopping-cart' +import { connect } from '@lib/api/db' +import { loadUser } from '@lib/api/middlewares' +import logger from '@lib/logger' +import methods from '@lib/api/api-helpers/methods' +import { purchasingPermission } from '@lib/permissions' +import runMiddleware from '@lib/api/api-helpers/run-middleware' + +function validateShoppingCart(item: ShoppingCartType) { + if (item.products.length === 0 ) { + return false + } + + // TODO: validate data structure. Maybe use yup schema +} + +async function createShoppingCart(req: Request, res) { + + if (!purchasingPermission(req.currentUser, 'buy')) { + return res.status(401).json({ + error: 'Unauthorized' + }) + } + + const shoppingCartObject: ShoppingCartType = { + ...req.body, + } + + if (!validateShoppingCart(shoppingCartObject)) { + return res.status(400).json({ + error: 'Malformed order entity' + }) + } + + try { + const result = await addShoppingCart(shoppingCartObject) + + return res.status(200).json(result) + } catch (err) { + res.status(500).json({ + error: 'Error creating shoppign cart ' + err.message + }) + } + + +} + +const getShoppingCarts = (req: Request, res) => { + const { + query: { userId, sortBy, sortOrder, from, limit, id }, + } = req + + const filterParams = { } + + if (userId) { + filterParams['userId'] = userId + } + + if (id) { + filterParams['id'] = id + } + + const canAccessWithBuyerId = (userId && req.currentUser.id === userId) || purchasingPermission(req.currentUser, 'admin') + const canManageShoppingCarts = purchasingPermission(req.currentUser, 'admin') + + const canAccess = canManageShoppingCarts || canAccessWithBuyerId + + if (!canAccess) { + return res.status(401).json({ + error: 'Unauthorized' + }) + } + + const paginationParams = { + sortBy, + sortOrder, + from, + limit, + } + + + return findShoppingCarts(filterParams, paginationParams) + .then(result => { + return res.status(200).json(result) + }) +} + + +export default async (req: Request, res) => { + + try { + // Connect to database + await connect() + } catch (e) { + logger('ERROR', 'Can not connect to db', e) + return res.status(500).json({ + error: e.message, + }) + } + + try { + await runMiddleware(req, res, loadUser) + } catch (e) { + return res.status(500).json({ + error: e.message, + }) + } + + + await methods(req, res, { + get: getShoppingCarts, + post: createShoppingCart, + }) +} From 7c294d33e41539706cee6ff23a527c8864d7cc43 Mon Sep 17 00:00:00 2001 From: rafinskipg Date: Thu, 21 Jan 2021 10:34:23 +0100 Subject: [PATCH 08/26] JWT --- DOCUMENTATION.md | 8 +++-- .../auth/{auth-cookies.js => auth-cookies.ts} | 0 lib/api/auth/passport-strategies.js | 2 +- lib/api/auth/{iron.js => token.ts} | 15 ++++++++-- lib/api/middlewares/load-user.middleware.js | 2 +- package.json | 2 +- pages/api/activity/[userId].ts | 4 +-- pages/api/auth/[...action].ts | 8 +++-- pages/api/users/[...slug].ts | 2 +- pages/content/[type].tsx | 7 ++--- pages/create/content/[type].tsx | 2 +- pages/create/group/[type].tsx | 11 +++---- pages/edit/content/[type]/[slug].tsx | 8 ++--- pages/edit/group/[type]/[slug].tsx | 8 ++--- pages/group/[type].tsx | 8 ++--- pages/group/[type]/[slug].tsx | 2 +- yarn.lock | 30 ------------------- 17 files changed, 51 insertions(+), 68 deletions(-) rename lib/api/auth/{auth-cookies.js => auth-cookies.ts} (100%) rename lib/api/auth/{iron.js => token.ts} (53%) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index d7f4dc54..303df135 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -749,11 +749,13 @@ MONGODB_DATABASE= Edge uses [Passport.js](http://www.passportjs.org) cookie based authentication with email and password. -The login cookie is httpOnly, meaning it can only be accessed by the API, and it's encrypted using [@hapi/iron](https://hapi.dev/family/iron) for more security. +The login cookie is httpOnly, meaning it can only be accessed by the API, and it's encrypted using [JWT](https://www.npmjs.com/package/jsonwebtoken) for more security. -Apart from the cookie authentication there are two more ways to authenticate: -- JWT token on the Authenthication header* +Apart from the cookie authentication there are two more ways to authorizate users: +- JWT token on the Authenthication header + - Using the format `Authentication: Bearer TOKEN` - API Key + - TO-DO ### Providers diff --git a/lib/api/auth/auth-cookies.js b/lib/api/auth/auth-cookies.ts similarity index 100% rename from lib/api/auth/auth-cookies.js rename to lib/api/auth/auth-cookies.ts diff --git a/lib/api/auth/passport-strategies.js b/lib/api/auth/passport-strategies.js index a8b1fd2d..e3d55fa1 100644 --- a/lib/api/auth/passport-strategies.js +++ b/lib/api/auth/passport-strategies.js @@ -8,7 +8,7 @@ import FacebookStrategy from 'passport-facebook' import GithubStrategy from 'passport-github2' import { OAuth2Strategy as GoogleStrategy } from 'passport-google-oauth' import Local from 'passport-local' -import { getSession } from './iron' +import { getSession } from './token' import logger from '@lib/logger' import slugify from 'slugify' diff --git a/lib/api/auth/iron.js b/lib/api/auth/token.ts similarity index 53% rename from lib/api/auth/iron.js rename to lib/api/auth/token.ts index ebc1d95b..e26cd290 100644 --- a/lib/api/auth/iron.js +++ b/lib/api/auth/token.ts @@ -1,5 +1,5 @@ -import Iron from '@hapi/iron' import { getTokenCookie } from './auth-cookies' +import jwt from 'jsonwebtoken' // Use an environment variable here instead of a hardcoded value for production const TOKEN_SECRET = @@ -7,10 +7,19 @@ const TOKEN_SECRET = 'default-secret-string-that-should-not-be-used' export function encryptSession(session) { - return Iron.seal(session, TOKEN_SECRET, Iron.defaults) + return jwt.sign(session, TOKEN_SECRET, { + expiresIn: '2 days' + } ) } export async function getSession(req) { const token = getTokenCookie(req) - return token && Iron.unseal(token, TOKEN_SECRET, Iron.defaults) + + if (token) { + return jwt.verify(token, TOKEN_SECRET) + } else if (req.headers['authorization']) { + return jwt.verify(req.headers['authorization'].replace('Bearer ', ''), TOKEN_SECRET) + } + + return null } diff --git a/lib/api/middlewares/load-user.middleware.js b/lib/api/middlewares/load-user.middleware.js index ce2ac781..c4fdc3e8 100644 --- a/lib/api/middlewares/load-user.middleware.js +++ b/lib/api/middlewares/load-user.middleware.js @@ -1,4 +1,4 @@ -import { getSession } from '../auth/iron' +import { getSession } from '../auth/token' export default async (req, res, cb) => { try { diff --git a/package.json b/package.json index 53fc54d5..ec7364ad 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ }, "dependencies": { "@babel/core": "^7.11.1", - "@hapi/iron": "6.0.0", "@sendgrid/mail": "^7.1.0", "azure-storage": "2.10.3", "content-type": "^1.0.4", @@ -23,6 +22,7 @@ "formidable-serverless": "^1.0.3", "gray-matter": "^4.0.2", "isomorphic-unfetch": "^3.0.0", + "jsonwebtoken": "^8.5.1", "line-awesome": "^1.3.0", "markdown-it": "^11.0.0", "markdown-it-emoji": "^1.4.0", diff --git a/pages/api/activity/[userId].ts b/pages/api/activity/[userId].ts index 661e04fe..8d4c9c8c 100644 --- a/pages/api/activity/[userId].ts +++ b/pages/api/activity/[userId].ts @@ -1,10 +1,10 @@ +import { Request } from '@lib/types' import { connect } from '@lib/api/db' import { findActivity } from '@lib/api/entities/activity' import { findOneUser } from '@lib/api/entities/users' -import { getSession } from '@lib/api/auth/iron' +import { getSession } from '@lib/api/auth/token' import { hasPermission } from '@lib/permissions' import logger from '@lib/logger' -import { Request } from '@lib/types' import methods from '@lib/api/api-helpers/methods' import runMiddleware from '@lib/api/api-helpers/run-middleware' diff --git a/pages/api/auth/[...action].ts b/pages/api/auth/[...action].ts index 390fc278..b1400fdd 100644 --- a/pages/api/auth/[...action].ts +++ b/pages/api/auth/[...action].ts @@ -12,7 +12,7 @@ import { Request } from 'express' import { UserType } from '@lib/types' import appConfig from '@lib/config' import { connect } from '@lib/api/db' -import { encryptSession } from '@lib/api/auth/iron' +import { encryptSession } from '@lib/api/auth/token' import express from 'express' import { generateSaltAndHash } from '@lib/api/entities/users/user.utils' import logger from '@lib/logger' @@ -91,6 +91,8 @@ const logUserIn = async (res, user) => { }) setTokenCookie(res, token) + + return token } // Normal login @@ -120,9 +122,9 @@ app.post('/api/auth/login', async (req: Request, res) => { }) } - await logUserIn(res, user) + const token = await logUserIn(res, user) - return res.status(200).json({ done: true }) + return res.status(200).json({ token, userId: user.id }) } catch (error) { console.error(error) return res.status(401).json({ error: error.message }) diff --git a/pages/api/users/[...slug].ts b/pages/api/users/[...slug].ts index 84b2e3e5..6b6dbbaa 100644 --- a/pages/api/users/[...slug].ts +++ b/pages/api/users/[...slug].ts @@ -19,7 +19,7 @@ import { onUserDeleted, onUserUpdated } from '@lib/api/hooks/user.hooks' import Cypher from '@lib/api/api-helpers/cypher-fields' import { connect } from '@lib/api/db' import edgeConfig from '@lib/config' -import { getSession } from '@lib/api/auth/iron' +import { getSession } from '@lib/api/auth/token' import { hasPermission } from '@lib/permissions' import { hidePrivateUserFields } from '@lib/api/entities/users/user.utils' import logger from '@lib/logger' diff --git a/pages/content/[type].tsx b/pages/content/[type].tsx index 8b59c760..98ca6d63 100644 --- a/pages/content/[type].tsx +++ b/pages/content/[type].tsx @@ -1,18 +1,17 @@ import { hasPermissionsForContent, loadUser } from '@lib/api/middlewares' import ContentListView from '@components/content/read-content/content-list-view/content-list-view' +import Cypher from '@lib/api/api-helpers/cypher-fields' import { GetServerSideProps } from 'next' import Layout from '@components/layout/three-panels/layout' import ListContentTypes from '@components/content/read-content/list-content-types/list-content-types' import ToolBar from '@components/generic/toolbar/toolbar' +import { appendInteractions } from '@lib/api/entities/interactions/interactions.utils' import { connect } from '@lib/api/db' import { findContent } from '@lib/api/entities/content' import { getContentTypeDefinition } from '@lib/config' +import { getSession } from '@lib/api/auth/token' import runMiddleware from '@lib/api/api-helpers/run-middleware' -import { getSession } from '@lib/api/auth/iron' -import { appendInteractions } from '@lib/api/entities/interactions/interactions.utils' -import Cypher from '@lib/api/api-helpers/cypher-fields' - // Get serversideProps is important for SEO, and only available at the pages level export const getServerSideProps: GetServerSideProps = async ({ diff --git a/pages/create/content/[type].tsx b/pages/create/content/[type].tsx index 8ee6530e..6a75c69e 100644 --- a/pages/create/content/[type].tsx +++ b/pages/create/content/[type].tsx @@ -12,7 +12,7 @@ import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3' import Layout from '@components/layout/normal/layout' import { connect } from '@lib/api/db' import { findOneContent } from '@lib/api/entities/content' -import { getSession } from '@lib/api/auth/iron' +import { getSession } from '@lib/api/auth/token' // Get serversideProps is important for SEO, and only available at the pages level export const getServerSideProps: GetServerSideProps = async ({ diff --git a/pages/create/group/[type].tsx b/pages/create/group/[type].tsx index 608b5b34..21a30f66 100644 --- a/pages/create/group/[type].tsx +++ b/pages/create/group/[type].tsx @@ -1,12 +1,13 @@ -import GroupForm from '@components/groups/write/group-form/group-form' import { cypheredFieldPermission, groupPermission } from '@lib/permissions' -import Layout from '@components/layout/normal/layout' -import { useState, useMemo, useCallback } from 'react' +import { useCallback, useMemo, useState } from 'react' + import { GetServerSideProps } from 'next' +import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3' +import GroupForm from '@components/groups/write/group-form/group-form' +import Layout from '@components/layout/normal/layout' import { connect } from '@lib/api/db' import { getGroupTypeDefinition } from '@lib/config' -import { getSession } from '@lib/api/auth/iron' -import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3' +import { getSession } from '@lib/api/auth/token' export const getServerSideProps: GetServerSideProps = async ({ req, diff --git a/pages/edit/content/[type]/[slug].tsx b/pages/edit/content/[type]/[slug].tsx index 74263def..75c41984 100644 --- a/pages/edit/content/[type]/[slug].tsx +++ b/pages/edit/content/[type]/[slug].tsx @@ -1,15 +1,15 @@ +import { contentPermission, cypheredFieldPermission } from '@lib/permissions' import { useCallback, useMemo, useState } from 'react' import ContentForm from '@components/content/write-content/content-form/content-form' +import Cypher from '@lib/api/api-helpers/cypher-fields' import { GetServerSideProps } from 'next' +import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3' import Layout from '@components/layout/normal/layout' import { connect } from '@lib/api/db' -import { contentPermission, cypheredFieldPermission } from '@lib/permissions' import { findOneContent } from '@lib/api/entities/content' import { getContentTypeDefinition } from '@lib/config' -import { getSession } from '@lib/api/auth/iron' -import Cypher from '@lib/api/api-helpers/cypher-fields' -import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3' +import { getSession } from '@lib/api/auth/token' function notFound(res) { res.writeHead(302, { Location: '/404' }) diff --git a/pages/edit/group/[type]/[slug].tsx b/pages/edit/group/[type]/[slug].tsx index c8124e99..532c6e76 100644 --- a/pages/edit/group/[type]/[slug].tsx +++ b/pages/edit/group/[type]/[slug].tsx @@ -1,15 +1,15 @@ +import { cypheredFieldPermission, groupPermission } from '@lib/permissions' import { useCallback, useMemo, useState } from 'react' +import Cypher from '@lib/api/api-helpers/cypher-fields' import { GetServerSideProps } from 'next' +import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3' import GroupForm from '@components/groups/write/group-form/group-form' import Layout from '@components/layout/normal/layout' import { connect } from '@lib/api/db' import { findOneContent } from '@lib/api/entities/content' import { getGroupTypeDefinition } from '@lib/config' -import { getSession } from '@lib/api/auth/iron' -import { cypheredFieldPermission, groupPermission } from '@lib/permissions' -import Cypher from '@lib/api/api-helpers/cypher-fields' -import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3' +import { getSession } from '@lib/api/auth/token' function notFound(res) { res.writeHead(302, { Location: '/404' }) diff --git a/pages/group/[type].tsx b/pages/group/[type].tsx index 3ca9a1d1..2c7969af 100644 --- a/pages/group/[type].tsx +++ b/pages/group/[type].tsx @@ -1,19 +1,19 @@ import { hasPermissionsForGroup, loadUser } from '@lib/api/middlewares' +import Cypher from '@lib/api/api-helpers/cypher-fields' import { GetServerSideProps } from 'next' import GroupListView from '@components/groups/read/group-list-view/group-list-view' import Layout from '@components/layout/three-panels/layout' import LinkList from '@components/generic/link-list/link-list' import ToolBar from '@components/generic/toolbar/toolbar' +import { appendInteractions } from '@lib/api/entities/interactions/interactions.utils' import { connect } from '@lib/api/db' import { findContent } from '@lib/api/entities/content' import { getGroupTypeDefinition } from '@lib/config' +import { getSession } from '@lib/api/auth/token' +import { groupPermission } from '@lib/permissions' import runMiddleware from '@lib/api/api-helpers/run-middleware' import { useGroupTypes } from '@lib/client/hooks' -import { getSession } from '@lib/api/auth/iron' -import { appendInteractions } from '@lib/api/entities/interactions/interactions.utils' -import Cypher from '@lib/api/api-helpers/cypher-fields' -import { groupPermission } from '@lib/permissions' // Get serversideProps is important for SEO, and only available at the pages level export const getServerSideProps: GetServerSideProps = async ({ diff --git a/pages/group/[type]/[slug].tsx b/pages/group/[type]/[slug].tsx index aa1f94b0..36bba8c9 100644 --- a/pages/group/[type]/[slug].tsx +++ b/pages/group/[type]/[slug].tsx @@ -11,7 +11,7 @@ import { appendInteractions } from '@lib/api/entities/interactions/interactions. import { connect } from '@lib/api/db' import { findOneContent } from '@lib/api/entities/content' import { getGroupTypeDefinition } from '@lib/config' -import { getSession } from '@lib/api/auth/iron' +import { getSession } from '@lib/api/auth/token' import { groupUserPermission } from '@lib/permissions' import runMiddleware from '@lib/api/api-helpers/run-middleware' diff --git a/yarn.lock b/yarn.lock index c24e97d6..ed119b7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1117,13 +1117,6 @@ resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5" integrity sha512-QD1PhQk+s31P1ixsX0H0Suoupp3VMXzIVMSwobR3F3MSUO2YCV0B7xqLcUw/Bh8yuvd3LhpyqLQWTNcRmp6IdQ== -"@hapi/b64@5.x.x": - version "5.0.0" - resolved "https://registry.yarnpkg.com/@hapi/b64/-/b64-5.0.0.tgz#b8210cbd72f4774985e78569b77e97498d24277d" - integrity sha512-ngu0tSEmrezoiIaNGG6rRvKOUkUuDdf4XTPnONHGYfSGRmDqPZX5oJL6HAdKTo1UQHECbdB4OzhWrfgVppjHUw== - dependencies: - "@hapi/hoek" "9.x.x" - "@hapi/boom@9.x.x": version "9.1.1" resolved "https://registry.yarnpkg.com/@hapi/boom/-/boom-9.1.1.tgz#89e6f0e01637c2a4228da0d113e8157c93677b04" @@ -1131,18 +1124,6 @@ dependencies: "@hapi/hoek" "9.x.x" -"@hapi/bourne@2.x.x": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-2.0.0.tgz#5bb2193eb685c0007540ca61d166d4e1edaf918d" - integrity sha512-WEezM1FWztfbzqIUbsDzFRVMxSoLy3HugVcux6KDDtTqzPsLE8NDRHfXvev66aH1i2oOKKar3/XDjbvh/OUBdg== - -"@hapi/cryptiles@5.x.x": - version "5.1.0" - resolved "https://registry.yarnpkg.com/@hapi/cryptiles/-/cryptiles-5.1.0.tgz#655de4cbbc052c947f696148c83b187fc2be8f43" - integrity sha512-fo9+d1Ba5/FIoMySfMqPBR/7Pa29J2RsiPrl7bkwo5W5o+AN1dAYQRi4SPrPwwVxVGKjgLOEWrsvt1BonJSfLA== - dependencies: - "@hapi/boom" "9.x.x" - "@hapi/formula@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@hapi/formula/-/formula-1.2.0.tgz#994649c7fea1a90b91a0a1e6d983523f680e10cd" @@ -1158,17 +1139,6 @@ resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-8.5.1.tgz#fde96064ca446dec8c55a8c2f130957b070c6e06" integrity sha512-yN7kbciD87WzLGc5539Tn0sApjyiGHAJgKvG9W8C7O+6c7qmoQMfVs0W4bX17eqz6C78QJqqFrtgdK5EWf6Qow== -"@hapi/iron@6.0.0": - version "6.0.0" - resolved "https://registry.yarnpkg.com/@hapi/iron/-/iron-6.0.0.tgz#ca3f9136cda655bdd6028de0045da0de3d14436f" - integrity sha512-zvGvWDufiTGpTJPG1Y/McN8UqWBu0k/xs/7l++HVU535NLHXsHhy54cfEMdW7EjwKfbBfM9Xy25FmTiobb7Hvw== - dependencies: - "@hapi/b64" "5.x.x" - "@hapi/boom" "9.x.x" - "@hapi/bourne" "2.x.x" - "@hapi/cryptiles" "5.x.x" - "@hapi/hoek" "9.x.x" - "@hapi/joi@^16.1.8": version "16.1.8" resolved "https://registry.yarnpkg.com/@hapi/joi/-/joi-16.1.8.tgz#84c1f126269489871ad4e2decc786e0adef06839" From 469ae9c9d44c73fdaf7a5ea89d84ba736a6ab57e Mon Sep 17 00:00:00 2001 From: rafinskipg Date: Thu, 4 Feb 2021 09:10:06 +0100 Subject: [PATCH 09/26] working on shopping cart tests --- .../content-detail-view.tsx | 11 +- .../purchasing-product-form.tsx | 255 +++++++++++++++++ .../content-form/purchasing-options-form.tsx | 2 +- components/layout/header/admin-sub-header.tsx | 131 +-------- lib/types/entities/shopping-cart.ts | 2 +- pages/api/shopping-carts/[id]/add-product.ts | 120 ++++++++ pages/api/shopping-carts/[id]/get-prices.ts | 1 + pages/api/shopping-carts/[id]/index.ts | 76 +++++ .../api/shopping-carts/[id]/remove-product.ts | 0 pages/api/shopping-carts/index.ts | 43 ++- pages/checkout/[cartId].tsx | 75 +++++ pages/content/[type]/[slug].tsx | 2 +- .../comments/delete/comments.delete.test.js | 4 +- .../comments/read/comment-list.read.test.js | 4 +- .../api/comments/write/comments.write.test.js | 4 +- .../api/content/delete/content.delete.test.js | 4 +- .../read/content-list.cypher.read.test.js | 5 +- .../content/read/content.cypher.read.test.js | 5 +- .../content/read/content.detail.read.test.js | 4 +- .../api/content/read/content.read.test.js | 4 +- .../api/content/write/content.create.test.js | 4 +- .../write/content.cypher.create.test.js | 6 +- .../write/content.cypher.update.test.js | 7 +- .../api/content/write/content.edit.test.js | 4 +- .../write/content.purchasing.create.test.js | 4 +- .../delete/groups.comment.delete.test.js | 4 +- .../groups/delete/groups.user.delete.test.js | 4 +- .../api/groups/read/group.cypher.read.test.js | 5 +- .../read/groups-list.cypher.read.test.js | 5 +- .../groups/read/groups.content.read.test.js | 4 +- .../api/groups/read/groups.read.test.js | 4 +- .../write/groups.comment.create.test.js | 4 +- .../write/groups.content.create.test.js | 4 +- .../groups/write/groups.content.edit.test.js | 4 +- .../api/groups/write/groups.create.test.js | 4 +- .../api/groups/write/groups.edit.test.js | 4 +- .../groups/write/groups.user.create.test.js | 4 +- .../groups/write/groups.user.update.test.js | 4 +- .../delete/interactions.delete.test.js | 8 +- .../write/interactions.create.test.js | 6 +- .../shopping-cart-detail.read.test.js | 0 .../shopping-cart.create.test.js | 260 ++++++++++++++++++ .../shopping-cart.delete.test.js | 0 .../shopping-cart/shopping-cart.read.test.js | 241 ++++++++++++++++ .../shopping-cart.update.test.js | 242 ++++++++++++++++ .../api/statistic/statistic.read.test.js | 5 +- .../api/superSearch/superSearch.read.test.js | 5 +- .../api/users/delete/user.delete.test.js | 4 +- .../api/users/read/user.cypher.read.test.js | 5 +- .../users/read/users-list.cypher.read.test.js | 5 +- .../api/users/read/users-list.read.test.js | 4 +- .../api/users/read/users.read.test.js | 4 +- .../api/users/write/users.edit.test.js | 4 +- .../api/users/write/users.register.test.js | 4 +- tsconfig.json | 3 +- 55 files changed, 1415 insertions(+), 216 deletions(-) create mode 100644 components/content/read-content/content-detail-view/purchasing-product-form.tsx create mode 100644 pages/api/shopping-carts/[id]/add-product.ts create mode 100644 pages/api/shopping-carts/[id]/get-prices.ts create mode 100644 pages/api/shopping-carts/[id]/index.ts create mode 100644 pages/api/shopping-carts/[id]/remove-product.ts create mode 100644 pages/checkout/[cartId].tsx create mode 100644 test/integration/api/shopping-cart/shopping-cart-detail.read.test.js create mode 100644 test/integration/api/shopping-cart/shopping-cart.create.test.js create mode 100644 test/integration/api/shopping-cart/shopping-cart.delete.test.js create mode 100644 test/integration/api/shopping-cart/shopping-cart.read.test.js create mode 100644 test/integration/api/shopping-cart/shopping-cart.update.test.js diff --git a/components/content/read-content/content-detail-view/content-detail-view.tsx b/components/content/read-content/content-detail-view/content-detail-view.tsx index 6d3969b0..bc169c4c 100644 --- a/components/content/read-content/content-detail-view/content-detail-view.tsx +++ b/components/content/read-content/content-detail-view/content-detail-view.tsx @@ -1,4 +1,5 @@ -import React, { useState, memo, Fragment } from 'react' +import React, { Fragment, memo, useState } from 'react' +import config, { getInteractionsDefinition } from '@lib/config' import AuthorBox from '@components/user/author-box/author-box' import Button from '@components/generic/button/button' @@ -8,14 +9,14 @@ import ContentActions from '../../content-actions/content-actions' import ContentSummaryView from '../content-summary-view/content-summary-view' import { ContentTypeDefinition } from '@lib/types/contentTypeDefinition' import FollowButton from '@components/user/follow-button/follow-button' +import { Interaction } from '@components/generic/interactions' import ReactionCounter from '@components/generic/reaction-counter/reaction-counter' import SocialShare from '@components/generic/social-share/social-share' -import config, { getInteractionsDefinition } from '@lib/config' import { format } from 'timeago.js' +import { purchasingPermission } from '@lib/permissions' import { useMonetizationState } from 'react-web-monetization' import { usePermission } from '@lib/client/hooks' import { useUser } from '@lib/client/hooks' -import { Interaction } from '@components/generic/interactions' interface Props { content: any @@ -82,6 +83,8 @@ function ContentDetailView(props: Props) { setShowComments(true) } } + + return ( <>
    @@ -154,6 +157,8 @@ function ContentDetailView(props: Props) { user={user} />
    + + {purchasingPermission(user, 'buy', props.type.slug) && }