diff --git a/.changeset/beige-dragons-jump.md b/.changeset/beige-dragons-jump.md new file mode 100644 index 00000000000..7df11bd3089 --- /dev/null +++ b/.changeset/beige-dragons-jump.md @@ -0,0 +1,7 @@ +--- +'@keystonejs/list-plugins': patch +--- + +Tweaked hooks and utility function. +* Renamed `composeResolveInput` utility function to `composeHook` to indicate right use by name, this can also be used in other hook type and not just `resolveInput` hook. +* Switch to use of `operation` param to hook for detecting if this is `create` or `update` operation instead of existingItem being `undefined`. \ No newline at end of file diff --git a/.changeset/dirty-cougars-smoke.md b/.changeset/dirty-cougars-smoke.md new file mode 100644 index 00000000000..2934da7147d --- /dev/null +++ b/.changeset/dirty-cougars-smoke.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/app-admin-ui': patch +--- + +Converted ResizeHandler and ScrollQuery components to custom hooks. diff --git a/.changeset/eighty-boxes-remember.md b/.changeset/eighty-boxes-remember.md new file mode 100644 index 00000000000..447e58be53a --- /dev/null +++ b/.changeset/eighty-boxes-remember.md @@ -0,0 +1,6 @@ +--- +'@keystonejs/keystone': patch +'@keystonejs/utils': patch +--- + +Converted some stray promise chains to async/await. diff --git a/.changeset/long-adults-travel.md b/.changeset/long-adults-travel.md new file mode 100644 index 00000000000..128894971d5 --- /dev/null +++ b/.changeset/long-adults-travel.md @@ -0,0 +1,10 @@ +--- +'@keystonejs/cypress-project-access-control': patch +'@keystonejs/cypress-project-basic': patch +'@keystonejs/cypress-project-client-validation': patch +'@keystonejs/cypress-project-login': patch +'@keystonejs/keystone': major +'@keystonejs/session': major +--- + +The `cookieSecret` option no longer defaults to a static value. It is now required in production mode. In development mode, if undefined, a random new value is generated each time the server is started. diff --git a/.changeset/tasty-numbers-turn.md b/.changeset/tasty-numbers-turn.md new file mode 100644 index 00000000000..d98a611eaa1 --- /dev/null +++ b/.changeset/tasty-numbers-turn.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/app-admin-ui': major +--- + +Removed the method `AdminUIApp.getAdminMeta()` in favour of the more complete `AdminUIApp.getAdminUIMeta(keystone)`. diff --git a/packages/app-admin-ui/client/components/Nav/ResizeHandler.js b/packages/app-admin-ui/client/components/Nav/ResizeHandler.js index 813fb2a49d2..08a60cca696 100644 --- a/packages/app-admin-ui/client/components/Nav/ResizeHandler.js +++ b/packages/app-admin-ui/client/components/Nav/ResizeHandler.js @@ -1,115 +1,128 @@ -import { Component } from 'react'; +import { useState, useRef, useEffect } from 'react'; import raf from 'raf-schd'; -import { withKeyboardConsumer } from '../KeyboardShortcuts'; +import { useKeyboardManager } from '../KeyboardShortcuts'; const LS_KEY = 'KEYSTONE_NAVIGATION_STATE'; const DEFAULT_STATE = { isCollapsed: false, width: 280 }; const MIN_WIDTH = 140; const MAX_WIDTH = 800; + export const KEYBOARD_SHORTCUT = '['; -function getCache() { - if (typeof localStorage !== 'undefined') { +const getCache = () => { + if (localStorage !== undefined) { const stored = localStorage.getItem(LS_KEY); - return stored ? JSON.parse(stored) : DEFAULT_STATE; + + if (stored) { + return JSON.parse(stored); + } } + return DEFAULT_STATE; -} -function setCache(state) { - if (typeof localStorage !== 'undefined') { +}; + +const setCache = state => { + if (localStorage !== undefined) { localStorage.setItem(LS_KEY, JSON.stringify(state)); } -} +}; -class ResizeHandler extends Component { - state = getCache(); +export const useResizeHandler = () => { + // TODO: should we be calling this in the function body? + const { width: cachedWidth, isCollapsed: cachedIsCollapsed } = getCache(); - componentDidMount() { - this.props.keyManager.subscribe(KEYBOARD_SHORTCUT, this.toggleCollapse); - } - componentWillUnmount() { - this.props.keyManager.unsubscribe(KEYBOARD_SHORTCUT); - } + // These should trigger renders + const [width, setWidth] = useState(cachedWidth); + const [isCollapsed, setIsCollapsed] = useState(cachedIsCollapsed); + const [isMouseDown, setIsMouseDown] = useState(false); + const [isDragging, setIsDragging] = useState(false); - storeState = s => { - // only keep the `isCollapsed` and `width` properties in locals storage - const isCollapsed = s.isCollapsed !== undefined ? s.isCollapsed : this.state.isCollapsed; - const width = s.width !== undefined ? s.width : this.state.width; + // Internal state tracking + const initialX = useRef(); + const initialWidth = useRef(); - setCache({ isCollapsed, width }); + const { addBinding, removeBinding } = useKeyboardManager(); - this.setState(s); - }; + useEffect(() => { + addBinding(KEYBOARD_SHORTCUT, toggleCollapse); + return () => { + removeBinding(KEYBOARD_SHORTCUT); + }; + }, []); + + useEffect(() => { + const handleResize = raf(event => { + // on occasion a mouse move event occurs before the event listeners have a chance to detach + if (!isMouseDown) return; + + // initialize dragging + if (!isDragging) { + setIsDragging(true); + initialWidth.current = width; + return; + } + + // allow the product nav to be 75% of the available page width + const adjustedMax = MAX_WIDTH - initialWidth.current; + const adjustedMin = MIN_WIDTH - initialWidth.current; + + const newDelta = Math.max(Math.min(event.pageX - initialX.current, adjustedMax), adjustedMin); + const newWidth = initialWidth.current + newDelta; + + setWidth(newWidth); + }); + + const handleResizeEnd = () => { + // reset non-width states + setIsDragging(false); + setIsMouseDown(false); + }; + + window.addEventListener('mousemove', handleResize, { passive: true }); + window.addEventListener('mouseup', handleResizeEnd, { passive: true }); + + return () => { + window.removeEventListener('mousemove', handleResize, { passive: true }); + window.removeEventListener('mouseup', handleResizeEnd, { passive: true }); + }; + }, [isMouseDown, isDragging]); + + // Only keep the `isCollapsed` and `width` properties in locals storage + useEffect(() => { + setCache({ isCollapsed, width }); + }, [isCollapsed, width]); - handleResizeStart = (event: MouseEvent) => { + const handleResizeStart = event => { // bail if not "left click" if (event.button && event.button > 0) return; // initialize resize gesture - this.setState({ initialX: event.pageX, mouseIsDown: true }); - - // attach handlers (handleResizeStart is a bound to onMouseDown) - window.addEventListener('mousemove', this.handleResize); - window.addEventListener('mouseup', this.handleResizeEnd); + initialX.current = event.pageX; + setIsMouseDown(true); }; - initializeDrag = () => { - let initialWidth = this.state.width; - - this.setState({ initialWidth, isDragging: true }); + const toggleCollapse = () => { + setIsCollapsed(prevCollapsed => !prevCollapsed); }; - handleResize = raf((event: MouseEvent) => { - const { initialX, initialWidth, isDragging, mouseIsDown } = this.state; - - // on occasion a mouse move event occurs before the event listeners - // have a chance to detach - if (!mouseIsDown) return; - - // initialize dragging - if (!isDragging) { - this.initializeDrag(event); - return; - } - - // allow the product nav to be 75% of the available page width - const adjustedMax = MAX_WIDTH - initialWidth; - const adjustedMin = MIN_WIDTH - initialWidth; - - const delta = Math.max(Math.min(event.pageX - initialX, adjustedMax), adjustedMin); - const width = initialWidth + delta; - - this.setState({ delta, width }); - }); - handleResizeEnd = () => { - // reset non-width states - this.setState({ delta: 0, isDragging: false, mouseIsDown: false }); - - // store the width - this.storeState({ width: this.state.width }); - - // cleanup - window.removeEventListener('mousemove', this.handleResize); - window.removeEventListener('mouseup', this.handleResizeEnd); - }; - toggleCollapse = () => { - const isCollapsed = !this.state.isCollapsed; - this.storeState({ isCollapsed }); + const resizeProps = { + title: 'Drag to Resize', + onMouseDown: handleResizeStart, }; - render() { - const resizeProps = { - title: 'Drag to Resize', - onMouseDown: this.handleResizeStart, - }; - const clickProps = { - onClick: this.toggleCollapse, - }; - const snapshot = this.state; + const clickProps = { + onClick: toggleCollapse, + }; - return this.props.children(resizeProps, clickProps, snapshot); - } -} + const snapshot = { + width, + isCollapsed, + isMouseDown, + isDragging, + initialX: initialX.current, + initialWidth: initialWidth.current, + }; -export default withKeyboardConsumer(ResizeHandler); + return { resizeProps, clickProps, snapshot }; +}; diff --git a/packages/app-admin-ui/client/components/Nav/index.js b/packages/app-admin-ui/client/components/Nav/index.js index 0747cc72236..33bea20c478 100644 --- a/packages/app-admin-ui/client/components/Nav/index.js +++ b/packages/app-admin-ui/client/components/Nav/index.js @@ -23,8 +23,8 @@ import { FlexGroup } from '@arch-ui/layout'; import { PersonIcon, SignOutIcon, TerminalIcon, MarkGithubIcon } from '@arch-ui/icons'; import { useAdminMeta } from '../../providers/AdminMeta'; -import ResizeHandler, { KEYBOARD_SHORTCUT } from './ResizeHandler'; -import ScrollQuery from '../ScrollQuery'; +import { useResizeHandler, KEYBOARD_SHORTCUT } from './ResizeHandler'; +import { useScrollQuery } from '../ScrollQuery'; import { useQuery } from '@apollo/react-hooks'; import gql from 'graphql-tag'; @@ -244,6 +244,7 @@ function PrimaryNavItems({ mouseIsOverNav, }) { const isAtDashboard = useRouteMatch({ path: adminPath, exact: true }); + const [scrollRef, snapshot] = useScrollQuery({ isPassive: false }); let hasRenderedIndexPage = false; const onRenderIndexPage = () => { @@ -278,23 +279,15 @@ function PrimaryNavItems({ ); return ( - - {(ref, snapshot) => ( - - {hasRenderedIndexPage === false && ( - - Dashboard - - )} - - {pageNavItems} - + + {hasRenderedIndexPage === false && ( + + Dashboard + )} - + + {pageNavItems} + ); } @@ -479,81 +472,81 @@ const Nav = ({ children }) => { setMouseIsOverNav(false); }; - return ( - - {(resizeProps, clickProps, { isCollapsed, isDragging, width }) => { - const navWidth = isCollapsed ? 0 : width; - const makeResizeStyles = key => { - const pointers = isDragging ? { pointerEvents: 'none' } : null; - const transitions = isDragging - ? null - : { - transition: `${camelToKebab(key)} ${TRANSITION_DURATION} ${TRANSITION_EASING}`, - }; - return { [key]: navWidth, ...pointers, ...transitions }; + const { + resizeProps, + clickProps, + snapshot: { isCollapsed, isDragging, width }, + } = useResizeHandler(); + + const navWidth = isCollapsed ? 0 : width; + const makeResizeStyles = key => { + const pointers = isDragging ? { pointerEvents: 'none' } : null; + const transitions = isDragging + ? null + : { + transition: `${camelToKebab(key)} ${TRANSITION_DURATION} ${TRANSITION_EASING}`, }; + return { [key]: navWidth, ...pointers, ...transitions }; + }; - return ( - - - + + + + {isCollapsed ? null : ( + + )} + + {isCollapsed ? 'Click to Expand' : 'Click to Collapse'} + + } + placement="right" + hideOnMouseDown + hideOnKeyDown + delay={600} + > + {ref => ( + - - {isCollapsed ? null : ( - - )} - - {isCollapsed ? 'Click to Expand' : 'Click to Collapse'} - - } - placement="right" - hideOnMouseDown - hideOnKeyDown - delay={600} + - {ref => ( - - - - - - )} - - - {children} - - ); - }} - + + + + )} + + + {children} + ); }; diff --git a/packages/app-admin-ui/client/components/ScrollQuery.js b/packages/app-admin-ui/client/components/ScrollQuery.js index 2f55e8f9a9c..c280755c114 100644 --- a/packages/app-admin-ui/client/components/ScrollQuery.js +++ b/packages/app-admin-ui/client/components/ScrollQuery.js @@ -1,76 +1,69 @@ -import { createRef, Component } from 'react'; -import PropTypes from 'prop-types'; +import { useState, useEffect, useRef, useCallback } from 'react'; import ResizeObserver from 'resize-observer-polyfill'; import raf from 'raf-schd'; const LISTENER_OPTIONS = { passive: true }; -export default class ScrollQuery extends Component { - scrollElement = createRef(); - state = { hasScroll: false, isScrollable: false, scrollTop: 0 }; - static propTypes = { - children: PropTypes.func, - isPassive: PropTypes.bool, - }; - static defaultProps = { - isPassive: true, - }; +export const useScrollQuery = ({ isPassive = true }) => { + const scrollElement = useRef(); + const resizeObserver = useRef(); - componentDidMount() { - const { isPassive } = this.props; - const scrollEl = this.scrollElement.current; + const [snapshot, setSnapshot] = useState({}); - if (!isPassive) { - scrollEl.addEventListener('scroll', this.handleScroll, LISTENER_OPTIONS); - } + const setScroll = useCallback(target => { + const { clientHeight, scrollHeight, scrollTop } = target; + + const isBottom = scrollTop === scrollHeight - clientHeight; + const isTop = scrollTop === 0; + const isScrollable = scrollHeight > clientHeight; + const hasScroll = !!scrollTop; - this.resizeObserver = new ResizeObserver(([entry]) => { - this.setScroll(entry.target); + setSnapshot({ + isBottom, + isTop, + isScrollable, + scrollHeight, + scrollTop, + hasScroll, }); - this.resizeObserver.observe(scrollEl); + }, []); - this.setScroll(scrollEl); - } - componentWillUnmount() { - const { isPassive } = this.props; + useEffect(() => { + const { current } = scrollElement; - if (!isPassive) { - this.scrollElement.current.removeEventListener('scroll', this.handleScroll, LISTENER_OPTIONS); - } + if (!isPassive && current) { + const handleScroll = raf(event => { + setScroll(event.target); + }); - if (this.resizeObserver && this.scrollElement.current) { - this.resizeObserver.disconnect(this.scrollElement.current); + current.addEventListener('scroll', handleScroll, LISTENER_OPTIONS); + return () => { + current.removeEventListener('scroll', handleScroll, LISTENER_OPTIONS); + }; } - this.resizeObserver = null; - } + }, [isPassive]); - handleScroll = raf(event => { - this.setScroll(event.target); - }); + // Not using useResizeObserver since we want to operate with the element on resize, not the dimensions + useEffect(() => { + const { current } = scrollElement; - setScroll = target => { - const { clientHeight, scrollHeight, scrollTop } = target; - const isScrollable = scrollHeight > clientHeight; - const isBottom = scrollTop === scrollHeight - clientHeight; - const isTop = scrollTop === 0; - const hasScroll = !!scrollTop; - if ( - // we only need to compare some parts of state - // because some of the parts are computed from scrollTop - this.state.isBottom !== isBottom || - this.state.isScrollable !== isScrollable || - this.state.scrollHeight !== scrollHeight || - this.state.scrollTop !== scrollTop - ) { - this.setState({ isBottom, isTop, isScrollable, scrollHeight, scrollTop, hasScroll }); - } - }; + resizeObserver.current = new ResizeObserver( + raf(([entry]) => { + setScroll(entry.target); + }) + ); + + resizeObserver.current.observe(current); + setScroll(current); + + return () => { + if (resizeObserver.current && current) { + resizeObserver.current.disconnect(current); + } - render() { - const { children, render } = this.props; - const ref = this.scrollElement; - const snapshot = this.state; + resizeObserver.current = null; + }; + }, []); - return render ? render(ref, snapshot) : children(ref, snapshot); - } -} + return [scrollElement, snapshot]; +}; diff --git a/packages/app-admin-ui/index.js b/packages/app-admin-ui/index.js index f7cf1b85ca3..795ad6c76d3 100644 --- a/packages/app-admin-ui/index.js +++ b/packages/app-admin-ui/index.js @@ -47,21 +47,6 @@ class AdminUIApp { }; } - getAdminMeta() { - return { - adminPath: this.adminPath, - pages: this.pages, - hooks: this.hooks, - ...this.routes, - ...(this.authStrategy - ? { - authStrategy: this.authStrategy.getAdminMeta(), - } - : {}), - ...this._adminMeta, - }; - } - isAccessAllowed(req) { if (!this.authStrategy) { return true; @@ -134,14 +119,23 @@ class AdminUIApp { } getAdminUIMeta(keystone) { - const { adminPath } = this; - + // This is exposed as the global `KEYSTONE_ADMIN_META` in the client. + const { adminPath, apiPath, graphiqlPath, pages, hooks } = this; + const { signinPath, signoutPath } = this.routes; + const { lists, name } = keystone.getAdminMeta({ schemaName: this._schemaName }); + const authStrategy = this.authStrategy ? this.authStrategy.getAdminMeta() : undefined; return { adminPath, - apiPath: this.apiPath, - graphiqlPath: this.graphiqlPath, - ...this.getAdminMeta(), - ...keystone.getAdminMeta({ schemaName: this._schemaName }), + apiPath, + graphiqlPath, + pages, + hooks, + signinPath, + signoutPath, + authStrategy, + lists, + name, + ...this._adminMeta, }; } diff --git a/packages/app-admin-ui/tests/server/AdminUI.test.js b/packages/app-admin-ui/tests/server/AdminUI.test.js index d1b96c085a0..b927670b689 100644 --- a/packages/app-admin-ui/tests/server/AdminUI.test.js +++ b/packages/app-admin-ui/tests/server/AdminUI.test.js @@ -21,7 +21,7 @@ jest.doMock('html-webpack-plugin', () => { const { AdminUIApp } = require('../../'); const keystone = { - getAdminMeta: jest.fn(), + getAdminMeta: jest.fn(() => ({ lists: [], name: 'test' })), }; const adminPath = 'admin_path'; diff --git a/packages/keystone/README.md b/packages/keystone/README.md index 70401a434c3..c39bda89b78 100644 --- a/packages/keystone/README.md +++ b/packages/keystone/README.md @@ -24,7 +24,7 @@ const keystone = new Keystone({ | `adapters` | `Object` | `undefined` | A list of named database adapters. Use the format `{ name: adapterObject }`. | | `appVersion` | `Object` | See [`appVersion`](#appversion) | Configure the application version and where it is made available. | | `cookie` | `Object` | See: [`cookie`](#cookie) | Cookie object used to configure the [express-session middleware](https://github.com/expressjs/session#cookie). | -| `cookieSecret` | `String` | `qwerty` | The secret used to sign session ID cookies. Should be long and unguessable. Don't use this default in production! | +| `cookieSecret` | `String` | Required in production | The secret used to sign session ID cookies. Should be long and unguessable. | | `defaultAccess` | `Object` | `undefined` | Default list, field, and custom schema access. See the [Access control API](https://www.keystonejs.com/api/access-control) docs for more details. | | `defaultAdapter` | `String` | `undefined` | The name of the database adapter to use by default if multiple are provided. | | `name` | `String` | `undefined` | The name of the project. Appears in the Admin UI. | @@ -99,6 +99,10 @@ const keystone = new Keystone({ }); ``` +### `cookieSecret` + +The secret used to sign session ID cookies. In production mode (`process.env.NODE_ENV === 'production'`) this option is required. In development mode, if undefined, a random `cookieSecret` will be generated each time Keystone starts (this will cause sessions to be reset between restarts). + ### `sessionStore` Sets the Express server's [session middleware](https://github.com/expressjs/session). This should be configured before deploying your app. diff --git a/packages/keystone/lib/Keystone/index.js b/packages/keystone/lib/Keystone/index.js index 485d6faeeb9..4802d154610 100644 --- a/packages/keystone/lib/Keystone/index.js +++ b/packages/keystone/lib/Keystone/index.js @@ -42,7 +42,7 @@ module.exports = class Keystone { defaultAdapter, name, onConnect, - cookieSecret = 'qwerty', + cookieSecret, sessionStore, queryLimits = {}, cookie = { @@ -453,27 +453,20 @@ module.exports = class Keystone { /** * @return Promise */ - connect() { + async connect() { const { adapters, name } = this; const rels = this._consolidateRelationships(); - return resolveAllKeys(mapKeys(adapters, adapter => adapter.connect({ name, rels }))).then( - () => { - if (this.eventHandlers.onConnect) { - return this.eventHandlers.onConnect(this); - } - } - ); + await resolveAllKeys(mapKeys(adapters, adapter => adapter.connect({ name, rels }))); + if (this.eventHandlers.onConnect) { + return this.eventHandlers.onConnect(this); + } } /** * @return Promise */ - disconnect() { - return resolveAllKeys( - mapKeys(this.adapters, adapter => adapter.disconnect()) - // Chain an empty function so that the result of this promise - // isn't unintentionally leaked to the caller - ).then(() => {}); + async disconnect() { + await resolveAllKeys(mapKeys(this.adapters, adapter => adapter.disconnect())); } getAdminMeta({ schemaName }) { diff --git a/packages/keystone/lib/List/index.js b/packages/keystone/lib/List/index.js index 344fa315691..a0c13431d1c 100644 --- a/packages/keystone/lib/List/index.js +++ b/packages/keystone/lib/List/index.js @@ -826,15 +826,17 @@ module.exports = class List { // Return these as functions so they're lazily evaluated depending // on what the user requested // Evaluation takes place in ../Keystone/index.js - getCount: () => { + getCount: async () => { const access = this.checkListAccess(context, undefined, 'read', { gqlName }); - return this._itemsQuery(mergeWhereClause(args, access), { + const { count } = await this._itemsQuery(mergeWhereClause(args, access), { meta: true, context, info, from, - }).then(({ count }) => count); + }); + + return count; }, }; } diff --git a/packages/list-plugins/lib/tracking/atTracking.js b/packages/list-plugins/lib/tracking/atTracking.js index df54eaa7a12..f54bfa5b608 100644 --- a/packages/list-plugins/lib/tracking/atTracking.js +++ b/packages/list-plugins/lib/tracking/atTracking.js @@ -1,5 +1,5 @@ const { DateTime } = require('@keystonejs/fields'); -const { composeResolveInput } = require('../utils'); +const { composeHook } = require('../utils'); const _atTracking = ({ created = true, updated = true }) => ({ updatedAtField = 'updatedAt', @@ -29,9 +29,9 @@ const _atTracking = ({ created = true, updated = true }) => ({ }; } - const newResolveInput = ({ resolvedData, existingItem, originalInput }) => { + const newResolveInput = ({ resolvedData, operation, originalInput }) => { const dateNow = new Date().toISOString(); - if (existingItem === undefined) { + if (operation === 'create') { // create mode if (created) { resolvedData[createdAtField] = dateNow; @@ -39,7 +39,8 @@ const _atTracking = ({ created = true, updated = true }) => ({ if (updated) { resolvedData[updatedAtField] = dateNow; } - } else { + } + if (operation === 'update') { // update mode // if no data received from the mutation, skip the update @@ -57,7 +58,7 @@ const _atTracking = ({ created = true, updated = true }) => ({ return resolvedData; }; const originalResolveInput = hooks.resolveInput; - hooks.resolveInput = composeResolveInput(originalResolveInput, newResolveInput); + hooks.resolveInput = composeHook(originalResolveInput, newResolveInput); return { fields, hooks, ...rest }; }; diff --git a/packages/list-plugins/lib/tracking/byTracking.js b/packages/list-plugins/lib/tracking/byTracking.js index 30bb54e5e11..dbcd7e8fe96 100644 --- a/packages/list-plugins/lib/tracking/byTracking.js +++ b/packages/list-plugins/lib/tracking/byTracking.js @@ -1,5 +1,5 @@ const { AuthedRelationship } = require('@keystonejs/fields-authed-relationship'); -const { composeResolveInput } = require('../utils'); +const { composeHook } = require('../utils'); const _byTracking = ({ created = true, updated = true }) => ({ updatedByField = 'updatedBy', @@ -24,14 +24,14 @@ const _byTracking = ({ created = true, updated = true }) => ({ }; } - const newResolveInput = ({ resolvedData, existingItem, originalInput, context }) => { + const newResolveInput = ({ resolvedData, operation, originalInput, context }) => { if ( // if no data received from the mutation, skip the update Object.keys(originalInput).length === 0 && // opted-in to updatedBy tracking updated && // this is an update - existingItem !== undefined + operation === 'update' ) { // If not logged in, the id is set to `null` const { authedItem: { id = null } = {} } = context; @@ -41,7 +41,7 @@ const _byTracking = ({ created = true, updated = true }) => ({ return resolvedData; }; const originalResolveInput = hooks.resolveInput; - hooks.resolveInput = composeResolveInput(originalResolveInput, newResolveInput); + hooks.resolveInput = composeHook(originalResolveInput, newResolveInput); return { fields, hooks, ...rest }; }; diff --git a/packages/list-plugins/lib/utils.js b/packages/list-plugins/lib/utils.js index 89c252d2771..f27958d8f4a 100644 --- a/packages/list-plugins/lib/utils.js +++ b/packages/list-plugins/lib/utils.js @@ -1,4 +1,4 @@ -exports.composeResolveInput = (originalHook, newHook) => async params => { +exports.composeHook = (originalHook, newHook) => async params => { let { resolvedData } = params; if (originalHook) { resolvedData = await originalHook(params); diff --git a/packages/session/lib/session.js b/packages/session/lib/session.js index eca1df01f94..c88fb43d2de 100644 --- a/packages/session/lib/session.js +++ b/packages/session/lib/session.js @@ -3,7 +3,21 @@ const expressSession = require('express-session'); const cookie = require('cookie'); class SessionManager { - constructor({ cookieSecret = 'qwerty', cookie, sessionStore }) { + constructor({ cookieSecret, cookie, sessionStore }) { + if (!cookieSecret) { + if (process.env.NODE_ENV === 'production') { + throw new Error( + 'The cookieSecret config option is required when running Keystone in a production environment. Update your app or environment config so this value is supplied to the Keystone constructor. See [https://www.keystonejs.com/keystonejs/keystone/#config] for details.' + ); + } else { + console.warn( + 'No cookieSecret value was provided. Please generate a secure value and add it to your app. Until this is done, a random cookieSecret will be generated each time Keystone is started. This will cause sessions to be reset between restarts. See [https://www.keystonejs.com/keystonejs/keystone/#config] for details.' + ); + + cookieSecret = [...Array(30)].map(() => ((Math.random() * 36) | 0).toString(36)).join(''); + } + } + this._cookieSecret = cookieSecret; this._cookie = cookie; this._sessionStore = sessionStore; diff --git a/packages/utils/src/index.js b/packages/utils/src/index.js index 5794cde7398..8e0636fe815 100644 --- a/packages/utils/src/index.js +++ b/packages/utils/src/index.js @@ -22,7 +22,7 @@ export const mapKeyNames = (obj, func) => {} ); -export const resolveAllKeys = obj => { +export const resolveAllKeys = async obj => { const returnValue = {}; const errors = {}; @@ -38,18 +38,19 @@ export const resolveAllKeys = obj => { }) ); - return Promise.all(allPromises).then(results => { - // If there are any errors, we want to surface them in the same shape as the - // input object - if (Object.keys(errors).length) { - const firstError = results.find(({ isRejected }) => isRejected).reason; - // Use the first error as the message so it's at least meaningful - const error = new Error(firstError.message || firstError.toString()); - error.errors = errors; - throw error; - } - return returnValue; - }); + const results = await Promise.all(allPromises); + + // If there are any errors, we want to surface them in the same shape as the + // input object + if (Object.keys(errors).length) { + const firstError = results.find(({ isRejected }) => isRejected).reason; + // Use the first error as the message so it's at least meaningful + const error = new Error(firstError.message || firstError.toString()); + error.errors = errors; + throw error; + } + + return returnValue; }; export const unique = arr => [...new Set(arr)]; diff --git a/test-projects/access-control/index.js b/test-projects/access-control/index.js index 78700698ab7..53f1b9f1453 100644 --- a/test-projects/access-control/index.js +++ b/test-projects/access-control/index.js @@ -20,6 +20,7 @@ const { MongooseAdapter } = require('@keystonejs/adapter-mongoose'); const keystone = new Keystone({ name: projectName, adapter: new MongooseAdapter(), + cookieSecret: 'qwerty', }); keystone.createList('User', { diff --git a/test-projects/basic/index.js b/test-projects/basic/index.js index 46c76b870af..af0404b1bb2 100644 --- a/test-projects/basic/index.js +++ b/test-projects/basic/index.js @@ -43,6 +43,7 @@ const { MongooseAdapter } = require('@keystonejs/adapter-mongoose'); const keystone = new Keystone({ name: 'Cypress Test Project Basic', adapter: new MongooseAdapter(), + cookieSecret: 'qwerty', }); const fileAdapter = new LocalFileAdapter({ diff --git a/test-projects/client-validation/index.js b/test-projects/client-validation/index.js index 9f7177e1f1c..746b549f64b 100644 --- a/test-projects/client-validation/index.js +++ b/test-projects/client-validation/index.js @@ -11,6 +11,7 @@ const { MongooseAdapter } = require('@keystonejs/adapter-mongoose'); const keystone = new Keystone({ name: 'Cypress Test Project Client Validation', adapter: new MongooseAdapter(), + cookieSecret: 'qwerty', }); keystone.createList('User', { diff --git a/test-projects/login/index.js b/test-projects/login/index.js index a547628e9ab..de804016a20 100644 --- a/test-projects/login/index.js +++ b/test-projects/login/index.js @@ -19,6 +19,7 @@ const defaultAccess = ({ authentication: { item } }) => !!item; const keystone = new Keystone({ name: 'Cypress Test Project For Login', adapter: new MongooseAdapter(), + cookieSecret: 'qwerty', defaultAccess: { list: defaultAccess, },