1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-18 12:49:43 +02:00

feat: Use environment variables for default admin configuration

This commit is contained in:
Maksim Eltyshev 2023-09-12 01:12:38 +02:00
parent 2dfa79801f
commit e59535b9b4
20 changed files with 224 additions and 121 deletions

View file

@ -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 (
<Form onSubmit={handleSubmit}>
@ -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,
};

View file

@ -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 (
<>
<Popup.Header onBack={onBack}>
{t('common.editInformation', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<UserInformationEdit defaultData={defaultData} onUpdate={handleUpdate} />
</Popup.Content>
</>
);
});
return (
<>
<Popup.Header onBack={onBack}>
{t('common.editInformation', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<UserInformationEdit
defaultData={defaultData}
isNameEditable={isNameEditable}
onUpdate={handleUpdate}
/>
</Popup.Content>
</>
);
},
);
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,

View file

@ -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}
/>
<Divider horizontal section>
@ -102,63 +104,67 @@ const AccountPane = React.memo(
value={language || 'auto'}
onChange={handleLanguageChange}
/>
<Divider horizontal section>
<Header as="h4">
{t('common.authentication', {
context: 'title',
})}
</Header>
</Divider>
<div className={styles.action}>
<UserUsernameEditPopup
usePasswordConfirmation
defaultData={usernameUpdateForm.data}
username={username}
isSubmitting={usernameUpdateForm.isSubmitting}
error={usernameUpdateForm.error}
onUpdate={onUsernameUpdate}
onMessageDismiss={onUsernameUpdateMessageDismiss}
>
<Button className={styles.actionButton}>
{t('action.editUsername', {
context: 'title',
})}
</Button>
</UserUsernameEditPopup>
</div>
<div className={styles.action}>
<UserEmailEditPopup
usePasswordConfirmation
defaultData={emailUpdateForm.data}
email={email}
isSubmitting={emailUpdateForm.isSubmitting}
error={emailUpdateForm.error}
onUpdate={onEmailUpdate}
onMessageDismiss={onEmailUpdateMessageDismiss}
>
<Button className={styles.actionButton}>
{t('action.editEmail', {
context: 'title',
})}
</Button>
</UserEmailEditPopup>
</div>
<div className={styles.action}>
<UserPasswordEditPopup
usePasswordConfirmation
defaultData={passwordUpdateForm.data}
isSubmitting={passwordUpdateForm.isSubmitting}
error={passwordUpdateForm.error}
onUpdate={onPasswordUpdate}
onMessageDismiss={onPasswordUpdateMessageDismiss}
>
<Button className={styles.actionButton}>
{t('action.editPassword', {
context: 'title',
})}
</Button>
</UserPasswordEditPopup>
</div>
{!isLocked && (
<>
<Divider horizontal section>
<Header as="h4">
{t('common.authentication', {
context: 'title',
})}
</Header>
</Divider>
<div className={styles.action}>
<UserUsernameEditPopup
usePasswordConfirmation
defaultData={usernameUpdateForm.data}
username={username}
isSubmitting={usernameUpdateForm.isSubmitting}
error={usernameUpdateForm.error}
onUpdate={onUsernameUpdate}
onMessageDismiss={onUsernameUpdateMessageDismiss}
>
<Button className={styles.actionButton}>
{t('action.editUsername', {
context: 'title',
})}
</Button>
</UserUsernameEditPopup>
</div>
<div className={styles.action}>
<UserEmailEditPopup
usePasswordConfirmation
defaultData={emailUpdateForm.data}
email={email}
isSubmitting={emailUpdateForm.isSubmitting}
error={emailUpdateForm.error}
onUpdate={onEmailUpdate}
onMessageDismiss={onEmailUpdateMessageDismiss}
>
<Button className={styles.actionButton}>
{t('action.editEmail', {
context: 'title',
})}
</Button>
</UserEmailEditPopup>
</div>
<div className={styles.action}>
<UserPasswordEditPopup
usePasswordConfirmation
defaultData={passwordUpdateForm.data}
isSubmitting={passwordUpdateForm.isSubmitting}
error={passwordUpdateForm.error}
onUpdate={onPasswordUpdate}
onMessageDismiss={onPasswordUpdateMessageDismiss}
>
<Button className={styles.actionButton}>
{t('action.editPassword', {
context: 'title',
})}
</Button>
</UserPasswordEditPopup>
</div>
</>
)}
</Tab.Pane>
);
},
@ -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,

View file

@ -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 */

View file

@ -64,6 +64,7 @@ const ActionsStep = React.memo(
return (
<UserInformationEditStep
defaultData={pick(user, ['name', 'phone', 'organization'])}
isNameEditable={!user.isLocked}
onUpdate={onUpdate}
onBack={handleBack}
onClose={onClose}
@ -135,26 +136,30 @@ const ActionsStep = React.memo(
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditUsernameClick}>
{t('action.editUsername', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditEmailClick}>
{t('action.editEmail', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditPasswordClick}>
{t('action.editPassword', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteUser', {
context: 'title',
})}
</Menu.Item>
{!user.isLocked && (
<>
<Menu.Item className={styles.menuItem} onClick={handleEditUsernameClick}>
{t('action.editUsername', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditEmailClick}>
{t('action.editEmail', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditPasswordClick}>
{t('action.editPassword', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteUser', {
context: 'title',
})}
</Menu.Item>
</>
)}
</Menu>
</Popup.Content>
</>

View file

@ -17,6 +17,7 @@ const Item = React.memo(
organization,
phone,
isAdmin,
isLocked,
emailUpdateForm,
passwordUpdateForm,
usernameUpdateForm,
@ -46,7 +47,7 @@ const Item = React.memo(
<Table.Cell>{username || '-'}</Table.Cell>
<Table.Cell>{email}</Table.Cell>
<Table.Cell>
<Radio toggle checked={isAdmin} onChange={handleIsAdminChange} />
<Radio toggle checked={isAdmin} disabled={isLocked} onChange={handleIsAdminChange} />
</Table.Cell>
<Table.Cell textAlign="right">
<ActionsPopup
@ -57,6 +58,7 @@ const Item = React.memo(
organization,
phone,
isAdmin,
isLocked,
emailUpdateForm,
passwordUpdateForm,
usernameUpdateForm,
@ -88,6 +90,7 @@ Item.propTypes = {
organization: PropTypes.string,
phone: PropTypes.string,
isAdmin: PropTypes.bool.isRequired,
isLocked: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */
emailUpdateForm: PropTypes.object.isRequired,
passwordUpdateForm: PropTypes.object.isRequired,

View file

@ -110,6 +110,7 @@ const UsersModal = React.memo(
organization={item.organization}
phone={item.phone}
isAdmin={item.isAdmin}
isLocked={item.isLocked}
emailUpdateForm={item.emailUpdateForm}
passwordUpdateForm={item.passwordUpdateForm}
usernameUpdateForm={item.usernameUpdateForm}

View file

@ -14,6 +14,7 @@ const mapStateToProps = (state) => {
phone,
organization,
language,
isLocked,
subscribeToOwnCards,
isAvatarUpdating,
emailUpdateForm,
@ -29,6 +30,7 @@ const mapStateToProps = (state) => {
phone,
organization,
language,
isLocked,
subscribeToOwnCards,
isAvatarUpdating,
emailUpdateForm,

View file

@ -50,6 +50,9 @@ export default class extends BaseModel {
isAdmin: attr({
getDefault: () => false,
}),
isLocked: attr({
getDefault: () => false,
}),
isAvatarUpdating: attr({
getDefault: () => false,
}),

View file

@ -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.

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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',

View file

@ -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,
};
},
};

View file

@ -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,
};

View file

@ -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;

View file

@ -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);
}
};