Compare commits

...
33 Commits
Author SHA1 Message Date
Maksim Eltyshev 94dc83706c chore: Update version 2022-09-30 11:52:19 +02:00
Jacques Lorentz b08e731419 fix: Front-end base url with path (#303)
Closes #43, closes #111, closes #272
2022-09-30 14:48:58 +05:00
Maksim Eltyshev 1ce0fac281 chore: Update version 2022-09-28 18:46:37 +02:00
Maksim Eltyshev e3d00c8791 fix: Disable quick timer actions for viewers 2022-09-28 18:38:03 +02:00
Maksim Eltyshev e1a0b635ea ref: Little refactoring 2022-09-28 18:30:03 +02:00
Maksim Eltyshev cc7959bb15 chore: Update version 2022-09-16 15:08:51 +05:00
Maksim Eltyshev 56044e6d8a ref: Fix filename 2022-09-16 15:08:09 +05:00
Maksim Eltyshev e5bd73f33e fix: Prevent popup from leaving window 2022-09-16 14:39:41 +05:00
Maksim Eltyshev e65ab6ece7 fix: Fix flag for English 2022-09-15 09:07:57 +05:00
Maksim Eltyshev 14cc55a8c0 feat: Highlight active timer, add quick actions 2022-09-15 00:20:05 +05:00
Maksim Eltyshev b4306d0a4b fix: Fix font of timer numbers 2022-09-14 21:22:08 +05:00
Maksim Eltyshev 283c850ebf feat: Add about pane 2022-09-14 16:52:50 +05:00
Maksim Eltyshev 62de71bd97 fix: Visually highlight card cover 2022-09-13 16:55:22 +05:00
Maksim Eltyshev 447f88880d chore: Update version 2022-09-07 18:43:32 +05:00
Maksim Eltyshev 8109936ce2 feat: Invalidate access token on logout 2022-09-07 18:39:33 +05:00
Maksim Eltyshev 640908320a ref: Remove unused style 2022-09-03 23:03:34 +05:00
Maksim Eltyshev 236c1020b3 chore: Update version 2022-09-03 22:48:19 +05:00
Maksim Eltyshev 3df07c10fa fix: Use password strength estimator
Closes #294
2022-09-03 22:47:06 +05:00
Steven Correia 543a992d98 fix: Fix proxy forwarding (#295) 2022-09-03 20:00:23 +05:00
Maksim Eltyshev 9cde4e63f9 chore: Update version 2022-09-02 18:03:44 +05:00
Maksim Eltyshev 95738ee7d3 fix: Add missing validation for password 2022-09-02 17:59:12 +05:00
Maksim Eltyshev bb7cdf5dd4 chore: Update version 2022-09-02 17:05:48 +05:00
Maksim Eltyshev 253af55790 fix: Add password requirements when creating user
Closes #293
2022-09-02 17:04:51 +05:00
Maksim Eltyshev 62c3cca9a7 fix: Fix password length validation 2022-08-30 15:13:17 +02:00
Maksim Eltyshev b476a19b79 chore: Update version 2022-08-30 13:50:45 +02:00
Maksim Eltyshev 3379c65a87 fix: Prevent tabnabbing in markdown 2022-08-30 13:47:50 +02:00
Maksim Eltyshev 1a6fc40804 chore: Update version 2022-08-26 19:13:10 +02:00
Maksim Eltyshev 42092efcd2 fix: Remove dots in translations 2022-08-26 19:11:32 +02:00
Maksim Eltyshev 5c91bddfe7 feat: Stronger password policy 2022-08-26 18:59:44 +02:00
Maksim Eltyshev fd8f40f1b0 chore: Update version 2022-08-26 16:05:47 +02:00
Maksim Eltyshev 5b64d465e2 fix: Use move-file instead of rename 2022-08-26 16:02:47 +02:00
Maksim Eltyshev 212fb8ac8c fix: Add missing border radius 2022-08-26 02:47:43 +02:00
Maksim Eltyshev cece2254d7 fix: Change mechanics of file uploading 2022-08-26 02:45:27 +02:00
83 changed files with 1082 additions and 445 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ COPY server .
RUN cp .env.sample .env RUN cp .env.sample .env
COPY --from=client-builder /app/build public COPY --from=client-builder /app/build public
COPY --from=client-builder /app/build/index.html views COPY --from=client-builder /app/build/index.html views/index.ejs
VOLUME /app/public/user-avatars VOLUME /app/public/user-avatars
VOLUME /app/public/project-background-images VOLUME /app/public/project-background-images
+56
View File
@@ -0,0 +1,56 @@
const fs = require('fs');
const path = require('path');
const BASE_URL_PLACEHOLDER = 'BASE_URL_PLACEHOLDER';
const replaceInFile = (file, search, replace) => {
fs.readFile(file, 'utf8', (readError, data) => {
if (readError) {
throw new Error(`${readError}`);
}
const res = data.replaceAll(search, replace);
fs.writeFile(file, res, 'utf8', (writeError) => {
if (writeError) {
throw new Error(`${writeError}`);
}
});
});
};
const replaceBaseUrl = (compiler) => {
compiler.hooks.assetEmitted.tap('ReplaceBaseUrlPlaceholder', (file, info) => {
if (info.content.indexOf(BASE_URL_PLACEHOLDER) >= 0) {
if (/\.css$/.exec(info.targetPath)) {
// For CSS 'url(...)' import we can use relative import
const relPath = path.relative(path.dirname(info.targetPath), info.outputPath);
replaceInFile(info.targetPath, BASE_URL_PLACEHOLDER, `${relPath}/`);
} else if (/\.js$/.exec(info.targetPath)) {
// For JS 'import ... from "some-asset"' we can get the variable injected in the window object
// eslint-disable-next-line no-template-curly-in-string
replaceInFile(info.targetPath, `"${BASE_URL_PLACEHOLDER}"`, '`${window.BASE_URL}/`');
} else if (/index\.html$/.exec(info.targetPath)) {
// For the main html file, we set a placeholder for sails to inject the correct value as runtime
replaceInFile(info.targetPath, BASE_URL_PLACEHOLDER, '<%= BASE_URL %>');
}
}
});
};
module.exports = function override(config, env) {
if (env === 'production') {
const plugins = config.plugins.map((plugin) => {
if (plugin.constructor.name === 'InterpolateHtmlPlugin') {
const newPlugin = plugin;
newPlugin.replacements.PUBLIC_URL = BASE_URL_PLACEHOLDER;
return newPlugin;
}
return plugin;
});
return {
...config,
output: { ...config.output, publicPath: BASE_URL_PLACEHOLDER },
plugins: [...plugins, { apply: replaceBaseUrl }],
};
}
return config;
};
+65 -1
View File
@@ -6,6 +6,7 @@
"": { "": {
"name": "planka-client", "name": "planka-client",
"dependencies": { "dependencies": {
"@juggle/resize-observer": "^3.4.0",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"connected-react-router": "^6.9.3", "connected-react-router": "^6.9.3",
"date-fns": "^2.29.1", "date-fns": "^2.29.1",
@@ -44,7 +45,8 @@
"semantic-ui-react": "^2.1.3", "semantic-ui-react": "^2.1.3",
"socket.io-client": "^2.3.1", "socket.io-client": "^2.3.1",
"validator": "^13.7.0", "validator": "^13.7.0",
"whatwg-fetch": "^3.6.2" "whatwg-fetch": "^3.6.2",
"zxcvbn": "^4.4.2"
}, },
"devDependencies": { "devDependencies": {
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.7", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.7",
@@ -61,6 +63,7 @@
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"jest-enzyme": "^7.1.2", "jest-enzyme": "^7.1.2",
"prettier": "2.7.1", "prettier": "2.7.1",
"react-app-rewired": "^2.2.1",
"react-test-renderer": "^17.0.2" "react-test-renderer": "^17.0.2"
} }
}, },
@@ -3029,6 +3032,11 @@
"@jridgewell/sourcemap-codec": "^1.4.10" "@jridgewell/sourcemap-codec": "^1.4.10"
} }
}, },
"node_modules/@juggle/resize-observer": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@leichtgewicht/ip-codec": { "node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
@@ -18658,6 +18666,30 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/react-app-rewired": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-app-rewired/-/react-app-rewired-2.2.1.tgz",
"integrity": "sha512-uFQWTErXeLDrMzOJHKp0h8P1z0LV9HzPGsJ6adOtGlA/B9WfT6Shh4j2tLTTGlXOfiVx6w6iWpp7SOC5pvk+gA==",
"dev": true,
"dependencies": {
"semver": "^5.6.0"
},
"bin": {
"react-app-rewired": "bin/index.js"
},
"peerDependencies": {
"react-scripts": ">=2.1.3"
}
},
"node_modules/react-app-rewired/node_modules/semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
"dev": true,
"bin": {
"semver": "bin/semver"
}
},
"node_modules/react-beautiful-dnd": { "node_modules/react-beautiful-dnd": {
"version": "13.1.0", "version": "13.1.0",
"resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz", "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz",
@@ -25310,6 +25342,11 @@
"type": "github", "type": "github",
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
},
"node_modules/zxcvbn": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz",
"integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ=="
} }
}, },
"dependencies": { "dependencies": {
@@ -27327,6 +27364,11 @@
"@jridgewell/sourcemap-codec": "^1.4.10" "@jridgewell/sourcemap-codec": "^1.4.10"
} }
}, },
"@juggle/resize-observer": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"@leichtgewicht/ip-codec": { "@leichtgewicht/ip-codec": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz",
@@ -38700,6 +38742,23 @@
"whatwg-fetch": "^3.6.2" "whatwg-fetch": "^3.6.2"
} }
}, },
"react-app-rewired": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-app-rewired/-/react-app-rewired-2.2.1.tgz",
"integrity": "sha512-uFQWTErXeLDrMzOJHKp0h8P1z0LV9HzPGsJ6adOtGlA/B9WfT6Shh4j2tLTTGlXOfiVx6w6iWpp7SOC5pvk+gA==",
"dev": true,
"requires": {
"semver": "^5.6.0"
},
"dependencies": {
"semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
"dev": true
}
}
},
"react-beautiful-dnd": { "react-beautiful-dnd": {
"version": "13.1.0", "version": "13.1.0",
"resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz", "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz",
@@ -43805,6 +43864,11 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.2.tgz", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.2.tgz",
"integrity": "sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA==" "integrity": "sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA=="
},
"zxcvbn": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz",
"integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ=="
} }
} }
} }
+8 -5
View File
@@ -2,11 +2,11 @@
"name": "planka-client", "name": "planka-client",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "react-scripts build", "build": "react-app-rewired build",
"eject": "react-scripts eject", "eject": "react-scripts eject",
"lint": "eslint --ext js,jsx src", "lint": "eslint --ext js,jsx src config-overrides.js",
"start": "react-scripts start", "start": "react-app-rewired start",
"test": "react-scripts test" "test": "react-app-rewired test"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [
@@ -63,6 +63,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@juggle/resize-observer": "^3.4.0",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"connected-react-router": "^6.9.3", "connected-react-router": "^6.9.3",
"date-fns": "^2.29.1", "date-fns": "^2.29.1",
@@ -101,7 +102,8 @@
"semantic-ui-react": "^2.1.3", "semantic-ui-react": "^2.1.3",
"socket.io-client": "^2.3.1", "socket.io-client": "^2.3.1",
"validator": "^13.7.0", "validator": "^13.7.0",
"whatwg-fetch": "^3.6.2" "whatwg-fetch": "^3.6.2",
"zxcvbn": "^4.4.2"
}, },
"devDependencies": { "devDependencies": {
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.7", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.7",
@@ -118,6 +120,7 @@
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"jest-enzyme": "^7.1.2", "jest-enzyme": "^7.1.2",
"prettier": "2.7.1", "prettier": "2.7.1",
"react-app-rewired": "^2.2.1",
"react-test-renderer": "^17.0.2" "react-test-renderer": "^17.0.2"
} }
} }
+1
View File
@@ -26,6 +26,7 @@
--> -->
<title>Planka</title> <title>Planka</title>
</head> </head>
<script>window.BASE_URL = "%PUBLIC_URL%";</script>
<body id="app"> <body id="app">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
+5
View File
@@ -44,6 +44,11 @@ const logout = () => ({
payload: {}, payload: {},
}); });
logout.invalidateAccessToken = () => ({
type: ActionTypes.LOGOUT__ACCESS_TOKEN_INVALIDATE,
payload: {},
});
export default { export default {
initializeCore, initializeCore,
logout, logout,
+2 -1
View File
@@ -103,10 +103,11 @@ const updateUserPassword = (id, data) => ({
}, },
}); });
updateUserPassword.success = (user) => ({ updateUserPassword.success = (user, accessToken) => ({
type: ActionTypes.USER_PASSWORD_UPDATE__SUCCESS, type: ActionTypes.USER_PASSWORD_UPDATE__SUCCESS,
payload: { payload: {
user, user,
accessToken,
}, },
}); });
+5
View File
@@ -1,9 +1,14 @@
import http from './http'; import http from './http';
import socket from './socket';
/* Actions */ /* Actions */
const createAccessToken = (data, headers) => http.post('/access-tokens', data, headers); const createAccessToken = (data, headers) => http.post('/access-tokens', data, headers);
const deleteCurrentAccessToken = (headers) =>
socket.delete('/access-tokens/me', undefined, headers);
export default { export default {
createAccessToken, createAccessToken,
deleteCurrentAccessToken,
}; };
+2 -1
View File
@@ -5,7 +5,7 @@ import Config from '../constants/Config';
const io = sailsIOClient(socketIOClient); const io = sailsIOClient(socketIOClient);
io.sails.url = Config.SERVER_BASE_URL; io.sails.url = Config.SERVER_HOST_NAME;
io.sails.autoConnect = false; io.sails.autoConnect = false;
io.sails.reconnection = true; io.sails.reconnection = true;
io.sails.useCORSRouteToGetCookie = false; io.sails.useCORSRouteToGetCookie = false;
@@ -13,6 +13,7 @@ io.sails.environment = process.env.NODE_ENV;
const { socket } = io; const { socket } = io;
socket.path = `${Config.BASE_PATH}/socket.io`;
socket.connect = socket._connect; // eslint-disable-line no-underscore-dangle socket.connect = socket._connect; // eslint-disable-line no-underscore-dangle
['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].forEach((method) => { ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].forEach((method) => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -19,7 +19,7 @@ const BoardMembershipPermissionsSelectStep = React.memo(
...defaultData, ...defaultData,
})); }));
const handleRoleSelectClick = useCallback((role) => { const handleSelectRoleClick = useCallback((role) => {
setData((prevData) => ({ setData((prevData) => ({
...prevData, ...prevData,
role, role,
@@ -50,7 +50,7 @@ const BoardMembershipPermissionsSelectStep = React.memo(
<Menu secondary vertical className={styles.menu}> <Menu secondary vertical className={styles.menu}>
<Menu.Item <Menu.Item
active={data.role === BoardMembershipRoles.EDITOR} active={data.role === BoardMembershipRoles.EDITOR}
onClick={() => handleRoleSelectClick(BoardMembershipRoles.EDITOR)} onClick={() => handleSelectRoleClick(BoardMembershipRoles.EDITOR)}
> >
<div className={styles.menuItemTitle}>{t('common.editor')}</div> <div className={styles.menuItemTitle}>{t('common.editor')}</div>
<div className={styles.menuItemDescription}> <div className={styles.menuItemDescription}>
@@ -59,7 +59,7 @@ const BoardMembershipPermissionsSelectStep = React.memo(
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
active={data.role === BoardMembershipRoles.VIEWER} active={data.role === BoardMembershipRoles.VIEWER}
onClick={() => handleRoleSelectClick(BoardMembershipRoles.VIEWER)} onClick={() => handleSelectRoleClick(BoardMembershipRoles.VIEWER)}
> >
<div className={styles.menuItemTitle}>{t('common.viewer')}</div> <div className={styles.menuItemTitle}>{t('common.viewer')}</div>
<div className={styles.menuItemDescription}>{t('common.canOnlyViewBoard')}</div> <div className={styles.menuItemDescription}>{t('common.canOnlyViewBoard')}</div>
+19 -4
View File
@@ -5,6 +5,7 @@ import { Button, Icon } from 'semantic-ui-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Draggable } from 'react-beautiful-dnd'; import { Draggable } from 'react-beautiful-dnd';
import { startTimer, stopTimer } from '../../utils/timer';
import Paths from '../../constants/Paths'; import Paths from '../../constants/Paths';
import Tasks from './Tasks'; import Tasks from './Tasks';
import NameEdit from './NameEdit'; import NameEdit from './NameEdit';
@@ -57,6 +58,17 @@ const Card = React.memo(
} }
}, []); }, []);
const handleToggleTimerClick = useCallback(
(event) => {
event.preventDefault();
onUpdate({
timer: timer.startedAt ? stopTimer(timer) : startTimer(timer),
});
},
[timer, onUpdate],
);
const handleNameUpdate = useCallback( const handleNameUpdate = useCallback(
(newName) => { (newName) => {
onUpdate({ onUpdate({
@@ -108,7 +120,13 @@ const Card = React.memo(
)} )}
{timer && ( {timer && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}> <span className={classNames(styles.attachment, styles.attachmentLeft)}>
<Timer startedAt={timer.startedAt} total={timer.total} size="tiny" /> <Timer
as="span"
startedAt={timer.startedAt}
total={timer.total}
size="tiny"
onClick={canEdit ? handleToggleTimerClick : undefined}
/>
</span> </span>
)} )}
</span> </span>
@@ -148,14 +166,11 @@ const Card = React.memo(
{canEdit && ( {canEdit && (
<ActionsPopup <ActionsPopup
card={{ card={{
id,
name,
dueDate, dueDate,
timer, timer,
boardId, boardId,
listId, listId,
projectId, projectId,
isPersisted,
}} }}
projectsToLists={allProjectsToLists} projectsToLists={allProjectsToLists}
boardMemberships={allBoardMemberships} boardMemberships={allBoardMemberships}
@@ -78,7 +78,9 @@
.cover { .cover {
border-radius: 3px 3px 0 0; border-radius: 3px 3px 0 0;
margin-bottom: 2px; margin-bottom: 2px;
padding: 2px 2px 0;
vertical-align: middle; vertical-align: middle;
width: 100%;
} }
.details { .details {
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { Button, Grid, Icon, Modal } from 'semantic-ui-react'; import { Button, Grid, Icon, Modal } from 'semantic-ui-react';
import { Markdown } from '../../lib/custom-ui'; import { Markdown } from '../../lib/custom-ui';
import { startTimer, stopTimer } from '../../utils/timer';
import NameField from './NameField'; import NameField from './NameField';
import DescriptionEdit from './DescriptionEdit'; import DescriptionEdit from './DescriptionEdit';
import Tasks from './Tasks'; import Tasks from './Tasks';
@@ -80,6 +81,12 @@ const CardModal = React.memo(
const isGalleryOpened = useRef(false); const isGalleryOpened = useRef(false);
const handleToggleTimerClick = useCallback(() => {
onUpdate({
timer: timer.startedAt ? stopTimer(timer) : startTimer(timer),
});
}, [timer, onUpdate]);
const handleNameUpdate = useCallback( const handleNameUpdate = useCallback(
(newName) => { (newName) => {
onUpdate({ onUpdate({
@@ -291,6 +298,19 @@ const CardModal = React.memo(
<Timer startedAt={timer.startedAt} total={timer.total} /> <Timer startedAt={timer.startedAt} total={timer.total} />
)} )}
</span> </span>
{canEdit && (
<button
onClick={handleToggleTimerClick}
type="button"
className={classNames(styles.attachment, styles.dueDate)}
>
<Icon
name={timer.startedAt ? 'pause' : 'play'}
size="small"
className={styles.addAttachment}
/>
</button>
)}
</div> </div>
)} )}
</div> </div>
+7 -1
View File
@@ -15,6 +15,7 @@ const Header = React.memo(
project, project,
user, user,
notifications, notifications,
isLogouting,
canEditProject, canEditProject,
canEditUsers, canEditUsers,
onProjectSettingsClick, onProjectSettingsClick,
@@ -75,7 +76,11 @@ const Header = React.memo(
)} )}
</Menu.Item> </Menu.Item>
</NotificationsPopup> </NotificationsPopup>
<UserPopup onSettingsClick={onUserSettingsClick} onLogout={onLogout}> <UserPopup
isLogouting={isLogouting}
onSettingsClick={onUserSettingsClick}
onLogout={onLogout}
>
<Menu.Item className={classNames(styles.item, styles.itemHoverable)}> <Menu.Item className={classNames(styles.item, styles.itemHoverable)}>
{user.name} {user.name}
</Menu.Item> </Menu.Item>
@@ -93,6 +98,7 @@ Header.propTypes = {
user: PropTypes.object.isRequired, user: PropTypes.object.isRequired,
notifications: PropTypes.array.isRequired, notifications: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */ /* eslint-enable react/forbid-prop-types */
isLogouting: PropTypes.bool.isRequired,
canEditProject: PropTypes.bool.isRequired, canEditProject: PropTypes.bool.isRequired,
canEditUsers: PropTypes.bool.isRequired, canEditUsers: PropTypes.bool.isRequired,
onProjectSettingsClick: PropTypes.func.isRequired, onProjectSettingsClick: PropTypes.func.isRequired,
+8 -3
View File
@@ -14,7 +14,7 @@ const SIZES = {
MEDIUM: 'medium', MEDIUM: 'medium',
}; };
const Timer = React.memo(({ startedAt, total, size, isDisabled, onClick }) => { const Timer = React.memo(({ as, startedAt, total, size, isDisabled, onClick }) => {
const prevStartedAt = usePrevious(startedAt); const prevStartedAt = usePrevious(startedAt);
const forceUpdate = useForceUpdate(); const forceUpdate = useForceUpdate();
@@ -52,6 +52,7 @@ const Timer = React.memo(({ startedAt, total, size, isDisabled, onClick }) => {
className={classNames( className={classNames(
styles.wrapper, styles.wrapper,
styles[`wrapper${upperFirst(size)}`], styles[`wrapper${upperFirst(size)}`],
startedAt && styles.wrapperActive,
onClick && styles.wrapperHoverable, onClick && styles.wrapperHoverable,
)} )}
> >
@@ -59,16 +60,19 @@ const Timer = React.memo(({ startedAt, total, size, isDisabled, onClick }) => {
</span> </span>
); );
const ElementType = as;
return onClick ? ( return onClick ? (
<button type="button" disabled={isDisabled} className={styles.button} onClick={onClick}> <ElementType type="button" disabled={isDisabled} className={styles.button} onClick={onClick}>
{contentNode} {contentNode}
</button> </ElementType>
) : ( ) : (
contentNode contentNode
); );
}); });
Timer.propTypes = { Timer.propTypes = {
as: PropTypes.elementType,
startedAt: PropTypes.instanceOf(Date), startedAt: PropTypes.instanceOf(Date),
total: PropTypes.number.isRequired, // eslint-disable-line react/no-unused-prop-types total: PropTypes.number.isRequired, // eslint-disable-line react/no-unused-prop-types
size: PropTypes.oneOf(Object.values(SIZES)), size: PropTypes.oneOf(Object.values(SIZES)),
@@ -77,6 +81,7 @@ Timer.propTypes = {
}; };
Timer.defaultProps = { Timer.defaultProps = {
as: 'button',
startedAt: undefined, startedAt: undefined,
size: SIZES.MEDIUM, size: SIZES.MEDIUM,
isDisabled: false, isDisabled: false,
+13 -3
View File
@@ -9,17 +9,28 @@
} }
.wrapper { .wrapper {
background: #f3f4f6; background: #dce0e4;
border: none; border: none;
border-radius: 3px; border-radius: 3px;
color: #7e8ea3; color: #6a808b;
display: inline-block; display: inline-block;
font-variant-numeric: tabular-nums;
outline: none; outline: none;
text-align: left; text-align: left;
transition: background 0.3s ease; transition: background 0.3s ease;
vertical-align: top; vertical-align: top;
} }
.wrapperActive {
background: #21ba45;
color: #fff;
&.wrapperHoverable:hover {
background: #16ab39;
color: #fff;
}
}
.wrapperHoverable:hover { .wrapperHoverable:hover {
background: #d2d8dc; background: #d2d8dc;
color: #17394d; color: #17394d;
@@ -42,6 +53,5 @@
.wrapperMedium { .wrapperMedium {
line-height: 20px; line-height: 20px;
padding: 6px 12px; padding: 6px 12px;
text-decoration: underline;
} }
} }
@@ -8,7 +8,7 @@ import { withPopup } from '../../lib/popup';
import { Input, Popup } from '../../lib/custom-ui'; import { Input, Popup } from '../../lib/custom-ui';
import { useForm } from '../../hooks'; import { useForm } from '../../hooks';
import { isUsername } from '../../utils/validator'; import { isPassword, isUsername } from '../../utils/validator';
import styles from './UserAddPopup.module.scss'; import styles from './UserAddPopup.module.scss';
@@ -69,7 +69,7 @@ const UserAddStep = React.memo(
return; return;
} }
if (!cleanData.password) { if (!cleanData.password || !isPassword(cleanData.password)) {
passwordField.current.focus(); passwordField.current.focus();
return; return;
} }
@@ -143,6 +143,7 @@ const UserAddStep = React.memo(
/> />
<div className={styles.text}>{t('common.password')}</div> <div className={styles.text}>{t('common.password')}</div>
<Input.Password <Input.Password
withStrengthBar
fluid fluid
ref={passwordField} ref={passwordField}
name="password" name="password"
@@ -7,6 +7,7 @@ import { useDidUpdate, usePrevious, useToggle } from '../../lib/hooks';
import { Input, Popup } from '../../lib/custom-ui'; import { Input, Popup } from '../../lib/custom-ui';
import { useForm } from '../../hooks'; import { useForm } from '../../hooks';
import { isPassword } from '../../utils/validator';
import styles from './UserPasswordEditStep.module.scss'; import styles from './UserPasswordEditStep.module.scss';
@@ -56,7 +57,7 @@ const UserPasswordEditStep = React.memo(
const currentPasswordField = useRef(null); const currentPasswordField = useRef(null);
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
if (!data.password) { if (!data.password || !isPassword(data.password)) {
passwordField.current.select(); passwordField.current.select();
return; return;
} }
@@ -113,6 +114,7 @@ const UserPasswordEditStep = React.memo(
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.newPassword')}</div> <div className={styles.text}>{t('common.newPassword')}</div>
<Input.Password <Input.Password
withStrengthBar
fluid fluid
ref={passwordField} ref={passwordField}
name="password" name="password"
+19 -3
View File
@@ -1,13 +1,13 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react'; import { Button, Menu } from 'semantic-ui-react';
import { withPopup } from '../../lib/popup'; import { withPopup } from '../../lib/popup';
import { Popup } from '../../lib/custom-ui'; import { Popup } from '../../lib/custom-ui';
import styles from './UserPopup.module.scss'; import styles from './UserPopup.module.scss';
const UserStep = React.memo(({ onSettingsClick, onLogout, onClose }) => { const UserStep = React.memo(({ isLogouting, onSettingsClick, onLogout, onClose }) => {
const [t] = useTranslation(); const [t] = useTranslation();
const handleSettingsClick = useCallback(() => { const handleSettingsClick = useCallback(() => {
@@ -15,6 +15,17 @@ const UserStep = React.memo(({ onSettingsClick, onLogout, onClose }) => {
onClose(); onClose();
}, [onSettingsClick, onClose]); }, [onSettingsClick, onClose]);
let logoutMenuItemProps;
if (isLogouting) {
logoutMenuItemProps = {
as: Button,
fluid: true,
basic: true,
loading: true,
disabled: true,
};
}
return ( return (
<> <>
<Popup.Header> <Popup.Header>
@@ -29,7 +40,11 @@ const UserStep = React.memo(({ onSettingsClick, onLogout, onClose }) => {
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
<Menu.Item className={styles.menuItem} onClick={onLogout}> <Menu.Item
{...logoutMenuItemProps} // eslint-disable-line react/jsx-props-no-spreading
className={styles.menuItem}
onClick={onLogout}
>
{t('action.logOut', { {t('action.logOut', {
context: 'title', context: 'title',
})} })}
@@ -41,6 +56,7 @@ const UserStep = React.memo(({ onSettingsClick, onLogout, onClose }) => {
}); });
UserStep.propTypes = { UserStep.propTypes = {
isLogouting: PropTypes.bool.isRequired,
onSettingsClick: PropTypes.func.isRequired, onSettingsClick: PropTypes.func.isRequired,
onLogout: PropTypes.func.isRequired, onLogout: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
@@ -0,0 +1,19 @@
import React from 'react';
// import { useTranslation } from 'react-i18next';
import { Image, Tab } from 'semantic-ui-react';
import logo from '../../assets/images/logo.png';
import styles from './AboutPane.module.scss';
const AboutPane = React.memo(() => {
// const [t] = useTranslation();
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<Image centered src={logo} size="large" className={styles.logo} />
</Tab.Pane>
);
});
export default AboutPane;
@@ -0,0 +1,10 @@
:global(#app) {
/* .logo {
margin-bottom: 16px;
} */
.wrapper {
border: none;
box-shadow: none;
}
}
@@ -1,6 +1,7 @@
:global(#app) { :global(#app) {
.action { .action {
border: none; border: none;
border-radius: 0.28571429rem;
display: inline-block; display: inline-block;
height: 36px; height: 36px;
overflow: hidden; overflow: hidden;
@@ -5,6 +5,7 @@ import { Modal, Tab } from 'semantic-ui-react';
import AccountPane from './AccountPane'; import AccountPane from './AccountPane';
import PreferencesPane from './PreferencesPane'; import PreferencesPane from './PreferencesPane';
import AboutPane from './AboutPane';
const UserSettingsModal = React.memo( const UserSettingsModal = React.memo(
({ ({
@@ -71,6 +72,10 @@ const UserSettingsModal = React.memo(
<PreferencesPane subscribeToOwnCards={subscribeToOwnCards} onUpdate={onUpdate} /> <PreferencesPane subscribeToOwnCards={subscribeToOwnCards} onUpdate={onUpdate} />
), ),
}, },
{
menuItem: 'About Planka',
render: () => <AboutPane />,
},
]; ];
return ( return (
+1
View File
@@ -21,6 +21,7 @@ export default {
CORE_INITIALIZE: 'CORE_INITIALIZE', CORE_INITIALIZE: 'CORE_INITIALIZE',
LOGOUT: 'LOGOUT', LOGOUT: 'LOGOUT',
LOGOUT__ACCESS_TOKEN_INVALIDATE: 'LOGOUT__ACCESS_TOKEN_INVALIDATE',
/* Modals */ /* Modals */
+8 -1
View File
@@ -1,6 +1,11 @@
const { BASE_URL } = window;
const BASE_PATH = BASE_URL.replace(/^.*\/\/[^/]*(.*)[^?#]*.*$/, '$1');
const SERVER_BASE_URL = const SERVER_BASE_URL =
process.env.REACT_APP_SERVER_BASE_URL || process.env.REACT_APP_SERVER_BASE_URL ||
(process.env.NODE_ENV === 'production' ? '' : 'http://localhost:1337'); (process.env.NODE_ENV === 'production' ? BASE_URL : 'http://localhost:1337');
const SERVER_HOST_NAME = SERVER_BASE_URL.replace(/^(.*\/\/[^/?#]*).*$/, '$1');
const ACCESS_TOKEN_KEY = 'accessToken'; const ACCESS_TOKEN_KEY = 'accessToken';
const ACCESS_TOKEN_VERSION_KEY = 'accessTokenVersion'; const ACCESS_TOKEN_VERSION_KEY = 'accessTokenVersion';
@@ -10,7 +15,9 @@ const POSITION_GAP = 65535;
const ACTIVITIES_LIMIT = 50; const ACTIVITIES_LIMIT = 50;
export default { export default {
BASE_PATH,
SERVER_BASE_URL, SERVER_BASE_URL,
SERVER_HOST_NAME,
ACCESS_TOKEN_KEY, ACCESS_TOKEN_KEY,
ACCESS_TOKEN_VERSION_KEY, ACCESS_TOKEN_VERSION_KEY,
ACCESS_TOKEN_VERSION, ACCESS_TOKEN_VERSION,
+7 -5
View File
@@ -1,8 +1,10 @@
const ROOT = '/'; import Config from './Config';
const LOGIN = '/login';
const PROJECTS = '/projects/:id'; const ROOT = `${Config.BASE_PATH}/`;
const BOARDS = '/boards/:id'; const LOGIN = `${Config.BASE_PATH}/login`;
const CARDS = '/cards/:id'; const PROJECTS = `${Config.BASE_PATH}/projects/:id`;
const BOARDS = `${Config.BASE_PATH}/boards/:id`;
const CARDS = `${Config.BASE_PATH}/cards/:id`;
export default { export default {
ROOT, ROOT,
+2
View File
@@ -6,6 +6,7 @@ import entryActions from '../entry-actions';
import Header from '../components/Header'; import Header from '../components/Header';
const mapStateToProps = (state) => { const mapStateToProps = (state) => {
const isLogouting = selectors.selectIsLogouting(state);
const currentUser = selectors.selectCurrentUser(state); const currentUser = selectors.selectCurrentUser(state);
const currentProject = selectors.selectCurrentProject(state); const currentProject = selectors.selectCurrentProject(state);
const notifications = selectors.selectNotificationsForCurrentUser(state); const notifications = selectors.selectNotificationsForCurrentUser(state);
@@ -13,6 +14,7 @@ const mapStateToProps = (state) => {
return { return {
notifications, notifications,
isLogouting,
project: currentProject, project: currentProject,
user: currentUser, user: currentUser,
canEditProject: isCurrentUserManager, canEditProject: isCurrentUserManager,
@@ -1,22 +1,74 @@
import React, { useCallback } from 'react'; import zxcvbn from 'zxcvbn';
import { Icon, Input } from 'semantic-ui-react'; import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import { Icon, Input, Progress } from 'semantic-ui-react';
import { useToggle } from '../../../hooks'; import { useToggle } from '../../../hooks';
const InputPassword = React.forwardRef((props, ref) => { import styles from './InputPassword.module.css';
const [isVisible, toggleVisible] = useToggle();
const handleToggleClick = useCallback(() => { const STRENGTH_SCORE_COLORS = ['red', 'orange', 'yellow', 'olive', 'green'];
toggleVisible();
}, [toggleVisible]);
return ( const InputPassword = React.forwardRef(
<Input ({ value, withStrengthBar, minStrengthScore, className, ...props }, ref) => {
{...props} // eslint-disable-line react/jsx-props-no-spreading const [isVisible, toggleVisible] = useToggle();
ref={ref}
type={isVisible ? 'text' : 'password'} const strengthScore = useMemo(() => {
icon={<Icon link name={isVisible ? 'eye' : 'eye slash'} onClick={handleToggleClick} />} if (!withStrengthBar) {
/> return undefined;
); }
});
return zxcvbn(value).score;
}, [value, withStrengthBar]);
const handleToggleClick = useCallback(() => {
toggleVisible();
}, [toggleVisible]);
const inputProps = {
...props,
ref,
type: isVisible ? 'text' : 'password',
icon: <Icon link name={isVisible ? 'eye' : 'eye slash'} onClick={handleToggleClick} />,
};
if (!withStrengthBar) {
return (
<Input
{...inputProps} // eslint-disable-line react/jsx-props-no-spreading
className={className}
/>
);
}
return (
<div className={className}>
<Input
{...inputProps} // eslint-disable-line react/jsx-props-no-spreading
error={!!value && strengthScore < minStrengthScore}
/>
<Progress
value={value ? strengthScore + 1 : 0}
total={5}
color={STRENGTH_SCORE_COLORS[strengthScore]}
size="tiny"
className={styles.strengthBar}
/>
</div>
);
},
);
InputPassword.propTypes = {
value: PropTypes.string.isRequired,
withStrengthBar: PropTypes.bool,
minStrengthScore: PropTypes.number,
className: PropTypes.string,
};
InputPassword.defaultProps = {
withStrengthBar: false,
minStrengthScore: 2,
className: undefined,
};
export default React.memo(InputPassword); export default React.memo(InputPassword);
@@ -0,0 +1,4 @@
.strengthBar {
margin: 4px 0 0 !important;
opacity: 0.5;
}
@@ -6,6 +6,8 @@ import remarkBreaks from 'remark-breaks';
import './Markdown.module.scss'; // FIXME: import as styles? import './Markdown.module.scss'; // FIXME: import as styles?
const ABSOLUTE_URL_REGEX = /^(?:https?:)?\/\//i;
const Markdown = React.memo(({ linkStopPropagation, ...props }) => { const Markdown = React.memo(({ linkStopPropagation, ...props }) => {
const handleLinkClick = useCallback((event) => { const handleLinkClick = useCallback((event) => {
event.stopPropagation(); event.stopPropagation();
@@ -16,25 +18,30 @@ const Markdown = React.memo(({ linkStopPropagation, ...props }) => {
jsx-a11y/click-events-have-key-events, jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions, jsx-a11y/no-static-element-interactions,
react/jsx-props-no-spreading */ react/jsx-props-no-spreading */
({ node, ...linkProps }) => <a {...linkProps} onClick={handleLinkClick} />, ({ node, ...linkProps }) => (
<a
{...linkProps}
rel={
ABSOLUTE_URL_REGEX.test(linkProps.href) && linkProps.target === '_blank'
? 'noreferrer'
: undefined
}
onClick={linkStopPropagation ? handleLinkClick : undefined}
/>
),
/* eslint-enable jsx-a11y/anchor-has-content, /* eslint-enable jsx-a11y/anchor-has-content,
jsx-a11y/click-events-have-key-events, jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions, jsx-a11y/no-static-element-interactions,
react/jsx-props-no-spreading */ react/jsx-props-no-spreading */
[handleLinkClick], [linkStopPropagation, handleLinkClick],
); );
let components;
if (linkStopPropagation) {
components = {
a: linkRenderer,
};
}
return ( return (
<ReactMarkdown <ReactMarkdown
{...props} // eslint-disable-line react/jsx-props-no-spreading {...props} // eslint-disable-line react/jsx-props-no-spreading
components={components} components={{
a: linkRenderer,
}}
remarkPlugins={[remarkGfm, remarkBreaks]} remarkPlugins={[remarkGfm, remarkBreaks]}
className="markdown-body" className="markdown-body"
/> />
+5 -5
View File
@@ -14453,11 +14453,11 @@ img.ui.bordered.image {
---------------------*/ ---------------------*/
.ui.input.error > input { .ui.input.error > input {
background-color: #fff6f6; background-color: #fff6f6 !important;
border-color: #e0b4b4; border-color: #e0b4b4 !important;
color: #9f3a38; color: #9f3a38 !important;
-webkit-box-shadow: none; -webkit-box-shadow: none !important;
box-shadow: none; box-shadow: none !important;
} }
/* Error Placeholder */ /* Error Placeholder */
+34 -4
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useState } from 'react'; import { ResizeObserver } from '@juggle/resize-observer';
import React, { useCallback, useRef, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Button, Popup as SemanticUIPopup } from 'semantic-ui-react'; import { Button, Popup as SemanticUIPopup } from 'semantic-ui-react';
@@ -8,6 +9,9 @@ export default (WrappedComponent, defaultProps) => {
const Popup = React.memo(({ children, ...props }) => { const Popup = React.memo(({ children, ...props }) => {
const [isOpened, setIsOpened] = useState(false); const [isOpened, setIsOpened] = useState(false);
const wrapper = useRef(null);
const resizeObserver = useRef(null);
const handleOpen = useCallback(() => { const handleOpen = useCallback(() => {
setIsOpened(true); setIsOpened(true);
}, []); }, []);
@@ -37,6 +41,29 @@ export default (WrappedComponent, defaultProps) => {
[children], [children],
); );
const handleContentRef = useCallback((element) => {
if (resizeObserver.current) {
resizeObserver.current.disconnect();
}
if (!element) {
resizeObserver.current = null;
return;
}
resizeObserver.current = new ResizeObserver(() => {
if (resizeObserver.current.isInitial) {
resizeObserver.current.isInitial = false;
return;
}
wrapper.current.positionUpdate();
});
resizeObserver.current.isInitial = true;
resizeObserver.current.observe(element);
}, []);
const tigger = React.cloneElement(children, { const tigger = React.cloneElement(children, {
onClick: handleTriggerClick, onClick: handleTriggerClick,
}); });
@@ -45,6 +72,7 @@ export default (WrappedComponent, defaultProps) => {
<SemanticUIPopup <SemanticUIPopup
basic basic
wide wide
ref={wrapper}
trigger={tigger} trigger={tigger}
on="click" on="click"
open={isOpened} open={isOpened}
@@ -64,9 +92,11 @@ export default (WrappedComponent, defaultProps) => {
onClick={handleClick} onClick={handleClick}
{...defaultProps} // eslint-disable-line react/jsx-props-no-spreading {...defaultProps} // eslint-disable-line react/jsx-props-no-spreading
> >
<Button icon="close" onClick={handleClose} className={styles.closeButton} /> <div ref={handleContentRef}>
{/* eslint-disable-next-line react/jsx-props-no-spreading */} <Button icon="close" onClick={handleClose} className={styles.closeButton} />
<WrappedComponent {...props} onClose={handleClose} /> {/* eslint-disable-next-line react/jsx-props-no-spreading */}
<WrappedComponent {...props} onClose={handleClose} />
</div>
</SemanticUIPopup> </SemanticUIPopup>
); );
}); });
+1 -1
View File
@@ -117,7 +117,7 @@ export default {
phone: 'Telefon', phone: 'Telefon',
preferences: 'Volby', preferences: 'Volby',
pressPasteShortcutToAddAttachmentFromClipboard: pressPasteShortcutToAddAttachmentFromClipboard:
'Tip: dejte Ctrl-V (Cmd-V na Mac) pro vložení přílohy ze schránky.', 'Tip: dejte Ctrl-V (Cmd-V na Mac) pro vložení přílohy ze schránky',
project: 'Projekt', project: 'Projekt',
projectNotFound_title: 'Projekt nenalezen', projectNotFound_title: 'Projekt nenalezen',
removeManager_title: 'Odstranit vedoucího', removeManager_title: 'Odstranit vedoucího',
+2 -2
View File
@@ -23,7 +23,7 @@ export default {
administrator: 'Administrator', administrator: 'Administrator',
all: 'Alle', all: 'Alle',
allChangesWillBeAutomaticallySavedAfterConnectionRestored: allChangesWillBeAutomaticallySavedAfterConnectionRestored:
'Alle Änderungen werden automatisch gespeichert,<br />sobald die Verbindung wiederhergestellt wurde.', 'Alle Änderungen werden automatisch gespeichert,<br />sobald die Verbindung wiederhergestellt wurde',
areYouSureYouWantToDeleteThisAttachment: areYouSureYouWantToDeleteThisAttachment:
'Sind Sie sicher, dass Sie diesen Anhang löschen möchten?', 'Sind Sie sicher, dass Sie diesen Anhang löschen möchten?',
areYouSureYouWantToDeleteThisBoard: 'Sind Sie sicher, dass Sie dieses Board löschen möchten?', areYouSureYouWantToDeleteThisBoard: 'Sind Sie sicher, dass Sie dieses Board löschen möchten?',
@@ -123,7 +123,7 @@ export default {
phone: 'Telefon', phone: 'Telefon',
preferences: 'Voreinstellungen', preferences: 'Voreinstellungen',
pressPasteShortcutToAddAttachmentFromClipboard: pressPasteShortcutToAddAttachmentFromClipboard:
'Tipp: Drücken Sie STRG-V (Cmd-V auf Mac), um einen Anhang aus der Zwischenablage hinzuzufügen.', 'Tipp: Drücken Sie STRG-V (Cmd-V auf Mac), um einen Anhang aus der Zwischenablage hinzuzufügen',
project: 'Projekt', project: 'Projekt',
projectNotFound_title: 'Projekt nicht gefunden', projectNotFound_title: 'Projekt nicht gefunden',
removeManager_title: 'Manager entfernen', removeManager_title: 'Manager entfernen',
+1 -1
View File
@@ -121,7 +121,7 @@ export default {
phone: 'Phone', phone: 'Phone',
preferences: 'Preferences', preferences: 'Preferences',
pressPasteShortcutToAddAttachmentFromClipboard: pressPasteShortcutToAddAttachmentFromClipboard:
'Tip: press Ctrl-V (Cmd-V on Mac) to add an attachment from the clipboard.', 'Tip: press Ctrl-V (Cmd-V on Mac) to add an attachment from the clipboard',
project: 'Project', project: 'Project',
projectNotFound_title: 'Project Not Found', projectNotFound_title: 'Project Not Found',
removeManager_title: 'Remove Manager', removeManager_title: 'Remove Manager',
+1 -1
View File
@@ -5,7 +5,7 @@ import core from './core';
export default { export default {
language: 'en', language: 'en',
country: 'us', country: 'gb',
name: 'English', name: 'English',
embeddedLocale: merge(login, core), embeddedLocale: merge(login, core),
}; };
+2 -2
View File
@@ -106,7 +106,7 @@ export default {
phone: 'Teléfono', phone: 'Teléfono',
preferences: 'Preferencias', preferences: 'Preferencias',
pressPasteShortcutToAddAttachmentFromClipboard: pressPasteShortcutToAddAttachmentFromClipboard:
'Tip: presiona Ctrl-V (Cmd-V en Mac) para añadir adjuntos desde el portapapeles.', 'Tip: presiona Ctrl-V (Cmd-V en Mac) para añadir adjuntos desde el portapapeles',
project: 'Proyecto', project: 'Proyecto',
projectNotFound_title: 'Proyecto no encontrado', projectNotFound_title: 'Proyecto no encontrado',
removeMember_title: 'Remover Miembro', removeMember_title: 'Remover Miembro',
@@ -115,7 +115,7 @@ export default {
selectList: 'Seleccionar Lista', selectList: 'Seleccionar Lista',
selectProject: 'Seleccionar Proyecto', selectProject: 'Seleccionar Proyecto',
settings: 'Configuraciones', settings: 'Configuraciones',
subscribeToMyOwnCardsByDefault: 'Suscribirme a mis propias tarjetas por defecto.', subscribeToMyOwnCardsByDefault: 'Suscribirme a mis propias tarjetas por defecto',
taskActions_title: 'Acciones de Tareas', taskActions_title: 'Acciones de Tareas',
tasks: 'Tareas', tasks: 'Tareas',
time: 'Tiempo', time: 'Tiempo',
+1 -1
View File
@@ -121,7 +121,7 @@ export default {
phone: 'Telefono', phone: 'Telefono',
preferences: 'Preferenze', preferences: 'Preferenze',
pressPasteShortcutToAddAttachmentFromClipboard: pressPasteShortcutToAddAttachmentFromClipboard:
'Tip: prmi Ctrl-V (Cmd-V on Mac) per aggiungere un allegato dalla clipboard.', 'Tip: prmi Ctrl-V (Cmd-V on Mac) per aggiungere un allegato dalla clipboard',
project: 'Progetto', project: 'Progetto',
projectNotFound_title: 'Progetto non trovato', projectNotFound_title: 'Progetto non trovato',
removeManager_title: 'Rimuovi Manager', removeManager_title: 'Rimuovi Manager',
+1 -1
View File
@@ -117,7 +117,7 @@ export default {
phone: '電話番号', phone: '電話番号',
preferences: '環境設定', preferences: '環境設定',
pressPasteShortcutToAddAttachmentFromClipboard: pressPasteShortcutToAddAttachmentFromClipboard:
'ヒント: Ctrl-V(MacではCmd-V)を押して、クリップボードから添付ファイルを追加します。', 'ヒント: Ctrl-V(MacではCmd-V)を押して、クリップボードから添付ファイルを追加します',
project: 'プロジェクト', project: 'プロジェクト',
projectNotFound_title: 'プロジェクトがありません', projectNotFound_title: 'プロジェクトがありません',
removeManager_title: 'マネージャーを削除', removeManager_title: 'マネージャーを削除',
+1 -1
View File
@@ -106,7 +106,7 @@ export default {
phone: 'Telefon', phone: 'Telefon',
preferences: 'Preferencje', preferences: 'Preferencje',
pressPasteShortcutToAddAttachmentFromClipboard: pressPasteShortcutToAddAttachmentFromClipboard:
'Podpowiedź: naciśnij Ctrl-V (Cmd-V na Macu) aby dodać załącznik ze schowka.', 'Podpowiedź: naciśnij Ctrl-V (Cmd-V na Macu) aby dodać załącznik ze schowka',
project: 'Projekt', project: 'Projekt',
projectNotFound_title: 'Projekt Nie Znaleziony', projectNotFound_title: 'Projekt Nie Znaleziony',
removeMember_title: 'Usuń Członka', removeMember_title: 'Usuń Członka',
+1 -1
View File
@@ -116,7 +116,7 @@ export default {
phone: 'Телефон', phone: 'Телефон',
preferences: 'Предпочтения', preferences: 'Предпочтения',
pressPasteShortcutToAddAttachmentFromClipboard: pressPasteShortcutToAddAttachmentFromClipboard:
'Совет: нажмите Ctrl-V (Cmd-V на Mac), чтобы добавить вложение из буфера обмена.', 'Совет: нажмите Ctrl-V (Cmd-V на Mac), чтобы добавить вложение из буфера обмена',
project: 'Проект', project: 'Проект',
projectActions: 'Действия с проектом', projectActions: 'Действия с проектом',
projectNotFound: 'Проект не найден', projectNotFound: 'Проект не найден',
+1 -1
View File
@@ -118,7 +118,7 @@ export default {
phone: 'Telefón', phone: 'Telefón',
preferences: 'Voľby', preferences: 'Voľby',
pressPasteShortcutToAddAttachmentFromClipboard: pressPasteShortcutToAddAttachmentFromClipboard:
'Tip: stlačte Ctrl-V (Cmd-V na Mac) pre vloženie prílohy zo schránky.', 'Tip: stlačte Ctrl-V (Cmd-V na Mac) pre vloženie prílohy zo schránky',
project: 'Projekt', project: 'Projekt',
projectNotFound_title: 'Projekt neexistuje', projectNotFound_title: 'Projekt neexistuje',
removeManager_title: 'Odstrániť správcu', removeManager_title: 'Odstrániť správcu',
+1 -1
View File
@@ -119,7 +119,7 @@ export default {
phone: 'Telefon', phone: 'Telefon',
preferences: 'Preferenser', preferences: 'Preferenser',
pressPasteShortcutToAddAttachmentFromClipboard: pressPasteShortcutToAddAttachmentFromClipboard:
'Tips: tryck på Ctrl-V (Cmd-V på Mac) för att lägga till en bilaga från urklipp.', 'Tips: tryck på Ctrl-V (Cmd-V på Mac) för att lägga till en bilaga från urklipp',
project: 'Projekt', project: 'Projekt',
projectNotFound_title: 'Projekt Ej Funnet', projectNotFound_title: 'Projekt Ej Funnet',
removeManager_title: 'Ta Bort Projektledare', removeManager_title: 'Ta Bort Projektledare',
+1 -1
View File
@@ -112,7 +112,7 @@ export default {
phone: 'Telefon', phone: 'Telefon',
preferences: 'Afzalliklar', preferences: 'Afzalliklar',
pressPasteShortcutToAddAttachmentFromClipboard: pressPasteShortcutToAddAttachmentFromClipboard:
"Tip: Buferdan ilova qo'shish uchun Ctrl-V (Mac da Cmd-V) ni bosing.", "Tip: Buferdan ilova qo'shish uchun Ctrl-V (Mac da Cmd-V) ni bosing",
project: 'Loyiha', project: 'Loyiha',
projectNotFound_title: 'Loyiha Topilmadi', projectNotFound_title: 'Loyiha Topilmadi',
removeManager_title: "Boshqaruvchini O'chirish", removeManager_title: "Boshqaruvchini O'chirish",
+1 -1
View File
@@ -110,7 +110,7 @@ export default {
phone: '电话', phone: '电话',
preferences: '偏好', preferences: '偏好',
pressPasteShortcutToAddAttachmentFromClipboard: pressPasteShortcutToAddAttachmentFromClipboard:
'提示: 点击 Ctrl-V (Mac: Cmd-V) 从剪切板添加附件.', '提示: 点击 Ctrl-V (Mac: Cmd-V) 从剪切板添加附件',
project: '项目', project: '项目',
projectNotFound_title: '项目未找到', projectNotFound_title: '项目未找到',
removeManager_title: '删除管理员', removeManager_title: '删除管理员',
+16
View File
@@ -1,18 +1,34 @@
import { getAccessToken } from '../utils/access-token-storage';
import ActionTypes from '../constants/ActionTypes'; import ActionTypes from '../constants/ActionTypes';
const initialState = { const initialState = {
accessToken: getAccessToken(),
userId: null, userId: null,
}; };
// eslint-disable-next-line default-param-last // eslint-disable-next-line default-param-last
export default (state = initialState, { type, payload }) => { export default (state = initialState, { type, payload }) => {
switch (type) { switch (type) {
case ActionTypes.AUTHENTICATE__SUCCESS:
return {
...state,
accessToken: payload.accessToken,
};
case ActionTypes.SOCKET_RECONNECT_HANDLE: case ActionTypes.SOCKET_RECONNECT_HANDLE:
case ActionTypes.CORE_INITIALIZE: case ActionTypes.CORE_INITIALIZE:
return { return {
...state, ...state,
userId: payload.user.id, userId: payload.user.id,
}; };
case ActionTypes.USER_PASSWORD_UPDATE__SUCCESS:
if (payload.accessToken) {
return {
...state,
accessToken: payload.accessToken,
};
}
return state;
default: default:
return state; return state;
} }
+6
View File
@@ -5,6 +5,7 @@ import ModalTypes from '../constants/ModalTypes';
const initialState = { const initialState = {
isInitializing: true, isInitializing: true,
isLogouting: false,
currentModal: null, currentModal: null,
}; };
@@ -22,6 +23,11 @@ export default (state = initialState, { type, payload }) => {
...state, ...state,
isInitializing: false, isInitializing: false,
}; };
case ActionTypes.LOGOUT__ACCESS_TOKEN_INVALIDATE:
return {
...state,
isLogouting: true,
};
case ActionTypes.MODAL_OPEN: case ActionTypes.MODAL_OPEN:
return { return {
...state, ...state,
+1 -3
View File
@@ -1,9 +1,8 @@
import { all, apply, call, fork, take } from 'redux-saga/effects'; import { all, apply, fork, take } from 'redux-saga/effects';
import watchers from './watchers'; import watchers from './watchers';
import services from './services'; import services from './services';
import { socket } from '../../api'; import { socket } from '../../api';
import { removeAccessToken } from '../../utils/access-token-storage';
import ActionTypes from '../../constants/ActionTypes'; import ActionTypes from '../../constants/ActionTypes';
import Paths from '../../constants/Paths'; import Paths from '../../constants/Paths';
@@ -15,6 +14,5 @@ export default function* coreSaga() {
yield take(ActionTypes.LOGOUT); yield take(ActionTypes.LOGOUT);
yield call(removeAccessToken);
window.location.href = Paths.LOGIN; window.location.href = Paths.LOGIN;
} }
+5 -3
View File
@@ -1,7 +1,8 @@
import { call, fork, join, put, take } from 'redux-saga/effects'; import { call, fork, join, put, select, take } from 'redux-saga/effects';
import selectors from '../../selectors';
import actions from '../../actions'; import actions from '../../actions';
import { getAccessToken } from '../../utils/access-token-storage'; import { removeAccessToken } from '../../utils/access-token-storage';
import ErrorCodes from '../../constants/ErrorCodes'; import ErrorCodes from '../../constants/ErrorCodes';
let lastRequestTask; let lastRequestTask;
@@ -13,7 +14,7 @@ function* queueRequest(method, ...args) {
} catch {} // eslint-disable-line no-empty } catch {} // eslint-disable-line no-empty
} }
const accessToken = yield call(getAccessToken); const accessToken = yield select(selectors.selectAccessToken);
try { try {
return yield call(method, ...args, { return yield call(method, ...args, {
@@ -21,6 +22,7 @@ function* queueRequest(method, ...args) {
}); });
} catch (error) { } catch (error) {
if (error.code === ErrorCodes.UNAUTHORIZED) { if (error.code === ErrorCodes.UNAUTHORIZED) {
yield call(removeAccessToken);
yield put(actions.logout()); // TODO: next url yield put(actions.logout()); // TODO: next url
yield take(); yield take();
} }
+14 -1
View File
@@ -1,8 +1,11 @@
import { call, put, take } from 'redux-saga/effects'; import { call, put, take } from 'redux-saga/effects';
import request from '../request';
import requests from '../requests'; import requests from '../requests';
import actions from '../../../actions'; import actions from '../../../actions';
import api from '../../../api';
import i18n from '../../../i18n'; import i18n from '../../../i18n';
import { removeAccessToken } from '../../../utils/access-token-storage';
export function* initializeCore() { export function* initializeCore() {
const { const {
@@ -60,7 +63,17 @@ export function* changeCoreLanguage(language) {
} }
} }
export function* logout() { export function* logout(invalidateAccessToken = true) {
yield call(removeAccessToken);
if (invalidateAccessToken) {
yield put(actions.logout.invalidateAccessToken());
try {
yield call(request, api.deleteCurrentAccessToken);
} catch (error) {} // eslint-disable-line no-empty
}
yield put(actions.logout()); yield put(actions.logout());
yield take(); yield take();
} }
+6 -4
View File
@@ -124,11 +124,13 @@ export function* updateUserPassword(id, data) {
return; return;
} }
if (accessTokens && accessTokens[0]) { const accessToken = accessTokens && accessTokens[0];
yield call(setAccessToken, accessTokens[0]);
if (accessToken) {
yield call(setAccessToken, accessToken);
} }
yield put(actions.updateUserPassword.success(user)); yield put(actions.updateUserPassword.success(user, accessToken));
} }
export function* updateCurrentUserPassword(data) { export function* updateCurrentUserPassword(data) {
@@ -215,7 +217,7 @@ export function* handleUserDelete(user) {
const currentUserId = yield select(selectors.selectCurrentUserId); const currentUserId = yield select(selectors.selectCurrentUserId);
if (user.id === currentUserId) { if (user.id === currentUserId) {
yield call(logout); yield call(logout, false);
} }
yield put(actions.handleUserDelete(user)); yield put(actions.handleUserDelete(user));
+1 -6
View File
@@ -2,18 +2,13 @@ import { all, call, cancel, fork, take } from 'redux-saga/effects';
import watchers from './watchers'; import watchers from './watchers';
import services from './services'; import services from './services';
import { setAccessToken } from '../../utils/access-token-storage';
import ActionTypes from '../../constants/ActionTypes'; import ActionTypes from '../../constants/ActionTypes';
export default function* loginSaga() { export default function* loginSaga() {
const watcherTasks = yield all(watchers.map((watcher) => fork(watcher))); const watcherTasks = yield all(watchers.map((watcher) => fork(watcher)));
const { yield take(ActionTypes.AUTHENTICATE__SUCCESS);
payload: { accessToken },
} = yield take(ActionTypes.AUTHENTICATE__SUCCESS);
yield cancel(watcherTasks); yield cancel(watcherTasks);
yield call(setAccessToken, accessToken);
yield call(services.goToRoot); yield call(services.goToRoot);
} }
+2
View File
@@ -2,6 +2,7 @@ import { call, put } from 'redux-saga/effects';
import actions from '../../../actions'; import actions from '../../../actions';
import api from '../../../api'; import api from '../../../api';
import { setAccessToken } from '../../../utils/access-token-storage';
export function* authenticate(data) { export function* authenticate(data) {
yield put(actions.authenticate(data)); yield put(actions.authenticate(data));
@@ -14,6 +15,7 @@ export function* authenticate(data) {
return; return;
} }
yield call(setAccessToken, accessToken);
yield put(actions.authenticate.success(accessToken)); yield put(actions.authenticate.success(accessToken));
} }
-5
View File
@@ -1,5 +0,0 @@
export const selectAccessToken = ({ auth: { accessToken } }) => accessToken;
export default {
selectAccessToken,
};
+6
View File
@@ -4,8 +4,12 @@ import isUndefined from 'lodash/isUndefined';
import orm from '../orm'; import orm from '../orm';
import Config from '../constants/Config'; import Config from '../constants/Config';
export const selectAccessToken = ({ auth: { accessToken } }) => accessToken;
export const selectIsCoreInitializing = ({ core: { isInitializing } }) => isInitializing; export const selectIsCoreInitializing = ({ core: { isInitializing } }) => isInitializing;
export const selectIsLogouting = ({ core: { isLogouting } }) => isLogouting;
const nextPosition = (items, index, excludedId) => { const nextPosition = (items, index, excludedId) => {
const filteredItems = isUndefined(excludedId) const filteredItems = isUndefined(excludedId)
? items ? items
@@ -94,7 +98,9 @@ export const selectNextTaskPosition = createSelector(
); );
export default { export default {
selectAccessToken,
selectIsCoreInitializing, selectIsCoreInitializing,
selectIsLogouting,
selectNextBoardPosition, selectNextBoardPosition,
selectNextListPosition, selectNextListPosition,
selectNextCardPosition, selectNextCardPosition,
-2
View File
@@ -1,5 +1,4 @@
import router from './router'; import router from './router';
import auth from './auth';
import core from './core'; import core from './core';
import modals from './modals'; import modals from './modals';
import users from './users'; import users from './users';
@@ -14,7 +13,6 @@ import attachments from './attachments';
export default { export default {
...router, ...router,
...auth,
...core, ...core,
...modals, ...modals,
...users, ...users,
+6 -4
View File
@@ -1,6 +1,8 @@
import zxcvbn from 'zxcvbn';
const USERNAME_REGEX = /^[a-zA-Z0-9]+((_|\.)?[a-zA-Z0-9])*$/; const USERNAME_REGEX = /^[a-zA-Z0-9]+((_|\.)?[a-zA-Z0-9])*$/;
// eslint-disable-next-line import/prefer-default-export export const isPassword = (string) => zxcvbn(string).score >= 2; // TODO: move to config
export const isUsername = (string) => {
return string.length >= 3 && string.length <= 16 && USERNAME_REGEX.test(string); export const isUsername = (string) =>
}; string.length >= 3 && string.length <= 16 && USERNAME_REGEX.test(string);
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "planka", "name": "planka",
"version": "1.7.1", "version": "1.8.3",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "planka", "name": "planka",
"version": "1.7.1", "version": "1.8.3",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "planka", "name": "planka",
"version": "1.7.1", "version": "1.8.3",
"private": true, "private": true,
"homepage": "https://plankanban.github.io/planka", "homepage": "https://plankanban.github.io/planka",
"repository": { "repository": {
+14 -5
View File
@@ -40,24 +40,33 @@ module.exports = {
}, },
async fn(inputs) { async fn(inputs) {
const remoteAddress = getRemoteAddress(this.req);
const user = await sails.helpers.users.getOneByEmailOrUsername(inputs.emailOrUsername); const user = await sails.helpers.users.getOneByEmailOrUsername(inputs.emailOrUsername);
if (!user) { if (!user) {
sails.log.warn( sails.log.warn(
`Invalid email or username: "${inputs.emailOrUsername}"! (IP: ${getRemoteAddress( `Invalid email or username: "${inputs.emailOrUsername}"! (IP: ${remoteAddress})`,
this.req,
)})`,
); );
throw Errors.INVALID_EMAIL_OR_USERNAME; throw Errors.INVALID_EMAIL_OR_USERNAME;
} }
if (!bcrypt.compareSync(inputs.password, user.password)) { if (!bcrypt.compareSync(inputs.password, user.password)) {
sails.log.warn(`Invalid password! (IP: ${getRemoteAddress(this.req)})`); sails.log.warn(`Invalid password! (IP: ${remoteAddress})`);
throw Errors.INVALID_PASSWORD; throw Errors.INVALID_PASSWORD;
} }
const accessToken = sails.helpers.utils.createToken(user.id);
await Session.create({
accessToken,
remoteAddress,
userId: user.id,
userAgent: this.req.headers['user-agent'],
});
return { return {
item: sails.helpers.utils.createToken(user.id), item: accessToken,
}; };
}, },
}; };
@@ -0,0 +1,16 @@
module.exports = {
async fn() {
const { accessToken } = this.req;
await Session.updateOne({
accessToken,
deletedAt: null,
}).set({
deletedAt: new Date().toUTCString(),
});
return {
item: accessToken,
};
},
};
+39 -26
View File
@@ -1,3 +1,6 @@
const util = require('util');
const { v4: uuid } = require('uuid');
const Errors = { const Errors = {
NOT_ENOUGH_RIGHTS: { NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights', notEnoughRights: 'Not enough rights',
@@ -5,6 +8,9 @@ const Errors = {
CARD_NOT_FOUND: { CARD_NOT_FOUND: {
cardNotFound: 'Card not found', cardNotFound: 'Card not found',
}, },
NO_FILE_WAS_UPLOADED: {
noFileWasUploaded: 'No file was uploaded',
},
}; };
module.exports = { module.exports = {
@@ -27,6 +33,9 @@ module.exports = {
cardNotFound: { cardNotFound: {
responseType: 'notFound', responseType: 'notFound',
}, },
noFileWasUploaded: {
responseType: 'unprocessableEntity',
},
uploadError: { uploadError: {
responseType: 'unprocessableEntity', responseType: 'unprocessableEntity',
}, },
@@ -52,33 +61,37 @@ module.exports = {
throw Errors.NOT_ENOUGH_RIGHTS; throw Errors.NOT_ENOUGH_RIGHTS;
} }
this.req const upload = util.promisify((options, callback) =>
.file('file') this.req.file('file').upload(options, (error, files) => callback(error, files)),
.upload(sails.helpers.utils.createAttachmentReceiver(), async (error, files) => { );
if (error) {
return exits.uploadError(error.message);
}
if (files.length === 0) { let files;
return exits.uploadError('No file was uploaded'); try {
} files = await upload({
saveAs: uuid(),
const file = files[0]; maxBytes: null,
const attachment = await sails.helpers.attachments.createOne(
{
...file.extra,
filename: file.filename,
},
currentUser,
card,
inputs.requestId,
this.req,
);
return exits.success({
item: attachment.toJSON(),
});
}); });
} catch (error) {
return exits.uploadError(error.message); // TODO: add error
}
if (files.length === 0) {
throw Errors.NO_FILE_WAS_UPLOADED;
}
const file = _.last(files);
const fileData = await sails.helpers.attachments.processUploadedFile(file);
const attachment = await sails.helpers.attachments.createOne(
fileData,
currentUser,
card,
inputs.requestId,
this.req,
);
return exits.success({
item: attachment,
});
}, },
}; };
+3 -3
View File
@@ -23,7 +23,7 @@ module.exports = {
}, },
}, },
async fn(inputs, exits) { async fn(inputs) {
const { currentUser } = this.req; const { currentUser } = this.req;
const { board } = await sails.helpers.boards const { board } = await sails.helpers.boards
@@ -61,7 +61,7 @@ module.exports = {
const tasks = await sails.helpers.cards.getTasks(cardIds); const tasks = await sails.helpers.cards.getTasks(cardIds);
const attachments = await sails.helpers.cards.getAttachments(cardIds); const attachments = await sails.helpers.cards.getAttachments(cardIds);
return exits.success({ return {
items: cards, items: cards,
included: { included: {
cardMemberships, cardMemberships,
@@ -69,6 +69,6 @@ module.exports = {
tasks, tasks,
attachments, attachments,
}, },
}); };
}, },
}; };
@@ -1,7 +1,17 @@
const util = require('util');
const rimraf = require('rimraf');
const { v4: uuid } = require('uuid');
const Errors = { const Errors = {
PROJECT_NOT_FOUND: { PROJECT_NOT_FOUND: {
projectNotFound: 'Project not found', projectNotFound: 'Project not found',
}, },
NO_FILE_WAS_UPLOADED: {
noFileWasUploaded: 'No file was uploaded',
},
FILE_IS_NOT_IMAGE: {
fileIsNotImage: 'File is not image',
},
}; };
module.exports = { module.exports = {
@@ -17,6 +27,12 @@ module.exports = {
projectNotFound: { projectNotFound: {
responseType: 'notFound', responseType: 'notFound',
}, },
noFileWasUploaded: {
responseType: 'unprocessableEntity',
},
fileIsNotImage: {
responseType: 'unprocessableEntity',
},
uploadError: { uploadError: {
responseType: 'unprocessableEntity', responseType: 'unprocessableEntity',
}, },
@@ -37,32 +53,52 @@ module.exports = {
throw Errors.PROJECT_NOT_FOUND; // Forbidden throw Errors.PROJECT_NOT_FOUND; // Forbidden
} }
this.req const upload = util.promisify((options, callback) =>
.file('file') this.req.file('file').upload(options, (error, files) => callback(error, files)),
.upload(sails.helpers.utils.createProjectBackgroundImageReceiver(), async (error, files) => { );
if (error) {
return exits.uploadError(error.message);
}
if (files.length === 0) { let files;
return exits.uploadError('No file was uploaded'); try {
} files = await upload({
saveAs: uuid(),
project = await sails.helpers.projects.updateOne( maxBytes: null,
project,
{
backgroundImageDirname: files[0].extra.dirname,
},
this.req,
);
if (!project) {
throw Errors.PROJECT_NOT_FOUND;
}
return exits.success({
item: project.toJSON(),
});
}); });
} catch (error) {
return exits.uploadError(error.message); // TODO: add error
}
if (files.length === 0) {
throw Errors.NO_FILE_WAS_UPLOADED;
}
const file = _.last(files);
const fileData = await sails.helpers.projects
.processUploadedBackgroundImageFile(file)
.intercept('fileIsNotImage', () => {
try {
rimraf.sync(file.fd);
} catch (error) {
console.warn(error.stack); // eslint-disable-line no-console
}
return Errors.FILE_IS_NOT_IMAGE;
});
project = await sails.helpers.projects.updateOne(
project,
{
backgroundImageDirname: fileData.dirname,
},
this.req,
);
if (!project) {
throw Errors.PROJECT_NOT_FOUND;
}
return exits.success({
item: project,
});
}, },
}; };
+3
View File
@@ -1,3 +1,5 @@
const zxcvbn = require('zxcvbn');
const Errors = { const Errors = {
EMAIL_ALREADY_IN_USE: { EMAIL_ALREADY_IN_USE: {
emailAlreadyInUse: 'Email already in use', emailAlreadyInUse: 'Email already in use',
@@ -16,6 +18,7 @@ module.exports = {
}, },
password: { password: {
type: 'string', type: 'string',
custom: (value) => zxcvbn(value).score >= 2, // TODO: move to config
required: true, required: true,
}, },
name: { name: {
+62 -26
View File
@@ -1,7 +1,17 @@
const util = require('util');
const rimraf = require('rimraf');
const { v4: uuid } = require('uuid');
const Errors = { const Errors = {
USER_NOT_FOUND: { USER_NOT_FOUND: {
userNotFound: 'User not found', userNotFound: 'User not found',
}, },
NO_FILE_WAS_UPLOADED: {
noFileWasUploaded: 'No file was uploaded',
},
FILE_IS_NOT_IMAGE: {
fileIsNotImage: 'File is not image',
},
}; };
module.exports = { module.exports = {
@@ -17,6 +27,12 @@ module.exports = {
userNotFound: { userNotFound: {
responseType: 'notFound', responseType: 'notFound',
}, },
noFileWasUploaded: {
responseType: 'unprocessableEntity',
},
fileIsNotImage: {
responseType: 'unprocessableEntity',
},
uploadError: { uploadError: {
responseType: 'unprocessableEntity', responseType: 'unprocessableEntity',
}, },
@@ -38,33 +54,53 @@ module.exports = {
user = currentUser; user = currentUser;
} }
this.req const upload = util.promisify((options, callback) =>
.file('file') this.req.file('file').upload(options, (error, files) => callback(error, files)),
.upload(sails.helpers.utils.createUserAvatarReceiver(), async (error, files) => { );
if (error) {
return exits.uploadError(error.message);
}
if (files.length === 0) { let files;
return exits.uploadError('No file was uploaded'); try {
} files = await upload({
saveAs: uuid(),
user = await sails.helpers.users.updateOne( maxBytes: null,
user,
{
avatarDirname: files[0].extra.dirname,
},
currentUser,
this.req,
);
if (!user) {
throw Errors.USER_NOT_FOUND;
}
return exits.success({
item: user.toJSON(),
});
}); });
} catch (error) {
return exits.uploadError(error.message); // TODO: add error
}
if (files.length === 0) {
throw Errors.NO_FILE_WAS_UPLOADED;
}
const file = _.last(files);
const fileData = await sails.helpers.users
.processUploadedAvatarFile(file)
.intercept('fileIsNotImage', () => {
try {
rimraf.sync(file.fd);
} catch (error) {
console.warn(error.stack); // eslint-disable-line no-console
}
return Errors.FILE_IS_NOT_IMAGE;
});
user = await sails.helpers.users.updateOne(
user,
{
avatarDirname: fileData.dirname,
},
currentUser,
this.req,
);
if (!user) {
throw Errors.USER_NOT_FOUND;
}
return exits.success({
item: user,
});
}, },
}; };
@@ -1,4 +1,7 @@
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const zxcvbn = require('zxcvbn');
const { getRemoteAddress } = require('../../../utils/remoteAddress');
const Errors = { const Errors = {
USER_NOT_FOUND: { USER_NOT_FOUND: {
@@ -18,6 +21,7 @@ module.exports = {
}, },
password: { password: {
type: 'string', type: 'string',
custom: (value) => zxcvbn(value).score >= 2, // TODO: move to config
required: true, required: true,
}, },
currentPassword: { currentPassword: {
@@ -69,6 +73,13 @@ module.exports = {
if (user.id === currentUser.id) { if (user.id === currentUser.id) {
const accessToken = sails.helpers.utils.createToken(user.id, user.passwordUpdatedAt); const accessToken = sails.helpers.utils.createToken(user.id, user.passwordUpdatedAt);
await Session.create({
accessToken,
userId: user.id,
remoteAddress: getRemoteAddress(this.req),
userAgent: this.req.headers['user-agent'],
});
return { return {
item: user, item: user,
included: { included: {
@@ -0,0 +1,77 @@
const fs = require('fs');
const path = require('path');
const rimraf = require('rimraf');
const moveFile = require('move-file');
const filenamify = require('filenamify');
const { v4: uuid } = require('uuid');
const sharp = require('sharp');
module.exports = {
inputs: {
file: {
type: 'json',
required: true,
},
},
async fn(inputs) {
const dirname = uuid();
// FIXME: https://github.com/sindresorhus/filenamify/issues/13
const filename = filenamify(inputs.file.filename);
const rootPath = path.join(sails.config.custom.attachmentsPath, dirname);
const filePath = path.join(rootPath, filename);
fs.mkdirSync(rootPath);
await moveFile(inputs.file.fd, filePath);
const image = sharp(filePath);
let metadata;
try {
metadata = await image.metadata();
} catch (error) {} // eslint-disable-line no-empty
const fileData = {
dirname,
filename,
image: null,
name: inputs.file.filename,
};
if (metadata && !['svg', 'pdf'].includes(metadata.format)) {
const thumbnailsPath = path.join(rootPath, 'thumbnails');
fs.mkdirSync(thumbnailsPath);
try {
await image
.resize(
metadata.height > metadata.width
? {
width: 256,
height: 320,
}
: {
width: 256,
},
)
.jpeg({
quality: 100,
chromaSubsampling: '4:4:4',
})
.toFile(path.join(thumbnailsPath, 'cover-256.jpg'));
fileData.image = _.pick(metadata, ['width', 'height']);
} catch (error1) {
try {
rimraf.sync(thumbnailsPath);
} catch (error2) {
console.warn(error2.stack); // eslint-disable-line no-console
}
}
}
return fileData;
},
};
@@ -0,0 +1,63 @@
const fs = require('fs');
const path = require('path');
const rimraf = require('rimraf');
const { v4: uuid } = require('uuid');
const sharp = require('sharp');
module.exports = {
inputs: {
file: {
type: 'json',
required: true,
},
},
exits: {
fileIsNotImage: {},
},
async fn(inputs) {
const image = sharp(inputs.file.fd);
try {
await image.metadata();
} catch (error) {
throw 'fileIsNotImage';
}
const dirname = uuid();
const rootPath = path.join(sails.config.custom.projectBackgroundImagesPath, dirname);
fs.mkdirSync(rootPath);
try {
await image.jpeg().toFile(path.join(rootPath, 'original.jpg'));
await image
.resize(336, 200)
.jpeg({
quality: 100,
chromaSubsampling: '4:4:4',
})
.toFile(path.join(rootPath, 'cover-336.jpg'));
} catch (error1) {
try {
rimraf.sync(rootPath);
} catch (error2) {
console.warn(error2.stack); // eslint-disable-line no-console
}
throw 'fileIsNotImage';
}
try {
rimraf.sync(inputs.file.fd);
} catch (error) {
console.warn(error.stack); // eslint-disable-line no-console
}
return {
dirname,
};
},
};
@@ -0,0 +1,68 @@
const fs = require('fs');
const path = require('path');
const rimraf = require('rimraf');
const { v4: uuid } = require('uuid');
const sharp = require('sharp');
module.exports = {
inputs: {
file: {
type: 'json',
required: true,
},
},
exits: {
fileIsNotImage: {},
},
async fn(inputs) {
const image = sharp(inputs.file.fd);
try {
await image.metadata();
} catch (error) {
throw 'fileIsNotImage';
}
const dirname = uuid();
const rootPath = path.join(sails.config.custom.userAvatarsPath, dirname);
fs.mkdirSync(rootPath);
try {
await image
.jpeg({
quality: 100,
chromaSubsampling: '4:4:4',
})
.toFile(path.join(rootPath, 'original.jpg'));
await image
.resize(100, 100)
.jpeg({
quality: 100,
chromaSubsampling: '4:4:4',
})
.toFile(path.join(rootPath, 'square-100.jpg'));
} catch (error1) {
try {
rimraf.sync(rootPath);
} catch (error2) {
console.warn(error2.stack); // eslint-disable-line no-console
}
throw 'fileIsNotImage';
}
try {
rimraf.sync(inputs.file.fd);
} catch (error) {
console.warn(error.stack); // eslint-disable-line no-console
}
return {
dirname,
};
},
};
@@ -1,102 +0,0 @@
const fs = require('fs');
const path = require('path');
const util = require('util');
const stream = require('stream');
const streamToArray = require('stream-to-array');
const filenamify = require('filenamify');
const { v4: uuid } = require('uuid');
const sharp = require('sharp');
const writeFile = util.promisify(fs.writeFile);
module.exports = {
sync: true,
fn() {
const receiver = stream.Writable({
objectMode: true,
});
let firstFileHandled = false;
// eslint-disable-next-line no-underscore-dangle
receiver._write = async (file, receiverEncoding, done) => {
if (firstFileHandled) {
file.pipe(new stream.Writable());
return done();
}
firstFileHandled = true;
const buffer = await streamToArray(file).then((parts) =>
Buffer.concat(parts.map((part) => (Buffer.isBuffer(part) ? part : Buffer.from(part)))),
);
try {
const dirname = uuid();
// FIXME: https://github.com/sindresorhus/filenamify/issues/13
const filename = filenamify(file.filename);
const rootPath = path.join(sails.config.custom.attachmentsPath, dirname);
fs.mkdirSync(rootPath);
await writeFile(path.join(rootPath, filename), buffer);
const image = sharp(buffer);
let metadata;
try {
metadata = await image.metadata();
} catch (error) {} // eslint-disable-line no-empty
const extra = {
dirname,
name: file.filename,
};
if (metadata && !['svg', 'pdf'].includes(metadata.format)) {
let cover256Buffer;
if (metadata.height > metadata.width) {
cover256Buffer = await image
.resize(256, 320)
.jpeg({
quality: 100,
chromaSubsampling: '4:4:4',
})
.toBuffer();
} else {
cover256Buffer = await image
.resize({
width: 256,
})
.jpeg({
quality: 100,
chromaSubsampling: '4:4:4',
})
.toBuffer();
}
const thumbnailsPath = path.join(rootPath, 'thumbnails');
fs.mkdirSync(thumbnailsPath);
await writeFile(path.join(thumbnailsPath, 'cover-256.jpg'), cover256Buffer);
extra.image = _.pick(metadata, ['width', 'height']);
} else {
extra.image = null;
}
Object.assign(file, {
filename,
extra,
});
return done();
} catch (error) {
return done(error);
}
};
return receiver;
},
};
@@ -1,65 +0,0 @@
const fs = require('fs');
const path = require('path');
const util = require('util');
const stream = require('stream');
const streamToArray = require('stream-to-array');
const { v4: uuid } = require('uuid');
const sharp = require('sharp');
const writeFile = util.promisify(fs.writeFile);
module.exports = {
sync: true,
fn() {
const receiver = stream.Writable({
objectMode: true,
});
let firstFileHandled = false;
// eslint-disable-next-line no-underscore-dangle
receiver._write = async (file, receiverEncoding, done) => {
if (firstFileHandled) {
file.pipe(new stream.Writable());
return done();
}
firstFileHandled = true;
const buffer = await streamToArray(file).then((parts) =>
Buffer.concat(parts.map((part) => (Buffer.isBuffer(part) ? part : Buffer.from(part)))),
);
try {
const originalBuffer = await sharp(buffer).jpeg().toBuffer();
const cover336Buffer = await sharp(buffer)
.resize(336, 200)
.jpeg({
quality: 100,
chromaSubsampling: '4:4:4',
})
.toBuffer();
const dirname = uuid();
const rootPath = path.join(sails.config.custom.projectBackgroundImagesPath, dirname);
fs.mkdirSync(rootPath);
await writeFile(path.join(rootPath, 'original.jpg'), originalBuffer);
await writeFile(path.join(rootPath, 'cover-336.jpg'), cover336Buffer);
// eslint-disable-next-line no-param-reassign
file.extra = {
dirname,
};
return done();
} catch (error) {
return done(error);
}
};
return receiver;
},
};
+4
View File
@@ -1,3 +1,4 @@
const { v4: uuid } = require('uuid');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
module.exports = { module.exports = {
@@ -24,6 +25,9 @@ module.exports = {
exp: iat + sails.config.custom.tokenExpiresIn * 24 * 60 * 60, exp: iat + sails.config.custom.tokenExpiresIn * 24 * 60 * 60,
}, },
sails.config.session.secret, sails.config.session.secret,
{
keyid: uuid(),
},
); );
}, },
}; };
@@ -1,70 +0,0 @@
const fs = require('fs');
const path = require('path');
const util = require('util');
const stream = require('stream');
const streamToArray = require('stream-to-array');
const { v4: uuid } = require('uuid');
const sharp = require('sharp');
const writeFile = util.promisify(fs.writeFile);
module.exports = {
sync: true,
fn() {
const receiver = stream.Writable({
objectMode: true,
});
let firstFileHandled = false;
// eslint-disable-next-line no-underscore-dangle
receiver._write = async (file, receiverEncoding, done) => {
if (firstFileHandled) {
file.pipe(new stream.Writable());
return done();
}
firstFileHandled = true;
const buffer = await streamToArray(file).then((parts) =>
Buffer.concat(parts.map((part) => (Buffer.isBuffer(part) ? part : Buffer.from(part)))),
);
try {
const originalBuffer = await sharp(buffer)
.jpeg({
quality: 100,
chromaSubsampling: '4:4:4',
})
.toBuffer();
const square100Buffer = await sharp(buffer)
.resize(100, 100)
.jpeg({
quality: 100,
chromaSubsampling: '4:4:4',
})
.toBuffer();
const dirname = uuid();
const rootPath = path.join(sails.config.custom.userAvatarsPath, dirname);
fs.mkdirSync(rootPath);
await writeFile(path.join(rootPath, 'original.jpg'), originalBuffer);
await writeFile(path.join(rootPath, 'square-100.jpg'), square100Buffer);
// eslint-disable-next-line no-param-reassign
file.extra = {
dirname,
};
return done();
} catch (error) {
return done(error);
}
};
return receiver;
},
};
+27 -3
View File
@@ -17,6 +17,15 @@ module.exports = function defineCurrentUserHook(sails) {
return null; return null;
} }
const session = await Session.findOne({
accessToken,
deletedAt: null,
});
if (!session) {
return null;
}
const user = await sails.helpers.users.getOne(payload.subject); const user = await sails.helpers.users.getOne(payload.subject);
if (user && user.passwordChangedAt > payload.issuedAt) { if (user && user.passwordChangedAt > payload.issuedAt) {
@@ -43,8 +52,14 @@ module.exports = function defineCurrentUserHook(sails) {
if (authorizationHeader && TOKEN_PATTERN.test(authorizationHeader)) { if (authorizationHeader && TOKEN_PATTERN.test(authorizationHeader)) {
const accessToken = authorizationHeader.replace(TOKEN_PATTERN, ''); const accessToken = authorizationHeader.replace(TOKEN_PATTERN, '');
const currentUser = await getUser(accessToken);
req.currentUser = await getUser(accessToken); if (currentUser) {
Object.assign(req, {
accessToken,
currentUser,
});
}
} }
return next(); return next();
@@ -52,8 +67,17 @@ module.exports = function defineCurrentUserHook(sails) {
}, },
'/attachments/*': { '/attachments/*': {
async fn(req, res, next) { async fn(req, res, next) {
if (req.cookies.accessToken) { const { accessToken } = req.cookies;
req.currentUser = await getUser(req.cookies.accessToken);
if (accessToken) {
const currentUser = await getUser(accessToken);
if (currentUser) {
Object.assign(req, {
accessToken,
currentUser,
});
}
} }
return next(); return next();
+49
View File
@@ -0,0 +1,49 @@
/**
* Session.js
*
* @description :: A model definition represents a database table/collection.
* @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models
*/
module.exports = {
attributes: {
// ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
accessToken: {
type: 'string',
required: true,
columnName: 'access_token',
},
remoteAddress: {
type: 'string',
required: true,
columnName: 'remote_address',
},
userAgent: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
columnName: 'user_agent',
},
deletedAt: {
type: 'ref',
columnName: 'deleted_at',
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
// ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝
// ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗
// ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝
userId: {
model: 'User',
required: true,
columnName: 'user_id',
},
},
};
+1 -1
View File
@@ -290,7 +290,7 @@ module.exports = {
* *
*/ */
trustProxy: process.env.TRUST_PROXY, trustProxy: !!process.env.TRUST_PROXY,
}, },
/** /**
+1
View File
@@ -10,6 +10,7 @@
module.exports.routes = { module.exports.routes = {
'POST /api/access-tokens': 'access-tokens/create', 'POST /api/access-tokens': 'access-tokens/create',
'DELETE /api/access-tokens/me': 'access-tokens/delete',
'GET /api/users': 'users/index', 'GET /api/users': 'users/index',
'POST /api/users': 'users/create', 'POST /api/users': 'users/create',
+2 -1
View File
@@ -24,7 +24,7 @@ module.exports.views = {
* *
*/ */
extension: 'html', extension: 'ejs',
/** /**
* *
@@ -36,4 +36,5 @@ module.exports.views = {
*/ */
layout: false, layout: false,
locals: { BASE_URL: process.env.BASE_URL },
}; };
@@ -0,0 +1,24 @@
module.exports.up = (knex) =>
knex.schema.createTable('session', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('user_id').notNullable();
table.text('access_token').notNullable();
table.text('remote_address').notNullable();
table.text('user_agent');
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
table.timestamp('deleted_at', true);
/* Indexes */
table.index('user_id');
table.unique('access_token');
table.index('remote_address');
});
module.exports.down = (knex) => knex.schema.dropTable('session');
+50 -1
View File
@@ -14,6 +14,7 @@
"knex": "^2.2.0", "knex": "^2.2.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.29.4", "moment": "^2.29.4",
"move-file": "^2.1.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"sails": "^1.5.2", "sails": "^1.5.2",
"sails-hook-orm": "^4.0.1", "sails-hook-orm": "^4.0.1",
@@ -23,7 +24,8 @@
"stream-to-array": "^2.3.0", "stream-to-array": "^2.3.0",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"validator": "^13.7.0", "validator": "^13.7.0",
"winston": "^3.8.1" "winston": "^3.8.1",
"zxcvbn": "^4.4.2"
}, },
"devDependencies": { "devDependencies": {
"chai": "^4.3.6", "chai": "^4.3.6",
@@ -4398,6 +4400,28 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/move-file": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/move-file/-/move-file-2.1.0.tgz",
"integrity": "sha512-i9qLW6gqboJ5Ht8bauZi7KlTnQ3QFpBCvMvFfEcHADKgHGeJ9BZMO7SFCTwHPV9Qa0du9DYY1Yx3oqlGt30nXA==",
"dependencies": {
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=10.17"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/move-file/node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"engines": {
"node": ">=8"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -7961,6 +7985,11 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zxcvbn": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz",
"integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ=="
} }
}, },
"dependencies": { "dependencies": {
@@ -11370,6 +11399,21 @@
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w=="
}, },
"move-file": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/move-file/-/move-file-2.1.0.tgz",
"integrity": "sha512-i9qLW6gqboJ5Ht8bauZi7KlTnQ3QFpBCvMvFfEcHADKgHGeJ9BZMO7SFCTwHPV9Qa0du9DYY1Yx3oqlGt30nXA==",
"requires": {
"path-exists": "^4.0.0"
},
"dependencies": {
"path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="
}
}
},
"ms": { "ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -14206,6 +14250,11 @@
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true "dev": true
},
"zxcvbn": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz",
"integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ=="
} }
} }
} }
+3 -1
View File
@@ -46,6 +46,7 @@
"knex": "^2.2.0", "knex": "^2.2.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.29.4", "moment": "^2.29.4",
"move-file": "^2.1.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"sails": "^1.5.2", "sails": "^1.5.2",
"sails-hook-orm": "^4.0.1", "sails-hook-orm": "^4.0.1",
@@ -55,7 +56,8 @@
"stream-to-array": "^2.3.0", "stream-to-array": "^2.3.0",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"validator": "^13.7.0", "validator": "^13.7.0",
"winston": "^3.8.1" "winston": "^3.8.1",
"zxcvbn": "^4.4.2"
}, },
"devDependencies": { "devDependencies": {
"chai": "^4.3.6", "chai": "^4.3.6",