merges with master

This commit is contained in:
Jarrod Flesch
2020-07-27 12:38:26 -04:00
271 changed files with 9042 additions and 6899 deletions

View File

@@ -72,7 +72,7 @@ describe('Users REST API', () => {
const data = await response.json();
expect(response.status).toBe(200);
expect(data.token).not.toBeNull();
expect(data.refreshedToken).toBeDefined();
token = data.refreshedToken;
});

View File

@@ -3,11 +3,17 @@ module.exports = [
name: 'enableAPIKey',
type: 'checkbox',
defaultValue: false,
admin: {
disabled: true,
},
},
{
name: 'apiKey',
type: 'text',
minLength: 24,
maxLength: 48,
admin: {
disabled: true,
},
},
];

View File

@@ -6,6 +6,9 @@ module.exports = [
label: 'Email',
type: 'email',
validate: validations.email,
admin: {
disabled: true,
},
},
{
name: 'resetPasswordToken',

View File

@@ -4,7 +4,9 @@ const defaultUser = {
singular: 'User',
plural: 'Users',
},
useAsTitle: 'email',
admin: {
useAsTitle: 'email',
},
auth: {
tokenExpiration: 7200,
},

22
src/auth/executeAccess.js Normal file
View File

@@ -0,0 +1,22 @@
const { Forbidden } = require('../errors');
const executeAccess = async (operation, access) => {
if (access) {
const result = await access(operation);
if (!result) {
if (!operation.disableErrors) throw new Forbidden();
}
return result;
}
if (operation.req.user) {
return true;
}
if (!operation.disableErrors) throw new Forbidden();
return false;
};
module.exports = executeAccess;

View File

@@ -1,21 +0,0 @@
const { Forbidden } = require('../errors');
const executePolicy = async (operation, policy) => {
if (policy) {
const result = await policy(operation);
if (!result) {
throw new Forbidden();
}
return result;
}
if (operation.req.user) {
return true;
}
throw new Forbidden();
};
module.exports = executePolicy;

View File

@@ -0,0 +1,40 @@
const executeAccess = require('./executeAccess');
const { Forbidden } = require('../errors');
const getExecuteStaticAccess = ({ config, Model }) => async (req, res, next) => {
try {
if (req.path) {
const accessResult = await executeAccess({ req, isReadingStaticFile: true }, config.access.read);
if (typeof accessResult === 'object') {
const filename = decodeURI(req.path).replace(/^\/|\/$/g, '');
const queryToBuild = {
where: {
and: [
{
filename: {
equals: filename,
},
},
accessResult,
],
},
};
const query = await Model.buildQuery(queryToBuild, req.locale);
const doc = await Model.findOne(query);
if (!doc) {
throw new Forbidden();
}
}
}
return next();
} catch (error) {
return next(error);
}
};
module.exports = getExecuteStaticAccess;

View File

@@ -1,42 +0,0 @@
const executePolicy = require('./executePolicy');
const { Forbidden } = require('../errors');
const getExecuteStaticPolicy = ({ config, Model }) => {
return async (req, res, next) => {
try {
if (req.path) {
const policyResult = await executePolicy({ req, isReadingStaticFile: true }, config.policies.read);
if (typeof policyResult === 'object') {
const filename = decodeURI(req.path).replace(/^\/|\/$/g, '');
const queryToBuild = {
where: {
and: [
{
filename: {
equals: filename,
},
},
policyResult,
],
},
};
const query = await Model.buildQuery(queryToBuild, req.locale);
const doc = await Model.findOne(query);
if (!doc) {
throw new Forbidden();
}
}
}
return next();
} catch (error) {
return next(error);
}
};
};
module.exports = getExecuteStaticPolicy;

21
src/auth/getExtractJWT.js Normal file
View File

@@ -0,0 +1,21 @@
const parseCookies = require('../utilities/parseCookies');
const getExtractJWT = config => (req) => {
const jwtFromHeader = req.get('Authorization');
if (jwtFromHeader && jwtFromHeader.indexOf('JWT ') === 0) {
return jwtFromHeader.replace('JWT ', '');
}
const cookies = parseCookies(req);
const tokenCookieName = `${config.cookiePrefix}-token`;
if (cookies && cookies[tokenCookieName]) {
const token = cookies[tokenCookieName];
return token;
}
return null;
};
module.exports = getExtractJWT;

View File

@@ -1,4 +1,3 @@
const { policies } = require('../../operations');
const formatName = require('../../../graphql/utilities/formatName');
const formatConfigNames = (results, configs) => {
@@ -13,18 +12,17 @@ const formatConfigNames = (results, configs) => {
return formattedResults;
};
const policyResolver = config => async (_, __, context) => {
async function access(_, __, context) {
const options = {
config,
req: context,
req: context.req,
};
let policyResults = await policies(options);
let accessResults = await this.operations.collections.auth.access(options);
policyResults = formatConfigNames(policyResults, config.collections);
policyResults = formatConfigNames(policyResults, config.globals);
accessResults = formatConfigNames(accessResults, this.config.collections);
accessResults = formatConfigNames(accessResults, this.config.globals);
return policyResults;
};
return accessResults;
}
module.exports = policyResolver;
module.exports = access;

View File

@@ -1,18 +1,18 @@
/* eslint-disable no-param-reassign */
const { forgotPassword } = require('../../operations');
function forgotPassword(collection) {
async function resolver(_, args, context) {
const options = {
collection,
data: args,
req: context.req,
};
const forgotPasswordResolver = (config, collection, email) => async (_, args, context) => {
const options = {
config,
collection,
data: args,
email,
req: context,
};
await this.operations.collections.auth.forgotPassword(options);
return true;
}
await forgotPassword(options);
const forgotPasswordResolver = resolver.bind(this);
return true;
};
return forgotPasswordResolver;
}
module.exports = forgotPasswordResolver;
module.exports = forgotPassword;

View File

@@ -1,21 +0,0 @@
const login = require('./login');
const me = require('./me');
const refresh = require('./refresh');
const register = require('./register');
const init = require('./init');
const forgotPassword = require('./forgotPassword');
const resetPassword = require('./resetPassword');
const update = require('./update');
const policies = require('./policies');
module.exports = {
login,
me,
refresh,
init,
register,
forgotPassword,
resetPassword,
update,
policies,
};

View File

@@ -1,15 +1,17 @@
/* eslint-disable no-param-reassign */
const { init } = require('../../operations');
function init({ Model }) {
async function resolver(_, __, context) {
const options = {
Model,
req: context.req,
};
const initResolver = ({ Model }) => async (_, __, context) => {
const options = {
Model,
req: context,
};
const result = await this.operations.collections.auth.init(options);
const result = await init(options);
return result;
}
return result;
};
const initResolver = resolver.bind(this);
return initResolver;
}
module.exports = initResolver;
module.exports = init;

View File

@@ -1,20 +1,22 @@
/* eslint-disable no-param-reassign */
const { login } = require('../../operations');
function login(collection) {
async function resolver(_, args, context) {
const options = {
collection,
data: {
email: args.email,
password: args.password,
},
req: context.req,
res: context.res,
};
const loginResolver = (config, collection) => async (_, args, context) => {
const options = {
collection,
config,
data: {
email: args.email,
password: args.password,
},
req: context,
};
const token = await this.operations.collections.auth.login(options);
const token = await login(options);
return token;
}
return token;
};
const loginResolver = resolver.bind(this);
return loginResolver;
}
module.exports = loginResolver;
module.exports = login;

View File

@@ -0,0 +1,18 @@
function logout(collection) {
async function resolver(_, __, context) {
const options = {
collection,
res: context.res,
req: context.req,
};
const result = await this.operations.collections.auth.logout(options);
return result;
}
const logoutResolver = resolver.bind(this);
return logoutResolver;
}
module.exports = logout;

View File

@@ -1,6 +1,5 @@
/* eslint-disable no-param-reassign */
const meResolver = async (_, args, context) => {
return context.user;
};
async function me(_, __, context) {
return this.operations.collections.auth.me({ req: context.req });
}
module.exports = meResolver;
module.exports = me;

View File

@@ -1,17 +1,24 @@
/* eslint-disable no-param-reassign */
const { refresh } = require('../../operations');
const getExtractJWT = require('../../getExtractJWT');
const refreshResolver = (config, collection) => async (_, __, context) => {
const options = {
config,
collection,
authorization: context.headers.authorization,
req: context,
};
function refresh(collection) {
async function resolver(_, __, context) {
const extractJWT = getExtractJWT(this.config);
const token = extractJWT(context);
const refreshedToken = await refresh(options);
const options = {
collection,
token,
req: context.req,
res: context.res,
};
return refreshedToken;
};
const result = await this.operations.collections.auth.refresh(options);
module.exports = refreshResolver;
return result;
}
const refreshResolver = resolver.bind(this);
return refreshResolver;
}
module.exports = refresh;

View File

@@ -1,28 +1,30 @@
/* eslint-disable no-param-reassign */
const { register } = require('../../operations');
function register(collection) {
async function resolver(_, args, context) {
const options = {
collection,
data: args.data,
depth: 0,
req: context.req,
};
const registerResolver = (config, collection) => async (_, args, context) => {
const options = {
config,
collection,
data: args.data,
depth: 0,
req: context,
};
if (args.locale) {
context.req.locale = args.locale;
options.locale = args.locale;
}
if (args.locale) {
context.locale = args.locale;
options.locale = args.locale;
if (args.fallbackLocale) {
context.req.fallbackLocale = args.fallbackLocale;
options.fallbackLocale = args.fallbackLocale;
}
const token = await this.operations.collections.auth.register(options);
return token;
}
if (args.fallbackLocale) {
context.fallbackLocale = args.fallbackLocale;
options.fallbackLocale = args.fallbackLocale;
}
const registerResolver = resolver.bind(this);
return registerResolver;
}
const token = await register(options);
return token;
};
module.exports = registerResolver;
module.exports = register;

View File

@@ -1,22 +1,23 @@
/* eslint-disable no-param-reassign */
const { resetPassword } = require('../../operations');
function resetPassword(collection) {
async function resolver(_, args, context) {
if (args.locale) context.req.locale = args.locale;
if (args.fallbackLocale) context.req.fallbackLocale = args.fallbackLocale;
const resetPasswordResolver = (config, collection) => async (_, args, context) => {
if (args.locale) context.locale = args.locale;
if (args.fallbackLocale) context.fallbackLocale = args.fallbackLocale;
const options = {
collection,
data: args,
req: context.req,
api: 'GraphQL',
};
const options = {
collection,
config,
data: args,
req: context,
api: 'GraphQL',
user: context.user,
};
const token = await this.operations.collections.auth.resetPassword(options);
const token = await resetPassword(options);
return token;
}
return token;
};
const resetPasswordResolver = resolver.bind(this);
return resetPasswordResolver;
}
module.exports = resetPasswordResolver;
module.exports = resetPassword;

View File

@@ -1,22 +1,25 @@
/* eslint-disable no-param-reassign */
const { update } = require('../../operations');
const updateResolver = ({ Model, config }) => async (_, args, context) => {
if (args.locale) context.locale = args.locale;
if (args.fallbackLocale) context.fallbackLocale = args.fallbackLocale;
function update(collection) {
async function resolver(_, args, context) {
if (args.locale) context.req.locale = args.locale;
if (args.fallbackLocale) context.req.fallbackLocale = args.fallbackLocale;
const options = {
config,
Model,
data: args.data,
id: args.id,
depth: 0,
req: context,
};
const options = {
collection,
data: args.data,
id: args.id,
depth: 0,
req: context.req,
};
const user = await update(options);
const user = await this.operations.collections.auth.update(options);
return user;
};
return user;
}
module.exports = updateResolver;
const updateResolver = resolver.bind(this);
return updateResolver;
}
module.exports = update;

10
src/auth/init.js Normal file
View File

@@ -0,0 +1,10 @@
const passport = require('passport');
const AnonymousStrategy = require('passport-anonymous');
const jwtStrategy = require('./strategies/jwt');
function initAuth() {
passport.use(new AnonymousStrategy.Strategy());
passport.use('jwt', jwtStrategy(this));
}
module.exports = initAuth;

View File

@@ -0,0 +1,107 @@
const allOperations = ['create', 'read', 'update', 'delete'];
async function accessOperation(args) {
const { config } = this;
const {
req,
req: { user },
} = args;
const results = {};
const promises = [];
const isLoggedIn = !!(user);
const userCollectionConfig = (user && user.collection) ? config.collections.find((collection) => collection.slug === user.collection) : null;
const createAccessPromise = async (obj, access, operation, disableWhere = false) => {
const updatedObj = obj;
const result = await access({ req });
if (typeof result === 'object' && !disableWhere) {
updatedObj[operation] = {
permission: true,
where: result,
};
} else {
updatedObj[operation] = {
permission: !!(result),
};
}
};
const executeFieldPolicies = (obj, fields, operation) => {
const updatedObj = obj;
fields.forEach(async (field) => {
if (field.name) {
if (!updatedObj[field.name]) updatedObj[field.name] = {};
if (field.access && typeof field.access[operation] === 'function') {
promises.push(createAccessPromise(updatedObj[field.name], field.access[operation], operation, true));
} else {
updatedObj[field.name][operation] = {
permission: isLoggedIn,
};
}
if (field.type === 'relationship') {
const relatedCollections = Array.isArray(field.relationTo) ? field.relationTo : [field.relationTo];
relatedCollections.forEach((slug) => {
const collection = config.collections.find((coll) => coll.slug === slug);
if (collection && collection.access && collection.access[operation]) {
promises.push(createAccessPromise(updatedObj[field.name], collection.access[operation], operation, true));
}
});
}
if (field.fields) {
if (!updatedObj[field.name].fields) updatedObj[field.name].fields = {};
executeFieldPolicies(updatedObj[field.name].fields, field.fields, operation);
}
} else if (field.fields) {
executeFieldPolicies(updatedObj, field.fields, operation);
}
});
};
const executeEntityPolicies = (entity, operations) => {
results[entity.slug] = {
fields: {},
};
operations.forEach((operation) => {
executeFieldPolicies(results[entity.slug].fields, entity.fields, operation);
if (typeof entity.access[operation] === 'function') {
promises.push(createAccessPromise(results[entity.slug], entity.access[operation], operation));
} else {
results[entity.slug][operation] = {
permission: isLoggedIn,
};
}
});
};
if (userCollectionConfig) {
results.canAccessAdmin = userCollectionConfig.access.admin ? userCollectionConfig.access.admin(args) : isLoggedIn;
} else {
results.canAccessAdmin = false;
}
config.collections.forEach((collection) => {
executeEntityPolicies(collection, allOperations);
});
config.globals.forEach((global) => {
executeEntityPolicies(global, ['read', 'update']);
});
await Promise.all(promises);
return results;
}
module.exports = accessOperation;

View File

@@ -1,75 +1,71 @@
const crypto = require('crypto');
const { APIError } = require('../../errors');
const forgotPassword = async (args) => {
try {
if (!Object.prototype.hasOwnProperty.call(args.data, 'email')) {
throw new APIError('Missing email.');
}
async function forgotPassword(args) {
const { config, sendEmail: email } = this;
let options = { ...args };
if (!Object.prototype.hasOwnProperty.call(args.data, 'email')) {
throw new APIError('Missing email.');
}
// /////////////////////////////////////
// 1. Execute before login hook
// /////////////////////////////////////
let options = { ...args };
const { beforeForgotPassword } = args.collection.config.hooks;
// /////////////////////////////////////
// 1. Execute before login hook
// /////////////////////////////////////
if (typeof beforeForgotPassword === 'function') {
options = await beforeForgotPassword(options);
}
const { beforeForgotPassword } = args.collection.config.hooks;
// /////////////////////////////////////
// 2. Perform forgot password
// /////////////////////////////////////
if (typeof beforeForgotPassword === 'function') {
options = await beforeForgotPassword(options);
}
const {
collection: {
Model,
},
config,
data,
email,
} = options;
// /////////////////////////////////////
// 2. Perform forgot password
// /////////////////////////////////////
let token = await crypto.randomBytes(20);
token = token.toString('hex');
const {
collection: {
Model,
},
data,
} = options;
const user = await Model.findOne({ email: data.email });
let token = await crypto.randomBytes(20);
token = token.toString('hex');
if (!user) return;
const user = await Model.findOne({ email: data.email });
user.resetPasswordToken = token;
user.resetPasswordExpiration = Date.now() + 3600000; // 1 hour
if (!user) return;
await user.save();
user.resetPasswordToken = token;
user.resetPasswordExpiration = Date.now() + 3600000; // 1 hour
const html = `You are receiving this because you (or someone else) have requested the reset of the password for your account.
await user.save();
const html = `You are receiving this because you (or someone else) have requested the reset of the password for your account.
Please click on the following link, or paste this into your browser to complete the process:
<a href="${config.serverURL}${config.routes.admin}/reset/${token}">
${config.serverURL}${config.routes.admin}/reset/${token}
</a>
If you did not request this, please ignore this email and your password will remain unchanged.`;
email({
from: `"${config.email.fromName}" <${config.email.fromAddress}>`,
to: data.email,
subject: 'Password Reset',
html,
});
email({
from: `"${config.email.fromName}" <${config.email.fromAddress}>`,
to: data.email,
subject: 'Password Reset',
html,
});
// /////////////////////////////////////
// 3. Execute after forgot password hook
// /////////////////////////////////////
// /////////////////////////////////////
// 3. Execute after forgot password hook
// /////////////////////////////////////
const { afterForgotPassword } = args.req.collection.config.hooks;
const { afterForgotPassword } = args.req.collection.config.hooks;
if (typeof afterForgotPassword === 'function') {
await afterForgotPassword(options);
}
} catch (error) {
throw error;
if (typeof afterForgotPassword === 'function') {
await afterForgotPassword(options);
}
};
}
module.exports = forgotPassword;

View File

@@ -1,21 +0,0 @@
const login = require('./login');
const refresh = require('./refresh');
const register = require('./register');
const init = require('./init');
const forgotPassword = require('./forgotPassword');
const resetPassword = require('./resetPassword');
const registerFirstUser = require('./registerFirstUser');
const update = require('./update');
const policies = require('./policies');
module.exports = {
login,
refresh,
init,
register,
forgotPassword,
update,
resetPassword,
registerFirstUser,
policies,
};

View File

@@ -1,17 +1,13 @@
const init = async (args) => {
try {
const {
Model,
} = args;
async function init(args) {
const {
Model,
} = args;
const count = await Model.countDocuments({});
const count = await Model.countDocuments({});
if (count >= 1) return true;
if (count >= 1) return true;
return false;
} catch (error) {
throw error;
}
};
return false;
}
module.exports = init;

View File

@@ -1,88 +1,112 @@
const jwt = require('jsonwebtoken');
const { Unauthorized, AuthenticationError } = require('../../errors');
const { AuthenticationError } = require('../../errors');
const login = async (args) => {
try {
// Await validation here
async function login(args) {
const { config, operations } = this;
let options = { ...args };
const options = { ...args };
// /////////////////////////////////////
// 1. Execute before login hook
// /////////////////////////////////////
// /////////////////////////////////////
// 1. Execute before login hook
// /////////////////////////////////////
const beforeLoginHook = args.collection.config.hooks.beforeLogin;
args.collection.config.hooks.beforeLogin.forEach((hook) => hook({ req: args.req }));
if (typeof beforeLoginHook === 'function') {
options = await beforeLoginHook(options);
}
// /////////////////////////////////////
// 2. Perform login
// /////////////////////////////////////
// /////////////////////////////////////
// 2. Perform login
// /////////////////////////////////////
const {
collection: {
Model,
config: collectionConfig,
},
data,
req,
} = options;
const {
collection: {
Model,
config: collectionConfig,
},
config,
data,
} = options;
const { email, password } = data;
const { email, password } = data;
const userDoc = await Model.findByUsername(email);
const user = await Model.findByUsername(email);
if (!user) throw new AuthenticationError();
if (!userDoc) throw new AuthenticationError();
const authResult = await user.authenticate(password);
const authResult = await userDoc.authenticate(password);
if (!authResult.user) {
throw new AuthenticationError();
}
const fieldsToSign = collectionConfig.fields.reduce((signedFields, field) => {
if (field.saveToJWT) {
return {
...signedFields,
[field.name]: user[field.name],
};
}
return signedFields;
}, {
email,
id: user.id,
});
fieldsToSign.collection = collectionConfig.slug;
const token = jwt.sign(
fieldsToSign,
config.secret,
{
expiresIn: collectionConfig.auth.tokenExpiration,
},
);
// /////////////////////////////////////
// 3. Execute after login hook
// /////////////////////////////////////
const afterLoginHook = args.collection.config.hooks.afterLogin;
if (typeof afterLoginHook === 'function') {
await afterLoginHook({ ...options, token, user });
}
// /////////////////////////////////////
// 4. Return token
// /////////////////////////////////////
return token;
} catch (error) {
throw error;
if (!authResult.user) {
throw new AuthenticationError();
}
};
const userQuery = await operations.collections.find({
where: {
email: {
equals: email,
},
},
collection: {
Model,
config: collectionConfig,
},
req,
overrideAccess: true,
});
const user = userQuery.docs[0];
const fieldsToSign = collectionConfig.fields.reduce((signedFields, field) => {
if (field.saveToJWT) {
return {
...signedFields,
[field.name]: user[field.name],
};
}
return signedFields;
}, {
email,
id: user.id,
});
fieldsToSign.collection = collectionConfig.slug;
const token = jwt.sign(
fieldsToSign,
config.secret,
{
expiresIn: collectionConfig.auth.tokenExpiration,
},
);
if (args.res) {
const cookieOptions = {
path: '/',
httpOnly: true,
};
if (collectionConfig.auth.secureCookie) {
cookieOptions.secure = true;
}
if (args.req.headers.origin && args.req.headers.origin.indexOf('localhost') === -1) {
let domain = args.req.headers.origin.replace('https://', '');
domain = args.req.headers.origin.replace('http://', '');
cookieOptions.domain = domain;
}
args.res.cookie(`${config.cookiePrefix}-token`, token, cookieOptions);
}
// /////////////////////////////////////
// 3. Execute after login hook
// /////////////////////////////////////
args.collection.config.hooks.afterLogin.forEach((hook) => hook({ token, user, req: args.req }));
// /////////////////////////////////////
// 4. Return token
// /////////////////////////////////////
return token;
}
module.exports = login;

View File

@@ -0,0 +1,34 @@
async function logout(args) {
const { config } = this;
const {
collection: {
config: collectionConfig,
},
res,
req,
} = args;
const cookieOptions = {
expires: new Date(0),
httpOnly: true,
path: '/',
overwrite: true,
};
if (collectionConfig.auth && collectionConfig.auth.secureCookie) {
cookieOptions.secure = true;
}
if (req.headers.origin && req.headers.origin.indexOf('localhost') === -1) {
let domain = req.headers.origin.replace('https://', '');
domain = req.headers.origin.replace('http://', '');
cookieOptions.domain = domain;
}
res.cookie(`${config.cookiePrefix}-token`, '', cookieOptions);
return 'Logged out successfully.';
}
module.exports = logout;

31
src/auth/operations/me.js Normal file
View File

@@ -0,0 +1,31 @@
const jwt = require('jsonwebtoken');
const getExtractJWT = require('../getExtractJWT');
async function me({ req }) {
const extractJWT = getExtractJWT(this.config);
if (req.user) {
const response = {
user: req.user,
};
const token = extractJWT(req);
if (token) {
response.token = token;
const decoded = jwt.decode(token);
if (decoded) {
response.user.exp = decoded.exp;
}
}
return response;
}
return {
user: null,
};
}
module.exports = me;

View File

@@ -1,98 +0,0 @@
const allOperations = ['create', 'read', 'update', 'delete'];
const policies = async (args) => {
const {
config,
req,
req: { user },
} = args;
const results = {};
const promises = [];
const isLoggedIn = !!(user);
const userCollectionConfig = (user && user.collection) ? config.collections.find(collection => collection.slug === user.collection) : null;
const createPolicyPromise = async (obj, policy, operation, disableWhere = false) => {
const updatedObj = obj;
const result = await policy({ req });
if (typeof result === 'object' && !disableWhere) {
updatedObj[operation] = {
permission: true,
where: result,
};
} else {
updatedObj[operation] = {
permission: !!(result),
};
}
};
const executeFieldPolicies = (obj, fields, operation) => {
const updatedObj = obj;
fields.forEach((field) => {
if (field.name) {
if (!updatedObj[field.name]) updatedObj[field.name] = {};
if (field.policies && typeof field.policies[operation] === 'function') {
promises.push(createPolicyPromise(updatedObj[field.name], field.policies[operation], operation, true));
} else {
updatedObj[field.name][operation] = {
permission: isLoggedIn,
};
}
if (field.fields) {
if (!updatedObj[field.name].fields) updatedObj[field.name].fields = {};
executeFieldPolicies(updatedObj[field.name].fields, field.fields, operation);
}
} else if (field.fields) {
executeFieldPolicies(updatedObj, field.fields, operation);
}
});
};
const executeEntityPolicies = (entity, operations) => {
results[entity.slug] = {
fields: {},
};
operations.forEach((operation) => {
executeFieldPolicies(results[entity.slug].fields, entity.fields, operation);
if (typeof entity.policies[operation] === 'function') {
promises.push(createPolicyPromise(results[entity.slug], entity.policies[operation], operation));
} else {
results[entity.slug][operation] = {
permission: isLoggedIn,
};
}
});
};
try {
if (userCollectionConfig) {
results.canAccessAdmin = userCollectionConfig.policies.admin ? userCollectionConfig.policies.admin(args) : isLoggedIn;
} else {
results.canAccessAdmin = false;
}
config.collections.forEach((collection) => {
executeEntityPolicies(collection, allOperations);
});
config.globals.forEach((global) => {
executeEntityPolicies(global, ['read', 'update']);
});
await Promise.all(promises);
return results;
} catch (error) {
throw error;
}
};
module.exports = policies;

View File

@@ -1,53 +1,76 @@
const jwt = require('jsonwebtoken');
const { Forbidden } = require('../../errors');
const refresh = async (args) => {
try {
// Await validation here
async function refresh(args) {
// Await validation here
let options = { ...args };
const { secret, cookiePrefix } = this.config;
let options = { ...args };
// /////////////////////////////////////
// 1. Execute before refresh hook
// /////////////////////////////////////
// /////////////////////////////////////
// 1. Execute before refresh hook
// /////////////////////////////////////
const { beforeRefresh } = args.collection.config.hooks;
const { beforeRefresh } = args.collection.config.hooks;
if (typeof beforeRefresh === 'function') {
options = await beforeRefresh(options);
}
// /////////////////////////////////////
// 2. Perform refresh
// /////////////////////////////////////
const { secret } = options.config;
const opts = {};
opts.expiresIn = options.collection.config.auth.tokenExpiration;
const token = options.authorization.replace('JWT ', '');
const payload = jwt.verify(token, secret, {});
delete payload.iat;
delete payload.exp;
const refreshedToken = jwt.sign(payload, secret, opts);
// /////////////////////////////////////
// 3. Execute after login hook
// /////////////////////////////////////
const { afterRefresh } = args.collection.config.hooks;
if (typeof afterRefresh === 'function') {
await afterRefresh(options, refreshedToken);
}
// /////////////////////////////////////
// 4. Return refreshed token
// /////////////////////////////////////
return refreshedToken;
} catch (error) {
throw error;
if (typeof beforeRefresh === 'function') {
options = await beforeRefresh(options);
}
};
// /////////////////////////////////////
// 2. Perform refresh
// /////////////////////////////////////
const opts = {};
opts.expiresIn = options.collection.config.auth.tokenExpiration;
if (typeof options.token !== 'string') throw new Forbidden();
const payload = jwt.verify(options.token, secret, {});
delete payload.iat;
delete payload.exp;
const refreshedToken = jwt.sign(payload, secret, opts);
if (args.res) {
const cookieOptions = {
path: '/',
httpOnly: true,
};
if (options.collection.config.auth.secureCookie) {
cookieOptions.secure = true;
}
if (args.req.headers.origin && args.req.headers.origin.indexOf('localhost') === -1) {
let domain = args.req.headers.origin.replace('https://', '');
domain = args.req.headers.origin.replace('http://', '');
cookieOptions.domain = domain;
}
args.res.cookie(`${cookiePrefix}-token`, refreshedToken, cookieOptions);
}
// /////////////////////////////////////
// 3. Execute after login hook
// /////////////////////////////////////
const { afterRefresh } = args.collection.config.hooks;
if (typeof afterRefresh === 'function') {
await afterRefresh(options, refreshedToken);
}
// /////////////////////////////////////
// 4. Return refreshed token
// /////////////////////////////////////
payload.exp = jwt.decode(refreshedToken).exp;
return {
refreshedToken,
user: payload,
};
}
module.exports = refresh;

View File

@@ -1,93 +1,106 @@
const passport = require('passport');
const executePolicy = require('../executePolicy');
const performFieldOperations = require('../../fields/performFieldOperations');
const executeAccess = require('../executeAccess');
const register = async (args) => {
try {
// /////////////////////////////////////
// 1. Retrieve and execute policy
// /////////////////////////////////////
async function register(args) {
const {
depth,
overrideAccess,
collection: {
Model,
config: collectionConfig,
},
req,
req: {
locale,
fallbackLocale,
},
} = args;
if (!args.overridePolicy) {
await executePolicy(args, args.collection.config.policies.create);
}
let { data } = args;
let options = { ...args };
// /////////////////////////////////////
// 1. Retrieve and execute access
// /////////////////////////////////////
// /////////////////////////////////////
// 2. Execute before register hook
// /////////////////////////////////////
const { beforeRegister } = args.collection.config.hooks;
if (typeof beforeRegister === 'function') {
options = await beforeRegister(options);
}
// /////////////////////////////////////
// 3. Execute field-level hooks, policies, and validation
// /////////////////////////////////////
options.data = await performFieldOperations(args.collection.config, { ...options, hook: 'beforeCreate', operationName: 'create' });
// /////////////////////////////////////
// 6. Perform register
// /////////////////////////////////////
const {
collection: {
Model,
},
data,
req: {
locale,
fallbackLocale,
},
} = options;
const modelData = { ...data };
delete modelData.password;
const user = new Model();
if (locale && user.setLocale) {
user.setLocale(locale, fallbackLocale);
}
Object.assign(user, modelData);
let result = await Model.register(user, data.password);
await passport.authenticate('local');
result = result.toJSON({ virtuals: true });
// /////////////////////////////////////
// 7. Execute field-level hooks and policies
// /////////////////////////////////////
result = await performFieldOperations(args.collection.config, {
...options, data: result, hook: 'afterRead', operationName: 'read',
});
// /////////////////////////////////////
// 8. Execute after register hook
// /////////////////////////////////////
const afterRegister = args.collection.config.hooks;
if (typeof afterRegister === 'function') {
result = await afterRegister(options, result);
}
// /////////////////////////////////////
// 9. Return user
// /////////////////////////////////////
return result;
} catch (error) {
throw error;
if (!overrideAccess) {
await executeAccess({ req }, collectionConfig.access.create);
}
};
// /////////////////////////////////////
// 2. Execute before create hook
// /////////////////////////////////////
await collectionConfig.hooks.beforeCreate.reduce(async (priorHook, hook) => {
await priorHook;
data = (await hook({
data,
req,
})) || data;
}, Promise.resolve());
// /////////////////////////////////////
// 3. Execute field-level hooks, access, and validation
// /////////////////////////////////////
data = await this.performFieldOperations(collectionConfig, {
data,
hook: 'beforeCreate',
operationName: 'create',
req,
});
// /////////////////////////////////////
// 6. Perform register
// /////////////////////////////////////
const modelData = { ...data };
delete modelData.password;
const user = new Model();
if (locale && user.setLocale) {
user.setLocale(locale, fallbackLocale);
}
Object.assign(user, modelData);
let result = await Model.register(user, data.password);
await passport.authenticate('local');
result = result.toJSON({ virtuals: true });
// /////////////////////////////////////
// 7. Execute field-level hooks and access
// /////////////////////////////////////
result = await this.performFieldOperations(collectionConfig, {
data: result,
hook: 'afterRead',
operationName: 'read',
req,
depth,
});
// /////////////////////////////////////
// 8. Execute after create hook
// /////////////////////////////////////
await collectionConfig.hooks.afterCreate.reduce(async (priorHook, hook) => {
await priorHook;
result = await hook({
doc: result,
req: args.req,
}) || result;
}, Promise.resolve());
// /////////////////////////////////////
// 9. Return user
// /////////////////////////////////////
return result;
}
module.exports = register;

View File

@@ -1,64 +1,43 @@
const register = require('./register');
const login = require('./login');
const { Forbidden } = require('../../errors');
const registerFirstUser = async (args) => {
try {
const count = await args.collection.Model.countDocuments({});
async function registerFirstUser(args) {
const {
collection: {
Model,
},
} = args;
if (count >= 1) throw new Forbidden();
const count = await Model.countDocuments({});
// Await validation here
if (count >= 1) throw new Forbidden();
let options = { ...args };
// /////////////////////////////////////
// 2. Perform register first user
// /////////////////////////////////////
// /////////////////////////////////////
// 1. Execute before register first user hook
// /////////////////////////////////////
const { beforeRegister } = args.collection.config.hooks;
if (typeof beforeRegister === 'function') {
options = await beforeRegister(options);
}
// /////////////////////////////////////
// 2. Perform register first user
// /////////////////////////////////////
let result = await register({
...options,
overridePolicy: true,
});
let result = await this.operations.collections.auth.register({
...args,
overrideAccess: true,
});
// /////////////////////////////////////
// 3. Log in new user
// /////////////////////////////////////
// /////////////////////////////////////
// 3. Log in new user
// /////////////////////////////////////
const token = await login({
...options,
});
const token = await this.operations.collections.auth.login({
...args,
});
result = {
...result,
token,
};
result = {
...result,
token,
};
// /////////////////////////////////////
// 4. Execute after register first user hook
// /////////////////////////////////////
const afterRegister = args.config.hooks;
if (typeof afterRegister === 'function') {
result = await afterRegister(options, result);
}
return result;
} catch (error) {
throw error;
}
};
return {
message: 'Registered successfully. Welcome to Payload!',
user: result,
};
}
module.exports = registerFirstUser;

View File

@@ -1,94 +1,91 @@
const jwt = require('jsonwebtoken');
const { APIError } = require('../../errors');
const resetPassword = async (args) => {
try {
if (!Object.prototype.hasOwnProperty.call(args.data, 'token')
|| !Object.prototype.hasOwnProperty.call(args.data, 'password')) {
throw new APIError('Missing required data.');
}
async function resetPassword(args) {
const { config } = this;
let options = { ...args };
// /////////////////////////////////////
// 1. Execute before reset password hook
// /////////////////////////////////////
const { beforeResetPassword } = args.collection.config.hooks;
if (typeof beforeResetPassword === 'function') {
options = await beforeResetPassword(options);
}
// /////////////////////////////////////
// 2. Perform password reset
// /////////////////////////////////////
const {
collection: {
Model,
config: collectionConfig,
},
config,
data,
} = options;
const { email } = data;
const user = await Model.findOne({
resetPasswordToken: data.token,
resetPasswordExpiration: { $gt: Date.now() },
});
if (!user) throw new APIError('Token is either invalid or has expired.');
await user.setPassword(data.password);
user.resetPasswordExpiration = Date.now();
await user.save();
await user.authenticate(data.password);
const fieldsToSign = collectionConfig.fields.reduce((signedFields, field) => {
if (field.saveToJWT) {
return {
...signedFields,
[field.name]: user[field.name],
};
}
return signedFields;
}, {
email,
});
const token = jwt.sign(
fieldsToSign,
config.secret,
{
expiresIn: collectionConfig.auth.tokenExpiration,
},
);
// /////////////////////////////////////
// 3. Execute after reset password hook
// /////////////////////////////////////
const { afterResetPassword } = collectionConfig.hooks;
if (typeof afterResetPassword === 'function') {
await afterResetPassword(options, user);
}
// /////////////////////////////////////
// 4. Return updated user
// /////////////////////////////////////
return token;
} catch (error) {
throw error;
if (!Object.prototype.hasOwnProperty.call(args.data, 'token')
|| !Object.prototype.hasOwnProperty.call(args.data, 'password')) {
throw new APIError('Missing required data.');
}
};
let options = { ...args };
// /////////////////////////////////////
// 1. Execute before reset password hook
// /////////////////////////////////////
const { beforeResetPassword } = args.collection.config.hooks;
if (typeof beforeResetPassword === 'function') {
options = await beforeResetPassword(options);
}
// /////////////////////////////////////
// 2. Perform password reset
// /////////////////////////////////////
const {
collection: {
Model,
config: collectionConfig,
},
data,
} = options;
const { email } = data;
const user = await Model.findOne({
resetPasswordToken: data.token,
resetPasswordExpiration: { $gt: Date.now() },
});
if (!user) throw new APIError('Token is either invalid or has expired.');
await user.setPassword(data.password);
user.resetPasswordExpiration = Date.now();
await user.save();
await user.authenticate(data.password);
const fieldsToSign = collectionConfig.fields.reduce((signedFields, field) => {
if (field.saveToJWT) {
return {
...signedFields,
[field.name]: user[field.name],
};
}
return signedFields;
}, {
email,
});
const token = jwt.sign(
fieldsToSign,
config.secret,
{
expiresIn: collectionConfig.auth.tokenExpiration,
},
);
// /////////////////////////////////////
// 3. Execute after reset password hook
// /////////////////////////////////////
const { afterResetPassword } = collectionConfig.hooks;
if (typeof afterResetPassword === 'function') {
await afterResetPassword(options, user);
}
// /////////////////////////////////////
// 4. Return updated user
// /////////////////////////////////////
return token;
}
module.exports = resetPassword;

View File

@@ -1,123 +1,142 @@
const deepmerge = require('deepmerge');
const overwriteMerge = require('../../utilities/overwriteMerge');
const { NotFound, Forbidden } = require('../../errors');
const executePolicy = require('../executePolicy');
const performFieldOperations = require('../../fields/performFieldOperations');
const executeAccess = require('../executeAccess');
const update = async (args) => {
try {
// /////////////////////////////////////
// 1. Execute policy
// /////////////////////////////////////
async function update(args) {
const { config } = this;
const policyResults = await executePolicy(args, args.config.policies.update);
const hasWherePolicy = typeof policyResults === 'object';
let options = { ...args };
// /////////////////////////////////////
// 2. Retrieve document
// /////////////////////////////////////
const {
const {
depth,
collection: {
Model,
id,
req: {
locale,
fallbackLocale,
},
} = options;
config: collectionConfig,
},
id,
req,
req: {
locale,
fallbackLocale,
},
} = args;
let query = { _id: id };
// /////////////////////////////////////
// 1. Execute access
// /////////////////////////////////////
if (hasWherePolicy) {
query = {
...query,
...policyResults,
};
}
const accessResults = await executeAccess({ req }, collectionConfig.access.update);
const hasWhereAccess = typeof accessResults === 'object';
let user = await Model.findOne(query);
// /////////////////////////////////////
// 2. Retrieve document
// /////////////////////////////////////
if (!user && !hasWherePolicy) throw new NotFound();
if (!user && hasWherePolicy) throw new Forbidden();
let query = { _id: id };
if (locale && user.setLocale) {
user.setLocale(locale, fallbackLocale);
}
const userJSON = user.toJSON({ virtuals: true });
// /////////////////////////////////////
// 2. Execute before update hook
// /////////////////////////////////////
const { beforeUpdate } = args.config.hooks;
if (typeof beforeUpdate === 'function') {
options = await beforeUpdate(options);
}
// /////////////////////////////////////
// 3. Merge updates into existing data
// /////////////////////////////////////
options.data = deepmerge(userJSON, options.data, { arrayMerge: overwriteMerge });
// /////////////////////////////////////
// 4. Execute field-level hooks, policies, and validation
// /////////////////////////////////////
options.data = await performFieldOperations(args.config, { ...options, hook: 'beforeUpdate', operationName: 'update' });
// /////////////////////////////////////
// 5. Handle password update
// /////////////////////////////////////
const dataToUpdate = { ...options.data };
const { password } = dataToUpdate;
if (password) {
delete dataToUpdate.password;
await user.setPassword(password);
}
// /////////////////////////////////////
// 6. Perform database operation
// /////////////////////////////////////
Object.assign(user, dataToUpdate);
await user.save();
user = user.toJSON({ virtuals: true });
// /////////////////////////////////////
// 7. Execute field-level hooks and policies
// /////////////////////////////////////
user = performFieldOperations(args.config, {
...options, data: user, hook: 'afterRead', operationName: 'read',
});
// /////////////////////////////////////
// 8. Execute after update hook
// /////////////////////////////////////
const afterUpdateHook = args.config.hooks && args.config.hooks.afterUpdate;
if (typeof afterUpdateHook === 'function') {
user = await afterUpdateHook(options, user);
}
// /////////////////////////////////////
// 9. Return user
// /////////////////////////////////////
return user;
} catch (error) {
throw error;
if (hasWhereAccess) {
query = {
...query,
...accessResults,
};
}
};
let user = await Model.findOne(query);
if (!user && !hasWhereAccess) throw new NotFound();
if (!user && hasWhereAccess) throw new Forbidden();
if (locale && user.setLocale) {
user.setLocale(locale, fallbackLocale);
}
const originalDoc = user.toJSON({ virtuals: true });
let { data } = args;
// /////////////////////////////////////
// 2. Execute before update hook
// /////////////////////////////////////
await collectionConfig.hooks.beforeUpdate.reduce(async (priorHook, hook) => {
await priorHook;
data = (await hook({
data,
req,
originalDoc,
})) || data;
}, Promise.resolve());
// /////////////////////////////////////
// 3. Merge updates into existing data
// /////////////////////////////////////
data = deepmerge(originalDoc, data, { arrayMerge: overwriteMerge });
// /////////////////////////////////////
// 4. Execute field-level hooks, access, and validation
// /////////////////////////////////////
data = await this.performFieldOperations(collectionConfig, {
data,
req,
hook: 'beforeUpdate',
operationName: 'update',
originalDoc,
});
// /////////////////////////////////////
// 5. Handle password update
// /////////////////////////////////////
const dataToUpdate = { ...data };
const { password } = dataToUpdate;
if (password) {
delete dataToUpdate.password;
await user.setPassword(password);
}
// /////////////////////////////////////
// 6. Perform database operation
// /////////////////////////////////////
Object.assign(user, dataToUpdate);
await user.save();
user = user.toJSON({ virtuals: true });
// /////////////////////////////////////
// 7. Execute field-level hooks and access
// /////////////////////////////////////
user = this.performFieldOperations(collectionConfig, {
data: user,
hook: 'afterRead',
operationName: 'read',
req,
depth,
});
// /////////////////////////////////////
// 8. Execute after update hook
// /////////////////////////////////////
await collectionConfig.hooks.afterUpdate.reduce(async (priorHook, hook) => {
await priorHook;
user = await hook({
doc: user,
req,
}) || user;
}, Promise.resolve());
// /////////////////////////////////////
// 9. Return user
// /////////////////////////////////////
return user;
}
module.exports = update;

View File

@@ -0,0 +1,16 @@
const httpStatus = require('http-status');
async function policiesHandler(req, res, next) {
try {
const accessResults = await this.operations.collections.auth.access({
req,
});
return res.status(httpStatus.OK)
.json(accessResults);
} catch (error) {
return next(error);
}
}
module.exports = policiesHandler;

View File

@@ -1,15 +1,11 @@
const httpStatus = require('http-status');
const formatErrorResponse = require('../../express/responses/formatError');
const { forgotPassword } = require('../operations');
const forgotPasswordHandler = (config, email) => async (req, res) => {
async function forgotPasswordHandler(req, res, next) {
try {
await forgotPassword({
await this.operations.collections.auth.forgotPassword({
req,
collection: req.collection,
config,
data: req.body,
email,
});
return res.status(httpStatus.OK)
@@ -17,8 +13,8 @@ const forgotPasswordHandler = (config, email) => async (req, res) => {
message: 'Success',
});
} catch (error) {
return res.status(error.status || httpStatus.INTERNAL_SERVER_ERROR).json(formatErrorResponse(error));
return next(error);
}
};
}
module.exports = forgotPasswordHandler;

View File

@@ -1,23 +0,0 @@
const login = require('./login');
const me = require('./me');
const refresh = require('./refresh');
const register = require('./register');
const init = require('./init');
const forgotPassword = require('./forgotPassword');
const resetPassword = require('./resetPassword');
const registerFirstUser = require('./registerFirstUser');
const update = require('./update');
const policies = require('./policies');
module.exports = {
login,
me,
refresh,
init,
register,
forgotPassword,
registerFirstUser,
resetPassword,
update,
policies,
};

View File

@@ -1,14 +1,10 @@
const httpStatus = require('http-status');
const { init } = require('../operations');
const formatError = require('../../express/responses/formatError');
const initHandler = async (req, res) => {
async function initHandler(req, res, next) {
try {
const initialized = await init({ Model: req.collection.Model });
const initialized = await this.operations.collections.auth.init({ Model: req.collection.Model });
return res.status(200).json({ initialized });
} catch (error) {
return res.status(error.status || httpStatus.INTERNAL_SERVER_ERROR).json(formatError(error));
return next(error);
}
};
}
module.exports = initHandler;

View File

@@ -1,13 +1,11 @@
const httpStatus = require('http-status');
const formatErrorResponse = require('../../express/responses/formatError');
const { login } = require('../operations');
const loginHandler = config => async (req, res) => {
async function loginHandler(req, res, next) {
try {
const token = await login({
const token = await this.operations.collections.auth.login({
req,
res,
collection: req.collection,
config,
data: req.body,
});
@@ -17,8 +15,8 @@ const loginHandler = config => async (req, res) => {
token,
});
} catch (error) {
return res.status(error.status || httpStatus.INTERNAL_SERVER_ERROR).json(formatErrorResponse(error));
return next(error);
}
};
}
module.exports = loginHandler;

View File

@@ -0,0 +1,15 @@
async function logoutHandler(req, res, next) {
try {
const message = await this.operations.collections.auth.logout({
collection: req.collection,
res,
req,
});
return res.status(200).json({ message });
} catch (error) {
return next(error);
}
}
module.exports = logoutHandler;

View File

@@ -1,5 +1,10 @@
const meHandler = async (req, res) => {
return res.status(200).json(req.user);
};
async function me(req, res, next) {
try {
const response = await this.operations.collections.auth.me({ req });
return res.status(200).json(response);
} catch (err) {
return next(err);
}
}
module.exports = meHandler;
module.exports = me;

View File

@@ -1,19 +0,0 @@
const httpStatus = require('http-status');
const formatErrorResponse = require('../../express/responses/formatError');
const { policies } = require('../operations');
const policiesHandler = config => async (req, res) => {
try {
const policyResults = await policies({
req,
config,
});
return res.status(httpStatus.OK)
.json(policyResults);
} catch (error) {
return res.status(error.status || httpStatus.INTERNAL_SERVER_ERROR).json(formatErrorResponse(error));
}
};
module.exports = policiesHandler;

View File

@@ -1,23 +1,24 @@
const httpStatus = require('http-status');
const formatErrorResponse = require('../../express/responses/formatError');
const { refresh } = require('../operations');
const getExtractJWT = require('../getExtractJWT');
const refreshHandler = config => async (req, res) => {
async function refreshHandler(req, res, next) {
try {
const refreshedToken = await refresh({
const extractJWT = getExtractJWT(this.config);
const token = extractJWT(req);
const result = await this.operations.collections.auth.refresh({
req,
res,
collection: req.collection,
config,
authorization: req.headers.authorization,
token,
});
return res.status(200).json({
message: 'Token refresh successful',
refreshedToken,
...result,
});
} catch (error) {
return res.status(error.status || httpStatus.INTERNAL_SERVER_ERROR).json(formatErrorResponse(error));
return next(error);
}
};
}
module.exports = refreshHandler;

View File

@@ -1,12 +1,9 @@
const httpStatus = require('http-status');
const formatErrorResponse = require('../../express/responses/formatError');
const formatSuccessResponse = require('../../express/responses/formatSuccess');
const { register } = require('../operations');
const registerHandler = config => async (req, res) => {
async function register(req, res, next) {
try {
const user = await register({
config,
const user = await this.operations.collections.auth.register({
collection: req.collection,
req,
data: req.body,
@@ -17,8 +14,8 @@ const registerHandler = config => async (req, res) => {
doc: user,
});
} catch (error) {
return res.status(error.status || httpStatus.UNAUTHORIZED).json(formatErrorResponse(error));
return next(error);
}
};
}
module.exports = registerHandler;
module.exports = register;

View File

@@ -1,20 +1,16 @@
const httpStatus = require('http-status');
const formatErrorResponse = require('../../express/responses/formatError');
const { registerFirstUser } = require('../operations');
const registerFirstUserHandler = config => async (req, res) => {
async function registerFirstUser(req, res, next) {
try {
const firstUser = await registerFirstUser({
const firstUser = await this.operations.collections.auth.registerFirstUser({
req,
config,
res,
collection: req.collection,
data: req.body,
});
return res.status(201).json(firstUser);
} catch (error) {
return res.status(error.status || httpStatus.INTERNAL_SERVER_ERROR).json(formatErrorResponse(error));
return next(error);
}
};
}
module.exports = registerFirstUserHandler;
module.exports = registerFirstUser;

View File

@@ -1,13 +1,10 @@
const httpStatus = require('http-status');
const formatErrorResponse = require('../../express/responses/formatError');
const { resetPassword } = require('../operations');
const resetPasswordHandler = config => async (req, res) => {
async function resetPassword(req, res, next) {
try {
const token = await resetPassword({
const token = await this.operations.collections.auth.resetPassword({
req,
collection: req.collection,
config,
data: req.body,
});
@@ -17,9 +14,8 @@ const resetPasswordHandler = config => async (req, res) => {
token,
});
} catch (error) {
return res.status(error.status || httpStatus.INTERNAL_SERVER_ERROR)
.json(formatErrorResponse(error));
return next(error);
}
};
}
module.exports = resetPasswordHandler;
module.exports = resetPassword;

View File

@@ -1,15 +1,12 @@
const httpStatus = require('http-status');
const formatErrorResponse = require('../../express/responses/formatError');
const formatSuccessResponse = require('../../express/responses/formatSuccess');
const { update } = require('../operations');
const updateHandler = async (req, res) => {
async function update(req, res, next) {
try {
const user = await update({
const user = await this.operations.collections.auth.update({
req,
data: req.body,
Model: req.collection.Model,
config: req.collection.config,
collection: req.collection,
id: req.params.id,
});
@@ -18,8 +15,8 @@ const updateHandler = async (req, res) => {
doc: user,
});
} catch (error) {
return res.status(httpStatus.UNAUTHORIZED).json(formatErrorResponse(error));
return next(error);
}
};
}
module.exports = updateHandler;
module.exports = update;

View File

@@ -1,74 +0,0 @@
const express = require('express');
const bindCollectionMiddleware = require('../collections/bindCollection');
const {
init,
login,
refresh,
me,
register,
registerFirstUser,
forgotPassword,
resetPassword,
update,
} = require('./requestHandlers');
const {
find,
findByID,
deleteHandler,
} = require('../collections/requestHandlers');
const router = express.Router();
const authRoutes = (collection, config, sendEmail) => {
const { slug } = collection.config;
router.all('*',
bindCollectionMiddleware(collection));
router
.route(`/${slug}/init`)
.get(init);
router
.route(`/${slug}/login`)
.post(login(config));
router
.route(`/${slug}/refresh-token`)
.post(refresh(config));
router
.route(`/${slug}/me`)
.get(me);
router
.route(`/${slug}/first-register`)
.post(registerFirstUser(config));
router
.route(`/${slug}/forgot-password`)
.post(forgotPassword(config, sendEmail));
router
.route(`${slug}/reset-password`)
.post(resetPassword);
router
.route(`/${slug}/register`)
.post(register(config));
router
.route(`/${slug}`)
.get(find);
router.route(`/${slug}/:id`)
.get(findByID)
.put(update)
.delete(deleteHandler);
return router;
};
module.exports = authRoutes;

View File

@@ -1,20 +1,36 @@
const PassportAPIKey = require('passport-headerapikey').HeaderAPIKeyStrategy;
module.exports = ({ Model, config }) => {
module.exports = ({ operations }, { Model, config }) => {
const opts = {
header: 'Authorization',
prefix: `${config.labels.singular} API-Key `,
};
return new PassportAPIKey(opts, false, (apiKey, done) => {
Model.findOne({ apiKey, enableAPIKey: true }, (err, user) => {
if (err) return done(err);
if (!user) return done(null, false);
return new PassportAPIKey(opts, true, async (req, apiKey, done) => {
try {
const userQuery = await operations.collections.find({
where: {
apiKey: {
equals: apiKey,
},
},
collection: {
Model,
config,
},
req,
overrideAccess: true,
});
const json = user.toJSON({ virtuals: true });
json.collection = config.slug;
return done(null, json);
});
if (userQuery.docs && userQuery.docs.length > 0) {
const user = userQuery.docs[0];
user.collection = config.slug;
done(null, user);
} else {
done(null, false);
}
} catch (err) {
done(null, false);
}
});
};

View File

@@ -1,25 +1,44 @@
const passportJwt = require('passport-jwt');
const getExtractJWT = require('../getExtractJWT');
const JwtStrategy = passportJwt.Strategy;
const { ExtractJwt } = passportJwt;
module.exports = (config, collections) => {
const opts = {};
opts.jwtFromRequest = ExtractJwt.fromAuthHeaderWithScheme('JWT');
module.exports = ({ config, collections, operations }) => {
const opts = {
session: false,
passReqToCallback: true,
};
const extractJWT = getExtractJWT(config);
opts.jwtFromRequest = extractJWT;
opts.secretOrKey = config.secret;
return new JwtStrategy(opts, async (token, done) => {
return new JwtStrategy(opts, async (req, token, done) => {
try {
const collection = collections[token.collection];
const user = await collection.Model.findByUsername(token.email);
const userQuery = await operations.collections.find({
where: {
email: {
equals: token.email,
},
},
collection,
req,
overrideAccess: true,
});
const json = user.toJSON({ virtuals: true });
json.collection = collection.config.slug;
if (userQuery.docs && userQuery.docs.length > 0) {
const user = userQuery.docs[0];
user.collection = collection.config.slug;
return done(null, json);
done(null, user);
} else {
done(null, false);
}
} catch (err) {
return done(null, false);
done(null, false);
}
});
};

View File

@@ -4,12 +4,14 @@
const webpack = require('webpack');
const getWebpackProdConfig = require('../webpack/getWebpackProdConfig');
const findConfig = require('../utilities/findConfig');
const sanitizeConfig = require('../utilities/sanitizeConfig');
module.exports = () => {
const configPath = findConfig();
try {
const config = require(configPath);
const unsanitizedConfig = require(configPath);
const config = sanitizeConfig(unsanitizedConfig);
const webpackProdConfig = getWebpackProdConfig({
...config,

View File

@@ -1,24 +1,9 @@
import Cookies from 'universal-cookie';
import qs from 'qs';
import config from 'payload/config';
const { cookiePrefix } = config;
const cookieTokenName = `${cookiePrefix}-token`;
export const getJWTHeader = () => {
const cookies = new Cookies();
const jwt = cookies.get(cookieTokenName);
return jwt ? { Authorization: `JWT ${jwt}` } : {};
};
export const requests = {
get: (url, params) => {
const query = qs.stringify(params, { addQueryPrefix: true, depth: 10 });
return fetch(`${url}${query}`, {
headers: {
...getJWTHeader(),
},
});
return fetch(`${url}${query}`);
},
post: (url, options = {}) => {
@@ -29,7 +14,6 @@ export const requests = {
method: 'post',
headers: {
...headers,
...getJWTHeader(),
},
};
@@ -44,7 +28,6 @@ export const requests = {
method: 'put',
headers: {
...headers,
...getJWTHeader(),
},
};
@@ -58,7 +41,6 @@ export const requests = {
method: 'delete',
headers: {
...headers,
...getJWTHeader(),
},
});
},

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import {
Route, Switch, withRouter, Redirect,
Route, Switch, withRouter, Redirect, useHistory,
} from 'react-router-dom';
import config from 'payload/config';
import List from './views/collections/List';
@@ -25,17 +25,22 @@ const {
} = config;
const Routes = () => {
const history = useHistory();
const [initialized, setInitialized] = useState(null);
const { user, permissions, permissions: { canAccessAdmin } } = useUser();
useEffect(() => {
requests.get(`${routes.api}/${userSlug}/init`).then(res => res.json().then((data) => {
requests.get(`${routes.api}/${userSlug}/init`).then((res) => res.json().then((data) => {
if (data && 'initialized' in data) {
setInitialized(data.initialized);
}
}));
}, []);
useEffect(() => {
history.replace();
}, [history]);
return (
<Route
path={routes.admin}
@@ -62,6 +67,9 @@ const Routes = () => {
<Route path={`${match.url}/logout`}>
<Logout />
</Route>
<Route path={`${match.url}/logout-inactivity`}>
<Logout inactivity />
</Route>
<Route path={`${match.url}/forgot`}>
<ForgotPassword />
</Route>
@@ -94,14 +102,12 @@ const Routes = () => {
key={`${collection.slug}-list`}
path={`${match.url}/collections/${collection.slug}`}
exact
render={(routeProps) => {
return (
<List
{...routeProps}
collection={collection}
/>
);
}}
render={(routeProps) => (
<List
{...routeProps}
collection={collection}
/>
)}
/>
);
}
@@ -116,14 +122,12 @@ const Routes = () => {
key={`${collection.slug}-create`}
path={`${match.url}/collections/${collection.slug}/create`}
exact
render={(routeProps) => {
return (
<Edit
{...routeProps}
collection={collection}
/>
);
}}
render={(routeProps) => (
<Edit
{...routeProps}
collection={collection}
/>
)}
/>
);
}
@@ -138,15 +142,13 @@ const Routes = () => {
key={`${collection.slug}-edit`}
path={`${match.url}/collections/${collection.slug}/:id`}
exact
render={(routeProps) => {
return (
<Edit
isEditing
{...routeProps}
collection={collection}
/>
);
}}
render={(routeProps) => (
<Edit
isEditing
{...routeProps}
collection={collection}
/>
)}
/>
);
}
@@ -161,14 +163,12 @@ const Routes = () => {
key={`${global.slug}`}
path={`${match.url}/globals/${global.slug}`}
exact
render={(routeProps) => {
return (
<EditGlobal
{...routeProps}
global={global}
/>
);
}}
render={(routeProps) => (
<EditGlobal
{...routeProps}
global={global}
/>
)}
/>
);
}
@@ -189,6 +189,10 @@ const Routes = () => {
return <Loading />;
}
if (user === undefined) {
return <Loading />;
}
return <Redirect to={`${match.url}/login`} />;
}}
/>

View File

@@ -24,9 +24,9 @@ function recursivelyAddFieldComponents(fields) {
};
}
if (field.components || field.fields) {
if (field.admin.components || field.fields) {
const fieldComponents = {
...(field.components || {}),
...(field.admin.components || {}),
};
if (field.fields) {
@@ -56,7 +56,7 @@ function customComponents(config) {
newComponents[collection.slug] = {
fields: recursivelyAddFieldComponents(collection.fields),
...(collection.components || {}),
...(collection.admin.components || {}),
};
return newComponents;
@@ -67,7 +67,7 @@ function customComponents(config) {
newComponents[global.slug] = {
fields: recursivelyAddFieldComponents(global.fields),
...(global.components || {}),
...(global.admin.components || {}),
};
return newComponents;
@@ -76,7 +76,7 @@ function customComponents(config) {
const string = stringify({
...(allCollectionComponents || {}),
...(allGlobalComponents || {}),
...(config.components || {}),
...(config.admin.components || {}),
}).replace(/\\/g, '\\\\');
return {

View File

@@ -1,18 +1,16 @@
import React, {
useState, createContext, useContext, useEffect, useCallback,
} from 'react';
import { useLocation, useHistory } from 'react-router-dom';
import jwt from 'jsonwebtoken';
import { useLocation, useHistory } from 'react-router-dom';
import PropTypes from 'prop-types';
import Cookies from 'universal-cookie';
import config from 'payload/config';
import { useModal } from '@trbl/react-modal';
import { useModal } from '@faceless-ui/modal';
import { requests } from '../../api';
import StayLoggedInModal from '../modals/StayLoggedIn';
import useDebounce from '../../hooks/useDebounce';
const {
cookiePrefix,
admin: {
user: userSlug,
},
@@ -23,16 +21,12 @@ const {
},
} = config;
const cookieTokenName = `${cookiePrefix}-token`;
const cookies = new Cookies();
const Context = createContext({});
const isNotExpired = decodedJWT => (decodedJWT?.exp || 0) > Date.now() / 1000;
const UserProvider = ({ children }) => {
const [token, setToken] = useState('');
const [user, setUser] = useState(null);
const [user, setUser] = useState(undefined);
const [tokenInMemory, setTokenInMemory] = useState(null);
const exp = user?.exp;
const [permissions, setPermissions] = useState({ canAccessAdmin: null });
@@ -42,64 +36,72 @@ const UserProvider = ({ children }) => {
const [lastLocationChange, setLastLocationChange] = useState(0);
const debouncedLocationChange = useDebounce(lastLocationChange, 10000);
const exp = user?.exp || 0;
const email = user?.email;
const refreshToken = useCallback(() => {
// Need to retrieve token straight from cookie so as to keep this function
// with no dependencies and to make sure we have the exact token that will be used
// in the request to the /refresh route
const tokenFromCookie = cookies.get(cookieTokenName);
const decodedToken = jwt.decode(tokenFromCookie);
const refreshCookie = useCallback(() => {
const now = Math.round((new Date()).getTime() / 1000);
const remainingTime = (exp || 0) - now;
if (decodedToken?.exp > (Date.now() / 1000)) {
if (exp && remainingTime < 120) {
setTimeout(async () => {
const request = await requests.post(`${serverURL}${api}/${userSlug}/refresh-token`);
if (request.status === 200) {
const json = await request.json();
setToken(json.refreshedToken);
setUser(json.user);
} else {
history.push(`${admin}/logout-inactivity`);
}
}, 1000);
}
}, [setToken]);
}, [setUser, history, exp]);
const setToken = useCallback((token) => {
const decoded = jwt.decode(token);
setUser(decoded);
setTokenInMemory(token);
}, []);
const logOut = () => {
setUser(null);
setToken(null);
cookies.remove(cookieTokenName, { path: '/' });
setTokenInMemory(null);
requests.get(`${serverURL}${api}/${userSlug}/logout`);
};
// On mount, get cookie and set as token
// On mount, get user and set
useEffect(() => {
const cookieToken = cookies.get(cookieTokenName);
if (cookieToken) setToken(cookieToken);
}, []);
const fetchMe = async () => {
const request = await requests.get(`${serverURL}${api}/${userSlug}/me`);
// When location changes, refresh token
if (request.status === 200) {
const json = await request.json();
setUser(json?.user || null);
if (json?.token) {
setToken(json.token);
}
}
};
fetchMe();
}, [setToken]);
// When location changes, refresh cookie
useEffect(() => {
refreshToken();
}, [debouncedLocationChange, refreshToken]);
if (email) {
refreshCookie();
}
}, [debouncedLocationChange, refreshCookie, email]);
useEffect(() => {
setLastLocationChange(Date.now());
}, [pathname]);
// When token changes, set cookie, decode and set user
useEffect(() => {
if (token) {
const decoded = jwt.decode(token);
if (isNotExpired(decoded)) {
setUser(decoded);
cookies.set(cookieTokenName, token, { path: '/' });
}
}
}, [token]);
// When user changes, get new policies
// When user changes, get new access
useEffect(() => {
async function getPermissions() {
const request = await requests.get(`${serverURL}${api}/policies`);
const request = await requests.get(`${serverURL}${api}/access`);
if (request.status === 200) {
const json = await request.json();
@@ -135,7 +137,6 @@ const UserProvider = ({ children }) => {
if (remainingTime > 0) {
forceLogOut = setTimeout(() => {
logOut();
history.push(`${admin}/logout`);
closeAllModals();
}, remainingTime * 1000);
@@ -149,15 +150,15 @@ const UserProvider = ({ children }) => {
return (
<Context.Provider value={{
user,
setToken,
logOut,
refreshToken,
token,
refreshCookie,
permissions,
setToken,
token: tokenInMemory,
}}
>
{children}
<StayLoggedInModal refreshToken={refreshToken} />
<StayLoggedInModal refreshCookie={refreshCookie} />
</Context.Provider>
);
};

View File

@@ -0,0 +1,49 @@
import React from 'react';
import PropTypes from 'prop-types';
import Button from '../Button';
import './index.scss';
const baseClass = 'card';
const Card = (props) => {
const { title, actions, onClick } = props;
const classes = [
baseClass,
onClick && `${baseClass}--has-onclick`,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<h5>
{title}
</h5>
{actions && (
<div className={`${baseClass}__actions`}>
{actions}
</div>
)}
{onClick && (
<Button
className={`${baseClass}__click`}
buttonStyle="none"
onClick={onClick}
/>
)}
</div>
);
};
Card.defaultProps = {
actions: null,
onClick: undefined,
};
Card.propTypes = {
title: PropTypes.string.isRequired,
actions: PropTypes.node,
onClick: PropTypes.func,
};
export default Card;

View File

@@ -0,0 +1,41 @@
@import '../../../scss/styles';
.card {
background: $color-background-gray;
padding: base(1.25) $baseline;
position: relative;
h5 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__actions {
position: relative;
z-index: 2;
margin-top: base(.5);
display: inline-flex;
.btn {
margin: 0;
}
}
&--has-onclick {
cursor: pointer;
&:hover {
background: darken($color-background-gray, 3%);
}
}
&__click {
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}

View File

@@ -1,10 +1,12 @@
const getInitialColumnState = (fields, useAsTitle, defaultColumns) => {
let initialColumns = [];
const hasThumbnail = fields.find(field => field.type === 'thumbnail');
const hasThumbnail = fields.find((field) => field.type === 'thumbnail');
if (Array.isArray(defaultColumns)) {
initialColumns = defaultColumns;
if (Array.isArray(defaultColumns) && defaultColumns.length >= 1) {
return {
columns: defaultColumns,
};
}
if (hasThumbnail) {
@@ -15,11 +17,8 @@ const getInitialColumnState = (fields, useAsTitle, defaultColumns) => {
initialColumns.push(useAsTitle);
}
const remainingColumns = fields.filter((field) => {
return field.name !== useAsTitle && field.type !== 'thumbnail';
}).slice(0, 3 - initialColumns.length).map((field) => {
return field.name;
});
const remainingColumns = fields.filter((field) => field.name !== useAsTitle && field.type !== 'thumbnail')
.slice(0, 3 - initialColumns.length).map((field) => field.name);
initialColumns = initialColumns.concat(remainingColumns);

View File

@@ -23,17 +23,19 @@ const reducer = (state, { type, payload }) => {
];
}
return state.filter(remainingColumn => remainingColumn !== payload);
return state.filter((remainingColumn) => remainingColumn !== payload);
};
const ColumnSelector = (props) => {
const {
collection: {
fields,
useAsTitle,
admin: {
useAsTitle,
defaultColumns,
},
},
handleChange,
defaultColumns,
} = props;
const [initialColumns, setInitialColumns] = useState([]);
@@ -55,7 +57,7 @@ const ColumnSelector = (props) => {
return (
<div className={baseClass}>
{fields && fields.map((field, i) => {
const isEnabled = columns.find(column => column === field.name);
const isEnabled = columns.find((column) => column === field.name);
return (
<Pill
onClick={() => dispatchColumns({ payload: field.name, type: isEnabled ? 'disable' : 'enable' })}
@@ -73,20 +75,18 @@ const ColumnSelector = (props) => {
);
};
ColumnSelector.defaultProps = {
defaultColumns: undefined,
};
ColumnSelector.propTypes = {
collection: PropTypes.shape({
fields: PropTypes.arrayOf(
PropTypes.shape({}),
),
useAsTitle: PropTypes.string,
admin: PropTypes.shape({
defaultColumns: PropTypes.arrayOf(
PropTypes.string,
),
useAsTitle: PropTypes.string,
}),
}).isRequired,
defaultColumns: PropTypes.arrayOf(
PropTypes.string,
),
handleChange: PropTypes.func.isRequired,
};

View File

@@ -2,7 +2,7 @@ import React, { useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import config from 'payload/config';
import { useHistory } from 'react-router-dom';
import { Modal, useModal } from '@trbl/react-modal';
import { Modal, useModal } from '@faceless-ui/modal';
import Button from '../Button';
import MinimalTemplate from '../../templates/Minimal';
import useTitle from '../../../hooks/useTitle';
@@ -20,7 +20,9 @@ const DeleteDocument = (props) => {
title: titleFromProps,
id,
collection: {
useAsTitle,
admin: {
useAsTitle,
},
slug,
labels: {
singular,
@@ -80,7 +82,7 @@ const DeleteDocument = (props) => {
if (id) {
return (
<>
<React.Fragment>
<button
type="button"
slug={modalSlug}
@@ -127,7 +129,7 @@ const DeleteDocument = (props) => {
</Button>
</MinimalTemplate>
</Modal>
</>
</React.Fragment>
);
}
@@ -141,7 +143,9 @@ DeleteDocument.defaultProps = {
DeleteDocument.propTypes = {
collection: PropTypes.shape({
useAsTitle: PropTypes.string,
admin: PropTypes.shape({
useAsTitle: PropTypes.string,
}),
slug: PropTypes.string,
labels: PropTypes.shape({
singular: PropTypes.string,

View File

@@ -1,8 +1,9 @@
import React from 'react';
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import config from 'payload/config';
import useForm from '../../forms/Form/useForm';
import Button from '../Button';
import { useForm } from '../../forms/Form/context';
import './index.scss';
@@ -11,21 +12,28 @@ const { routes: { admin } } = config;
const baseClass = 'duplicate';
const Duplicate = ({ slug }) => {
const { push } = useHistory();
const { getData } = useForm();
const data = getData();
const handleClick = useCallback(() => {
const data = getData();
push({
pathname: `${admin}/collections/${slug}/create`,
state: {
data,
},
});
}, [push, getData, slug]);
return (
<Link
<Button
buttonStyle="none"
className={baseClass}
to={{
pathname: `${admin}/collections/${slug}/create`,
state: {
data,
},
}}
onClick={handleClick}
>
Duplicate
</Link>
</Button>
);
};

View File

@@ -91,6 +91,7 @@
&__main-detail {
border-top: $style-stroke-width-m solid white;
order: 3;
width: 100%;
}
}
}

View File

@@ -14,10 +14,13 @@ const ListControls = (props) => {
const {
handleChange,
collection,
enableColumns,
collection: {
fields,
useAsTitle,
defaultColumns,
admin: {
useAsTitle,
defaultColumns,
},
},
} = props;
@@ -29,7 +32,7 @@ const ListControls = (props) => {
useEffect(() => {
if (useAsTitle) {
const foundTitleField = fields.find(field => field.name === useAsTitle);
const foundTitleField = fields.find((field) => field.name === useAsTitle);
if (foundTitleField) {
setTitleField(foundTitleField);
@@ -71,35 +74,43 @@ const ListControls = (props) => {
fieldName={titleField ? titleField.name : undefined}
fieldLabel={titleField ? titleField.label : undefined}
/>
<Button
className={`${baseClass}__toggle-columns`}
buttonStyle={visibleDrawer === 'columns' ? undefined : 'secondary'}
onClick={() => setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : false)}
icon="chevron"
iconStyle="none"
>
Columns
</Button>
<Button
className={`${baseClass}__toggle-where`}
buttonStyle={visibleDrawer === 'where' ? undefined : 'secondary'}
onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : false)}
icon="chevron"
iconStyle="none"
>
Filters
</Button>
<div className={`${baseClass}__buttons`}>
<div className={`${baseClass}__buttons-wrap`}>
{enableColumns && (
<Button
className={`${baseClass}__toggle-columns`}
buttonStyle={visibleDrawer === 'columns' ? undefined : 'secondary'}
onClick={() => setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : false)}
icon="chevron"
iconStyle="none"
>
Columns
</Button>
)}
<Button
className={`${baseClass}__toggle-where`}
buttonStyle={visibleDrawer === 'where' ? undefined : 'secondary'}
onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : false)}
icon="chevron"
iconStyle="none"
>
Filters
</Button>
</div>
</div>
</div>
<AnimateHeight
className={`${baseClass}__columns`}
height={visibleDrawer === 'columns' ? 'auto' : 0}
>
<ColumnSelector
collection={collection}
defaultColumns={defaultColumns}
handleChange={setColumns}
/>
</AnimateHeight>
{enableColumns && (
<AnimateHeight
className={`${baseClass}__columns`}
height={visibleDrawer === 'columns' ? 'auto' : 0}
>
<ColumnSelector
collection={collection}
defaultColumns={defaultColumns}
handleChange={setColumns}
/>
</AnimateHeight>
)}
<AnimateHeight
className={`${baseClass}__where`}
height={visibleDrawer === 'where' ? 'auto' : 0}
@@ -113,13 +124,20 @@ const ListControls = (props) => {
);
};
ListControls.defaultProps = {
enableColumns: true,
};
ListControls.propTypes = {
enableColumns: PropTypes.bool,
collection: PropTypes.shape({
useAsTitle: PropTypes.string,
admin: PropTypes.shape({
useAsTitle: PropTypes.string,
defaultColumns: PropTypes.arrayOf(
PropTypes.string,
),
}),
fields: PropTypes.arrayOf(PropTypes.shape),
defaultColumns: PropTypes.arrayOf(
PropTypes.string,
),
}).isRequired,
handleChange: PropTypes.func.isRequired,
};

View File

@@ -15,9 +15,20 @@
}
}
&__buttons {
margin-left: $baseline;
}
&__buttons-wrap {
display: flex;
margin-left: - base(.5);
margin-right: - base(.5);
width: calc(100% + #{$baseline});
}
&__toggle-columns,
&__toggle-where {
margin: 0 0 0 $baseline;
margin: 0 base(.5);
min-width: 140px;
&.btn--style-primary {
@@ -33,9 +44,8 @@
}
@include mid-break {
&__toggle-columns,
&__toggle-where {
margin: 0 0 0 base(.5);
&__buttons {
margin-left: base(.5);
}
}
@@ -49,17 +59,17 @@
width: 100%;
}
&__buttons {
margin: 0;
}
&__buttons {
width: 100%;
}
&__toggle-columns,
&__toggle-where {
width: calc(50% - #{base(.25)});
}
&__toggle-columns {
margin: 0 base(.25) 0 0;
}
&__toggle-where {
margin: 0 0 0 base(.25);
flex-grow: 1;
}
}
}

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { NavLink, Link } from 'react-router-dom';
import React, { useState, useEffect } from 'react';
import { NavLink, Link, useHistory } from 'react-router-dom';
import config from 'payload/config';
import { useUser } from '../../data/User';
import Chevron from '../../icons/Chevron';
@@ -25,12 +25,17 @@ const {
const Nav = () => {
const { permissions } = useUser();
const [menuActive, setMenuActive] = useState(false);
const history = useHistory();
const classes = [
baseClass,
menuActive && `${baseClass}--menu-active`,
].filter(Boolean).join(' ');
useEffect(() => history.listen(() => {
setMenuActive(false);
}), []);
return (
<aside className={classes}>
<div className={`${baseClass}__scroll`}>
@@ -76,27 +81,31 @@ const Nav = () => {
return null;
})}
</nav>
<span className={`${baseClass}__label`}>Globals</span>
<nav>
{globals && globals.map((global, i) => {
const href = `${admin}/globals/${global.slug}`;
{(globals && globals.length > 0) && (
<React.Fragment>
<span className={`${baseClass}__label`}>Globals</span>
<nav>
{globals.map((global, i) => {
const href = `${admin}/globals/${global.slug}`;
if (permissions?.[global.slug].read.permission) {
return (
<NavLink
activeClassName="active"
key={i}
to={href}
>
<Chevron />
{global.label}
</NavLink>
);
}
if (permissions?.[global.slug].read.permission) {
return (
<NavLink
activeClassName="active"
key={i}
to={href}
>
<Chevron />
{global.label}
</NavLink>
);
}
return null;
})}
</nav>
return null;
})}
</nav>
</React.Fragment>
)}
<div className={`${baseClass}__controls`}>
<Localizer />
<Link

View File

@@ -159,5 +159,11 @@
opacity: 1;
}
}
nav a {
font-size: base(.875);
line-height: base(1.25);
font-weight: 600;
}
}
}

View File

@@ -29,7 +29,7 @@
outline: 0;
padding: base(.5);
color: $color-dark-gray;
line-height: 1;
line-height: base(1);
&:hover:not(.clickable-arrow--is-disabled) {
background: $color-background-gray;

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { useWindowInfo } from '@trbl/react-window-info';
import { useScrollInfo } from '@trbl/react-scroll-info';
import { useWindowInfo } from '@faceless-ui/window-info';
import { useScrollInfo } from '@faceless-ui/scroll-info';
import useThrottledEffect from '../../../hooks/useThrottledEffect';
import PopupButton from './PopupButton';
@@ -115,8 +115,7 @@ const Popup = (props) => {
setActive={setActive}
active={active}
/>
)
}
)}
</div>
<div

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import useForm from '../../forms/Form/useForm';
import { useForm } from '../../forms/Form/context';
import { useUser } from '../../data/User';
import Button from '../Button';
@@ -11,9 +11,9 @@ const PreviewButton = ({ generatePreviewURL }) => {
const { getFields } = useForm();
const fields = getFields();
const previewURL = (generatePreviewURL && typeof generatePreviewURL === 'function') ? generatePreviewURL(fields, token) : null;
if (generatePreviewURL && typeof generatePreviewURL === 'function') {
const previewURL = generatePreviewURL(fields, token);
if (previewURL) {
return (
<Button
el="anchor"

View File

@@ -1,7 +1,11 @@
import React from 'react';
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import useTitle from '../../../hooks/useTitle';
import './index.scss';
const baseClass = 'render-title';
const RenderTitle = (props) => {
const {
useAsTitle, title: titleFromProps, data, fallback,
@@ -12,11 +16,27 @@ const RenderTitle = (props) => {
let title = titleFromData;
if (!title) title = titleFromForm;
if (!title) title = data.id;
if (!title) title = data?.id;
if (!title) title = fallback;
title = titleFromProps || title;
return <>{title}</>;
const idAsTitle = title === data?.id;
const classes = [
baseClass,
idAsTitle && `${baseClass}--id-as-title`,
].filter(Boolean).join(' ');
return (
<span className={classes}>
{idAsTitle && (
<Fragment>
ID:&nbsp;&nbsp;
</Fragment>
)}
{title}
</span>
);
};
RenderTitle.defaultProps = {

View File

@@ -0,0 +1,12 @@
@import '../../../scss/styles.scss';
.render-title {
&--id-as-title {
font-size: base(.75);
font-weight: normal;
color: $color-gray;
background: $color-background-gray;
padding: base(.25) base(.5);
border-radius: $style-radius-m;
}
}

View File

@@ -17,10 +17,10 @@ const StatusListProvider = ({ children }) => {
const [statusList, dispatchStatus] = useReducer(reducer, []);
const { pathname, state } = useLocation();
const removeStatus = useCallback(i => dispatchStatus({ type: 'REMOVE', payload: i }), []);
const addStatus = useCallback(status => dispatchStatus({ type: 'ADD', payload: status }), []);
const removeStatus = useCallback((i) => dispatchStatus({ type: 'REMOVE', payload: i }), []);
const addStatus = useCallback((status) => dispatchStatus({ type: 'ADD', payload: status }), []);
const clearStatus = useCallback(() => dispatchStatus({ type: 'CLEAR' }), []);
const replaceStatus = useCallback(status => dispatchStatus({ type: 'REPLACE', payload: status }), []);
const replaceStatus = useCallback((status) => dispatchStatus({ type: 'REPLACE', payload: status }), []);
useEffect(() => {
if (state && state.status) {

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import Button from '../../../elements/Button';
import Popup from '../../../elements/Popup';
import BlockSelector from '../../field-types/Flexible/BlockSelector';
import BlockSelector from '../../field-types/Blocks/BlockSelector';
import './index.scss';
@@ -52,7 +52,7 @@ const ActionPanel = (props) => {
{singularLabel}
</Popup>
{blockType === 'flexible'
{blockType === 'blocks'
? (
<Popup
buttonType="custom"
@@ -102,8 +102,7 @@ const ActionPanel = (props) => {
Add&nbsp;
{singularLabel}
</Popup>
)
}
)}
</div>
</div>
</div>
@@ -115,13 +114,17 @@ ActionPanel.defaultProps = {
verticalAlignment: 'center',
blockType: null,
isHovered: false,
blocks: [],
};
ActionPanel.propTypes = {
singularLabel: PropTypes.string,
addRow: PropTypes.func.isRequired,
removeRow: PropTypes.func.isRequired,
blockType: PropTypes.oneOf(['flexible', 'repeater']),
blockType: PropTypes.oneOf(['blocks', 'array']),
blocks: PropTypes.arrayOf(
PropTypes.shape({}),
),
verticalAlignment: PropTypes.oneOf(['top', 'center', 'sticky']),
isHovered: PropTypes.bool,
rowIndex: PropTypes.number.isRequired,

View File

@@ -61,7 +61,7 @@ $controls-top-adjustment: base(.1);
// External scopes
.field-type.flexible {
.field-type.blocks {
.position-panel {
&__controls-container {
min-height: calc(100% + #{$controls-top-adjustment});

View File

@@ -8,7 +8,7 @@ import './index.scss';
const baseClass = 'editable-block-title';
const EditableBlockTitle = (props) => {
const { path, initialData } = props;
const { path } = props;
const inputRef = useRef(null);
const inputCloneRef = useRef(null);
const [inputWidth, setInputWidth] = useState(0);
@@ -18,7 +18,6 @@ const EditableBlockTitle = (props) => {
setValue,
} = useFieldType({
path,
initialData,
});
useEffect(() => {
@@ -31,7 +30,7 @@ const EditableBlockTitle = (props) => {
};
return (
<>
<React.Fragment>
<div className={baseClass}>
<input
ref={inputRef}
@@ -53,17 +52,12 @@ const EditableBlockTitle = (props) => {
>
{value || 'Untitled'}
</span>
</>
</React.Fragment>
);
};
EditableBlockTitle.defaultProps = {
initialData: undefined,
};
EditableBlockTitle.propTypes = {
path: PropTypes.string.isRequired,
initialData: PropTypes.string,
};
export default EditableBlockTitle;

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import AnimateHeight from 'react-animate-height';
import { Draggable } from 'react-beautiful-dnd';
@@ -22,17 +22,16 @@ const DraggableSection = (props) => {
rowCount,
parentPath,
fieldSchema,
initialData,
singularLabel,
blockType,
fieldTypes,
customComponentsPath,
isOpen,
toggleRowCollapse,
id,
positionPanelVerticalAlignment,
actionPanelVerticalAlignment,
toggleRowCollapse,
permissions,
isOpen,
} = props;
const [isHovered, setIsHovered] = useState(false);
@@ -48,79 +47,73 @@ const DraggableSection = (props) => {
draggableId={id}
index={rowIndex}
>
{(providedDrag) => {
return (
<div
ref={providedDrag.innerRef}
className={classes}
onMouseLeave={() => setIsHovered(false)}
onMouseOver={() => setIsHovered(true)}
onFocus={() => setIsHovered(true)}
{...providedDrag.draggableProps}
>
{(providedDrag) => (
<div
ref={providedDrag.innerRef}
className={classes}
onMouseLeave={() => setIsHovered(false)}
onMouseOver={() => setIsHovered(true)}
onFocus={() => setIsHovered(true)}
{...providedDrag.draggableProps}
>
<div className={`${baseClass}__content-wrapper`}>
<PositionPanel
dragHandleProps={providedDrag.dragHandleProps}
moveRow={moveRow}
rowCount={rowCount}
positionIndex={rowIndex}
verticalAlignment={positionPanelVerticalAlignment}
/>
<div className={`${baseClass}__content-wrapper`}>
<PositionPanel
dragHandleProps={providedDrag.dragHandleProps}
moveRow={moveRow}
rowCount={rowCount}
positionIndex={rowIndex}
verticalAlignment={positionPanelVerticalAlignment}
/>
<div className={`${baseClass}__render-fields-wrapper`}>
<div className={`${baseClass}__render-fields-wrapper`}>
{blockType === 'flexible' && (
<div className={`${baseClass}__section-header`}>
<SectionTitle
label={singularLabel}
initialData={initialData?.blockName}
path={`${parentPath}.${rowIndex}.blockName`}
/>
<Button
icon="chevron"
onClick={toggleRowCollapse}
buttonStyle="icon-label"
className={`toggle-collapse toggle-collapse--is-${isOpen ? 'open' : 'closed'}`}
round
/>
</div>
)}
<AnimateHeight
height={isOpen ? 'auto' : 0}
duration={0}
>
<RenderFields
initialData={initialData}
customComponentsPath={customComponentsPath}
fieldTypes={fieldTypes}
key={rowIndex}
permissions={permissions}
fieldSchema={fieldSchema.map((field) => {
return ({
...field,
path: `${parentPath}.${rowIndex}${field.name ? `.${field.name}` : ''}`,
});
})}
{blockType === 'blocks' && (
<div className={`${baseClass}__section-header`}>
<SectionTitle
label={singularLabel}
path={`${parentPath}.${rowIndex}.blockName`}
/>
</AnimateHeight>
</div>
<ActionPanel
rowIndex={rowIndex}
addRow={addRow}
removeRow={removeRow}
singularLabel={singularLabel}
verticalAlignment={actionPanelVerticalAlignment}
isHovered={isHovered}
{...props}
/>
<Button
icon="chevron"
onClick={toggleRowCollapse}
buttonStyle="icon-label"
className={`toggle-collapse toggle-collapse--is-${isOpen ? 'open' : 'closed'}`}
round
/>
</div>
)}
<AnimateHeight
height={isOpen ? 'auto' : 0}
duration={0}
>
<RenderFields
customComponentsPath={customComponentsPath}
fieldTypes={fieldTypes}
key={rowIndex}
permissions={permissions}
fieldSchema={fieldSchema.map((field) => ({
...field,
path: `${parentPath}.${rowIndex}${field.name ? `.${field.name}` : ''}`,
}))}
/>
</AnimateHeight>
</div>
<ActionPanel
rowIndex={rowIndex}
addRow={addRow}
removeRow={removeRow}
singularLabel={singularLabel}
verticalAlignment={actionPanelVerticalAlignment}
isHovered={isHovered}
{...props}
/>
</div>
);
}}
</div>
)}
</Draggable>
);
};

View File

@@ -4,7 +4,7 @@
// HELPER MIXINS
//////////////////////
@mixin realtively-position-panels {
@mixin relatively-position-panels {
.position-panel {
position: relative;
right: 0;
@@ -117,7 +117,7 @@
}
@include mid-break {
@include realtively-position-panels();
@include relatively-position-panels();
.position-panel__move-forward,
.position-panel__move-backward {
@@ -130,21 +130,25 @@
// EXTERNAL SCOPES
//////////////////////
.global-edit,
.collection-edit {
@include absolutely-position-panels();
@include mid-break {
@include realtively-position-panels();
@include relatively-position-panels();
}
}
.field-type.repeater .field-type.repeater {
@include realtively-position-panels();
.field-type.blocks .field-type.array,
.field-type.array .field-type.array,
.field-type.array .field-type.blocks,
.field-type.blocks .field-type.blocks {
@include relatively-position-panels();
}
// remove padding above repeater rows to level
// remove padding above array rows to level
// the line with the top of the input label
.field-type.repeater {
.field-type.array {
.draggable-section {
&__content-wrapper {
padding-top: 0;

View File

@@ -1,3 +0,0 @@
import { createContext } from 'react';
export default createContext({});

View File

@@ -1,3 +0,0 @@
import { createContext } from 'react';
export default createContext({});

View File

@@ -0,0 +1,101 @@
const buildValidationPromise = async (fieldState, field) => {
const validatedFieldState = fieldState;
validatedFieldState.valid = typeof field.validate === 'function' ? await field.validate(fieldState.value, field) : true;
if (typeof validatedFieldState.valid === 'string') {
validatedFieldState.errorMessage = validatedFieldState.valid;
validatedFieldState.valid = false;
}
};
const buildStateFromSchema = async (fieldSchema, fullData) => {
if (fieldSchema && fullData) {
const validationPromises = [];
const structureFieldState = (field, data = {}) => {
const value = data[field.name] || field.defaultValue;
const fieldState = {
value,
initialValue: value,
};
validationPromises.push(buildValidationPromise(fieldState, field));
return fieldState;
};
const iterateFields = (fields, data, path = '') => fields.reduce((state, field) => {
if (field.name && data[field.name]) {
if (Array.isArray(data[field.name])) {
if (field.type === 'array') {
return {
...state,
...data[field.name].reduce((rowState, row, i) => ({
...rowState,
...iterateFields(field.fields, row, `${path}${field.name}.${i}.`),
}), {}),
};
}
if (field.type === 'blocks') {
return {
...state,
...data[field.name].reduce((rowState, row, i) => {
const block = field.blocks.find((blockType) => blockType.slug === row.blockType);
const rowPath = `${path}${field.name}.${i}.`;
return {
...rowState,
[`${rowPath}blockType`]: {
value: row.blockType,
initialValue: row.blockType,
valid: true,
},
[`${rowPath}blockName`]: {
value: row.blockName,
initialValue: row.blockName,
valid: true,
},
...iterateFields(block.fields, row, rowPath),
};
}, {}),
};
}
}
if (field.fields) {
return {
...state,
...iterateFields(field.fields, data[field.name], `${path}${field.name}.`),
};
}
return {
...state,
[`${path}${field.name}`]: structureFieldState(field, data),
};
}
if (field.fields) {
return {
...state,
...iterateFields(field.fields, data, path),
};
}
return state;
}, {});
const resultingState = iterateFields(fieldSchema, fullData);
await Promise.all(validationPromises);
return resultingState;
}
return {};
};
module.exports = buildStateFromSchema;

View File

@@ -0,0 +1,26 @@
import { createContext, useContext } from 'react';
const FormContext = createContext({});
const FieldContext = createContext({});
const SubmittedContext = createContext(false);
const ProcessingContext = createContext(false);
const ModifiedContext = createContext(false);
const useForm = () => useContext(FormContext);
const useFormFields = () => useContext(FieldContext);
const useFormSubmitted = () => useContext(SubmittedContext);
const useFormProcessing = () => useContext(ProcessingContext);
const useFormModified = () => useContext(ModifiedContext);
export {
FormContext,
FieldContext,
SubmittedContext,
ProcessingContext,
ModifiedContext,
useForm,
useFormFields,
useFormSubmitted,
useFormProcessing,
useFormModified,
};

View File

@@ -1,9 +1,43 @@
import { unflatten, flatten } from 'flatley';
import flattenFilters from './flattenFilters';
//
const unflattenRowsFromState = (state, path) => {
// Take a copy of state
const remainingFlattenedState = { ...state };
const rowsFromStateObject = {};
const pathPrefixToRemove = path.substring(0, path.lastIndexOf('.') + 1);
// Loop over all keys from state
// If the key begins with the name of the parent field,
// Add value to rowsFromStateObject and delete it from remaining state
Object.keys(state).forEach((key) => {
if (key.indexOf(`${path}.`) === 0) {
if (!state[key].ignoreWhileFlattening) {
const name = key.replace(pathPrefixToRemove, '');
rowsFromStateObject[name] = state[key];
rowsFromStateObject[name].initialValue = rowsFromStateObject[name].value;
}
delete remainingFlattenedState[key];
}
});
const unflattenedRows = unflatten(rowsFromStateObject);
return {
unflattenedRows: unflattenedRows[path.replace(pathPrefixToRemove, '')] || [],
remainingFlattenedState,
};
};
function fieldReducer(state, action) {
switch (action.type) {
case 'REPLACE_ALL':
return {
...action.value,
};
case 'REPLACE_STATE': {
return action.state;
}
case 'REMOVE': {
const newState = { ...state };
@@ -11,15 +45,112 @@ function fieldReducer(state, action) {
return newState;
}
case 'REMOVE_ROW': {
const { rowIndex, path } = action;
const { unflattenedRows, remainingFlattenedState } = unflattenRowsFromState(state, path);
unflattenedRows.splice(rowIndex, 1);
const flattenedRowState = unflattenedRows.length > 0 ? flatten({ [path]: unflattenedRows }, { filters: flattenFilters }) : {};
return {
...remainingFlattenedState,
...flattenedRowState,
};
}
case 'ADD_ROW': {
const {
rowIndex, path, fieldSchema, blockType,
} = action;
const { unflattenedRows, remainingFlattenedState } = unflattenRowsFromState(state, path);
// Get paths of sub fields
const subFields = fieldSchema.reduce((acc, field) => {
if (field.type === 'flexible' || field.type === 'repeater') {
return acc;
}
if (field.name) {
return {
...acc,
[field.name]: {
value: null,
valid: !field.required,
},
};
}
if (field.fields) {
return {
...acc,
...(field.fields.reduce((fields, subField) => ({
...fields,
[subField.name]: {
value: null,
valid: !field.required,
},
}), {})),
};
}
return acc;
}, {});
if (blockType) {
subFields.blockType = {
value: blockType,
initialValue: blockType,
valid: true,
};
subFields.blockName = {
value: null,
initialValue: null,
valid: true,
};
}
// Add new object containing subfield names to unflattenedRows array
unflattenedRows.splice(rowIndex + 1, 0, subFields);
const newState = {
...remainingFlattenedState,
...(flatten({ [path]: unflattenedRows }, { filters: flattenFilters })),
};
return newState;
}
case 'MOVE_ROW': {
const { moveFromIndex, moveToIndex, path } = action;
const { unflattenedRows, remainingFlattenedState } = unflattenRowsFromState(state, path);
// copy the row to move
const copyOfMovingRow = unflattenedRows[moveFromIndex];
// delete the row by index
unflattenedRows.splice(moveFromIndex, 1);
// insert row copyOfMovingRow back in
unflattenedRows.splice(moveToIndex, 0, copyOfMovingRow);
const newState = {
...remainingFlattenedState,
...(flatten({ [path]: unflattenedRows }, { filters: flattenFilters })),
};
return newState;
}
default: {
const newField = {
value: action.value,
valid: action.valid,
errorMessage: action.errorMessage,
disableFormData: action.disableFormData,
ignoreWhileFlattening: action.ignoreWhileFlattening,
initialValue: action.initialValue,
};
if (action.disableFormData) newField.disableFormData = action.disableFormData;
return {
...state,
[action.path]: newField,

View File

@@ -0,0 +1,10 @@
const flattenFilters = [{
test: (_, value) => {
const hasValidProperty = Object.prototype.hasOwnProperty.call(value, 'valid');
const hasValueProperty = Object.prototype.hasOwnProperty.call(value, 'value');
return (hasValidProperty && hasValueProperty);
},
}];
export default flattenFilters;

View File

@@ -1,13 +1,10 @@
import React, {
useReducer, useEffect, useRef,
useReducer, useEffect, useRef, useState, useCallback,
} from 'react';
import { objectToFormData } from 'object-to-formdata';
import { useHistory } from 'react-router-dom';
import PropTypes from 'prop-types';
import { unflatten } from 'flatley';
import HiddenInput from '../field-types/HiddenInput';
import FormContext from './FormContext';
import FieldContext from './FieldContext';
import { useLocale } from '../../utilities/Locale';
import { useStatusList } from '../../elements/Status';
import { requests } from '../../../api';
@@ -16,6 +13,8 @@ import { useUser } from '../../data/User';
import fieldReducer from './fieldReducer';
import initContextState from './initContextState';
import { SubmittedContext, ProcessingContext, ModifiedContext, FormContext, FieldContext } from './context';
import './index.scss';
const baseClass = 'form';
@@ -40,36 +39,41 @@ const Form = (props) => {
const {
disabled,
onSubmit,
ajax,
method,
action,
handleAjaxResponse,
handleResponse,
onSuccess,
children,
className,
redirect,
disableSuccessStatus,
initialState,
} = props;
const history = useHistory();
const locale = useLocale();
const { replaceStatus, addStatus, clearStatus } = useStatusList();
const { refreshToken } = useUser();
const { refreshCookie } = useUser();
const [modified, setModified] = useState(false);
const [processing, setProcessing] = useState(false);
const [submitted, setSubmitted] = useState(false);
const contextRef = useRef({ ...initContextState });
contextRef.current.initialState = initialState;
const [fields, dispatchFields] = useReducer(fieldReducer, {});
contextRef.current.fields = fields;
contextRef.current.dispatchFields = dispatchFields;
contextRef.current.submit = (e) => {
const submit = useCallback((e) => {
if (disabled) {
e.preventDefault();
return false;
}
e.stopPropagation();
contextRef.current.setSubmitted(true);
setSubmitted(true);
const isValid = contextRef.current.validateForm();
@@ -96,116 +100,129 @@ const Form = (props) => {
return onSubmit(fields);
}
// If form is AJAX, fetch data
if (ajax !== false) {
e.preventDefault();
e.preventDefault();
window.scrollTo({
top: 0,
behavior: 'smooth',
});
window.scrollTo({
top: 0,
behavior: 'smooth',
});
const formData = contextRef.current.createFormData();
contextRef.current.setProcessing(true);
const formData = contextRef.current.createFormData();
setProcessing(true);
// Make the API call from the action
return requests[method.toLowerCase()](action, {
body: formData,
}).then((res) => {
contextRef.current.setModified(false);
if (typeof handleAjaxResponse === 'function') return handleAjaxResponse(res);
// Make the API call from the action
return requests[method.toLowerCase()](action, {
body: formData,
}).then((res) => {
setModified(false);
if (typeof handleResponse === 'function') return handleResponse(res);
return res.json().then((json) => {
contextRef.current.setProcessing(false);
clearStatus();
return res.json().then((json) => {
setProcessing(false);
clearStatus();
if (res.status < 400) {
if (typeof onSuccess === 'function') onSuccess(json);
if (res.status < 400) {
if (typeof onSuccess === 'function') onSuccess(json);
if (redirect) {
return history.push(redirect, json);
}
if (!disableSuccessStatus) {
replaceStatus([{
message: json.message,
type: 'success',
disappear: 3000,
}]);
}
} else {
if (json.message) {
addStatus({
message: json.message,
type: 'error',
});
return json;
}
if (Array.isArray(json.errors)) {
const [fieldErrors, nonFieldErrors] = json.errors.reduce(([fieldErrs, nonFieldErrs], err) => {
return err.field && err.message ? [[...fieldErrs, err], nonFieldErrs] : [fieldErrs, [...nonFieldErrs, err]];
}, [[], []]);
fieldErrors.forEach((err) => {
dispatchFields({
valid: false,
errorMessage: err.message,
path: err.field,
value: contextRef.current.fields?.[err.field]?.value,
});
});
nonFieldErrors.forEach((err) => {
addStatus({
message: err.message || 'An unknown error occurred.',
type: 'error',
});
});
if (fieldErrors.length > 0 && nonFieldErrors.length === 0) {
addStatus({
message: 'Please correct the fields below.',
type: 'error',
});
}
return json;
if (redirect) {
const destination = {
pathname: redirect,
};
if (json.message && !disableSuccessStatus) {
destination.state = {
status: [
{
message: json.message,
type: 'success',
},
],
};
}
history.push(destination);
} else if (!disableSuccessStatus) {
replaceStatus([{
message: json.message,
type: 'success',
disappear: 3000,
}]);
}
} else {
if (json.message) {
addStatus({
message: 'An unknown error occurred.',
message: json.message,
type: 'error',
});
return json;
}
return json;
});
}).catch((err) => {
addStatus({
message: err,
type: 'error',
});
if (Array.isArray(json.errors)) {
const [fieldErrors, nonFieldErrors] = json.errors.reduce(([fieldErrs, nonFieldErrs], err) => (err.field && err.message ? [[...fieldErrs, err], nonFieldErrs] : [fieldErrs, [...nonFieldErrs, err]]), [[], []]);
fieldErrors.forEach((err) => {
dispatchFields({
...(contextRef.current?.fields?.[err.field] || {}),
valid: false,
errorMessage: err.message,
path: err.field,
});
});
nonFieldErrors.forEach((err) => {
addStatus({
message: err.message || 'An unknown error occurred.',
type: 'error',
});
});
if (fieldErrors.length > 0 && nonFieldErrors.length === 0) {
addStatus({
message: 'Please correct the fields below.',
type: 'error',
});
}
return json;
}
addStatus({
message: 'An unknown error occurred.',
type: 'error',
});
}
return json;
});
}
}).catch((err) => {
addStatus({
message: err,
type: 'error',
});
});
}, [
action,
addStatus,
clearStatus,
disableSuccessStatus,
disabled,
fields,
handleResponse,
history,
method,
onSubmit,
onSuccess,
redirect,
replaceStatus,
]);
return true;
};
contextRef.current.getFields = () => {
return contextRef.current.fields;
};
const getFields = useCallback(() => contextRef.current.fields, [contextRef]);
const getField = useCallback((path) => contextRef.current.fields[path], [contextRef]);
const getData = useCallback(() => reduceFieldsToValues(contextRef.current.fields, true), [contextRef]);
contextRef.current.getField = (path) => {
return contextRef.current.fields[path];
};
contextRef.current.getData = () => {
return reduceFieldsToValues(contextRef.current.fields, true);
};
contextRef.current.getSiblingData = (path) => {
const getSiblingData = useCallback((path) => {
let siblingFields = contextRef.current.fields;
// If this field is nested
@@ -225,9 +242,9 @@ const Form = (props) => {
}
return reduceFieldsToValues(siblingFields, true);
};
}, [contextRef]);
contextRef.current.getDataByPath = (path) => {
const getDataByPath = useCallback((path) => {
const pathPrefixToRemove = path.substring(0, path.lastIndexOf('.') + 1);
const name = path.split('.').pop();
@@ -245,42 +262,42 @@ const Form = (props) => {
const values = reduceFieldsToValues(data, true);
const unflattenedData = unflatten(values);
return unflattenedData?.[name];
};
}, [contextRef]);
contextRef.current.getUnflattenedValues = () => {
return reduceFieldsToValues(contextRef.current.fields);
};
const getUnflattenedValues = useCallback(() => reduceFieldsToValues(contextRef.current.fields), [contextRef]);
contextRef.current.validateForm = () => {
return !Object.values(contextRef.current.fields).some((field) => {
return field.valid === false;
});
};
const validateForm = useCallback(() => !Object.values(contextRef.current.fields).some((field) => field.valid === false), [contextRef]);
contextRef.current.createFormData = () => {
const createFormData = useCallback(() => {
const data = reduceFieldsToValues(contextRef.current.fields);
return objectToFormData(data, { indices: true });
};
}, [contextRef]);
contextRef.current.setModified = (modified) => {
contextRef.current.modified = modified;
};
contextRef.current.dispatchFields = dispatchFields;
contextRef.current.submit = submit;
contextRef.current.getFields = getFields;
contextRef.current.getField = getField;
contextRef.current.getData = getData;
contextRef.current.getSiblingData = getSiblingData;
contextRef.current.getDataByPath = getDataByPath;
contextRef.current.getUnflattenedValues = getUnflattenedValues;
contextRef.current.validateForm = validateForm;
contextRef.current.createFormData = createFormData;
contextRef.current.setModified = setModified;
contextRef.current.setProcessing = setProcessing;
contextRef.current.setSubmitted = setSubmitted;
contextRef.current.setSubmitted = (submitted) => {
contextRef.current.submitted = submitted;
};
contextRef.current.setProcessing = (processing) => {
contextRef.current.processing = processing;
};
useEffect(() => {
dispatchFields({ type: 'REPLACE_STATE', state: initialState });
}, [initialState]);
useThrottledEffect(() => {
refreshToken();
refreshCookie();
}, 15000, [fields]);
useEffect(() => {
contextRef.current.modified = false;
}, [locale, contextRef.current.modified]);
setModified(false);
}, [locale]);
const classes = [
className,
@@ -301,13 +318,16 @@ const Form = (props) => {
...contextRef.current,
}}
>
<HiddenInput
path="locale"
defaultValue={locale}
/>
{children}
<SubmittedContext.Provider value={submitted}>
<ProcessingContext.Provider value={processing}>
<ModifiedContext.Provider value={modified}>
{children}
</ModifiedContext.Provider>
</ProcessingContext.Provider>
</SubmittedContext.Provider>
</FieldContext.Provider>
</FormContext.Provider>
</form>
);
};
@@ -315,23 +335,22 @@ const Form = (props) => {
Form.defaultProps = {
redirect: '',
onSubmit: null,
ajax: true,
method: 'POST',
action: '',
handleAjaxResponse: null,
handleResponse: null,
onSuccess: null,
className: '',
disableSuccessStatus: false,
disabled: false,
initialState: {},
};
Form.propTypes = {
disableSuccessStatus: PropTypes.bool,
onSubmit: PropTypes.func,
ajax: PropTypes.bool,
method: PropTypes.oneOf(['post', 'POST', 'get', 'GET', 'put', 'PUT', 'delete', 'DELETE']),
action: PropTypes.string,
handleAjaxResponse: PropTypes.func,
handleResponse: PropTypes.func,
onSuccess: PropTypes.func,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
@@ -340,6 +359,7 @@ Form.propTypes = {
className: PropTypes.string,
redirect: PropTypes.string,
disabled: PropTypes.bool,
initialState: PropTypes.shape({}),
};
export default Form;

View File

@@ -1,7 +1,4 @@
export default {
processing: false,
modified: false,
submitted: false,
getFields: () => { },
getField: () => { },
getData: () => { },
@@ -13,4 +10,6 @@ export default {
submit: () => { },
dispatchFields: () => { },
setModified: () => { },
initialState: {},
reset: 0,
};

View File

@@ -1,4 +0,0 @@
import { useContext } from 'react';
import FormContext from './FormContext';
export default () => useContext(FormContext);

View File

@@ -1,4 +0,0 @@
import { useContext } from 'react';
import FieldContext from './FieldContext';
export default () => useContext(FieldContext);

View File

@@ -6,7 +6,7 @@ slides.1.heroInfo.title
fields: [
{
name: slides,
type: repeater,
type: array,
fields: [
{
type: group,

View File

@@ -1,8 +1,13 @@
import React, { createContext, useContext } from 'react';
import React, { createContext, useEffect, useContext, useState } from 'react';
import PropTypes from 'prop-types';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import useIntersect from '../../../hooks/useIntersect';
import './index.scss';
const baseClass = 'render-fields';
const intersectionObserverOptions = {
rootMargin: '1000px',
};
const RenderedFieldContext = createContext({});
@@ -11,86 +16,118 @@ export const useRenderedFields = () => useContext(RenderedFieldContext);
const RenderFields = (props) => {
const {
fieldSchema,
initialData,
customComponentsPath: customComponentsPathFromProps,
fieldTypes,
filter,
permissions,
readOnly: readOnlyOverride,
operation: operationFromProps,
className,
} = props;
const [hasIntersected, setHasIntersected] = useState(false);
const [intersectionRef, entry] = useIntersect(intersectionObserverOptions);
const isIntersecting = Boolean(entry?.isIntersecting);
const { customComponentsPath: customComponentsPathFromContext, operation: operationFromContext } = useRenderedFields();
const customComponentsPath = customComponentsPathFromProps || customComponentsPathFromContext;
const operation = operationFromProps || operationFromContext;
const customComponentsPath = customComponentsPathFromProps || customComponentsPathFromContext;
const [contextValue, setContextValue] = useState({
operation,
customComponentsPath,
});
useEffect(() => {
setContextValue({
operation,
customComponentsPath,
});
}, [operation, customComponentsPath]);
useEffect(() => {
if (isIntersecting && !hasIntersected) {
setHasIntersected(true);
}
}, [isIntersecting, hasIntersected]);
const classes = [
baseClass,
className,
].filter(Boolean).join(' ');
if (fieldSchema) {
return (
<RenderedFieldContext.Provider value={{ customComponentsPath, operation }}>
{fieldSchema.map((field, i) => {
if (field?.hidden !== 'api' && field?.hidden !== true) {
if ((filter && typeof filter === 'function' && filter(field)) || !filter) {
const FieldComponent = field?.hidden === 'admin' ? fieldTypes.hidden : fieldTypes[field.type];
<div
ref={intersectionRef}
className={classes}
>
{hasIntersected && (
<RenderedFieldContext.Provider value={contextValue}>
{fieldSchema.map((field, i) => {
if (!field?.hidden && field?.admin?.disabled !== true) {
if ((filter && typeof filter === 'function' && filter(field)) || !filter) {
const FieldComponent = field?.admin?.hidden ? fieldTypes.hidden : fieldTypes[field.type];
let initialFieldData;
let fieldPermissions = permissions[field.name];
let fieldPermissions = permissions[field.name];
if (!field.name) {
initialFieldData = initialData;
fieldPermissions = permissions;
} else if (initialData?.[field.name] !== undefined) {
initialFieldData = initialData[field.name];
}
if (!field.name) {
fieldPermissions = permissions;
}
let { readOnly } = field;
let { admin: { readOnly } = {} } = field;
if (readOnlyOverride) readOnly = true;
if (readOnlyOverride) readOnly = true;
if (permissions?.[field?.name]?.read?.permission !== false) {
if (permissions?.[field?.name]?.[operation]?.permission === false) {
readOnly = true;
if (permissions?.[field?.name]?.read?.permission !== false) {
if (permissions?.[field?.name]?.[operation]?.permission === false) {
readOnly = true;
}
if (FieldComponent) {
return (
<RenderCustomComponent
key={i}
path={`${customComponentsPath}${field.name ? `${field.name}.field` : ''}`}
DefaultComponent={FieldComponent}
componentProps={{
...field,
path: field.path || field.name,
fieldTypes,
admin: {
...(field.admin || {}),
readOnly,
},
permissions: fieldPermissions,
}}
/>
);
}
return (
<div
className="missing-field"
key={i}
>
No matched field found for
{' '}
&quot;
{field.label}
&quot;
</div>
);
}
}
if (FieldComponent) {
return (
<RenderCustomComponent
key={field.name || `field-${i}`}
path={`${customComponentsPath}${field.name ? `${field.name}.field` : ''}`}
DefaultComponent={FieldComponent}
componentProps={{
...field,
path: field.path || field.name,
fieldTypes,
initialData: initialFieldData,
readOnly,
permissions: fieldPermissions,
}}
/>
);
}
return (
<div
className="missing-field"
key={i}
>
No matched field found for
{' '}
&quot;
{field.label}
&quot;
</div>
);
return null;
}
}
return null;
}
return null;
})}
</RenderedFieldContext.Provider>
return null;
})}
</RenderedFieldContext.Provider>
)}
</div>
);
}
@@ -98,19 +135,18 @@ const RenderFields = (props) => {
};
RenderFields.defaultProps = {
initialData: {},
customComponentsPath: '',
filter: null,
readOnly: false,
permissions: {},
operation: undefined,
className: undefined,
};
RenderFields.propTypes = {
fieldSchema: PropTypes.arrayOf(
PropTypes.shape({}),
).isRequired,
initialData: PropTypes.shape({}),
customComponentsPath: PropTypes.string,
fieldTypes: PropTypes.shape({
hidden: PropTypes.function,
@@ -118,6 +154,8 @@ RenderFields.propTypes = {
filter: PropTypes.func,
permissions: PropTypes.shape({}),
readOnly: PropTypes.bool,
operation: PropTypes.string,
className: PropTypes.string,
};
export default RenderFields;

View File

@@ -1,3 +0,0 @@
.missing-field {
}

View File

@@ -1,6 +1,6 @@
import React, { useContext } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import FormContext from '../Form/FormContext';
import { useFormProcessing } from '../Form/context';
import Button from '../../elements/Button';
import './index.scss';
@@ -8,12 +8,13 @@ import './index.scss';
const baseClass = 'form-submit';
const FormSubmit = ({ children }) => {
const formContext = useContext(FormContext);
const processing = useFormProcessing();
return (
<div className={baseClass}>
<Button
type="submit"
disabled={formContext.processing ? true : undefined}
disabled={processing ? true : undefined}
>
{children}
</Button>

View File

@@ -0,0 +1,261 @@
import React, { useEffect, useReducer, useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import withCondition from '../../withCondition';
import Button from '../../../elements/Button';
import DraggableSection from '../../DraggableSection';
import reducer from '../rowReducer';
import { useRenderedFields } from '../../RenderFields';
import { useForm } from '../../Form/context';
import useFieldType from '../../useFieldType';
import Error from '../../Error';
import { array } from '../../../../../fields/validations';
import './index.scss';
const baseClass = 'field-type array';
const ArrayFieldType = (props) => {
const {
label,
name,
path: pathFromProps,
fields,
fieldTypes,
validate,
required,
maxRows,
minRows,
singularLabel,
permissions,
} = props;
const [rows, dispatchRows] = useReducer(reducer, []);
const { customComponentsPath } = useRenderedFields();
const { getDataByPath, initialState, dispatchFields } = useForm();
const path = pathFromProps || name;
const memoizedValidate = useCallback((value) => {
const validationResult = validate(value, { minRows, maxRows, required });
return validationResult;
}, [validate, maxRows, minRows, required]);
const [disableFormData, setDisableFormData] = useState(false);
const {
showError,
errorMessage,
value,
setValue,
} = useFieldType({
path,
validate: memoizedValidate,
disableFormData,
ignoreWhileFlattening: true,
required,
});
const addRow = useCallback((rowIndex) => {
dispatchRows({ type: 'ADD', rowIndex });
dispatchFields({ type: 'ADD_ROW', rowIndex, fieldSchema: fields, path });
setValue(value + 1);
}, [dispatchRows, dispatchFields, fields, path, setValue, value]);
const removeRow = useCallback((rowIndex) => {
dispatchRows({ type: 'REMOVE', rowIndex });
dispatchFields({ type: 'REMOVE_ROW', rowIndex, path });
}, [dispatchRows, dispatchFields, path]);
const moveRow = useCallback((moveFromIndex, moveToIndex) => {
dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex });
dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path });
}, [dispatchRows, dispatchFields, path]);
const onDragEnd = useCallback((result) => {
if (!result.destination) return;
const sourceIndex = result.source.index;
const destinationIndex = result.destination.index;
moveRow(sourceIndex, destinationIndex);
}, [moveRow]);
useEffect(() => {
const data = getDataByPath(path);
dispatchRows({ type: 'SET_ALL', data });
}, [initialState, getDataByPath, path]);
useEffect(() => {
setValue(rows?.length || 0);
if (rows?.length === 0) {
setDisableFormData(false);
} else {
setDisableFormData(true);
}
}, [rows, setValue]);
return (
<RenderArray
onDragEnd={onDragEnd}
label={label}
showError={showError}
errorMessage={errorMessage}
rows={rows}
singularLabel={singularLabel}
addRow={addRow}
removeRow={removeRow}
moveRow={moveRow}
path={path}
customComponentsPath={customComponentsPath}
name={name}
fieldTypes={fieldTypes}
fields={fields}
permissions={permissions}
value={value}
/>
);
};
ArrayFieldType.defaultProps = {
label: '',
validate: array,
required: false,
maxRows: undefined,
minRows: undefined,
singularLabel: 'Row',
permissions: {},
};
ArrayFieldType.propTypes = {
fields: PropTypes.arrayOf(
PropTypes.shape({}),
).isRequired,
label: PropTypes.string,
singularLabel: PropTypes.string,
name: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
fieldTypes: PropTypes.shape({}).isRequired,
validate: PropTypes.func,
required: PropTypes.bool,
maxRows: PropTypes.number,
minRows: PropTypes.number,
permissions: PropTypes.shape({
fields: PropTypes.shape({}),
}),
};
const RenderArray = React.memo((props) => {
const {
onDragEnd,
label,
showError,
errorMessage,
rows,
singularLabel,
addRow,
removeRow,
moveRow,
path,
customComponentsPath,
name,
fieldTypes,
fields,
permissions,
value,
} = props;
return (
<DragDropContext onDragEnd={onDragEnd}>
<div className={baseClass}>
<header className={`${baseClass}__header`}>
<h3>{label}</h3>
<Error
showError={showError}
message={errorMessage}
/>
</header>
<Droppable droppableId="array-drop">
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
>
{rows.length > 0 && rows.map((row, i) => (
<DraggableSection
key={row.key}
id={row.key}
blockType="array"
singularLabel={singularLabel}
isOpen={row.open}
rowCount={rows.length}
rowIndex={i}
addRow={() => addRow(i)}
removeRow={() => removeRow(i)}
moveRow={moveRow}
parentPath={path}
initNull={row.initNull}
customComponentsPath={`${customComponentsPath}${name}.fields.`}
fieldTypes={fieldTypes}
fieldSchema={fields}
permissions={permissions.fields}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
<div className={`${baseClass}__add-button-wrap`}>
<Button
onClick={() => addRow(value)}
buttonStyle="icon-label"
icon="plus"
iconStyle="with-border"
iconPosition="left"
>
{`Add ${singularLabel}`}
</Button>
</div>
</div>
</DragDropContext>
);
});
RenderArray.defaultProps = {
label: undefined,
showError: false,
errorMessage: undefined,
rows: [],
singularLabel: 'Row',
path: '',
customComponentsPath: undefined,
value: undefined,
};
RenderArray.propTypes = {
label: PropTypes.string,
showError: PropTypes.bool,
errorMessage: PropTypes.string,
rows: PropTypes.arrayOf(
PropTypes.shape({}),
),
singularLabel: PropTypes.string,
path: PropTypes.string,
customComponentsPath: PropTypes.string,
name: PropTypes.string.isRequired,
value: PropTypes.number,
onDragEnd: PropTypes.func.isRequired,
addRow: PropTypes.func.isRequired,
removeRow: PropTypes.func.isRequired,
moveRow: PropTypes.func.isRequired,
fieldTypes: PropTypes.shape({}).isRequired,
fields: PropTypes.arrayOf(
PropTypes.shape({}),
).isRequired,
permissions: PropTypes.shape({
fields: PropTypes.shape({}),
}).isRequired,
};
export default withCondition(ArrayFieldType);

View File

@@ -1,6 +1,6 @@
@import '../../../../scss/styles.scss';
.field-type.repeater {
.field-type.array {
background: white;
&__add-button-wrap {
@@ -25,14 +25,13 @@
width: 100%;
}
}
}
.field-type.repeater {
.field-type.repeater__add-button-wrap {
.field-type.array,
.field-type.blocks {
.field-type.array {
.field-type.array__add-button-wrap {
margin-left: base(2.65);
}
.field-type.repeater__header {
display: none;
}
}
}

View File

@@ -17,16 +17,15 @@ const BlockSelection = (props) => {
} = block;
const handleBlockSelection = () => {
console.log('adding');
close();
addRow(addRowIndex, slug);
};
return (
<div
<button
className={baseClass}
role="button"
tabIndex={0}
type="button"
onClick={handleBlockSelection}
>
<div className={`${baseClass}__image`}>
@@ -37,11 +36,10 @@ const BlockSelection = (props) => {
alt={blockImageAltText}
/>
)
: <DefaultBlockImage />
}
: <DefaultBlockImage />}
</div>
<div className={`${baseClass}__label`}>{labels.singular}</div>
</div>
</button>
);
};

View File

@@ -8,6 +8,10 @@
padding: base(.75) base(.5);
cursor: pointer;
align-items: center;
background: none;
border-radius: 0;
box-shadow: 0;
border: 0;
&:hover {
background-color: $color-background-gray;

Some files were not shown because too many files have changed in this diff Show More