Compare commits

...
50 Commits
Author SHA1 Message Date
Maksim Eltyshev a43af8eac5 chore: Update version 2022-11-20 15:34:04 +01:00
Maksim Eltyshev 409a7cde6b fix: Fix notifications popup styles 2022-11-20 15:30:07 +01:00
Maksim Eltyshev f62cba9bcd fix: Fix board actions for mobile 2022-11-20 15:14:49 +01:00
Maksim Eltyshev 9495234066 ref: Little refactoring 2022-11-20 15:05:21 +01:00
Rafly Maulana 5dcbad7b5a feat: Add markdown editor in card description (#333) 2022-11-20 18:48:42 +05:00
Maksim Eltyshev 064b37050c fix: Hide label actions based on role 2022-11-10 15:28:43 +01:00
Rafly Maulana bf8b8474ec meta: Fix documentation for installing Planka without Docker (#324) 2022-11-10 18:56:55 +05:00
Rafly Maulana 37a9c9bb42 meta: Add documentation for installing Planka without Docker (#323) 2022-11-10 18:48:38 +05:00
Maksim Eltyshev 00fccbaa3d fix: Fix z-indexes 2022-11-02 21:54:30 +01:00
Maksim Eltyshev b3323308c1 fix: Lock postgres version
Closes #312
2022-10-20 12:22:35 +02:00
Jacob Weisz 9108dc5b3e meta: Fix readme typo (#309) 2022-10-12 22:46:00 +05:00
Maksim Eltyshev 513f9a8ee5 chore: Update version 2022-10-03 13:13:59 +02:00
Maksim Eltyshev e051f624a8 ref: Little change for consistency 2022-10-03 13:12:16 +02:00
Maksim Eltyshev 172f4fcf34 fix: Fix missing board memberships loading 2022-10-03 13:04:52 +02:00
Maksim Eltyshev 9f16881965 fix: Socket bug fixes and improvements 2022-10-03 12:11:19 +02:00
Connor Lanigan 4e876922f8 feat: Prefill label creation with search term (#307) 2022-10-03 14:08:39 +05:00
Connor Lanigan d82d928e29 fix: Update German translation (#306) 2022-10-03 14:06:51 +05:00
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
109 changed files with 1661 additions and 634 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
+144 -2
View File
@@ -21,7 +21,12 @@ A Trello-like kanban board built with React and Redux.
## Deploy ## Deploy
### Docker Compose There are 2 types of installation:
1. [Dockerized](#1-docker-compose)
2. [Without Docker](#2-without-docker)
### 1. Docker Compose
[![](https://d207aa93qlcgug.cloudfront.net/1.95.5.qa/img/nav/docker-logo-loggedout.png)](https://hub.docker.com/r/meltyshev/planka) [![](https://d207aa93qlcgug.cloudfront.net/1.95.5.qa/img/nav/docker-logo-loggedout.png)](https://hub.docker.com/r/meltyshev/planka)
@@ -44,9 +49,146 @@ docker-compose up -d
Demo user: demo@demo.demo demo Demo user: demo@demo.demo demo
### 2. Without Docker
Installing without Docker is a bit more complicated, here's what you need to do:
1. Clone this repository into a directory of your choice. (e.g. `/var/www/planka`)
```bash
mkdir -p /var/www/planka
cd /var/www/planka
git clone https://github.com/plankanban/planka.git .
```
2. Install dependencies for client and build it.
```bash
cd client
npm install
npm run build
```
**Note**: You can use `yarn` or `pnpm` instead of `npm`.
3. Copy the `build` directory to the `server/public` directory.
```bash
cp -r build ../server/public
cp build/index.html ../server/views/index.ejs
```
4. Install dependencies for server.
```bash
cd ../server
npm install
```
5. Configure environment variables.
```bash
cp .env.sample .env
# Edit .env file (You could use nano, vim, etc.)
nano .env
```
**Note**: Before continuing, make sure you have your selected database created and running.
6. Copy start script from the root directory to the `server` directory.
```bash
cp ../docker-start.sh start.sh
```
7. Start the server.
```bash
./start.sh
```
**Note**: You can use `pm2` or `systemd` to run the server in the background.
## Additional information
### Nginx configuration
Here is an example of Nginx configuration for Planka, make sure to replace `<domain>` with your domain name, and make sure to configure the SSL.
```nginx
upstream planka {
server localhost:1337;
keepalive 32;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name <domain>;
access_log /var/log/nginx/planka-access.log;
error_log /var/log/nginx/planka-error.log error;
# SSL Configuration - Replace the example <domain> with your domain
ssl_certificate /etc/letsencrypt/live/<domain>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<domain>/privkey.pem;
ssl_session_cache shared:SSL:10m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384";
ssl_prefer_server_ciphers on;
client_max_body_size 120M;
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Max-Age 3600;
add_header Access-Control-Expose-Headers Content-Length;
add_header Access-Control-Allow-Headers Range;
# Make sure to allow socket.io connections
location ~* \.io {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
client_max_body_size 50M;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Frame-Options SAMEORIGIN;
proxy_buffers 256 16k;
proxy_buffer_size 16k;
client_body_timeout 60;
send_timeout 300;
lingering_timeout 5;
proxy_connect_timeout 1d;
proxy_send_timeout 1d;
proxy_read_timeout 1d;
proxy_pass http://planka;
}
location / {
client_max_body_size 50M;
proxy_set_header Connection "";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Frame-Options SAMEORIGIN;
proxy_buffers 256 16k;
proxy_buffer_size 16k;
proxy_read_timeout 600s;
proxy_cache_revalidate on;
proxy_cache_min_uses 2;
proxy_cache_use_stale timeout;
proxy_cache_lock on;
proxy_http_version 1.1;
proxy_pass http://planka;
}
}
```
### Logging ### Logging
Planka currently allows you to expose the applciation's logfile directory to the host machine via a shared volume. This feature is not enabled by default. Planka currently allows you to expose the application's logfile directory to the host machine via a shared volume. This feature is not enabled by default.
To expose the logfile director to the host machine, add the item `./logs/:/app/logs/` under `services.planka.volumes`. To expose the logfile director to the host machine, add the item `./logs/:/app/logs/` under `services.planka.volumes`.
+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;
};
+206 -1
View File
@@ -6,10 +6,12 @@
"": { "": {
"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",
"dequal": "^2.0.3", "dequal": "^2.0.3",
"easymde": "^2.18.0",
"history": "^4.10.1", "history": "^4.10.1",
"i18next": "^21.8.14", "i18next": "^21.8.14",
"i18next-browser-languagedetector": "^6.1.4", "i18next-browser-languagedetector": "^6.1.4",
@@ -32,6 +34,7 @@
"react-redux": "^7.2.8", "react-redux": "^7.2.8",
"react-router-dom": "^5.3.1", "react-router-dom": "^5.3.1",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"react-simplemde-editor": "^5.2.0",
"react-textarea-autosize": "^8.3.4", "react-textarea-autosize": "^8.3.4",
"redux": "^4.2.0", "redux": "^4.2.0",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
@@ -44,7 +47,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 +65,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 +3034,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",
@@ -3687,6 +3697,14 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/codemirror": {
"version": "5.60.5",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.5.tgz",
"integrity": "sha512-TiECZmm8St5YxjFUp64LK0c8WU5bxMDt9YaAek1UqUb9swrSCoJhh92fWu1p3mTEqlHjhB5sY7OFBhWroJXZVg==",
"dependencies": {
"@types/tern": "*"
}
},
"node_modules/@types/connect": { "node_modules/@types/connect": {
"version": "3.4.35", "version": "3.4.35",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz",
@@ -3825,6 +3843,11 @@
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==" "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="
}, },
"node_modules/@types/marked": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.0.7.tgz",
"integrity": "sha512-eEAhnz21CwvKVW+YvRvcTuFKNU9CV1qH+opcgVK3pIMI6YZzDm6gc8o2vHjldFk6MGKt5pueSB7IOpvpx5Qekw=="
},
"node_modules/@types/mdast": { "node_modules/@types/mdast": {
"version": "3.0.10", "version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",
@@ -3962,6 +3985,14 @@
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz",
"integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw=="
}, },
"node_modules/@types/tern": {
"version": "0.23.4",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.4.tgz",
"integrity": "sha512-JAUw1iXGO1qaWwEOzxTKJZ/5JxVeON9kvGZ/osgZaJImBnyjyn0cjovPsf6FNLmyGY8Vw9DoXZCMlfMkMwHRWg==",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz",
@@ -6164,6 +6195,19 @@
"node": ">= 4.0" "node": ">= 4.0"
} }
}, },
"node_modules/codemirror": {
"version": "5.65.9",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.9.tgz",
"integrity": "sha512-19Jox5sAKpusTDgqgKB5dawPpQcY+ipQK7xoEI+MVucEF9qqFaXpeqY1KaoyGBso/wHQoDa4HMMxMjdsS3Zzzw=="
},
"node_modules/codemirror-spell-checker": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/codemirror-spell-checker/-/codemirror-spell-checker-1.1.2.tgz",
"integrity": "sha512-2Tl6n0v+GJRsC9K3MLCdLaMOmvWL0uukajNJseorZJsslaxZyZMgENocPU8R0DyoTAiKsyqiemSOZo7kjGV0LQ==",
"dependencies": {
"typo-js": "*"
}
},
"node_modules/collect-v8-coverage": { "node_modules/collect-v8-coverage": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz",
@@ -7411,6 +7455,18 @@
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
}, },
"node_modules/easymde": {
"version": "2.18.0",
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.18.0.tgz",
"integrity": "sha512-IxVVUxNWIoXLeqtBU4BLc+eS/ScYhT1Dcb6yF5Wchoj1iXAV+TIIDWx+NCaZhY7RcSHqDPKllbYq7nwGKILnoA==",
"dependencies": {
"@types/codemirror": "^5.60.4",
"@types/marked": "^4.0.7",
"codemirror": "^5.63.1",
"codemirror-spell-checker": "1.1.2",
"marked": "^4.1.0"
}
},
"node_modules/ecc-jsbn": { "node_modules/ecc-jsbn": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
@@ -14755,6 +14811,17 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/marked": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.2.2.tgz",
"integrity": "sha512-JjBTFTAvuTgANXx82a5vzK9JLSMoV6V3LBVn4Uhdso6t7vXrGx7g1Cd2r6NYSsxrYbQGFCMqBDhFHyK5q2UvcQ==",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/mdast-util-definitions": { "node_modules/mdast-util-definitions": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.1.tgz", "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.1.tgz",
@@ -18658,6 +18725,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",
@@ -20484,6 +20575,19 @@
"react": "^16.0.0 || ^17.0.0 || ^18.0.0" "react": "^16.0.0 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/react-simplemde-editor": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-simplemde-editor/-/react-simplemde-editor-5.2.0.tgz",
"integrity": "sha512-GkTg1MlQHVK2Rks++7sjuQr/GVS/xm6y+HchZ4GPBWrhcgLieh4CjK04GTKbsfYorSRYKa0n37rtNSJmOzEDkQ==",
"dependencies": {
"@types/codemirror": "~5.60.5"
},
"peerDependencies": {
"easymde": ">= 2.0.0 < 3.0.0",
"react": ">=16.8.2",
"react-dom": ">=16.8.2"
}
},
"node_modules/react-test-renderer": { "node_modules/react-test-renderer": {
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.2.tgz",
@@ -23841,6 +23945,11 @@
"typescript-compare": "^0.0.2" "typescript-compare": "^0.0.2"
} }
}, },
"node_modules/typo-js": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.2.2.tgz",
"integrity": "sha512-C7pYBQK17EjSg8tVNY91KHdUt5Nf6FMJ+c3js076quPmBML57PmNMzAcIq/2kf/hSYtFABNDIYNYlJRl5BJhGw=="
},
"node_modules/unbox-primitive": { "node_modules/unbox-primitive": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
@@ -25310,6 +25419,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 +27441,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",
@@ -27772,6 +27891,14 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"@types/codemirror": {
"version": "5.60.5",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.5.tgz",
"integrity": "sha512-TiECZmm8St5YxjFUp64LK0c8WU5bxMDt9YaAek1UqUb9swrSCoJhh92fWu1p3mTEqlHjhB5sY7OFBhWroJXZVg==",
"requires": {
"@types/tern": "*"
}
},
"@types/connect": { "@types/connect": {
"version": "3.4.35", "version": "3.4.35",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz",
@@ -27910,6 +28037,11 @@
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==" "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="
}, },
"@types/marked": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.0.7.tgz",
"integrity": "sha512-eEAhnz21CwvKVW+YvRvcTuFKNU9CV1qH+opcgVK3pIMI6YZzDm6gc8o2vHjldFk6MGKt5pueSB7IOpvpx5Qekw=="
},
"@types/mdast": { "@types/mdast": {
"version": "3.0.10", "version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",
@@ -28047,6 +28179,14 @@
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz",
"integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw=="
}, },
"@types/tern": {
"version": "0.23.4",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.4.tgz",
"integrity": "sha512-JAUw1iXGO1qaWwEOzxTKJZ/5JxVeON9kvGZ/osgZaJImBnyjyn0cjovPsf6FNLmyGY8Vw9DoXZCMlfMkMwHRWg==",
"requires": {
"@types/estree": "*"
}
},
"@types/trusted-types": { "@types/trusted-types": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz",
@@ -29691,6 +29831,19 @@
"q": "^1.1.2" "q": "^1.1.2"
} }
}, },
"codemirror": {
"version": "5.65.9",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.9.tgz",
"integrity": "sha512-19Jox5sAKpusTDgqgKB5dawPpQcY+ipQK7xoEI+MVucEF9qqFaXpeqY1KaoyGBso/wHQoDa4HMMxMjdsS3Zzzw=="
},
"codemirror-spell-checker": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/codemirror-spell-checker/-/codemirror-spell-checker-1.1.2.tgz",
"integrity": "sha512-2Tl6n0v+GJRsC9K3MLCdLaMOmvWL0uukajNJseorZJsslaxZyZMgENocPU8R0DyoTAiKsyqiemSOZo7kjGV0LQ==",
"requires": {
"typo-js": "*"
}
},
"collect-v8-coverage": { "collect-v8-coverage": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz",
@@ -30603,6 +30756,18 @@
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
}, },
"easymde": {
"version": "2.18.0",
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.18.0.tgz",
"integrity": "sha512-IxVVUxNWIoXLeqtBU4BLc+eS/ScYhT1Dcb6yF5Wchoj1iXAV+TIIDWx+NCaZhY7RcSHqDPKllbYq7nwGKILnoA==",
"requires": {
"@types/codemirror": "^5.60.4",
"@types/marked": "^4.0.7",
"codemirror": "^5.63.1",
"codemirror-spell-checker": "1.1.2",
"marked": "^4.1.0"
}
},
"ecc-jsbn": { "ecc-jsbn": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
@@ -36130,6 +36295,11 @@
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.2.tgz", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.2.tgz",
"integrity": "sha512-y8j3a5/DkJCmS5x4dMCQL+OR0+2EAq3DOtio1COSHsmW2BGXnNCK3v12hJt1LrUz5iZH5g0LmuYOjDdI+czghA==" "integrity": "sha512-y8j3a5/DkJCmS5x4dMCQL+OR0+2EAq3DOtio1COSHsmW2BGXnNCK3v12hJt1LrUz5iZH5g0LmuYOjDdI+czghA=="
}, },
"marked": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.2.2.tgz",
"integrity": "sha512-JjBTFTAvuTgANXx82a5vzK9JLSMoV6V3LBVn4Uhdso6t7vXrGx7g1Cd2r6NYSsxrYbQGFCMqBDhFHyK5q2UvcQ=="
},
"mdast-util-definitions": { "mdast-util-definitions": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.1.tgz", "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.1.tgz",
@@ -38700,6 +38870,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",
@@ -40061,6 +40248,14 @@
"react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0"
} }
}, },
"react-simplemde-editor": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-simplemde-editor/-/react-simplemde-editor-5.2.0.tgz",
"integrity": "sha512-GkTg1MlQHVK2Rks++7sjuQr/GVS/xm6y+HchZ4GPBWrhcgLieh4CjK04GTKbsfYorSRYKa0n37rtNSJmOzEDkQ==",
"requires": {
"@types/codemirror": "~5.60.5"
}
},
"react-test-renderer": { "react-test-renderer": {
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.2.tgz",
@@ -42677,6 +42872,11 @@
"typescript-compare": "^0.0.2" "typescript-compare": "^0.0.2"
} }
}, },
"typo-js": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.2.2.tgz",
"integrity": "sha512-C7pYBQK17EjSg8tVNY91KHdUt5Nf6FMJ+c3js076quPmBML57PmNMzAcIq/2kf/hSYtFABNDIYNYlJRl5BJhGw=="
},
"unbox-primitive": { "unbox-primitive": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
@@ -43805,6 +44005,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=="
} }
} }
} }
+10 -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,10 +63,12 @@
} }
}, },
"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",
"dequal": "^2.0.3", "dequal": "^2.0.3",
"easymde": "^2.18.0",
"history": "^4.10.1", "history": "^4.10.1",
"i18next": "^21.8.14", "i18next": "^21.8.14",
"i18next-browser-languagedetector": "^6.1.4", "i18next-browser-languagedetector": "^6.1.4",
@@ -89,6 +91,7 @@
"react-redux": "^7.2.8", "react-redux": "^7.2.8",
"react-router-dom": "^5.3.1", "react-router-dom": "^5.3.1",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"react-simplemde-editor": "^5.2.0",
"react-textarea-autosize": "^8.3.4", "react-textarea-autosize": "^8.3.4",
"redux": "^4.2.0", "redux": "^4.2.0",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
@@ -101,7 +104,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 +122,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

@@ -14,6 +14,7 @@ const BoardActions = React.memo(
filterUsers, filterUsers,
filterLabels, filterLabels,
allUsers, allUsers,
canEdit,
canEditMemberships, canEditMemberships,
onMembershipCreate, onMembershipCreate,
onMembershipUpdate, onMembershipUpdate,
@@ -27,32 +28,35 @@ const BoardActions = React.memo(
onLabelDelete, onLabelDelete,
}) => { }) => {
return ( return (
<div className={styles.actions}> <div className={styles.wrapper}>
<div className={styles.action}> <div className={styles.actions}>
<Memberships <div className={styles.action}>
items={memberships} <Memberships
allUsers={allUsers} items={memberships}
permissionsSelectStep={BoardMembershipPermissionsSelectStep} allUsers={allUsers}
canEdit={canEditMemberships} permissionsSelectStep={BoardMembershipPermissionsSelectStep}
onCreate={onMembershipCreate} canEdit={canEditMemberships}
onUpdate={onMembershipUpdate} onCreate={onMembershipCreate}
onDelete={onMembershipDelete} onUpdate={onMembershipUpdate}
/> onDelete={onMembershipDelete}
</div> />
<div className={styles.action}> </div>
<Filters <div className={styles.action}>
users={filterUsers} <Filters
labels={filterLabels} users={filterUsers}
allBoardMemberships={memberships} labels={filterLabels}
allLabels={labels} allBoardMemberships={memberships}
onUserAdd={onUserToFilterAdd} allLabels={labels}
onUserRemove={onUserFromFilterRemove} canEdit={canEdit}
onLabelAdd={onLabelToFilterAdd} onUserAdd={onUserToFilterAdd}
onLabelRemove={onLabelFromFilterRemove} onUserRemove={onUserFromFilterRemove}
onLabelCreate={onLabelCreate} onLabelAdd={onLabelToFilterAdd}
onLabelUpdate={onLabelUpdate} onLabelRemove={onLabelFromFilterRemove}
onLabelDelete={onLabelDelete} onLabelCreate={onLabelCreate}
/> onLabelUpdate={onLabelUpdate}
onLabelDelete={onLabelDelete}
/>
</div>
</div> </div>
</div> </div>
); );
@@ -67,6 +71,7 @@ BoardActions.propTypes = {
filterLabels: PropTypes.array.isRequired, filterLabels: PropTypes.array.isRequired,
allUsers: PropTypes.array.isRequired, allUsers: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */ /* eslint-enable react/forbid-prop-types */
canEdit: PropTypes.bool.isRequired,
canEditMemberships: PropTypes.bool.isRequired, canEditMemberships: PropTypes.bool.isRequired,
onMembershipCreate: PropTypes.func.isRequired, onMembershipCreate: PropTypes.func.isRequired,
onMembershipUpdate: PropTypes.func.isRequired, onMembershipUpdate: PropTypes.func.isRequired,
@@ -1,5 +1,6 @@
:global(#app) { :global(#app) {
.action { .action {
flex: 0 0 auto;
margin-right: 20px; margin-right: 20px;
} }
@@ -8,4 +9,15 @@
display: flex; display: flex;
margin: 20px 20px; margin: 20px 20px;
} }
.wrapper {
overflow-x: auto;
overflow-y: hidden;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
} }
@@ -15,6 +15,7 @@ const Filters = React.memo(
labels, labels,
allBoardMemberships, allBoardMemberships,
allLabels, allLabels,
canEdit,
onUserAdd, onUserAdd,
onUserRemove, onUserRemove,
onLabelAdd, onLabelAdd,
@@ -74,6 +75,7 @@ const Filters = React.memo(
title={t('common.filterByLabels', { title={t('common.filterByLabels', {
context: 'title', context: 'title',
})} })}
canEdit={canEdit}
onSelect={onLabelAdd} onSelect={onLabelAdd}
onDeselect={onLabelRemove} onDeselect={onLabelRemove}
onCreate={onLabelCreate} onCreate={onLabelCreate}
@@ -108,6 +110,7 @@ Filters.propTypes = {
allBoardMemberships: PropTypes.array.isRequired, allBoardMemberships: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired, allLabels: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */ /* eslint-enable react/forbid-prop-types */
canEdit: PropTypes.bool.isRequired,
onUserAdd: PropTypes.func.isRequired, onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired, onUserRemove: PropTypes.func.isRequired,
onLabelAdd: PropTypes.func.isRequired, onLabelAdd: PropTypes.func.isRequired,
@@ -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 {
@@ -10,7 +10,7 @@
position: absolute; position: absolute;
text-align: center; text-align: center;
width: 100%; width: 100%;
z-index: 2000; z-index: 2001;
} }
.wrapper { .wrapper {
@@ -29,7 +29,6 @@
padding: 6px 32px 6px 128px; padding: 6px 32px 6px 128px;
margin: 0; margin: 0;
min-height: 80px; min-height: 80px;
z-index: 0;
} }
.extension { .extension {
@@ -95,7 +94,6 @@
text-decoration: none; text-decoration: none;
top: 50%; top: 50%;
width: 112px; width: 112px;
z-index: 1;
} }
.thumbnailLabel { .thumbnailLabel {
@@ -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>
@@ -1,19 +1,15 @@
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; import React, { useCallback, useImperativeHandle, useMemo, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize'; import { Button, Form } from 'semantic-ui-react';
import { Button, Form, TextArea } from 'semantic-ui-react'; import SimpleMDE from 'react-simplemde-editor';
import { useClosableForm, useField } from '../../hooks';
import styles from './DescriptionEdit.module.scss'; import styles from './DescriptionEdit.module.scss';
const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) => { const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) => {
const [t] = useTranslation(); const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false); const [isOpened, setIsOpened] = useState(false);
const [value, handleFieldChange, setValue] = useField(null); const [value, setValue] = useState(null);
const field = useRef(null);
const open = useCallback(() => { const open = useCallback(() => {
setIsOpened(true); setIsOpened(true);
@@ -55,20 +51,37 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
[close], [close],
); );
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
close,
isOpened,
);
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
close(); close();
}, [close]); }, [close]);
useEffect(() => { const mdEditorOptions = useMemo(
if (isOpened) { () => ({
field.current.ref.current.focus(); autofocus: true,
} spellChecker: false,
}, [isOpened]); status: false,
toolbar: [
'bold',
'italic',
'heading',
'strikethrough',
'|',
'quote',
'unordered-list',
'ordered-list',
'table',
'|',
'link',
'image',
'|',
'undo',
'redo',
'|',
'guide',
],
}),
[],
);
if (!isOpened) { if (!isOpened) {
return React.cloneElement(children, { return React.cloneElement(children, {
@@ -78,26 +91,17 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
return ( return (
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<TextArea <SimpleMDE
ref={field}
as={TextareaAutosize}
value={value} value={value}
options={mdEditorOptions}
placeholder={t('common.enterDescription')} placeholder={t('common.enterDescription')}
minRows={3}
spellCheck={false}
className={styles.field} className={styles.field}
onKeyDown={handleFieldKeyDown} onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange} onChange={setValue}
onBlur={handleFieldBlur}
/> />
<div className={styles.controls}> <div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */} {/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<Button <Button positive content={t('action.save')} />
positive
content={t('action.save')}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
</div> </div>
</Form> </Form>
); );
@@ -6,15 +6,12 @@
.field { .field {
background: #fff; background: #fff;
border: 1px solid rgba(9, 30, 66, 0.13);
border-radius: 3px;
color: #17394d; color: #17394d;
display: block; display: block;
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
margin-bottom: 4px; margin-bottom: 4px;
overflow: hidden; overflow: hidden;
padding: 8px 12px;
resize: none; resize: none;
&:focus { &:focus {
@@ -2,5 +2,6 @@
.wrapper { .wrapper {
position: fixed; position: fixed;
width: 100%; width: 100%;
z-index: 1;
} }
} }
+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,
@@ -7,7 +7,8 @@
background: none; background: none;
} }
&:active, &:hover { &:active,
&:hover {
background: transparent; background: transparent;
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
} }
@@ -42,7 +43,6 @@
height: 50px; height: 50px;
margin: 0; margin: 0;
width: 100%; width: 100%;
z-index: 1;
} }
.notification { .notification {
@@ -1,3 +1,4 @@
import truncate from 'lodash/truncate';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useTranslation, Trans } from 'react-i18next'; import { useTranslation, Trans } from 'react-i18next';
@@ -47,23 +48,26 @@ const NotificationsStep = React.memo(({ items, onDelete, onClose }) => {
{activity.data.toList.name} {activity.data.toList.name}
</Trans> </Trans>
); );
case ActivityTypes.COMMENT_CARD: case ActivityTypes.COMMENT_CARD: {
const commentText = truncate(activity.data.text);
return ( return (
<Trans <Trans
i18nKey="common.userLeftNewCommentToCard" i18nKey="common.userLeftNewCommentToCard"
values={{ values={{
user: activity.user.name, user: activity.user.name,
comment: activity.data.text, comment: commentText,
card: card.name, card: card.name,
}} }}
> >
{activity.user.name} {activity.user.name}
{` left a new comment «${activity.data.text}» to `} {` left a new comment «${commentText}» to `}
<Link to={Paths.CARDS.replace(':id', card.id)} onClick={onClose}> <Link to={Paths.CARDS.replace(':id', card.id)} onClick={onClose}>
{card.name} {card.name}
</Link> </Link>
</Trans> </Trans>
); );
}
default: default:
} }
@@ -80,9 +84,10 @@ const NotificationsStep = React.memo(({ items, onDelete, onClose }) => {
})} })}
</Popup.Header> </Popup.Header>
<Popup.Content> <Popup.Content>
{items.length > 0 {items.length > 0 ? (
? items.map((item) => ( <div className={styles.wrapper}>
<div key={item.id} className={styles.wrapper}> {items.map((item) => (
<div key={item.id} className={styles.item}>
{item.card && item.activity ? ( {item.card && item.activity ? (
<> <>
<User <User
@@ -90,20 +95,23 @@ const NotificationsStep = React.memo(({ items, onDelete, onClose }) => {
avatarUrl={item.activity.user.avatarUrl} avatarUrl={item.activity.user.avatarUrl}
size="large" size="large"
/> />
<span className={styles.content}>{renderItemContent(item)}</span> <span className={styles.itemContent}>{renderItemContent(item)}</span>
</> </>
) : ( ) : (
<div className={styles.deletedContent}>{t('common.cardOrActionAreDeleted')}</div> <div className={styles.itemDeleted}>{t('common.cardOrActionAreDeleted')}</div>
)} )}
<Button <Button
type="button" type="button"
icon="close" icon="trash alternate outline"
className={styles.button} className={styles.itemButton}
onClick={() => handleDelete(item.id)} onClick={() => handleDelete(item.id)}
/> />
</div> </div>
)) ))}
: t('common.noUnreadNotifications')} </div>
) : (
t('common.noUnreadNotifications')
)}
</Popup.Content> </Popup.Content>
</> </>
); );
@@ -1,5 +1,13 @@
:global(#app) { :global(#app) {
.button { .item {
padding: 12px;
&:hover {
background: #f0f0f0;
}
}
.itemButton {
background: transparent; background: transparent;
box-shadow: none; box-shadow: none;
float: right; float: right;
@@ -7,7 +15,7 @@
line-height: 20px; line-height: 20px;
margin: 0; margin: 0;
min-height: auto; min-height: auto;
padding: 5px 0; padding: 0;
transition: background 0.3s ease; transition: background 0.3s ease;
width: 20px; width: 20px;
@@ -16,7 +24,7 @@
} }
} }
.content { .itemContent {
display: inline-block; display: inline-block;
font-size: 12px; font-size: 12px;
min-height: 36px; min-height: 36px;
@@ -27,7 +35,7 @@
word-break: break-word; word-break: break-word;
} }
.deletedContent { .itemDeleted {
display: inline-block; display: inline-block;
line-height: 20px; line-height: 20px;
min-height: 20px; min-height: 20px;
@@ -38,10 +46,21 @@
.wrapper { .wrapper {
margin: 0 -12px; margin: 0 -12px;
padding: 12px; max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
&:hover { &::-webkit-scrollbar {
background: #f0f0f0; width: 5px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
} }
} }
} }
+3 -1
View File
@@ -10,12 +10,13 @@ import Editor from './Editor';
import styles from './AddStep.module.scss'; import styles from './AddStep.module.scss';
const AddStep = React.memo(({ onCreate, onBack }) => { const AddStep = React.memo(({ defaultData, onCreate, onBack }) => {
const [t] = useTranslation(); const [t] = useTranslation();
const [data, handleFieldChange] = useForm(() => ({ const [data, handleFieldChange] = useForm(() => ({
name: '', name: '',
color: LabelColors[0], color: LabelColors[0],
...defaultData,
})); }));
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
@@ -46,6 +47,7 @@ const AddStep = React.memo(({ onCreate, onBack }) => {
}); });
AddStep.propTypes = { AddStep.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onCreate: PropTypes.func.isRequired, onCreate: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired, onBack: PropTypes.func.isRequired,
}; };
+37 -31
View File
@@ -8,45 +8,51 @@ import { Button } from 'semantic-ui-react';
import styles from './Item.module.scss'; import styles from './Item.module.scss';
import globalStyles from '../../styles.module.scss'; import globalStyles from '../../styles.module.scss';
const Item = React.memo(({ name, color, isPersisted, isActive, onSelect, onDeselect, onEdit }) => { const Item = React.memo(
const handleToggleClick = useCallback(() => { ({ name, color, isPersisted, isActive, canEdit, onSelect, onDeselect, onEdit }) => {
if (isActive) { const handleToggleClick = useCallback(() => {
onDeselect(); if (isActive) {
} else { onDeselect();
onSelect(); } else {
} onSelect();
}, [isActive, onSelect, onDeselect]); }
}, [isActive, onSelect, onDeselect]);
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<Button <Button
fluid fluid
content={name} content={name}
active={isActive} active={isActive}
disabled={!isPersisted} disabled={!isPersisted}
className={classNames( className={classNames(
styles.labelButton, styles.labelButton,
isActive && styles.labelButtonActive, isActive && styles.labelButtonActive,
globalStyles[`background${upperFirst(camelCase(color))}`], globalStyles[`background${upperFirst(camelCase(color))}`],
)}
onClick={handleToggleClick}
/>
{canEdit && (
<Button
icon="pencil"
size="small"
floated="right"
disabled={!isPersisted}
className={styles.editButton}
onClick={onEdit}
/>
)} )}
onClick={handleToggleClick} </div>
/> );
<Button },
icon="pencil" );
size="small"
disabled={!isPersisted}
className={styles.editButton}
onClick={onEdit}
/>
</div>
);
});
Item.propTypes = { Item.propTypes = {
name: PropTypes.string, name: PropTypes.string,
color: PropTypes.string.isRequired, color: PropTypes.string.isRequired,
isPersisted: PropTypes.bool.isRequired, isPersisted: PropTypes.bool.isRequired,
isActive: PropTypes.bool.isRequired, isActive: PropTypes.bool.isRequired,
canEdit: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired, onSelect: PropTypes.func.isRequired,
onDeselect: PropTypes.func.isRequired, onDeselect: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired, onEdit: PropTypes.func.isRequired,
@@ -4,7 +4,6 @@
box-shadow: none; box-shadow: none;
flex: 0 0 auto; flex: 0 0 auto;
font-weight: normal; font-weight: normal;
margin: 0;
padding: 8px 10px; padding: 8px 10px;
text-decoration: underline; text-decoration: underline;
@@ -18,6 +17,7 @@
flex: 1 1 auto; flex: 1 1 auto;
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
margin-right: 0;
overflow: hidden; overflow: hidden;
padding: 8px 32px 8px 10px; padding: 8px 32px 8px 10px;
position: relative; position: relative;
@@ -18,7 +18,18 @@ const StepTypes = {
}; };
const LabelsStep = React.memo( const LabelsStep = React.memo(
({ items, currentIds, title, onSelect, onDeselect, onCreate, onUpdate, onDelete, onBack }) => { ({
items,
currentIds,
title,
canEdit,
onSelect,
onDeselect,
onCreate,
onUpdate,
onDelete,
onBack,
}) => {
const [t] = useTranslation(); const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps(); const [step, openStep, handleBack] = useSteps();
const [search, handleSearchChange] = useField(''); const [search, handleSearchChange] = useField('');
@@ -84,7 +95,15 @@ const LabelsStep = React.memo(
if (step) { if (step) {
switch (step.type) { switch (step.type) {
case StepTypes.ADD: case StepTypes.ADD:
return <AddStep onCreate={onCreate} onBack={handleBack} />; return (
<AddStep
defaultData={{
name: search,
}}
onCreate={onCreate}
onBack={handleBack}
/>
);
case StepTypes.EDIT: { case StepTypes.EDIT: {
const currentItem = items.find((item) => item.id === step.params.id); const currentItem = items.find((item) => item.id === step.params.id);
@@ -132,6 +151,7 @@ const LabelsStep = React.memo(
color={item.color} color={item.color}
isPersisted={item.isPersisted} isPersisted={item.isPersisted}
isActive={currentIds.includes(item.id)} isActive={currentIds.includes(item.id)}
canEdit={canEdit}
onSelect={() => handleSelect(item.id)} onSelect={() => handleSelect(item.id)}
onDeselect={() => handleDeselect(item.id)} onDeselect={() => handleDeselect(item.id)}
onEdit={() => handleEdit(item.id)} onEdit={() => handleEdit(item.id)}
@@ -139,12 +159,14 @@ const LabelsStep = React.memo(
))} ))}
</div> </div>
)} )}
<Button {canEdit && (
fluid <Button
content={t('action.createNewLabel')} fluid
className={styles.addButton} content={t('action.createNewLabel')}
onClick={handleAddClick} className={styles.addButton}
/> onClick={handleAddClick}
/>
)}
</Popup.Content> </Popup.Content>
</> </>
); );
@@ -157,6 +179,7 @@ LabelsStep.propTypes = {
currentIds: PropTypes.array.isRequired, currentIds: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */ /* eslint-enable react/forbid-prop-types */
title: PropTypes.string, title: PropTypes.string,
canEdit: PropTypes.bool,
onSelect: PropTypes.func.isRequired, onSelect: PropTypes.func.isRequired,
onDeselect: PropTypes.func.isRequired, onDeselect: PropTypes.func.isRequired,
onCreate: PropTypes.func.isRequired, onCreate: PropTypes.func.isRequired,
@@ -167,6 +190,7 @@ LabelsStep.propTypes = {
LabelsStep.defaultProps = { LabelsStep.defaultProps = {
title: 'common.labels', title: 'common.labels',
canEdit: true,
onBack: undefined, onBack: undefined,
}; };
+1
View File
@@ -11,6 +11,7 @@ import NotFound from './NotFound';
import 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';
import 'photoswipe/dist/photoswipe.css'; import 'photoswipe/dist/photoswipe.css';
import 'easymde/dist/easymde.min.css';
import '../lib/custom-ui/styles.css'; import '../lib/custom-ui/styles.css';
import '../styles.module.scss'; import '../styles.module.scss';
+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,
@@ -3,6 +3,7 @@ import { connect } from 'react-redux';
import selectors from '../selectors'; import selectors from '../selectors';
import entryActions from '../entry-actions'; import entryActions from '../entry-actions';
import { BoardMembershipRoles } from '../constants/Enums';
import BoardActions from '../components/BoardActions'; import BoardActions from '../components/BoardActions';
const mapStateToProps = (state) => { const mapStateToProps = (state) => {
@@ -12,6 +13,7 @@ const mapStateToProps = (state) => {
const labels = selectors.selectLabelsForCurrentBoard(state); const labels = selectors.selectLabelsForCurrentBoard(state);
const filterUsers = selectors.selectFilterUsersForCurrentBoard(state); const filterUsers = selectors.selectFilterUsersForCurrentBoard(state);
const filterLabels = selectors.selectFilterLabelsForCurrentBoard(state); const filterLabels = selectors.selectFilterLabelsForCurrentBoard(state);
const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
return { return {
memberships, memberships,
@@ -19,6 +21,7 @@ const mapStateToProps = (state) => {
filterUsers, filterUsers,
filterLabels, filterLabels,
allUsers, allUsers,
canEdit: !!currentUserMembership && currentUserMembership.role === BoardMembershipRoles.EDITOR,
canEditMemberships: isCurrentUserManager, canEditMemberships: isCurrentUserManager,
}; };
}; };
+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',
+18 -17
View File
@@ -4,11 +4,11 @@ export default {
dateFns, dateFns,
format: { format: {
date: 'd/M/yyyy', date: 'd.MM.yyyy',
time: 'p', time: 'p',
dateTime: '$t(format:date) $t(format:time)', dateTime: '$t(format:date) $t(format:time)',
longDate: 'MMM d', longDate: 'd. MMM',
longDateTime: "d MMMM 'at' p", longDateTime: "d. MMMM 'um' p",
}, },
translation: { translation: {
@@ -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, 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?',
@@ -55,10 +55,11 @@ export default {
color: 'Farbe', color: 'Farbe',
createBoard_title: 'Board erstellen', createBoard_title: 'Board erstellen',
createLabel_title: 'Label erstellen', createLabel_title: 'Label erstellen',
createNewOneOrSelectExistingOne: 'Ein Neues erstellen, oder <br />ein Bestehendes auswählen', createNewOneOrSelectExistingOne:
'Erstellen Sie ein neues oder wählen Sie ein bestehendes aus.',
createProject_title: 'Projekt erstellen', createProject_title: 'Projekt erstellen',
createTextFile_title: 'Textdatei erstellen', createTextFile_title: 'Textdatei erstellen',
currentPassword: 'Derzeitiges Password', currentPassword: 'Derzeitiges Passwort',
dangerZone_title: 'Gefährlicher Bereich', dangerZone_title: 'Gefährlicher Bereich',
date: 'Datum', date: 'Datum',
dueDate_title: 'Fälligkeitsdatum', dueDate_title: 'Fälligkeitsdatum',
@@ -73,18 +74,18 @@ export default {
deleteUser_title: 'Benutzer löschen', deleteUser_title: 'Benutzer löschen',
description: 'Beschreibung', description: 'Beschreibung',
detectAutomatically: 'Automatische Erkennung', detectAutomatically: 'Automatische Erkennung',
dropFileToUpload: 'Datei ablegen, um hochzuladen', dropFileToUpload: 'Datei ablegen, um sie hochzuladen',
editAttachment_title: 'Anhang bearbieten', editAttachment_title: 'Anhang bearbieten',
editAvatar_title: 'Avatar bearbeiten', editAvatar_title: 'Avatar bearbeiten',
editBoard_title: 'Board bearbeiten', editBoard_title: 'Board bearbeiten',
editDueDate_title: 'Fälligkeitsdatum bearbeiten', editDueDate_title: 'Fälligkeitsdatum bearbeiten',
editEmail_title: 'E-Mail bearbeiten', editEmail_title: 'E-Mail-Adresse bearbeiten',
editLabel_title: 'Label bearbeiten', editLabel_title: 'Label bearbeiten',
editPassword_title: 'Passwort ändern', editPassword_title: 'Passwort ändern',
editTimer_title: 'Timer bearbeiten', editTimer_title: 'Timer bearbeiten',
editUsername_title: 'Benutzername ändern', editUsername_title: 'Benutzername ändern',
email: 'E-Mail', email: 'E-Mail-Adresse',
emailAlreadyInUse: 'E-Mail bereits in Verwendung', emailAlreadyInUse: 'E-Mail-Adresse bereits in Verwendung',
enterCardTitle: 'Kartentitel eingeben...', enterCardTitle: 'Kartentitel eingeben...',
enterDescription: 'Beschreibung eingeben...', enterDescription: 'Beschreibung eingeben...',
enterFilename: 'Dateiname eingeben', enterFilename: 'Dateiname eingeben',
@@ -108,7 +109,7 @@ export default {
minutes: 'Minuten', minutes: 'Minuten',
moveCard_title: 'Karte verschieben', moveCard_title: 'Karte verschieben',
name: 'Name', name: 'Name',
newEmail: 'Neue E-Mail', newEmail: 'Neue E-Mail-Adresse',
newPassword: 'Neues Passwort', newPassword: 'Neues Passwort',
newUsername: 'Neuer Benutzername', newUsername: 'Neuer Benutzername',
noConnectionToServer: 'Keine Verbindung zum Server', noConnectionToServer: 'Keine Verbindung zum Server',
@@ -123,7 +124,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',
@@ -140,10 +141,10 @@ export default {
time: 'Zeit', time: 'Zeit',
timer: 'Timer', timer: 'Timer',
title: 'Titel', title: 'Titel',
userActions_title: 'Benutzerraktionen', userActions_title: 'Benutzeraktionen',
userAddedThisCardToList: '<0>{{user}}</0><1> hat diese Karte hinzugefügt zu {{list}}</1>', userAddedThisCardToList: '<0>{{user}}</0><1> hat diese Karte hinzugefügt zu {{list}}</1>',
userLeftNewCommentToCard: userLeftNewCommentToCard:
'{{user}} hat einen neuen Kommentar verfasst «{{comment}}» to <2>{{card}}</2>', '{{user}} hat einen neuen Kommentar verfasst: «{{comment}}» in <2>{{card}}</2>',
userMovedCardFromListToList: userMovedCardFromListToList:
'{{user}} bewegte <2>{{card}}</2> von {{fromList}} nach {{toList}}', '{{user}} bewegte <2>{{card}}</2> von {{fromList}} nach {{toList}}',
userMovedThisCardFromListToList: userMovedThisCardFromListToList:
@@ -190,7 +191,7 @@ export default {
edit: 'Bearbeiten', edit: 'Bearbeiten',
editDueDate_title: 'Fälligkeitsdatum bearbeiten', editDueDate_title: 'Fälligkeitsdatum bearbeiten',
editDescription_title: 'Beschreibung ändern', editDescription_title: 'Beschreibung ändern',
editEmail_title: 'E-Mail bearbeiten', editEmail_title: 'E-Mail-Adresse bearbeiten',
editPassword_title: 'Passwort ändern', editPassword_title: 'Passwort ändern',
editTimer_title: 'Timer bearbeiten', editTimer_title: 'Timer bearbeiten',
editTitle_title: 'Titel bearbeiten', editTitle_title: 'Titel bearbeiten',
@@ -199,7 +200,7 @@ export default {
leaveBoard: 'Board verlassen', leaveBoard: 'Board verlassen',
leaveProject: 'Projekt verlassen', leaveProject: 'Projekt verlassen',
logOut_title: 'Ausloggen', logOut_title: 'Ausloggen',
makeCover_title: 'als Vorschau festlegen', makeCover_title: 'Als Vorschau festlegen',
move: 'Verschieben', move: 'Verschieben',
moveCard_title: 'Karte bewegen', moveCard_title: 'Karte bewegen',
remove: 'Löschen', remove: 'Löschen',
@@ -216,7 +217,7 @@ export default {
start: 'Start', start: 'Start',
stop: 'Stopp', stop: 'Stopp',
subscribe: 'Abonnieren', subscribe: 'Abonnieren',
unsubscribe: 'Deabonnieren', unsubscribe: 'De-abonnieren',
uploadNewAvatar: 'Neuen Avatar hochladen', uploadNewAvatar: 'Neuen Avatar hochladen',
uploadNewImage: 'Neues Bild hochladen', uploadNewImage: 'Neues Bild hochladen',
}, },
+3 -3
View File
@@ -1,14 +1,14 @@
export default { export default {
translation: { translation: {
common: { common: {
emailOrUsername: 'E-mail oder Benutzername', emailOrUsername: 'E-Mail-Adresse oder Benutzername',
invalidEmailOrUsername: 'Ungültige E-mail oder Benutzername', invalidEmailOrUsername: 'Ungültige E-Mail-Adresse oder Benutzername',
invalidPassword: 'Ungültiges Passwort', invalidPassword: 'Ungültiges Passwort',
logInToPlanka: 'Einloggen', logInToPlanka: 'Einloggen',
noInternetConnection: 'Keine Internetverbindung', noInternetConnection: 'Keine Internetverbindung',
pageNotFound_title: 'Seite nicht gefunden', pageNotFound_title: 'Seite nicht gefunden',
password: 'Passwort', password: 'Passwort',
projectManagement: 'Projekt Management', projectManagement: 'Projekt-Management',
serverConnectionFailed: 'Serververbindung fehlgeschlagen', serverConnectionFailed: 'Serververbindung fehlgeschlagen',
unknownError: 'Unbekannter Fehler, bitte später erneut versuchen', unknownError: 'Unbekannter Fehler, bitte später erneut versuchen',
}, },
+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);
+1 -1
View File
@@ -27,7 +27,7 @@ services:
- postgres - postgres
postgres: postgres:
image: postgres:alpine image: postgres:14-alpine
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- db-data:/var/lib/postgresql/data - db-data:/var/lib/postgresql/data
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "planka", "name": "planka",
"version": "1.7.1", "version": "1.8.5",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "planka", "name": "planka",
"version": "1.7.1", "version": "1.8.5",
"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.5",
"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,
}, },
}); };
}, },
}; };
+11 -11
View File
@@ -7,7 +7,7 @@ module.exports = {
let boardMemberships = await sails.helpers.users.getBoardMemberships(currentUser.id); let boardMemberships = await sails.helpers.users.getBoardMemberships(currentUser.id);
let membershipBoardIds = sails.helpers.utils.mapRecords(boardMemberships, 'boardId'); const membershipBoardIds = sails.helpers.utils.mapRecords(boardMemberships, 'boardId');
let membershipBoards = await sails.helpers.boards.getMany({ let membershipBoards = await sails.helpers.boards.getMany({
id: membershipBoardIds, id: membershipBoardIds,
@@ -21,16 +21,6 @@ module.exports = {
membershipProjectIds = sails.helpers.utils.mapRecords(membershipProjects); membershipProjectIds = sails.helpers.utils.mapRecords(membershipProjects);
membershipBoards = membershipBoards.filter((membershipBoard) =>
membershipProjectIds.includes(membershipBoard.projectId),
);
membershipBoardIds = sails.helpers.utils.mapRecords(membershipBoards);
boardMemberships = boardMemberships.filter((boardMembership) =>
membershipBoardIds.includes(boardMembership.boardId),
);
const projectIds = [...managerProjectIds, ...membershipProjectIds]; const projectIds = [...managerProjectIds, ...membershipProjectIds];
const projects = [...managerProjects, ...membershipProjects]; const projects = [...managerProjects, ...membershipProjects];
@@ -40,7 +30,17 @@ module.exports = {
const users = await sails.helpers.users.getMany(userIds); const users = await sails.helpers.users.getMany(userIds);
const managerBoards = await sails.helpers.projects.getBoards(managerProjectIds); const managerBoards = await sails.helpers.projects.getBoards(managerProjectIds);
membershipBoards = membershipBoards.filter((membershipBoard) =>
membershipProjectIds.includes(membershipBoard.projectId),
);
const boards = [...managerBoards, ...membershipBoards]; const boards = [...managerBoards, ...membershipBoards];
const boardIds = sails.helpers.utils.mapRecords(boards);
boardMemberships = boardMemberships.filter((boardMembership) =>
boardIds.includes(boardMembership.boardId),
);
return { return {
items: projects, items: projects,
@@ -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;
},
};
@@ -1,3 +1,5 @@
const { v4: uuid } = require('uuid');
module.exports = { module.exports = {
inputs: { inputs: {
record: { record: {
@@ -29,18 +31,9 @@ module.exports = {
const boardMembership = await BoardMembership.destroyOne(inputs.record.id); const boardMembership = await BoardMembership.destroyOne(inputs.record.id);
if (boardMembership) { if (boardMembership) {
sails.sockets.broadcast( const notify = (room) => {
`user:${boardMembership.userId}`,
'boardMembershipDelete',
{
item: boardMembership,
},
inputs.request,
);
const notifyBoard = () => {
sails.sockets.broadcast( sails.sockets.broadcast(
`board:${boardMembership.boardId}`, room,
'boardMembershipDelete', 'boardMembershipDelete',
{ {
item: boardMembership, item: boardMembership,
@@ -54,16 +47,32 @@ module.exports = {
inputs.project.id, inputs.project.id,
); );
if (isProjectManager) { if (!isProjectManager) {
notifyBoard();
} else {
// TODO: also remove if unsubscribed to user
sails.sockets.removeRoomMembersFromRooms( sails.sockets.removeRoomMembersFromRooms(
`user:${boardMembership.userId}`, `@user:${boardMembership.userId}`,
`board:${boardMembership.boardId}`, `board:${boardMembership.boardId}`,
notifyBoard, () => {
notify(`board:${boardMembership.boardId}`);
},
); );
} }
notify(`user:${boardMembership.userId}`);
if (isProjectManager) {
const tempRoom = uuid();
sails.sockets.addRoomMembersToRooms(`board:${boardMembership.boardId}`, tempRoom, () => {
sails.sockets.removeRoomMembersFromRooms(
`user:${boardMembership.userId}`,
tempRoom,
() => {
notify(tempRoom);
sails.sockets.removeRoomMembersFromRooms(tempRoom, tempRoom);
},
);
});
}
} }
return boardMembership; return boardMembership;
+16 -9
View File
@@ -17,16 +17,23 @@ module.exports = {
const board = await Board.archiveOne(inputs.record.id); const board = await Board.archiveOne(inputs.record.id);
if (board) { if (board) {
sails.sockets.leaveAll(`board:${board.id}`); sails.sockets.removeRoomMembersFromRooms(`board:${board.id}`, `board:${board.id}`);
sails.sockets.broadcast( const managerUserIds = await sails.helpers.projects.getManagerUserIds(board.projectId);
`project:${board.projectId}`, const memberUserIds = await sails.helpers.boards.getMemberUserIds(board.id);
'boardDelete',
{ const userIds = _.union(managerUserIds, memberUserIds);
item: board,
}, userIds.forEach((userId) => {
inputs.request, sails.sockets.broadcast(
); `user:${userId}`,
'boardDelete',
{
item: board,
},
inputs.request,
);
});
} }
return board; return board;
+4 -3
View File
@@ -25,9 +25,10 @@ module.exports = {
}, },
async fn(inputs) { async fn(inputs) {
const userIds = await sails.helpers.projects.getManagerAndBoardMemberUserIds( const managerUserIds = await sails.helpers.projects.getManagerUserIds(inputs.record.projectId);
inputs.record.projectId, const memberUserIds = await sails.helpers.boards.getMemberUserIds(inputs.record.id);
);
const userIds = _.union(managerUserIds, memberUserIds);
if (!_.isUndefined(inputs.values.position)) { if (!_.isUndefined(inputs.values.position)) {
const boards = await sails.helpers.projects.getBoards( const boards = await sails.helpers.projects.getBoards(
+1 -1
View File
@@ -26,7 +26,7 @@ module.exports = {
const userIds = _.union(managerUserIds, memberUserIds); const userIds = _.union(managerUserIds, memberUserIds);
userIds.forEach((userId) => { userIds.forEach((userId) => {
sails.sockets.removeRoomMembersFromRooms(`user:${userId}`, boardRooms); sails.sockets.removeRoomMembersFromRooms(`@user:${userId}`, boardRooms);
sails.sockets.broadcast( sails.sockets.broadcast(
`user:${userId}`, `user:${userId}`,
@@ -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,
};
},
};
+2 -2
View File
@@ -123,13 +123,13 @@ module.exports = {
if (user.id === inputs.user.id && inputs.request && inputs.request.isSocket) { if (user.id === inputs.user.id && inputs.request && inputs.request.isSocket) {
const tempRoom = uuid(); const tempRoom = uuid();
sails.sockets.addRoomMembersToRooms(`user:${user.id}`, tempRoom, () => { sails.sockets.addRoomMembersToRooms(`@user:${user.id}`, tempRoom, () => {
sails.sockets.leave(inputs.request, tempRoom, () => { sails.sockets.leave(inputs.request, tempRoom, () => {
sails.sockets.leaveAll(tempRoom); sails.sockets.leaveAll(tempRoom);
}); });
}); });
} else { } else {
sails.sockets.leaveAll(`user:${user.id}`); sails.sockets.leaveAll(`@user:${user.id}`);
} }
} }
@@ -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(),
},
); );
}, },
}; };

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