merges with master
This commit is contained in:
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -6,6 +6,9 @@ module.exports = [
|
||||
label: 'Email',
|
||||
type: 'email',
|
||||
validate: validations.email,
|
||||
admin: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'resetPasswordToken',
|
||||
|
||||
@@ -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
22
src/auth/executeAccess.js
Normal 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;
|
||||
@@ -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;
|
||||
40
src/auth/getExecuteStaticAccess.js
Normal file
40
src/auth/getExecuteStaticAccess.js
Normal 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;
|
||||
@@ -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
21
src/auth/getExtractJWT.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
18
src/auth/graphql/resolvers/logout.js
Normal file
18
src/auth/graphql/resolvers/logout.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
10
src/auth/init.js
Normal 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;
|
||||
107
src/auth/operations/access.js
Normal file
107
src/auth/operations/access.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
34
src/auth/operations/logout.js
Normal file
34
src/auth/operations/logout.js
Normal 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
31
src/auth/operations/me.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
16
src/auth/requestHandlers/access.js
Normal file
16
src/auth/requestHandlers/access.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
15
src/auth/requestHandlers/logout.js
Normal file
15
src/auth/requestHandlers/logout.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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`} />;
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
49
src/client/components/elements/Card/index.js
Normal file
49
src/client/components/elements/Card/index.js
Normal 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;
|
||||
41
src/client/components/elements/Card/index.scss
Normal file
41
src/client/components/elements/Card/index.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
&__main-detail {
|
||||
border-top: $style-stroke-width-m solid white;
|
||||
order: 3;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -159,5 +159,11 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
nav a {
|
||||
font-size: base(.875);
|
||||
line-height: base(1.25);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
</Fragment>
|
||||
)}
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
RenderTitle.defaultProps = {
|
||||
|
||||
12
src/client/components/elements/RenderTitle/index.scss
Normal file
12
src/client/components/elements/RenderTitle/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
{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,
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export default createContext({});
|
||||
@@ -1,3 +0,0 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export default createContext({});
|
||||
101
src/client/components/forms/Form/buildStateFromSchema.js
Normal file
101
src/client/components/forms/Form/buildStateFromSchema.js
Normal 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;
|
||||
26
src/client/components/forms/Form/context.js
Normal file
26
src/client/components/forms/Form/context.js
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
10
src/client/components/forms/Form/flattenFilters.js
Normal file
10
src/client/components/forms/Form/flattenFilters.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { useContext } from 'react';
|
||||
import FormContext from './FormContext';
|
||||
|
||||
export default () => useContext(FormContext);
|
||||
@@ -1,4 +0,0 @@
|
||||
import { useContext } from 'react';
|
||||
import FieldContext from './FieldContext';
|
||||
|
||||
export default () => useContext(FieldContext);
|
||||
@@ -6,7 +6,7 @@ slides.1.heroInfo.title
|
||||
fields: [
|
||||
{
|
||||
name: slides,
|
||||
type: repeater,
|
||||
type: array,
|
||||
fields: [
|
||||
{
|
||||
type: group,
|
||||
|
||||
@@ -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
|
||||
{' '}
|
||||
"
|
||||
{field.label}
|
||||
"
|
||||
</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
|
||||
{' '}
|
||||
"
|
||||
{field.label}
|
||||
"
|
||||
</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;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.missing-field {
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
261
src/client/components/forms/field-types/Array/index.js
Normal file
261
src/client/components/forms/field-types/Array/index.js
Normal 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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user