From e59535b9b4139205b6f1a9d5c17115a043c44d92 Mon Sep 17 00:00:00 2001 From: Maksim Eltyshev Date: Tue, 12 Sep 2023 01:12:38 +0200 Subject: [PATCH] feat: Use environment variables for default admin configuration --- .../UserInformationEdit.jsx | 21 ++- .../components/UserInformationEditStep.jsx | 51 ++++---- .../AccountPane/AccountPane.jsx | 121 +++++++++--------- .../UserSettingsModal/UserSettingsModal.jsx | 3 + .../UsersModal/Item/ActionsStep.jsx | 45 ++++--- .../src/components/UsersModal/Item/Item.jsx | 5 +- .../src/components/UsersModal/UsersModal.jsx | 1 + .../containers/UserSettingsModalContainer.js | 2 + client/src/models/User.js | 3 + docker-compose.yml | 6 + server/.env.sample | 7 + server/api/controllers/users/delete.js | 4 + server/api/controllers/users/update-email.js | 4 + .../api/controllers/users/update-password.js | 4 + .../api/controllers/users/update-username.js | 4 + server/api/controllers/users/update.js | 7 + server/api/models/User.js | 1 + server/config/custom.js | 2 + server/db/init.js | 6 +- server/db/seeds/default.js | 48 +++++-- 20 files changed, 224 insertions(+), 121 deletions(-) diff --git a/client/src/components/UserInformationEdit/UserInformationEdit.jsx b/client/src/components/UserInformationEdit/UserInformationEdit.jsx index f580cdc8..0a740591 100644 --- a/client/src/components/UserInformationEdit/UserInformationEdit.jsx +++ b/client/src/components/UserInformationEdit/UserInformationEdit.jsx @@ -1,4 +1,5 @@ import { dequal } from 'dequal'; +import omit from 'lodash/omit'; import pickBy from 'lodash/pickBy'; import React, { useCallback, useMemo, useRef } from 'react'; import PropTypes from 'prop-types'; @@ -9,7 +10,7 @@ import { useForm } from '../../hooks'; import styles from './UserInformationEdit.module.scss'; -const UserInformationEdit = React.memo(({ defaultData, onUpdate }) => { +const UserInformationEdit = React.memo(({ defaultData, isNameEditable, onUpdate }) => { const [t] = useTranslation(); const [data, handleFieldChange] = useForm(() => ({ @@ -32,13 +33,17 @@ const UserInformationEdit = React.memo(({ defaultData, onUpdate }) => { const nameField = useRef(null); const handleSubmit = useCallback(() => { - if (!cleanData.name) { - nameField.current.select(); - return; - } + if (isNameEditable) { + if (!cleanData.name) { + nameField.current.select(); + return; + } - onUpdate(cleanData); - }, [onUpdate, cleanData]); + onUpdate(cleanData); + } else { + onUpdate(omit(cleanData, 'name')); + } + }, [isNameEditable, onUpdate, cleanData]); return (
@@ -48,6 +53,7 @@ const UserInformationEdit = React.memo(({ defaultData, onUpdate }) => { ref={nameField} name="name" value={data.name} + disabled={!isNameEditable} className={styles.field} onChange={handleFieldChange} /> @@ -74,6 +80,7 @@ const UserInformationEdit = React.memo(({ defaultData, onUpdate }) => { UserInformationEdit.propTypes = { defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + isNameEditable: PropTypes.bool.isRequired, onUpdate: PropTypes.func.isRequired, }; diff --git a/client/src/components/UserInformationEditStep.jsx b/client/src/components/UserInformationEditStep.jsx index f8d1f8ab..3601a7dd 100644 --- a/client/src/components/UserInformationEditStep.jsx +++ b/client/src/components/UserInformationEditStep.jsx @@ -5,33 +5,40 @@ import { Popup } from '../lib/custom-ui'; import UserInformationEdit from './UserInformationEdit'; -const UserInformationEditStep = React.memo(({ defaultData, onUpdate, onBack, onClose }) => { - const [t] = useTranslation(); +const UserInformationEditStep = React.memo( + ({ defaultData, isNameEditable, onUpdate, onBack, onClose }) => { + const [t] = useTranslation(); - const handleUpdate = useCallback( - (data) => { - onUpdate(data); - onClose(); - }, - [onUpdate, onClose], - ); + const handleUpdate = useCallback( + (data) => { + onUpdate(data); + onClose(); + }, + [onUpdate, onClose], + ); - return ( - <> - - {t('common.editInformation', { - context: 'title', - })} - - - - - - ); -}); + return ( + <> + + {t('common.editInformation', { + context: 'title', + })} + + + + + + ); + }, +); UserInformationEditStep.propTypes = { defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + isNameEditable: PropTypes.bool.isRequired, onUpdate: PropTypes.func.isRequired, onBack: PropTypes.func, onClose: PropTypes.func.isRequired, diff --git a/client/src/components/UserSettingsModal/AccountPane/AccountPane.jsx b/client/src/components/UserSettingsModal/AccountPane/AccountPane.jsx index 1a581349..7333cf5d 100644 --- a/client/src/components/UserSettingsModal/AccountPane/AccountPane.jsx +++ b/client/src/components/UserSettingsModal/AccountPane/AccountPane.jsx @@ -23,6 +23,7 @@ const AccountPane = React.memo( phone, organization, language, + isLocked, isAvatarUpdating, usernameUpdateForm, emailUpdateForm, @@ -74,6 +75,7 @@ const AccountPane = React.memo( phone, organization, }} + isNameEditable={!isLocked} onUpdate={onUpdate} /> @@ -102,63 +104,67 @@ const AccountPane = React.memo( value={language || 'auto'} onChange={handleLanguageChange} /> - -
- {t('common.authentication', { - context: 'title', - })} -
-
-
- - - -
-
- - - -
-
- - - -
+ {!isLocked && ( + <> + +
+ {t('common.authentication', { + context: 'title', + })} +
+
+
+ + + +
+
+ + + +
+
+ + + +
+ + )} ); }, @@ -172,6 +178,7 @@ AccountPane.propTypes = { phone: PropTypes.string, organization: PropTypes.string, language: PropTypes.string, + isLocked: PropTypes.bool.isRequired, isAvatarUpdating: PropTypes.bool.isRequired, /* eslint-disable react/forbid-prop-types */ usernameUpdateForm: PropTypes.object.isRequired, diff --git a/client/src/components/UserSettingsModal/UserSettingsModal.jsx b/client/src/components/UserSettingsModal/UserSettingsModal.jsx index da4cbf5e..6cc0b826 100644 --- a/client/src/components/UserSettingsModal/UserSettingsModal.jsx +++ b/client/src/components/UserSettingsModal/UserSettingsModal.jsx @@ -16,6 +16,7 @@ const UserSettingsModal = React.memo( phone, organization, language, + isLocked, subscribeToOwnCards, isAvatarUpdating, usernameUpdateForm, @@ -48,6 +49,7 @@ const UserSettingsModal = React.memo( phone={phone} organization={organization} language={language} + isLocked={isLocked} isAvatarUpdating={isAvatarUpdating} usernameUpdateForm={usernameUpdateForm} emailUpdateForm={emailUpdateForm} @@ -104,6 +106,7 @@ UserSettingsModal.propTypes = { phone: PropTypes.string, organization: PropTypes.string, language: PropTypes.string, + isLocked: PropTypes.bool.isRequired, subscribeToOwnCards: PropTypes.bool.isRequired, isAvatarUpdating: PropTypes.bool.isRequired, /* eslint-disable react/forbid-prop-types */ diff --git a/client/src/components/UsersModal/Item/ActionsStep.jsx b/client/src/components/UsersModal/Item/ActionsStep.jsx index c9b2c8ac..a97d13a8 100644 --- a/client/src/components/UsersModal/Item/ActionsStep.jsx +++ b/client/src/components/UsersModal/Item/ActionsStep.jsx @@ -64,6 +64,7 @@ const ActionsStep = React.memo( return ( - - {t('action.editUsername', { - context: 'title', - })} - - - {t('action.editEmail', { - context: 'title', - })} - - - {t('action.editPassword', { - context: 'title', - })} - - - {t('action.deleteUser', { - context: 'title', - })} - + {!user.isLocked && ( + <> + + {t('action.editUsername', { + context: 'title', + })} + + + {t('action.editEmail', { + context: 'title', + })} + + + {t('action.editPassword', { + context: 'title', + })} + + + {t('action.deleteUser', { + context: 'title', + })} + + + )} diff --git a/client/src/components/UsersModal/Item/Item.jsx b/client/src/components/UsersModal/Item/Item.jsx index 248de7fc..0627baf6 100755 --- a/client/src/components/UsersModal/Item/Item.jsx +++ b/client/src/components/UsersModal/Item/Item.jsx @@ -17,6 +17,7 @@ const Item = React.memo( organization, phone, isAdmin, + isLocked, emailUpdateForm, passwordUpdateForm, usernameUpdateForm, @@ -46,7 +47,7 @@ const Item = React.memo( {username || '-'} {email} - + { phone, organization, language, + isLocked, subscribeToOwnCards, isAvatarUpdating, emailUpdateForm, @@ -29,6 +30,7 @@ const mapStateToProps = (state) => { phone, organization, language, + isLocked, subscribeToOwnCards, isAvatarUpdating, emailUpdateForm, diff --git a/client/src/models/User.js b/client/src/models/User.js index 8e2f6768..39a4330f 100755 --- a/client/src/models/User.js +++ b/client/src/models/User.js @@ -50,6 +50,9 @@ export default class extends BaseModel { isAdmin: attr({ getDefault: () => false, }), + isLocked: attr({ + getDefault: () => false, + }), isAvatarUpdating: attr({ getDefault: () => false, }), diff --git a/docker-compose.yml b/docker-compose.yml index 20f5e1be..c77790de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,12 @@ services: - DATABASE_URL=postgresql://postgres@postgres/planka - SECRET_KEY=notsecretkey + # Can be removed after installation + - DEFAULT_ADMIN_EMAIL=demo@demo.demo # Do not remove if you want to prevent this user from being edited/deleted + - DEFAULT_ADMIN_PASSWORD=demo + - DEFAULT_ADMIN_NAME=Demo Demo + - DEFAULT_ADMIN_USERNAME=demo + # related: https://github.com/knex/knex/issues/2354 # As knex does not pass query parameters from the connection string we # have to use environment variables in order to pass the desired values, e.g. diff --git a/server/.env.sample b/server/.env.sample index 9024a6b5..36de1985 100644 --- a/server/.env.sample +++ b/server/.env.sample @@ -4,6 +4,13 @@ BASE_URL=http://localhost:1337 DATABASE_URL=postgresql://postgres@localhost/planka SECRET_KEY=notsecretkey +## Can be removed after installation + +DEFAULT_ADMIN_EMAIL=demo@demo.demo # Do not remove if you want to prevent this user from being edited/deleted +DEFAULT_ADMIN_PASSWORD=demo +DEFAULT_ADMIN_NAME=Demo Demo +DEFAULT_ADMIN_USERNAME=demo + ## Optional # TRUST_PROXY=0 diff --git a/server/api/controllers/users/delete.js b/server/api/controllers/users/delete.js index 37df2d6f..46edb042 100755 --- a/server/api/controllers/users/delete.js +++ b/server/api/controllers/users/delete.js @@ -26,6 +26,10 @@ module.exports = { throw Errors.USER_NOT_FOUND; } + if (user.email === sails.config.custom.defaultAdminEmail) { + throw Errors.USER_NOT_FOUND; // Forbidden + } + user = await sails.helpers.users.deleteOne.with({ record: user, request: this.req, diff --git a/server/api/controllers/users/update-email.js b/server/api/controllers/users/update-email.js index a148099f..182e0c10 100644 --- a/server/api/controllers/users/update-email.js +++ b/server/api/controllers/users/update-email.js @@ -59,6 +59,10 @@ module.exports = { throw Errors.USER_NOT_FOUND; } + if (user.email === sails.config.custom.defaultAdminEmail) { + throw Errors.USER_NOT_FOUND; // Forbidden + } + if ( inputs.id === currentUser.id && !bcrypt.compareSync(inputs.currentPassword, user.password) diff --git a/server/api/controllers/users/update-password.js b/server/api/controllers/users/update-password.js index 1cf27361..c3d9c724 100644 --- a/server/api/controllers/users/update-password.js +++ b/server/api/controllers/users/update-password.js @@ -58,6 +58,10 @@ module.exports = { throw Errors.USER_NOT_FOUND; } + if (user.email === sails.config.custom.defaultAdminEmail) { + throw Errors.USER_NOT_FOUND; // Forbidden + } + if ( inputs.id === currentUser.id && !bcrypt.compareSync(inputs.currentPassword, user.password) diff --git a/server/api/controllers/users/update-username.js b/server/api/controllers/users/update-username.js index 13fc4bc4..03bb1d3c 100644 --- a/server/api/controllers/users/update-username.js +++ b/server/api/controllers/users/update-username.js @@ -61,6 +61,10 @@ module.exports = { throw Errors.USER_NOT_FOUND; } + if (user.email === sails.config.custom.defaultAdminEmail) { + throw Errors.USER_NOT_FOUND; // Forbidden + } + if ( inputs.id === currentUser.id && !bcrypt.compareSync(inputs.currentPassword, user.password) diff --git a/server/api/controllers/users/update.js b/server/api/controllers/users/update.js index a6631027..7de8a730 100755 --- a/server/api/controllers/users/update.js +++ b/server/api/controllers/users/update.js @@ -67,6 +67,13 @@ module.exports = { throw Errors.USER_NOT_FOUND; } + if (user.email === sails.config.custom.defaultAdminEmail) { + /* eslint-disable no-param-reassign */ + delete inputs.isAdmin; + delete inputs.name; + /* eslint-enable no-param-reassign */ + } + const values = { ..._.pick(inputs, [ 'isAdmin', diff --git a/server/api/models/User.js b/server/api/models/User.js index 73e876b6..f63dc68f 100755 --- a/server/api/models/User.js +++ b/server/api/models/User.js @@ -114,6 +114,7 @@ module.exports = { avatarUrl: this.avatar && `${sails.config.custom.userAvatarsUrl}/${this.avatar.dirname}/square-100.${this.avatar.extension}`, + isLocked: this.email === sails.config.custom.defaultAdminEmail, }; }, }; diff --git a/server/config/custom.js b/server/config/custom.js index 4d21b642..6608ee0a 100644 --- a/server/config/custom.js +++ b/server/config/custom.js @@ -40,4 +40,6 @@ module.exports.custom = { oidcJwksUri: process.env.OIDC_JWKS_URI, oidcScopes: process.env.OIDC_SCOPES || 'openid profile email', oidcSkipUserInfo: process.env.OIDC_SKIP_USER_INFO === 'true', + + defaultAdminEmail: process.env.DEFAULT_ADMIN_EMAIL, }; diff --git a/server/db/init.js b/server/db/init.js index 6646e925..cd68752b 100644 --- a/server/db/init.js +++ b/server/db/init.js @@ -6,12 +6,8 @@ const knex = initKnex(knexfile); (async () => { try { - const isExists = await knex.schema.hasTable(knexfile.migrations.tableName); - await knex.migrate.latest(); - if (!isExists) { - await knex.seed.run(); - } + await knex.seed.run(); } catch (error) { process.exitCode = 1; diff --git a/server/db/seeds/default.js b/server/db/seeds/default.js index f1f81ecc..8fa4cdc2 100644 --- a/server/db/seeds/default.js +++ b/server/db/seeds/default.js @@ -1,12 +1,42 @@ const bcrypt = require('bcrypt'); -exports.seed = (knex) => - knex('user_account').insert({ - email: 'demo@demo.demo', - password: bcrypt.hashSync('demo', 10), +const buildData = () => { + const data = { isAdmin: true, - name: 'Demo Demo', - username: 'demo', - subscribeToOwnCards: false, - createdAt: new Date().toISOString(), - }); + }; + + if (process.env.DEFAULT_ADMIN_PASSWORD) { + data.password = bcrypt.hashSync(process.env.DEFAULT_ADMIN_PASSWORD, 10); + } + if (process.env.DEFAULT_ADMIN_NAME) { + data.name = process.env.DEFAULT_ADMIN_NAME; + } + if (process.env.DEFAULT_ADMIN_USERNAME) { + data.username = process.env.DEFAULT_ADMIN_USERNAME; + } + + return data; +}; + +exports.seed = async (knex) => { + if (!process.env.DEFAULT_ADMIN_EMAIL) { + return; + } + + const data = buildData(); + + try { + await knex('user_account').insert({ + ...data, + email: process.env.DEFAULT_ADMIN_EMAIL, + subscribeToOwnCards: false, + createdAt: new Date().toISOString(), + }); + } catch (error) { + if (Object.keys(data).length === 0) { + return; + } + + await knex('user_account').update(data).where('email', process.env.DEFAULT_ADMIN_EMAIL); + } +};