Compare commits

..
45 Commits
Author SHA1 Message Date
Maksim Eltyshev f1c72df8b6 chore: Update version 2023-10-20 22:38:43 +02:00
Maksim Eltyshev 5056d38848 fix: Fix logging out with invalid token 2023-10-20 22:34:58 +02:00
Maksim Eltyshev 3ef5b3ead8 fix: Make default admin environment variables optional
Closes #526
2023-10-20 21:52:12 +02:00
Vishnu Kaushik ded58dc8b0 fix: Add task counter in card modal (#525)
Closes #467
2023-10-21 00:20:04 +05:00
Maksim Eltyshev d8ee1ecdd1 ci: Do not mark released chart as latest 2023-10-19 23:30:58 +02:00
Maksim Eltyshev fc2f4cd5d3 chore: Update version 2023-10-19 23:08:40 +02:00
Maksim Eltyshev 7ef457157e fix: Fallback to query if there is no fragment 2023-10-19 23:06:37 +02:00
Maksim Eltyshev 23ee815200 chore: Update version 2023-10-19 16:07:52 +02:00
Maksim Eltyshev 40c04c35ff ref: Refactoring 2023-10-19 16:05:34 +02:00
Lorenz Brun 743f2956c8 feat: Improve OIDC SSO (#524)
The OIDC implementation merged in https://github.com/plankanban/planka/pull/491 is flawed for multiple reasons.

It assumes that the access_token returned by the IDP has to be a JWT parseable by the RP which is not the case [1].
Many major IDPs do issue tokens which are not JWTs and RPs should not rely on the contents of these at all.
The only signed token which has a standardized format for direct RP consumption is the OIDC ID token (id_token), but this by default doesn't contain many claims, especially role claims are omitted from them by default for size reasons. To get these additional claims into the ID token, one needs an IDP with support for the "claims" parameter.

It requires manual specification of the JWKS URL which is mandatory in any OIDC discovery document and thus never needs to be manually specified.

It also makes the questionable decision to use a client-side code flow with PKCE where a normal code flow would be much more appropriate as all user data is processed in the backend which can securely hold a client secret (confidential client). This has far wider IDP support, is safer (due to direct involvement of the IDP in obtaining user information) and doesn't require working with ID tokens and claim parameters.

By using a server-side code flow we can also offload most complexity to the server alone, no longer requiring an additional OIDC library on the web client.

Also silent logout doesn't work on most IDPs for security reasons, one needs to actually redirect the user over to the IDP, which then prompts them once more if they actually want to log out.

This implementation should work with any OIDC-compliant IDP and even OAuth 2.0-only IDPs as long as they serve and OIDC discovery document.

[1] rfc-editor.org/rfc/rfc6749#section-5.1
2023-10-19 17:39:21 +05:00
Maksim Eltyshev 5bf5db27d8 chore: Update chart version 2023-10-18 23:33:30 +02:00
Maksim Eltyshev 667c5daf25 chore: Update version 2023-10-18 23:20:30 +02:00
Maksim Eltyshev 6662b0a717 fix: Fix order of checks when logging in 2023-10-18 23:07:57 +02:00
Maksim Eltyshev 348bf4d6c4 Merge branch 'master' of github.com:gorrilla10101/planka 2023-10-17 22:31:16 +02:00
Maksim Eltyshev 3ac2d289d0 chore: Update version 2023-10-17 19:31:21 +02:00
Maksim Eltyshev 3bc73c43ea chore: Bump sails version 2023-10-17 19:23:09 +02:00
Maksim Eltyshev 8e0c60f5be fix: OIDC finalization and refactoring 2023-10-17 19:18:19 +02:00
Daniel Hiller aae69cb5e4 CI: Add prebuild planka package on release 2023-09-30 01:59:09 +02:00
Daniel Hiller 782c0356c8 Meta: Add missing return 2023-09-23 03:48:32 +02:00
Daniel Hiller 757cdb14a4 Meta: Add Automated installation 2023-09-23 03:47:46 +02:00
gorrilla10101 8a4bdbd1fe Merge branch 'plankanban:master' into master 2023-09-16 22:15:54 -05:00
Jeffrey 5bf42be786 dynamic environment variables 2023-09-16 21:37:12 -05:00
Daniel Hiller d896c3183b chore: fix docker dev build 2023-09-14 01:27:29 +02:00
Daniel Hiller 13ee0c3466 chore: fix runs-on name 2023-09-13 22:55:09 +02:00
Daniel Hiller 211c841379 chore: Switch to self hostet action runner 2023-09-13 22:47:08 +02:00
Daniel Hiller 90a30961ff chore: Add dev container, based on latest commit 2023-09-13 22:39:37 +02:00
Daniel Hiller 7cb9774e05 chore: Switch to pnpm package manager 2023-09-13 22:38:37 +02:00
Maksim Eltyshev 875895b331 fix: Fix starting without OIDC environment variables
Closes #503
2023-09-12 01:20:21 +02:00
Maksim Eltyshev 91bc889fed feat: Use environment variables for default admin configuration 2023-09-12 01:12:38 +02:00
Gilberto Vidal a34d8e9753 fix: Fix Turkish translation linting (#504) 2023-09-11 22:21:46 +05:00
gorrilla10101 107cb85ba2 feat: OIDC with PKCE flow (#491) 2023-09-04 20:06:59 +05:00
creador30 9333ef562e feat: Add Turkish translation (#501) 2023-09-04 19:41:11 +05:00
RocketProto 7d28abc7e6 fix: Decouple BASE_URL from Ingress host (#498)
Closes #496
2023-08-27 02:26:45 +05:00
Andrey 5317950b5c fix: Update Russian translation (#497) 2023-08-22 14:34:45 +05:00
Maksim Eltyshev 7501619b7e fix: Fix Czech translation linting
Closes #487
2023-07-31 11:54:50 +02:00
leroyloren 080e29aa03 fix: Update Czech translation (#485) 2023-07-27 04:07:47 +05:00
[object Object] 48c532b2c2 feat: Add edit icon to project name (#457) 2023-07-09 20:49:08 +05:00
Maksim Eltyshev a62815f825 ref: Little refactoring 2023-06-13 12:06:28 +02:00
Maksim Eltyshev 6938232dc4 fix: Keep task edits when textarea loses focus 2023-06-13 12:04:49 +02:00
[object Object] 4c4cfa9c66 fix: Keep comment edits when textarea loses focus (#443)
Closes #441
2023-06-13 14:49:54 +05:00
Maksim Eltyshev ba89d41b19 chore: Update chart version 2023-06-13 00:46:17 +02:00
Maksim Eltyshev 5c435414f7 chore: Update version 2023-06-13 00:25:40 +02:00
Maksim Eltyshev 2d7812a679 fix: Use creation timestamp instead of id for ordering
Closes #465
2023-06-13 00:19:17 +02:00
Maksim Eltyshev d386c82973 fix: Fix saving milliseconds for timestamps 2023-06-12 23:54:57 +02:00
ULiiAn 6c4db967c2 fix: Fix Chinese translation (#464)
Closes #463
2023-06-12 14:51:31 +05:00
118 changed files with 2109 additions and 784 deletions
@@ -0,0 +1,57 @@
name: Build and publish release package
on:
release:
types: [created]
jobs:
build-and-publish-release-package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Workflow install pnpm
run: npm install pnpm -g
- name: Client install dependencies
run: pnpm install
- name: Server install dependencies
run: pnpm install
- name: Server include into dist
run: mv server/ dist/
- name: Client build production
run: |
npm run build
working-directory: ./client
- name: Client include into dist
run: |
mv build/index.html ../dist/views/index.ejs
mv build/* ../dist/public/
working-directory: ./client
- name: Dist include README.md SECURITY.md LICENSE start.sh
run: mv README.md SECURITY.md LICENSE start.sh dist/
- name: Dist Remove node modules
run: rm -R dist/node_modules
- name: Dist create .zip file
run: |
mv dist/ planka/
zip -r planka-prebuild-${{ github.event.release.tag_name }}.zip planka
- name: Dist upload assets
run: |
gh release upload ${{ github.event.release.tag_name }} planka-prebuild-${{ github.event.release.tag_name }}.zip
env:
GH_TOKEN: ${{ github.token }}
@@ -13,26 +13,26 @@ env:
jobs:
build-and-push-docker-base-image:
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
@@ -0,0 +1,34 @@
name: Build and push Docker DEV image
on:
push:
branches: [master]
jobs:
build-and-push-docker-image-dev:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: |
ghcr.io/plankanban/planka:dev
@@ -6,19 +6,19 @@ on:
jobs:
build-and-push-docker-image:
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -32,7 +32,7 @@ jobs:
script: return context.payload.release.tag_name.replace('v', '')
- name: Build and push
uses: docker/build-push-action@v2
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
+3 -2
View File
@@ -13,10 +13,10 @@ jobs:
# see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token
permissions:
contents: write
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -38,5 +38,6 @@ jobs:
uses: helm/chart-releaser-action@v1.5.0
with:
charts_dir: charts
mark_as_latest: false
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
+4 -2
View File
@@ -5,7 +5,8 @@ WORKDIR /app
COPY server/package.json server/package-lock.json .
RUN npm install npm@latest --global \
&& npm clean-install --omit=dev
&& npm install pnpm --global \
&& pnpm install --prod
FROM node:lts AS client
@@ -14,7 +15,8 @@ WORKDIR /app
COPY client/package.json client/package-lock.json .
RUN npm install npm@latest --global \
&& npm clean-install --omit=dev
&& npm install pnpm --global \
&& pnpm install --prod
COPY client .
RUN DISABLE_ESLINT_PLUGIN=true npm run build
+1
View File
@@ -23,6 +23,7 @@
There are 2 types of installation:
- [Without Docker](https://docs.planka.cloud/docs/installl-planka/Debian%20&%20Ubuntu) ([for Windows](https://docs.planka.cloud/docs/installl-planka/Windows))
- [Dockerized](https://docs.planka.cloud/docs/installl-planka/Docker%20Compose)
- [Automated installation](https://github.com/plankanban/planka-installer)
For configuration, please see the [configuration section](https://docs.planka.cloud/docs/category/configuration).
+7 -7
View File
@@ -15,17 +15,17 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.0
version: 0.1.7
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.11.0"
appVersion: "1.14.2"
dependencies:
- alias: postgresql
condition: postgresql.enabled
name: postgresql
repository: &bitnami-repo https://charts.bitnami.com/bitnami
version: 12.5.1
- alias: postgresql
condition: postgresql.enabled
name: postgresql
repository: &bitnami-repo https://charts.bitnami.com/bitnami
version: 12.5.1
+1 -1
View File
@@ -81,4 +81,4 @@ If you want to host Planka for more than just playing around with, you might wan
- Create your `secretkey` once and store it either in a secure vault, or in your `values.yaml` file so it will be the same for upgrading in the future.
- Specify a password for `postgresql.auth.password` as there have been issues with the postgresql chart generating new passwords locking you out of the data you've already stored. (see [this issue](https://github.com/bitnami/charts/issues/2061))
Any questions or concerns, [raise an issue](https://github.com/Chris-Greaves/planka-helm-chart/issues/new).
Any questions or concerns, [raise an issue](https://github.com/Chris-Greaves/planka-helm-chart/issues/new).
+7 -1
View File
@@ -64,7 +64,9 @@ spec:
name: planka-postgresql-svcbind-custom-user
key: uri
- name: BASE_URL
{{- if .Values.ingress.enabled }}
{{- if .Values.baseUrl }}
value: {{ .Values.baseUrl }}
{{- else if .Values.ingress.enabled }}
value: {{ printf "https://%s" (first .Values.ingress.hosts).host }}
{{- else }}
value: http://localhost:3000
@@ -73,6 +75,10 @@ spec:
value: {{ required "A secret key needs to be generated using 'openssl rand -hex 64' and assigned to secretkey." .Values.secretkey }}
- name: TRUST_PROXY
value: "0"
{{ range $k, $v := .Values.env }}
- name: {{ $k | quote }}
value: {{ $v | quote }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
+1 -1
View File
@@ -22,4 +22,4 @@ spec:
storageClassName: "{{ .Values.persistence.storageClass }}"
{{- end }}
{{- end }}
{{- end }}
{{- end }}
+5
View File
@@ -17,6 +17,10 @@ fullnameOverride: ""
# Generate a secret using openssl rand -base64 45
secretkey: ""
# Base url for Planka. Will override `ingress.hosts[0].host`
# Defaults to `http://localhost:3000` if ingress is disabled.
baseUrl: ""
serviceAccount:
# Specifies whether a service account should be created
create: true
@@ -50,6 +54,7 @@ ingress:
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
hosts:
# Used to set planka BASE_URL if no `baseurl` is provided.
- host: planka.local
paths:
- path: /
+1 -1
View File
@@ -1 +1 @@
REACT_APP_VERSION=1.10.3
REACT_APP_VERSION=1.14.2
+39 -8
View File
@@ -18,6 +18,7 @@
"js-cookie": "^3.0.1",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"nanoid": "^5.0.2",
"node-sass": "^8.0.0",
"photoswipe": "^5.3.3",
"prop-types": "^15.8.1",
@@ -16407,14 +16408,20 @@
"integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ=="
},
"node_modules/nanoid": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
"integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.2.tgz",
"integrity": "sha512-2ustYUX1R2rL/Br5B/FMhi8d5/QzvkJ912rBYxskcpu0myTHzSZfTr1LAS2Sm7jxRUObRrSBFoyzwAhL49aVSg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
"node": "^18 || >=20"
}
},
"node_modules/natural-compare": {
@@ -18658,6 +18665,23 @@
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -35471,9 +35495,9 @@
"integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ=="
},
"nanoid": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
"integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw=="
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.2.tgz",
"integrity": "sha512-2ustYUX1R2rL/Br5B/FMhi8d5/QzvkJ912rBYxskcpu0myTHzSZfTr1LAS2Sm7jxRUObRrSBFoyzwAhL49aVSg=="
},
"natural-compare": {
"version": "1.4.0",
@@ -36252,6 +36276,13 @@
"nanoid": "^3.3.4",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"dependencies": {
"nanoid": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA=="
}
}
},
"postcss-attribute-case-insensitive": {
+1
View File
@@ -65,6 +65,7 @@
"js-cookie": "^3.0.1",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"nanoid": "^5.0.2",
"node-sass": "^8.0.0",
"photoswipe": "^5.3.3",
"prop-types": "^15.8.1",
+8
View File
@@ -39,6 +39,14 @@ const initializeCore = (
},
});
// TODO: with success?
initializeCore.fetchConfig = (config) => ({
type: ActionTypes.CORE_INITIALIZE__CONFIG_FETCH,
payload: {
config,
},
});
const logout = () => ({
type: ActionTypes.LOGOUT,
payload: {},
+28
View File
@@ -1,5 +1,12 @@
import ActionTypes from '../constants/ActionTypes';
const initializeLogin = (config) => ({
type: ActionTypes.LOGIN_INITIALIZE,
payload: {
config,
},
});
const authenticate = (data) => ({
type: ActionTypes.AUTHENTICATE,
payload: {
@@ -21,12 +28,33 @@ authenticate.failure = (error) => ({
},
});
const authenticateUsingOidc = () => ({
type: ActionTypes.USING_OIDC_AUTHENTICATE,
payload: {},
});
authenticateUsingOidc.success = (accessToken) => ({
type: ActionTypes.USING_OIDC_AUTHENTICATE__SUCCESS,
payload: {
accessToken,
},
});
authenticateUsingOidc.failure = (error) => ({
type: ActionTypes.USING_OIDC_AUTHENTICATE__FAILURE,
payload: {
error,
},
});
const clearAuthenticateError = () => ({
type: ActionTypes.AUTHENTICATE_ERROR_CLEAR,
payload: {},
});
export default {
initializeLogin,
authenticate,
authenticateUsingOidc,
clearAuthenticateError,
};
+4
View File
@@ -5,10 +5,14 @@ import socket from './socket';
const createAccessToken = (data, headers) => http.post('/access-tokens', data, headers);
const exchangeForAccessTokenUsingOidc = (data, headers) =>
http.post('/access-tokens/exchange-using-oidc', data, headers);
const deleteCurrentAccessToken = (headers) =>
socket.delete('/access-tokens/me', undefined, headers);
export default {
createAccessToken,
exchangeForAccessTokenUsingOidc,
deleteCurrentAccessToken,
};
+5
View File
@@ -1,4 +1,5 @@
import socket from './socket';
import { transformUser } from './users';
/* Transformers */
@@ -13,6 +14,10 @@ const getActivities = (cardId, data, headers) =>
socket.get(`/cards/${cardId}/actions`, data, headers).then((body) => ({
...body,
items: body.items.map(transformActivity),
included: {
...body.included,
users: body.included.users.map(transformUser),
},
}));
/* Event handlers */
+35 -3
View File
@@ -1,18 +1,50 @@
import socket from './socket';
/* Transformers */
export const transformBoardMembership = (boardMembership) => ({
...boardMembership,
createdAt: new Date(boardMembership.createdAt),
});
/* Actions */
const createBoardMembership = (boardId, data, headers) =>
socket.post(`/boards/${boardId}/memberships`, data, headers);
socket.post(`/boards/${boardId}/memberships`, data, headers).then((body) => ({
...body,
item: transformBoardMembership(body.item),
}));
const updateBoardMembership = (id, data, headers) =>
socket.patch(`/board-memberships/${id}`, data, headers);
socket.patch(`/board-memberships/${id}`, data, headers).then((body) => ({
...body,
item: transformBoardMembership(body.item),
}));
const deleteBoardMembership = (id, headers) =>
socket.delete(`/board-memberships/${id}`, undefined, headers);
socket.delete(`/board-memberships/${id}`, undefined, headers).then((body) => ({
...body,
item: transformBoardMembership(body.item),
}));
/* Event handlers */
const makeHandleBoardMembershipCreate = (next) => (body) => {
next({
...body,
item: transformBoardMembership(body.item),
});
};
const makeHandleBoardMembershipUpdate = makeHandleBoardMembershipCreate;
const makeHandleBoardMembershipDelete = makeHandleBoardMembershipCreate;
export default {
createBoardMembership,
updateBoardMembership,
deleteBoardMembership,
makeHandleBoardMembershipCreate,
makeHandleBoardMembershipUpdate,
makeHandleBoardMembershipDelete,
};
+11 -1
View File
@@ -1,12 +1,20 @@
import socket from './socket';
import http from './http';
import { transformUser } from './users';
import { transformBoardMembership } from './board-memberships';
import { transformCard } from './cards';
import { transformAttachment } from './attachments';
/* Actions */
const createBoard = (projectId, data, headers) =>
socket.post(`/projects/${projectId}/boards`, data, headers);
socket.post(`/projects/${projectId}/boards`, data, headers).then((body) => ({
...body,
included: {
...body.included,
boardMemberships: body.included.boardMemberships.map(transformBoardMembership),
},
}));
const createBoardWithImport = (projectId, data, requestId, headers) =>
http.post(`/projects/${projectId}/boards?requestId=${requestId}`, data, headers);
@@ -18,6 +26,8 @@ const getBoard = (id, subscribe, headers) =>
...body,
included: {
...body.included,
users: body.included.users.map(transformUser),
boardMemberships: body.included.boardMemberships.map(transformBoardMembership),
cards: body.included.cards.map(transformCard),
attachments: body.included.attachments.map(transformAttachment),
},
+7 -5
View File
@@ -5,13 +5,15 @@ import Config from '../constants/Config';
const http = {};
// TODO: add all methods
['POST'].forEach((method) => {
['GET', 'POST'].forEach((method) => {
http[method.toLowerCase()] = (url, data, headers) => {
const formData = Object.keys(data).reduce((result, key) => {
result.append(key, data[key]);
const formData =
data &&
Object.keys(data).reduce((result, key) => {
result.append(key, data[key]);
return result;
}, new FormData());
return result;
}, new FormData());
return fetch(`${Config.SERVER_BASE_URL}/api${url}`, {
method,
+2
View File
@@ -1,5 +1,6 @@
import http from './http';
import socket from './socket';
import root from './root';
import accessTokens from './access-tokens';
import users from './users';
import projects from './projects';
@@ -20,6 +21,7 @@ import notifications from './notifications';
export { http, socket };
export default {
...root,
...accessTokens,
...users,
...projects,
+3
View File
@@ -1,6 +1,7 @@
import omit from 'lodash/omit';
import socket from './socket';
import { transformUser } from './users';
import { transformCard } from './cards';
import { transformActivity } from './activities';
@@ -19,6 +20,7 @@ const getNotifications = (headers) =>
items: body.items.map(transformNotification),
included: {
...omit(body.included, 'actions'),
users: body.included.users.map(transformUser),
cards: body.included.cards.map(transformCard),
activities: body.included.actions.map(transformActivity),
},
@@ -30,6 +32,7 @@ const getNotification = (id, headers) =>
item: transformNotification(body.item),
included: {
...omit(body.included, 'actions'),
users: body.included.users.map(transformUser),
cards: body.included.cards.map(transformCard),
activities: body.included.actions.map(transformActivity),
},
+28 -2
View File
@@ -1,14 +1,40 @@
import socket from './socket';
/* Transformers */
export const transformProjectManager = (projectManager) => ({
...projectManager,
createdAt: new Date(projectManager.createdAt),
});
/* Actions */
const createProjectManager = (projectId, data, headers) =>
socket.post(`/projects/${projectId}/managers`, data, headers);
socket.post(`/projects/${projectId}/managers`, data, headers).then((body) => ({
...body,
item: transformProjectManager(body.item),
}));
const deleteProjectManager = (id, headers) =>
socket.delete(`/project-managers/${id}`, undefined, headers);
socket.delete(`/project-managers/${id}`, undefined, headers).then((body) => ({
...body,
item: transformProjectManager(body.item),
}));
/* Event handlers */
const makeHandleProjectManagerCreate = (next) => (body) => {
next({
...body,
item: transformProjectManager(body.item),
});
};
const makeHandleProjectManagerDelete = makeHandleProjectManagerCreate;
export default {
createProjectManager,
deleteProjectManager,
makeHandleProjectManagerCreate,
makeHandleProjectManagerDelete,
};
+31 -3
View File
@@ -1,13 +1,41 @@
import http from './http';
import socket from './socket';
import { transformUser } from './users';
import { transformProjectManager } from './project-managers';
import { transformBoardMembership } from './board-memberships';
/* Actions */
const getProjects = (headers) => socket.get('/projects', undefined, headers);
const getProjects = (headers) =>
socket.get('/projects', undefined, headers).then((body) => ({
...body,
included: {
...body.included,
users: body.included.users.map(transformUser),
projectManagers: body.included.projectManagers.map(transformProjectManager),
boardMemberships: body.included.boardMemberships.map(transformBoardMembership),
},
}));
const createProject = (data, headers) => socket.post('/projects', data, headers);
const createProject = (data, headers) =>
socket.post('/projects', data, headers).then((body) => ({
...body,
included: {
...body.included,
projectManagers: body.included.projectManagers.map(transformProjectManager),
},
}));
const getProject = (id, headers) => socket.get(`/projects/${id}`, undefined, headers);
const getProject = (id, headers) =>
socket.get(`/projects/${id}`, undefined, headers).then((body) => ({
...body,
included: {
...body.included,
users: body.included.users.map(transformUser),
projectManagers: body.included.projectManagers.map(transformProjectManager),
boardMemberships: body.included.boardMemberships.map(transformBoardMembership),
},
}));
const updateProject = (id, data, headers) => socket.patch(`/projects/${id}`, data, headers);
+9
View File
@@ -0,0 +1,9 @@
import http from './http';
/* Actions */
const getConfig = (headers) => http.get('/config', undefined, headers);
export default {
getConfig,
};
+70 -10
View File
@@ -1,30 +1,87 @@
import http from './http';
import socket from './socket';
/* Transformers */
export const transformUser = (user) => ({
...user,
createdAt: new Date(user.createdAt),
});
/* Actions */
const getUsers = (headers) => socket.get('/users', undefined, headers);
const getUsers = (headers) =>
socket.get('/users', undefined, headers).then((body) => ({
...body,
items: body.items.map(transformUser),
}));
const createUser = (data, headers) => socket.post('/users', data, headers);
const createUser = (data, headers) =>
socket.post('/users', data, headers).then((body) => ({
...body,
item: transformUser(body.item),
}));
const getUser = (id, headers) => socket.get(`/users/${id}`, undefined, headers);
const getUser = (id, headers) =>
socket.get(`/users/${id}`, undefined, headers).then((body) => ({
...body,
item: transformUser(body.item),
}));
const getCurrentUser = (subscribe, headers) =>
socket.get(`/users/me${subscribe ? '?subscribe=true' : ''}`, undefined, headers);
socket.get(`/users/me${subscribe ? '?subscribe=true' : ''}`, undefined, headers).then((body) => ({
...body,
item: transformUser(body.item),
}));
const updateUser = (id, data, headers) => socket.patch(`/users/${id}`, data, headers);
const updateUser = (id, data, headers) =>
socket.patch(`/users/${id}`, data, headers).then((body) => ({
...body,
item: transformUser(body.item),
}));
const updateUserEmail = (id, data, headers) => socket.patch(`/users/${id}/email`, data, headers);
const updateUserEmail = (id, data, headers) =>
socket.patch(`/users/${id}/email`, data, headers).then((body) => ({
...body,
item: transformUser(body.item),
}));
const updateUserPassword = (id, data, headers) =>
socket.patch(`/users/${id}/password`, data, headers);
socket.patch(`/users/${id}/password`, data, headers).then((body) => ({
...body,
item: transformUser(body.item),
}));
const updateUserUsername = (id, data, headers) =>
socket.patch(`/users/${id}/username`, data, headers);
socket.patch(`/users/${id}/username`, data, headers).then((body) => ({
...body,
item: transformUser(body.item),
}));
const updateUserAvatar = (id, data, headers) => http.post(`/users/${id}/avatar`, data, headers);
const updateUserAvatar = (id, data, headers) =>
http.post(`/users/${id}/avatar`, data, headers).then((body) => ({
...body,
item: transformUser(body.item),
}));
const deleteUser = (id, headers) => socket.delete(`/users/${id}`, undefined, headers);
const deleteUser = (id, headers) =>
socket.delete(`/users/${id}`, undefined, headers).then((body) => ({
...body,
item: transformUser(body.item),
}));
/* Event handlers */
const makeHandleUserCreate = (next) => (body) => {
next({
...body,
item: transformUser(body.item),
});
};
const makeHandleUserUpdate = makeHandleUserCreate;
const makeHandleUserDelete = makeHandleUserCreate;
export default {
getUsers,
@@ -37,4 +94,7 @@ export default {
updateUserUsername,
updateUserAvatar,
deleteUser,
makeHandleUserCreate,
makeHandleUserUpdate,
makeHandleUserDelete,
};
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import { useClosableForm, useForm } from '../../../hooks';
import { useForm } from '../../../hooks';
import styles from './CommentEdit.module.scss';
@@ -35,12 +35,7 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
text: data.text.trim(),
};
if (!cleanData.text) {
textField.current.ref.current.select();
return;
}
if (!dequal(cleanData, defaultData)) {
if (cleanData.text && !dequal(cleanData, defaultData)) {
onUpdate(cleanData);
}
@@ -65,10 +60,9 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
[submit],
);
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
close,
isOpened,
);
const handleFieldBlur = useCallback(() => {
submit();
}, [submit]);
const handleSubmit = useCallback(() => {
submit();
@@ -99,13 +93,7 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
onBlur={handleFieldBlur}
/>
<div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<Button
positive
content={t('action.save')}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
<Button positive content={t('action.save')} />
</div>
</Form>
);
@@ -100,7 +100,6 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
onChange={setValue}
/>
<div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<Button positive content={t('action.save')} />
</div>
</Form>
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import { useClosableForm, useField } from '../../../hooks';
import { useField } from '../../../hooks';
import styles from './NameEdit.module.scss';
@@ -28,12 +28,7 @@ const NameEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) =>
const submit = useCallback(() => {
const cleanValue = value.trim();
if (!cleanValue) {
field.current.ref.current.select();
return;
}
if (cleanValue !== defaultValue) {
if (cleanValue && cleanValue !== defaultValue) {
onUpdate(cleanValue);
}
@@ -60,10 +55,9 @@ const NameEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) =>
[submit],
);
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
close,
isOpened,
);
const handleFieldBlur = useCallback(() => {
submit();
}, [submit]);
const handleSubmit = useCallback(() => {
submit();
@@ -93,13 +87,7 @@ const NameEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) =>
onBlur={handleFieldBlur}
/>
<div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<Button
positive
content={t('action.save')}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
<Button positive content={t('action.save')} />
</div>
</Form>
);
@@ -48,14 +48,21 @@ const Tasks = React.memo(({ items, canEdit, onCreate, onUpdate, onMove, onDelete
return (
<>
{items.length > 0 && (
<Progress
autoSuccess
value={completedItems.length}
total={items.length}
color="blue"
size="tiny"
className={styles.progress}
/>
<>
<span className={styles.progressWrapper}>
<Progress
autoSuccess
value={completedItems.length}
total={items.length}
color="blue"
size="tiny"
className={styles.progress}
/>
</span>
<span className={styles.count}>
{completedItems.length}/{items.length}
</span>
</>
)}
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<Droppable droppableId="tasks" type={DroppableTypes.TASK}>
@@ -3,6 +3,23 @@
margin: 0 0 16px;
}
.progressWrapper {
display: inline-block;
padding: 3px 0;
vertical-align: top;
width: calc(100% - 50px);
}
.count {
color: #8c8c8c;
display: inline-block;
font-size: 14px;
line-height: 14px;
text-align: right;
vertical-align: top;
width: 50px;
}
.taskButton {
background: transparent;
border: none;
+10 -9
View File
@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { Icon, Menu } from 'semantic-ui-react';
import { Button, Icon, Menu } from 'semantic-ui-react';
import { usePopup } from '../../lib/popup';
import Paths from '../../constants/Paths';
@@ -55,15 +55,16 @@ const Header = React.memo(
>
<Icon fitted name="arrow left" />
</Menu.Item>
<Menu.Item
className={classNames(
styles.item,
canEditProject && styles.itemHoverable,
styles.title,
)}
onClick={handleProjectSettingsClick}
>
<Menu.Item className={classNames(styles.item, styles.title)}>
{project.name}
{canEditProject && (
<Button
className={classNames(styles.editButton, styles.target)}
onClick={handleProjectSettingsClick}
>
<Icon fitted name="pencil" size="small" />
</Button>
)}
</Menu.Item>
</Menu.Menu>
)}
@@ -1,4 +1,20 @@
:global(#app) {
.editButton {
background: transparent;
box-shadow: none;
color: #fff;
font-size: 15px;
line-height: 34px;
margin-left: 8px;
opacity: 0;
padding: 0;
width: 34px;
&:hover {
background: rgba(255, 255, 255, 0.08);
}
}
.item {
cursor: auto;
user-select: auto;
@@ -11,6 +27,10 @@
&:hover {
background: transparent;
color: rgba(255, 255, 255, 0.9);
.target {
opacity: 1;
}
}
}
+43 -4
View File
@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Form, Grid, Header, Message } from 'semantic-ui-react';
import { Button, Form, Grid, Header, Message } from 'semantic-ui-react';
import { useDidUpdate, usePrevious, useToggle } from '../../lib/hooks';
import { Input } from '../../lib/custom-ui';
@@ -28,6 +28,21 @@ const createMessage = (error) => {
type: 'error',
content: 'common.invalidPassword',
};
case 'Use single sign-on':
return {
type: 'error',
content: 'common.useSingleSignOn',
};
case 'Email already in use':
return {
type: 'error',
content: 'common.emailAlreadyInUse',
};
case 'Username already in use':
return {
type: 'error',
content: 'common.usernameAlreadyInUse',
};
case 'Failed to fetch':
return {
type: 'warning',
@@ -47,7 +62,16 @@ const createMessage = (error) => {
};
const Login = React.memo(
({ defaultData, isSubmitting, error, onAuthenticate, onMessageDismiss }) => {
({
defaultData,
isSubmitting,
isSubmittingUsingOidc,
error,
withOidc,
onAuthenticate,
onAuthenticateUsingOidc,
onMessageDismiss,
}) => {
const [t] = useTranslation();
const wasSubmitting = usePrevious(isSubmitting);
@@ -168,9 +192,19 @@ const Login = React.memo(
content={t('action.logIn')}
floated="right"
loading={isSubmitting}
disabled={isSubmitting}
disabled={isSubmitting || isSubmittingUsingOidc}
/>
</Form>
{withOidc && (
<Button
type="button"
loading={isSubmittingUsingOidc}
disabled={isSubmitting || isSubmittingUsingOidc}
onClick={onAuthenticateUsingOidc}
>
{t('action.logInWithSSO')}
</Button>
)}
</div>
</div>
</Grid.Column>
@@ -201,10 +235,15 @@ const Login = React.memo(
);
Login.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
/* eslint-disable react/forbid-prop-types */
defaultData: PropTypes.object.isRequired,
/* eslint-enable react/forbid-prop-types */
isSubmitting: PropTypes.bool.isRequired,
isSubmittingUsingOidc: PropTypes.bool.isRequired,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
withOidc: PropTypes.bool.isRequired,
onAuthenticate: PropTypes.func.isRequired,
onAuthenticateUsingOidc: PropTypes.func.isRequired,
onMessageDismiss: PropTypes.func.isRequired,
};
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Loader } from 'semantic-ui-react';
import LoginContainer from '../containers/LoginContainer';
const LoginWrapper = React.memo(({ isInitializing }) => {
if (isInitializing) {
return <Loader active size="massive" />;
}
return <LoginContainer />;
});
LoginWrapper.propTypes = {
isInitializing: PropTypes.bool.isRequired,
};
export default LoginWrapper;
+3 -3
View File
@@ -5,7 +5,7 @@ import { Route, Routes } from 'react-router-dom';
import { ReduxRouter } from '../lib/redux-router';
import Paths from '../constants/Paths';
import LoginContainer from '../containers/LoginContainer';
import LoginWrapperContainer from '../containers/LoginWrapperContainer';
import CoreContainer from '../containers/CoreContainer';
import NotFound from './NotFound';
@@ -13,7 +13,6 @@ import 'react-datepicker/dist/react-datepicker.css';
import 'photoswipe/dist/photoswipe.css';
import 'easymde/dist/easymde.min.css';
import '../lib/custom-ui/styles.css';
import '../styles.module.scss';
function Root({ store, history }) {
@@ -21,7 +20,8 @@ function Root({ store, history }) {
<Provider store={store}>
<ReduxRouter history={history}>
<Routes>
<Route path={Paths.LOGIN} element={<LoginContainer />} />
<Route path={Paths.LOGIN} element={<LoginWrapperContainer />} />
<Route path={Paths.OIDC_CALLBACK} element={<LoginWrapperContainer />} />
<Route path={Paths.ROOT} element={<CoreContainer />} />
<Route path={Paths.PROJECTS} element={<CoreContainer />} />
<Route path={Paths.BOARDS} element={<CoreContainer />} />
@@ -1,4 +1,5 @@
import { dequal } from 'dequal';
import omit from 'lodash/omit';
import pickBy from 'lodash/pickBy';
import React, { useCallback, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
@@ -9,7 +10,7 @@ import { useForm } from '../../hooks';
import styles from './UserInformationEdit.module.scss';
const UserInformationEdit = React.memo(({ defaultData, onUpdate }) => {
const UserInformationEdit = React.memo(({ defaultData, isNameEditable, onUpdate }) => {
const [t] = useTranslation();
const [data, handleFieldChange] = useForm(() => ({
@@ -32,13 +33,17 @@ const UserInformationEdit = React.memo(({ defaultData, onUpdate }) => {
const nameField = useRef(null);
const handleSubmit = useCallback(() => {
if (!cleanData.name) {
nameField.current.select();
return;
}
if (isNameEditable) {
if (!cleanData.name) {
nameField.current.select();
return;
}
onUpdate(cleanData);
}, [onUpdate, cleanData]);
onUpdate(cleanData);
} else {
onUpdate(omit(cleanData, 'name'));
}
}, [isNameEditable, onUpdate, cleanData]);
return (
<Form onSubmit={handleSubmit}>
@@ -48,6 +53,7 @@ const UserInformationEdit = React.memo(({ defaultData, onUpdate }) => {
ref={nameField}
name="name"
value={data.name}
disabled={!isNameEditable}
className={styles.field}
onChange={handleFieldChange}
/>
@@ -74,6 +80,7 @@ const UserInformationEdit = React.memo(({ defaultData, onUpdate }) => {
UserInformationEdit.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
isNameEditable: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
};
@@ -5,33 +5,40 @@ import { Popup } from '../lib/custom-ui';
import UserInformationEdit from './UserInformationEdit';
const UserInformationEditStep = React.memo(({ defaultData, onUpdate, onBack, onClose }) => {
const [t] = useTranslation();
const UserInformationEditStep = React.memo(
({ defaultData, isNameEditable, onUpdate, onBack, onClose }) => {
const [t] = useTranslation();
const handleUpdate = useCallback(
(data) => {
onUpdate(data);
onClose();
},
[onUpdate, onClose],
);
const handleUpdate = useCallback(
(data) => {
onUpdate(data);
onClose();
},
[onUpdate, onClose],
);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.editInformation', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<UserInformationEdit defaultData={defaultData} onUpdate={handleUpdate} />
</Popup.Content>
</>
);
});
return (
<>
<Popup.Header onBack={onBack}>
{t('common.editInformation', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<UserInformationEdit
defaultData={defaultData}
isNameEditable={isNameEditable}
onUpdate={handleUpdate}
/>
</Popup.Content>
</>
);
},
);
UserInformationEditStep.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
isNameEditable: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
@@ -23,6 +23,7 @@ const AccountPane = React.memo(
phone,
organization,
language,
isLocked,
isAvatarUpdating,
usernameUpdateForm,
emailUpdateForm,
@@ -74,6 +75,7 @@ const AccountPane = React.memo(
phone,
organization,
}}
isNameEditable={!isLocked}
onUpdate={onUpdate}
/>
<Divider horizontal section>
@@ -102,63 +104,67 @@ const AccountPane = React.memo(
value={language || 'auto'}
onChange={handleLanguageChange}
/>
<Divider horizontal section>
<Header as="h4">
{t('common.authentication', {
context: 'title',
})}
</Header>
</Divider>
<div className={styles.action}>
<UserUsernameEditPopup
usePasswordConfirmation
defaultData={usernameUpdateForm.data}
username={username}
isSubmitting={usernameUpdateForm.isSubmitting}
error={usernameUpdateForm.error}
onUpdate={onUsernameUpdate}
onMessageDismiss={onUsernameUpdateMessageDismiss}
>
<Button className={styles.actionButton}>
{t('action.editUsername', {
context: 'title',
})}
</Button>
</UserUsernameEditPopup>
</div>
<div className={styles.action}>
<UserEmailEditPopup
usePasswordConfirmation
defaultData={emailUpdateForm.data}
email={email}
isSubmitting={emailUpdateForm.isSubmitting}
error={emailUpdateForm.error}
onUpdate={onEmailUpdate}
onMessageDismiss={onEmailUpdateMessageDismiss}
>
<Button className={styles.actionButton}>
{t('action.editEmail', {
context: 'title',
})}
</Button>
</UserEmailEditPopup>
</div>
<div className={styles.action}>
<UserPasswordEditPopup
usePasswordConfirmation
defaultData={passwordUpdateForm.data}
isSubmitting={passwordUpdateForm.isSubmitting}
error={passwordUpdateForm.error}
onUpdate={onPasswordUpdate}
onMessageDismiss={onPasswordUpdateMessageDismiss}
>
<Button className={styles.actionButton}>
{t('action.editPassword', {
context: 'title',
})}
</Button>
</UserPasswordEditPopup>
</div>
{!isLocked && (
<>
<Divider horizontal section>
<Header as="h4">
{t('common.authentication', {
context: 'title',
})}
</Header>
</Divider>
<div className={styles.action}>
<UserUsernameEditPopup
usePasswordConfirmation
defaultData={usernameUpdateForm.data}
username={username}
isSubmitting={usernameUpdateForm.isSubmitting}
error={usernameUpdateForm.error}
onUpdate={onUsernameUpdate}
onMessageDismiss={onUsernameUpdateMessageDismiss}
>
<Button className={styles.actionButton}>
{t('action.editUsername', {
context: 'title',
})}
</Button>
</UserUsernameEditPopup>
</div>
<div className={styles.action}>
<UserEmailEditPopup
usePasswordConfirmation
defaultData={emailUpdateForm.data}
email={email}
isSubmitting={emailUpdateForm.isSubmitting}
error={emailUpdateForm.error}
onUpdate={onEmailUpdate}
onMessageDismiss={onEmailUpdateMessageDismiss}
>
<Button className={styles.actionButton}>
{t('action.editEmail', {
context: 'title',
})}
</Button>
</UserEmailEditPopup>
</div>
<div className={styles.action}>
<UserPasswordEditPopup
usePasswordConfirmation
defaultData={passwordUpdateForm.data}
isSubmitting={passwordUpdateForm.isSubmitting}
error={passwordUpdateForm.error}
onUpdate={onPasswordUpdate}
onMessageDismiss={onPasswordUpdateMessageDismiss}
>
<Button className={styles.actionButton}>
{t('action.editPassword', {
context: 'title',
})}
</Button>
</UserPasswordEditPopup>
</div>
</>
)}
</Tab.Pane>
);
},
@@ -172,6 +178,7 @@ AccountPane.propTypes = {
phone: PropTypes.string,
organization: PropTypes.string,
language: PropTypes.string,
isLocked: PropTypes.bool.isRequired,
isAvatarUpdating: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */
usernameUpdateForm: PropTypes.object.isRequired,
@@ -17,6 +17,7 @@ const UserSettingsModal = React.memo(
organization,
language,
subscribeToOwnCards,
isLocked,
isAvatarUpdating,
usernameUpdateForm,
emailUpdateForm,
@@ -48,6 +49,7 @@ const UserSettingsModal = React.memo(
phone={phone}
organization={organization}
language={language}
isLocked={isLocked}
isAvatarUpdating={isAvatarUpdating}
usernameUpdateForm={usernameUpdateForm}
emailUpdateForm={emailUpdateForm}
@@ -105,6 +107,7 @@ UserSettingsModal.propTypes = {
organization: PropTypes.string,
language: PropTypes.string,
subscribeToOwnCards: PropTypes.bool.isRequired,
isLocked: PropTypes.bool.isRequired,
isAvatarUpdating: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */
usernameUpdateForm: PropTypes.object.isRequired,
@@ -64,6 +64,7 @@ const ActionsStep = React.memo(
return (
<UserInformationEditStep
defaultData={pick(user, ['name', 'phone', 'organization'])}
isNameEditable={!user.isLocked}
onUpdate={onUpdate}
onBack={handleBack}
onClose={onClose}
@@ -135,26 +136,32 @@ const ActionsStep = React.memo(
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditUsernameClick}>
{t('action.editUsername', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditEmailClick}>
{t('action.editEmail', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditPasswordClick}>
{t('action.editPassword', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteUser', {
context: 'title',
})}
</Menu.Item>
{!user.isLocked && (
<>
<Menu.Item className={styles.menuItem} onClick={handleEditUsernameClick}>
{t('action.editUsername', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditEmailClick}>
{t('action.editEmail', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditPasswordClick}>
{t('action.editPassword', {
context: 'title',
})}
</Menu.Item>
</>
)}
{!user.isLockedAdmin && (
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteUser', {
context: 'title',
})}
</Menu.Item>
)}
</Menu>
</Popup.Content>
</>
@@ -17,6 +17,8 @@ const Item = React.memo(
organization,
phone,
isAdmin,
isLocked,
isLockedAdmin,
emailUpdateForm,
passwordUpdateForm,
usernameUpdateForm,
@@ -46,7 +48,7 @@ const Item = React.memo(
<Table.Cell>{username || '-'}</Table.Cell>
<Table.Cell>{email}</Table.Cell>
<Table.Cell>
<Radio toggle checked={isAdmin} onChange={handleIsAdminChange} />
<Radio toggle checked={isAdmin} disabled={isLockedAdmin} onChange={handleIsAdminChange} />
</Table.Cell>
<Table.Cell textAlign="right">
<ActionsPopup
@@ -57,6 +59,8 @@ const Item = React.memo(
organization,
phone,
isAdmin,
isLocked,
isLockedAdmin,
emailUpdateForm,
passwordUpdateForm,
usernameUpdateForm,
@@ -88,6 +92,8 @@ Item.propTypes = {
organization: PropTypes.string,
phone: PropTypes.string,
isAdmin: PropTypes.bool.isRequired,
isLocked: PropTypes.bool.isRequired,
isLockedAdmin: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */
emailUpdateForm: PropTypes.object.isRequired,
passwordUpdateForm: PropTypes.object.isRequired,
@@ -110,6 +110,8 @@ const UsersModal = React.memo(
organization={item.organization}
phone={item.phone}
isAdmin={item.isAdmin}
isLocked={item.isLocked}
isLockedAdmin={item.isLockedAdmin}
emailUpdateForm={item.emailUpdateForm}
passwordUpdateForm={item.passwordUpdateForm}
usernameUpdateForm={item.usernameUpdateForm}
+5
View File
@@ -12,14 +12,19 @@ export default {
/* Login */
LOGIN_INITIALIZE: 'LOGIN_INITIALIZE',
AUTHENTICATE: 'AUTHENTICATE',
AUTHENTICATE__SUCCESS: 'AUTHENTICATE__SUCCESS',
AUTHENTICATE__FAILURE: 'AUTHENTICATE__FAILURE',
USING_OIDC_AUTHENTICATE: 'USING_OIDC_AUTHENTICATE',
USING_OIDC_AUTHENTICATE__SUCCESS: 'USING_OIDC_AUTHENTICATE__SUCCESS',
USING_OIDC_AUTHENTICATE__FAILURE: 'USING_OIDC_AUTHENTICATE__FAILURE',
AUTHENTICATE_ERROR_CLEAR: 'AUTHENTICATE_ERROR_CLEAR',
/* Core */
CORE_INITIALIZE: 'CORE_INITIALIZE',
CORE_INITIALIZE__CONFIG_FETCH: 'CORE_INITIALIZE__CONFIG_FETCH',
LOGOUT: 'LOGOUT',
LOGOUT__ACCESS_TOKEN_INVALIDATE: 'LOGOUT__ACCESS_TOKEN_INVALIDATE',
+1 -1
View File
@@ -11,11 +11,11 @@ export default {
/* Login */
AUTHENTICATE: `${PREFIX}/AUTHENTICATE`,
USING_OIDC_AUTHENTICATE: `${PREFIX}/USING_OIDC_AUTHENTICATE`,
AUTHENTICATE_ERROR_CLEAR: `${PREFIX}/AUTHENTICATE_ERROR_CLEAR`,
/* Core */
CORE_INITIALIZE: `${PREFIX}/CORE_INITIALIZE`,
LOGOUT: `${PREFIX}/LOGOUT`,
/* Modals */
+2
View File
@@ -2,6 +2,7 @@ import Config from './Config';
const ROOT = `${Config.BASE_PATH}/`;
const LOGIN = `${Config.BASE_PATH}/login`;
const OIDC_CALLBACK = `${Config.BASE_PATH}/oidc-callback`;
const PROJECTS = `${Config.BASE_PATH}/projects/:id`;
const BOARDS = `${Config.BASE_PATH}/boards/:id`;
const CARDS = `${Config.BASE_PATH}/cards/:id`;
@@ -9,6 +10,7 @@ const CARDS = `${Config.BASE_PATH}/cards/:id`;
export default {
ROOT,
LOGIN,
OIDC_CALLBACK,
PROJECTS,
BOARDS,
CARDS,
+2 -2
View File
@@ -4,18 +4,18 @@ import selectors from '../selectors';
import Core from '../components/Core';
const mapStateToProps = (state) => {
const isCoreInitializing = selectors.selectIsCoreInitializing(state);
const isInitializing = selectors.selectIsInitializing(state);
const isSocketDisconnected = selectors.selectIsSocketDisconnected(state);
const currentModal = selectors.selectCurrentModal(state);
const currentProject = selectors.selectCurrentProject(state);
const currentBoard = selectors.selectCurrentBoard(state);
return {
isInitializing,
isSocketDisconnected,
currentModal,
currentProject,
currentBoard,
isInitializing: isCoreInitializing,
};
};
+19 -9
View File
@@ -1,23 +1,33 @@
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import selectors from '../selectors';
import entryActions from '../entry-actions';
import Login from '../components/Login';
const mapStateToProps = ({
ui: {
authenticateForm: { data: defaultData, isSubmitting, error },
},
}) => ({
defaultData,
isSubmitting,
error,
});
const mapStateToProps = (state) => {
const oidcConfig = selectors.selectOidcConfig(state);
const {
ui: {
authenticateForm: { data: defaultData, isSubmitting, isSubmittingUsingOidc, error },
},
} = state;
return {
defaultData,
isSubmitting,
isSubmittingUsingOidc,
error,
withOidc: !!oidcConfig,
};
};
const mapDispatchToProps = (dispatch) =>
bindActionCreators(
{
onAuthenticate: entryActions.authenticate,
onAuthenticateUsingOidc: entryActions.authenticateUsingOidc,
onMessageDismiss: entryActions.clearAuthenticateError,
},
dispatch,
@@ -0,0 +1,14 @@
import { connect } from 'react-redux';
import selectors from '../selectors';
import LoginWrapper from '../components/LoginWrapper';
const mapStateToProps = (state) => {
const isInitializing = selectors.selectIsInitializing(state);
return {
isInitializing,
};
};
export default connect(mapStateToProps)(LoginWrapper);
@@ -15,6 +15,7 @@ const mapStateToProps = (state) => {
organization,
language,
subscribeToOwnCards,
isLocked,
isAvatarUpdating,
emailUpdateForm,
passwordUpdateForm,
@@ -30,6 +31,7 @@ const mapStateToProps = (state) => {
organization,
language,
subscribeToOwnCards,
isLocked,
isAvatarUpdating,
emailUpdateForm,
passwordUpdateForm,
-6
View File
@@ -1,16 +1,10 @@
import EntryActionTypes from '../constants/EntryActionTypes';
const initializeCore = () => ({
type: EntryActionTypes.CORE_INITIALIZE,
payload: {},
});
const logout = () => ({
type: EntryActionTypes.LOGOUT,
payload: {},
});
export default {
initializeCore,
logout,
};
+6
View File
@@ -7,6 +7,11 @@ const authenticate = (data) => ({
},
});
const authenticateUsingOidc = () => ({
type: EntryActionTypes.USING_OIDC_AUTHENTICATE,
payload: {},
});
const clearAuthenticateError = () => ({
type: EntryActionTypes.AUTHENTICATE_ERROR_CLEAR,
payload: {},
@@ -14,5 +19,6 @@ const clearAuthenticateError = () => ({
export default {
authenticate,
authenticateUsingOidc,
clearAuthenticateError,
};
-31
View File
@@ -1,31 +0,0 @@
import fromPairs from 'lodash/fromPairs';
import cs from './cs/embed';
import da from './da/embed';
import de from './de/embed';
import en from './en/embed';
import es from './es/embed';
import fr from './fr/embed';
import ja from './ja/embed';
import pl from './pl/embed';
import ru from './ru/embed';
import uz from './uz/embed';
import zh from './zh/embed';
const localePairs = [
['cs', cs],
['da', da],
['de', de],
['en', en],
['es', es],
['fr', fr],
['ja', ja],
['pl', pl],
['ru', ru],
['uz', uz],
['zh', zh],
];
export const languages = localePairs.map((locale) => locale[0]);
export const embedLocales = fromPairs(localePairs);
-207
View File
@@ -1,207 +0,0 @@
export default {
format: {
date: 'M/d/yyyy',
time: 'p',
dateTime: '$t(format:date) $t(format:time)',
longDate: 'MMM d',
longDateTime: "MMMM d 'at' p",
},
translation: {
common: {
account: '账号',
actions: '操作',
addAttachment_title: '添加附件',
addComment: '添加评论',
addManager_title: '添加管理员',
addMember_title: '添加成员',
addUser_title: '添加用户',
administrator: '管理员',
all: '全部',
allChangesWillBeAutomaticallySavedAfterConnectionRestored: '所有修改会在重连后自动保存',
areYouSureYouWantToDeleteThisAttachment: '确认删除此附件吗?',
areYouSureYouWantToDeleteThisBoard: '确认删除此面板吗?',
areYouSureYouWantToDeleteThisCard: '确认删除此卡片吗?',
areYouSureYouWantToDeleteThisComment: '确认删除此评论吗?',
areYouSureYouWantToDeleteThisLabel: '确认删除此标签吗?',
areYouSureYouWantToDeleteThisList: '确认删除此列表吗?',
areYouSureYouWantToDeleteThisProject: '确认删除此项目吗?',
areYouSureYouWantToDeleteThisTask: '确认删除此任务吗?',
areYouSureYouWantToDeleteThisUser: '确认删除此用户吗?',
areYouSureYouWantToLeaveBoard: '确认离开此面板吗?',
areYouSureYouWantToLeaveProject: '确认离开此项目吗?',
areYouSureYouWantToRemoveThisManagerFromProject: '确认从本项目删除该管理员吗?',
areYouSureYouWantToRemoveThisMemberFromBoard: '确认本面板删除该成员吗?',
attachment: '附件',
attachments: '多个附件',
authentication: '认证',
background: '背景',
board: '面板',
boardNotFound_title: '面板不存在',
cardActions_title: '卡片操作',
cardNotFound_title: '卡片不存在',
cardOrActionAreDeleted: '卡片或操作已经被删除',
color: '颜色',
createBoard_title: '创建面板',
createLabel_title: '创建标签',
createNewOneOrSelectExistingOne: '创建一个新的或者选择一个已创建的',
createProject_title: '创建项目',
createTextFile_title: '创建文本文件',
currentPassword: '当前密码',
dangerZone_title: '危险区域',
date: '日期',
dueDate_title: '截止日期',
deleteAttachment_title: '删除附件',
deleteBoard_title: '删除面板',
deleteCard_title: '删除擦片',
deleteComment_title: '删除评论',
deleteLabel_title: '删除标签',
deleteList_title: '删除列表',
deleteProject_title: '删除项目',
deleteTask_title: '删除任务',
deleteUser_title: '删除用户',
description: '描述',
dropFileToUpload: '拖放文件以上传',
editAttachment_title: '编辑附件',
editAvatar_title: '编辑头像',
editBoard_title: '编辑面板',
editDueDate_title: '编辑截止时间',
editEmail_title: '编辑邮箱',
editLabel_title: '编辑标签',
editPassword_title: '修改密码',
editTimer_title: '修改时间',
editUsername_title: '修改用户名',
email: '邮箱',
emailAlreadyInUse: '邮箱已使用',
enterCardTitle: '输入卡片标题...',
enterDescription: '输入描述...',
enterFilename: '输入文件名',
enterListTitle: '输入列表标题...',
enterProjectTitle: '输入项目标题',
enterTaskDescription: '输入任务描述...',
filterByLabels_title: '通过标签筛选',
filterByMembers_title: '通过成员筛选',
fromComputer_title: '从电脑',
general: '全体',
hours: '小时',
invalidCurrentPassword: '当前密码错误',
labels: '标签',
leaveBoard_title: '离开面板',
leaveProject_title: '离开项目',
list: '列表',
listActions_title: '列表操作',
managers: '管理员',
members: '成员',
minutes: '分钟',
moveCard_title: '移动卡片',
name: '姓名',
newEmail: '新邮箱',
newPassword: '新密码',
newUsername: '新用户名',
noConnectionToServer: '未连接服务器',
noBoards: '没有面板',
noLists: '没有列表',
noProjects: '没有项目',
notifications: '通知',
noUnreadNotifications: '没有未读通知',
openBoard_title: '打开面板',
optional_inline: '可选的',
organization: '组织机构',
phone: '电话',
preferences: '偏好',
pressPasteShortcutToAddAttachmentFromClipboard:
'提示: 点击 Ctrl-V (Mac: Cmd-V) 从剪切板添加附件.',
project: '项目',
projectNotFound_title: '项目未找到',
removeManager_title: '删除管理员',
removeMember_title: '删除成员',
seconds: '秒',
selectBoard: '选择面板',
selectList: '选择列表',
selectProject: '选择项目',
settings: '设置',
subscribeToMyOwnCardsByDefault: '默认关注自己创建的卡片',
taskActions_title: '任务操作',
tasks: '任务',
time: '时间',
timer: '计时器',
title: '标题',
userActions_title: '用户操作',
userAddedThisCardToList: '<0>{{user}}</0><1> 向列表 {{list}} 添加了该卡片</1>',
userLeftNewCommentToCard: '{{user}} 给 {{card}} 添加了一个新评论 «{{comment}}»',
userMovedCardFromListToList:
'{{user}} 将卡片 <2>{{card}}</2> 从 {{fromList}} 移动到 {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> 将该卡片从 {{fromList}} 移动到 {{toList}}</1>',
username: '用户名',
usernameAlreadyInUse: '用户名已占用',
users: '用户',
writeComment: '编写评论...',
},
action: {
addAnotherCard: '添加别的卡片',
addAnotherList: '添加别的列表',
addAnotherTask: '添加别的任务',
addCard: '添加卡片',
addCard_title: '添加卡片',
addComment: '添加评论',
addList: '添加列表',
addMoreDetailedDescription: '添加更多详细描述',
addTask: '添加任务',
addToCard: '添加擦篇',
addUser: '添加用户',
createBoard: '创建面板',
createFile: '创建文件',
createLabel: '创建标签',
createNewLabel: '创建新标签',
createProject: '创建项目',
delete: '删除',
deleteAttachment: '删除附件',
deleteAvatar: '删除头像',
deleteBoard: '删除面板',
deleteCard: '删除卡片',
deleteCard_title: '删除卡片',
deleteComment: '删除评论',
deleteImage: '删除图片',
deleteLabel: '删除标签',
deleteList: '删除列表',
deleteList_title: '删除列表',
deleteProject: '删除项目',
deleteProject_title: '删除项目',
deleteTask: '删除任务',
deleteTask_title: '删除任务',
deleteUser: '删除用户',
edit: '编辑',
editDueDate_title: '编辑到期时间',
editDescription_title: '编辑描述',
editEmail_title: '编辑邮箱',
editPassword_title: '编辑密码',
editTimer_title: '编辑实践',
editTitle_title: '编辑标题',
editUsername_title: '编辑用户名',
leaveBoard: '离开面板',
leaveProject: '离开项目',
logOut_title: '退出',
makeCover_title: '设置标题',
move: '移动',
moveCard_title: '移动卡片',
remove: '删除',
removeBackground: '删除背景',
removeCover_title: '删除封面',
removeFromBoard: '从面板中删除',
removeFromProject: '从项目中删除',
removeManager: '删除管理者',
removeMember: '删除成员',
save: '保存',
showAllAttachments: '显示所有的附件 ({{hidden}} 隐藏)',
showFewerAttachments: '显示较少的附件',
start: '开始',
stop: '结束',
subscribe: '关注',
unsubscribe: '取消关注',
uploadNewAvatar: '上传新头像',
uploadNewImage: '上传图片',
},
},
};
-20
View File
@@ -1,20 +0,0 @@
export default {
translation: {
common: {
emailOrUsername: '邮箱或用户名',
invalidEmailOrUsername: '错误的邮箱或用户名',
invalidPassword: '密码错误',
logInToPlanka: '登录至 Planka',
noInternetConnection: '没有网络连接',
pageNotFound_title: '页面找不到',
password: '密码',
projectManagement: '项目管理',
serverConnectionFailed: '服务连接失败',
unknownError: '未知错误,请稍后重试',
},
action: {
logIn: '登录',
},
},
};
+24
View File
@@ -13,6 +13,7 @@ export default {
translation: {
common: {
aboutPlanka: 'O aplikaci Planka',
account: 'Účet',
actions: 'Akce',
addAttachment_title: 'Přidat přílohu',
@@ -45,6 +46,9 @@ export default {
background: 'Pozadí',
board: 'Tabule',
boardNotFound_title: 'Tabule nenalezena',
canComment: 'Může komentovat',
canEditContentOfBoard: 'Může upravovat obsah tabule',
canOnlyViewBoard: 'Může zobrazit pouze tabuli',
cardActions_title: 'Akce na kartě',
cardNotFound_title: 'Karta nenalezena',
cardOrActionAreDeleted: 'Karta nebo akce je smazána',
@@ -68,14 +72,18 @@ export default {
deleteTask_title: 'Smazat úkol',
deleteUser_title: 'Smazat uživatele',
description: 'Popis',
detectAutomatically: 'Automatická detekce',
dropFileToUpload: 'Přetáhnutím nahraj soubor',
editor: 'Editor',
editAttachment_title: 'Upravit přílohu',
editAvatar_title: 'Upravit avatar',
editBoard_title: 'Upravit tabuli',
editDueDate_title: 'Upravit Termín do',
editEmail_title: 'Upravit e-mail',
editInformation_title: 'Upravit informace',
editLabel_title: 'Upravit štítek',
editPassword_title: 'Upravit heslo',
editPermissions_title: 'Upravit oprávnění',
editStopwatch_title: 'Upravit časovač',
editUsername_title: 'Upravit uživatelské jméno',
email: 'E-mail',
@@ -89,10 +97,13 @@ export default {
filterByLabels_title: 'Filtruj podle štítku',
filterByMembers_title: 'Filtruj podle člena',
fromComputer_title: 'Z počítače',
fromTrello: 'Z Trella',
general: 'Obecné',
hours: 'Hodiny',
importBoard_title: 'Importovat tabuli',
invalidCurrentPassword: 'Neplatné aktuální heslo',
labels: 'Štítky',
language: 'Jazyk',
leaveBoard_title: 'Opustit tabuli',
leaveProject_title: 'Opustit projekt',
list: 'Seznam',
@@ -122,15 +133,20 @@ export default {
projectNotFound_title: 'Projekt nenalezen',
removeManager_title: 'Odstranit vedoucího',
removeMember_title: 'Odstranit člena',
searchLabels: 'Hledat štítky...',
searchMembers: 'Hledat členy...',
searchUsers: 'Hledat uživatele...',
seconds: 'Vteřin',
selectBoard: 'Vybrat tabuli',
selectList: 'Vybrat seznam',
selectPermissions_title: 'Vybrat oprávnění',
selectProject: 'Vybrat projekt',
settings: 'Nastavení',
stopwatch: 'Časovač',
subscribeToMyOwnCardsByDefault: 'Ve výchozím nastavení odebírat vlastní karty',
taskActions_title: 'Akce na úkolu',
tasks: 'Úkoly',
thereIsNoPreviewAvailableForThisAttachment: 'Pro tuto přílohu není k dispozici žádný náhled',
time: 'Čas',
title: 'Titulek',
userActions_title: 'Akce na uživateli',
@@ -142,6 +158,8 @@ export default {
username: 'Uživatelské jméno',
usernameAlreadyInUse: 'Uživatelské jméno se již používá',
users: 'Uživatelé',
version: 'Verze',
viewer: 'Prohlížeč',
writeComment: 'Napsat komentář...',
},
@@ -153,6 +171,7 @@ export default {
addCard_title: 'Přidat kartu',
addComment: 'Přidat komentář',
addList: 'Přidat seznam',
addMember: 'Přidat člena',
addMoreDetailedDescription: 'Přidat další detailní popis',
addTask: 'Přidat úkol',
addToCard: 'Přidat na kartu',
@@ -182,10 +201,14 @@ export default {
editDueDate_title: 'Upravit termín do',
editDescription_title: 'Upravit popis',
editEmail_title: 'Upravit e-mail',
editInformation_title: 'Upravit informace',
editPassword_title: 'Upravit heslo',
editPermissions: 'Upravit oprávnění',
editStopwatch_title: 'Upravit časovač',
editTitle_title: 'Upravit titulek',
editUsername_title: 'Upravit uživatelské jméno',
hideDetails: 'Skrýt detaily',
import: 'Import',
leaveBoard: 'Opustit tabuli',
leaveProject: 'Opustit projekt',
logOut_title: 'Odhlásit se',
@@ -202,6 +225,7 @@ export default {
save: 'Uložit',
showAllAttachments: 'Zozbrazit všechny přílohy ({{hidden}} skryté)',
showFewerAttachments: 'Zobrazit méně příloh',
showDetails: 'Zobrazit detaily',
start: 'Start',
stop: 'Stop',
subscribe: 'Odebírat',
+2
View File
@@ -11,10 +11,12 @@ export default {
projectManagement: 'Project management',
serverConnectionFailed: 'Server connection failed',
unknownError: 'Unknown error, try again later',
useSingleSignOn: 'Use single sign-on',
},
action: {
logIn: 'Log in',
logInWithSSO: 'Log in with SSO',
},
},
};
+2 -1
View File
@@ -11,10 +11,11 @@ import pl from './pl';
import ru from './ru';
import sk from './sk';
import sv from './sv';
import tr from './tr';
import uz from './uz';
import zh from './zh';
const locales = [cs, da, de, en, es, fr, it, ja, ko, pl, ru, sk, sv, uz, zh];
const locales = [cs, da, de, en, es, fr, it, ja, ko, pl, ru, sk, sv, tr, uz, zh];
export default locales;
+27 -4
View File
@@ -13,10 +13,12 @@ export default {
translation: {
common: {
aboutPlanka: 'О проекте Planka',
account: 'Учетная запись',
actions: 'Действия',
addAttachment: 'Добавление вложения',
addComment: 'Добавление комментария',
addManager_title: 'Добавление менеджера',
addMember: 'Добавление участника',
addUser: 'Добавление пользователя',
administrator: 'Администратор',
@@ -32,11 +34,16 @@ export default {
areYouSureYouWantToDeleteThisProject: 'Вы уверены, что хотите удалить этот проект?',
areYouSureYouWantToDeleteThisTask: 'Вы уверены, что хотите удалить эту задачу?',
areYouSureYouWantToDeleteThisUser: 'Вы уверены, что хотите удалить этого пользователя?',
areYouSureYouWantToRemoveThisMemberFromProject:
'Вы уверены, что хотите удалить этого участника из проекта?',
areYouSureYouWantToLeaveBoard: 'Вы уверены, что хотите покинуть эту доску?',
areYouSureYouWantToLeaveProject: 'Вы уверены, что хотите покинуть этот проект?',
areYouSureYouWantToRemoveThisManagerFromProject:
'Вы уверены, что хотите удалить этого менеджера из проекта?',
areYouSureYouWantToRemoveThisMemberFromBoard:
'Вы уверены, что хотите удалить этого участника из доски?',
attachment: 'Вложение',
attachments: 'Вложения',
authentication: 'Аутентификация',
background: 'Фон',
board: 'Доска',
boardNotFound: 'Доска не найдена',
canComment: 'Может комментировать',
@@ -53,6 +60,7 @@ export default {
createProject: 'Создание проекта',
createTextFile: 'Создание текстового файла',
currentPassword: 'Текущий пароль',
dangerZone_title: 'Опасная зона',
date: 'Дата',
dueDate: 'Срок',
deleteAttachment: 'Удаление вложения',
@@ -74,7 +82,8 @@ export default {
editBackground: 'Изменение фона',
editDueDate: 'Изменение срока',
editEmail: 'Изменение e-mail',
editLabel: 'Изменения метки',
editInformation: 'Изменение информации',
editLabel: 'Изменение метки',
editPassword: 'Изменение пароля',
editPermissions: 'Редактирование разрешений',
editStopwatch: 'Изменение секундомера',
@@ -91,12 +100,18 @@ export default {
filterByLabels: 'Фильтр по меткам',
filterByMembers: 'Фильтр по участникам',
fromComputer: 'С компьютера',
fromTrello: 'Из Trello',
general: 'Основные',
hours: 'Часы',
importBoard: 'Импорт доски',
invalidCurrentPassword: 'Неверный текущий пароль',
labels: 'Метки',
language: 'Язык',
leaveBoard: 'Покинуть доску',
leaveProject: 'Покинуть проект',
list: 'Список',
listActions: 'Действия со списком',
managers: 'Менеджеры',
members: 'Участники',
minutes: 'Минуты',
moveCard: 'Перемещение карточки',
@@ -120,6 +135,7 @@ export default {
project: 'Проект',
projectActions: 'Действия с проектом',
projectNotFound: 'Проект не найден',
removeManager: 'Удалить менеджера',
removeMember: 'Удаление участника',
searchLabels: 'Поиск меток...',
searchMembers: 'Поиск участников...',
@@ -131,7 +147,7 @@ export default {
selectProject: 'Выберите проект',
settings: 'Настройки',
stopwatch: 'Секундомер',
subscribeToMyOwnCardsByDefault: 'По умолчанию подписаться на мои собственные карточки',
subscribeToMyOwnCardsByDefault: 'По умолчанию подписываться на мои собственные карточки',
taskActions: 'Действия с задачей',
tasks: 'Задачи',
thereIsNoPreviewAvailableForThisAttachment: 'Предпросмотр для этого вложения недоступен',
@@ -147,6 +163,7 @@ export default {
username: 'Имя пользователя',
usernameAlreadyInUse: 'Имя пользователя уже занято',
users: 'Пользователи',
version: 'Версия',
viewer: 'Читатель',
writeComment: 'Напишите комментарий...',
},
@@ -185,6 +202,7 @@ export default {
editDueDate: 'Изменить срок',
editDescription: 'Изменить описание',
editEmail: 'Изменить e-mail',
editInformation: 'Изменить информацию',
editPassword: 'Изменить пароль',
editPermissions: 'Изменить разрешения',
editStopwatch: 'Изменить секундомер',
@@ -192,6 +210,9 @@ export default {
editTitle: 'Изменить название',
editUsername: 'Изменить имя пользователя',
hideDetails: 'Скрыть подробности',
import: 'Импорт',
leaveBoard: 'Покинуть доску',
leaveProject: 'Покинуть проект',
logOut: 'Выйти',
makeCover: 'Сделать обложкой',
move: 'Переместить',
@@ -199,7 +220,9 @@ export default {
remove: 'Убрать',
removeBackground: 'Убрать фон',
removeCover: 'Убрать обложку',
removeFromBoard: 'Удалить из доски',
removeFromProject: 'Удалить из проекта',
removeManager: 'Удалить менеджера',
removeMember: 'Удалить участника',
save: 'Сохранить',
showAllAttachments: 'Показать все вложения ({{hidden}} скрыто)',
+219
View File
@@ -0,0 +1,219 @@
import dateFns from 'date-fns/locale/tr';
export default {
dateFns,
format: {
date: 'd.MM.yyyy',
time: 'p',
dateTime: '$t(format:date) $t(format:time)',
longDate: 'd. MMM',
longDateTime: "d. MMMM 'Saat' p",
},
translation: {
common: {
account: 'Hesap',
actions: 'Eylemler',
addAttachment_title: 'Dosya ekle',
addComment: 'Yorum ekle',
addManager_title: 'Yönetici ekle',
addMember_title: 'Üye ekle',
addUser_title: 'Kullanıcı ekle',
administrator: 'Administrator',
all: 'Tümü',
allChangesWillBeAutomaticallySavedAfterConnectionRestored:
'Bağlantı yeniden kurulduğunda tüm değişiklikler kaydedilecektir.',
areYouSureYouWantToDeleteThisAttachment: 'Bu eki silmek istediğinize emin misiniz?',
areYouSureYouWantToDeleteThisBoard: 'Bu panoyu silmek istediğinizden emin misiniz?',
areYouSureYouWantToDeleteThisCard: 'Bu kartı silmek istediğinizden emin misiniz?',
areYouSureYouWantToDeleteThisComment: 'Bu yorumu silmek istediğinizden emin misiniz?',
areYouSureYouWantToDeleteThisLabel: 'Bu etiketi silmek istediğinizden emin misiniz?',
areYouSureYouWantToDeleteThisList: 'Bu listeyi silmek istediğinizden emin misiniz?',
areYouSureYouWantToDeleteThisProject: 'Bu projeyi silmek istediğinizden emin misiniz?',
areYouSureYouWantToDeleteThisTask: 'Bu görevi silmek istediğinizden emin misiniz?',
areYouSureYouWantToDeleteThisUser: 'Bu kullanıcıyı silmek istediğinizden emin misiniz?',
areYouSureYouWantToLeaveBoard: 'Panodan ayrılmak istediğinizden emin misiniz?',
areYouSureYouWantToLeaveProject: 'Projeden ayrılmak istediğinizden emin misiniz?',
areYouSureYouWantToRemoveThisManagerFromProject:
'Bu yöneticiyi projeden çıkarmak istediğinizden emin misiniz?',
areYouSureYouWantToRemoveThisMemberFromBoard:
'Bu üyeyi panodan çıkarmak istediğinizden emin misiniz?',
attachment: 'ek',
attachments: 'ekler',
authentication: 'kimlik doğrulama',
background: 'arka plan',
board: 'pano',
boardNotFound_title: 'Pano bulunamadı',
cardActions_title: 'Kart İşlemleri',
cardNotFound_title: 'Kart bulunamadı',
cardOrActionAreDeleted: 'Kart veya işlem silindi',
color: 'renk',
createBoard_title: 'Pano Oluştur',
createLabel_title: 'Etiket Oluştur',
createNewOneOrSelectExistingOne: 'Yeni bir tane oluşturun veya mevcut bir tanesini seçin.',
createProject_title: 'Proje Oluştur',
createTextFile_title: 'Metin Dosyası Oluştur',
currentPassword: 'Geçerli şifre',
dangerZone_title: 'Tehlikeli Bölge',
date: 'tarih',
dueDate_title: 'Termin Tarihi',
deleteAttachment_title: 'Eki Sil',
deleteBoard_title: 'Panoyu Sil',
deleteCard_title: 'Kartı Sil',
deleteComment_title: 'Yorumu Sil',
deleteLabel_title: 'Etiketi Sil',
deleteList_title: 'Listeyi Sil',
deleteProject_title: 'Projeyi Sil',
deleteTask_title: 'Görevi Sil',
deleteUser_title: 'Kullanıcıyı Sil',
description: 'açıklama',
detectAutomaically: 'Otomatik olarak algıla',
dropFileToUpload: 'Yüklenecek dosyayı buraya bırakın',
editAttachment_title: 'Eki Düzenle',
editAvatar_title: 'Avatarı Düzenle',
editBoard_title: 'Panoyu Düzenle',
editDueDate_title: 'Son Tarihi Düzenle',
editEmail_title: 'E-posta Adresini Düzenle',
editLabel_title: 'Etiketi Düzenle',
editPassword_title: 'Şifreyi Değiştir',
editStopwatch_title: 'Kronometreyi Düzenle',
editUsername_title: 'Kullanıcı Adını Düzenle',
email: 'e-posta',
emailAlreadyInUse: 'E-posta adresi zaten kullanımda',
enterCardTitle: 'Kart başlığını girin',
enterDescription: 'Açıklamayı girin',
enterFilename: 'Dosya adını girin',
enterListTitle: 'Liste başlığını girin',
enterProjectTitle: 'Proje başlığını girin',
enterTaskDescription: 'Görev açıklamasını girin',
filterByLabels_title: 'Etikete Göre Filtrele',
filterByMembers_title: 'Üyelere göre filtrele',
fromComputer_title: 'Bilgisayardan',
general: 'Genel',
hours: 'saat',
invalidCurrentPassword: 'Mevcut şifre yanlış',
labels: 'etiketler',
language: 'dil',
leaveBoard_title: 'Panodan Ayrıl',
leaveProject_title: 'Projeden Ayrıl',
list: 'Listeler',
listActions_title: 'Görevleri Listele',
managers: 'Yöneticiler',
members: 'Üyeler',
minutes: 'dakika',
moveCard_title: 'Kartı Taşı',
name: 'isim',
newEmail: 'Yeni e-posta adresi',
newPassword: 'Yeni şifre',
newUsername: 'Yeni kullanıcı adı',
noConnectionToServer: 'Sunucuya bağlantı yok',
noBoards: 'Pano yok',
noLists: 'Liste yok',
noProjects: 'Proje yok',
notifications: 'Bildirimler',
noUnreadNotifications: 'Okunmamış bildirim yok',
openBoard_title: 'Açık Pano',
optional_inline: 'İsteğe bağlı',
organization: 'Organizasyon',
phone: 'telefon',
preferences: 'Tercihler',
pressPasteShortcutToAddAttachmentFromClipboard:
'İpucu: Panodan bir ek eklemek için CTRL-V ye (Macte Cmd-V) basın',
project: 'Proje',
projectNotFound_title: 'Proje bulunamadı',
removeManager_title: 'Yöneticiyi Kaldır',
removeMember_title: 'Üyeyi Kaldır',
seconds: 'saniye',
selectBoard: 'Pano Seç',
selectList: 'Liste seç',
selectProject: 'Proje seç',
settings: 'Ayarlar',
stopwatch: 'kronometre',
subscribeToMyOwnCardsByDefault: 'Varsayılan olarak kendi kartlarıma abone ol',
taskActions_title: 'Görev Eylemleri',
tasks: 'Görevler',
thereIsNoPreviewAvailableForThisAttachment: 'Bu ek için önizleme mevcut değil',
time: 'zaman',
title: 'başlık',
userActions_title: 'Kullanıcı İşlemleri',
userAddedThisCardToList: '<0>{{user}}</0><1> bu kartı {{list}</1> listesine ekledi',
userLeftNewCommentToCard:
'{{user}} yeni bir yorum yazdı: <2>{{card}</2> kartına «{{comment}}»',
userMovedCardFromListToList:
'{{user}}, <2>{{card}></2> kartını {{fromList}} listesinden {{toList}} listesine taşıdı',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> bu kartı {{fromList}} konumundan {{toList}}</1> konumuna taşıdı',
username: 'kullanıcı adı',
usernameAlreadyInUse: 'Kullanıcı adı zaten kullanımda',
users: 'kullanıcı',
writeComment: 'Yorum yazın',
},
action: {
addAnotherCard: 'Başka bir kart ekle',
addAnotherList: 'Başka bir liste ekle',
addAnotherTask: 'Başka bir görev ekle',
addCard: 'Kart ekle',
addCard_title: 'Kart Ekle',
addComment: 'Yorum ekle',
addList: 'Liste ekle',
addMoreDetailedDescription: 'Ayrıntılı bir açıklama ekleyin',
addTask: 'Görev ekle',
addToCard: 'Karta ekle',
addUser: 'Kullanıcı ekle',
createBoard: 'Pano oluştur',
createFile: 'Dosya oluştur',
createLabel: 'Etiket Oluştur',
createNewLabel: 'Yeni etiket oluştur',
createProject: 'Proje oluştur',
delete: 'Sil',
deleteAttachment: 'Eki sil',
deleteAvatar: 'Avatarı sil',
deleteBoard: 'Panoyu Sil',
deleteCard: 'Kartı sil',
deleteCard_title: 'Kartı Sil',
deleteComment: 'Yorumu sil',
deleteImage: 'Resmi sil',
deleteLabel: 'Etiketi sil',
deleteList: 'Listeyi sil',
deleteList_title: 'Listeyi Sil',
deleteProject: 'Projeyi sil',
deleteProject_title: 'Projeyi Sil',
deleteTask: 'Görevi sil',
deleteTask_title: 'Görevi Sil',
deleteUser: 'Kullanıcıyı sil',
edit: 'Düzenle',
editDueDate_title: 'Son Tarihi Düzenle',
editDescription_title: 'Açıklamayı Düzenle',
editEmail_title: 'E-posta Adresini Düzenle',
editPassword_title: 'Şifreyi Değiştir',
editStopwatch_title: 'Kronometreyi Düzenle',
editTitle_title: 'Başlığı düzenle',
editUsername_title: 'Kullanıcı Adını Düzenle',
hideDetails: 'Ayrıntıları gizle',
leaveBoard: 'Panodan Ayrıl',
leaveProject: 'Projeden ayrıl',
logOut_title: 'Çıkış',
makeCover_title: 'Önizleme olarak ayarla',
move: 'Taşı',
moveCard_title: 'Kartı Taşı',
remove: 'Sil',
removeBackground: 'Arka planı kaldır',
removeCover_title: 'Önizlemeyi Kaldır',
removeFromBoard: 'Panodan kaldır',
removeFromProject: 'Projeden kaldır',
removeManager: 'Yöneticiyi kaldır',
removeMember: 'Üyeyi kaldır',
save: 'Kaydet',
showAllAttachments: 'Tüm ekleri göster ({{hidden}} gizli)',
showDetails: 'Ayrıntıları göster',
showFewerAttachments: 'Daha az ek göster',
start: 'başlat',
stop: 'dur',
subscribe: 'Abone ol',
unsubscribe: 'Abonelikten çık',
uploadNewAvatar: 'Yeni avatar yükle',
uploadNewImage: 'Yeni resim yükle',
},
},
};
+8
View File
@@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'tr',
country: 'tr',
name: 'Türkçe',
embeddedLocale: login,
};
+20
View File
@@ -0,0 +1,20 @@
export default {
translation: {
common: {
emailOrUsername: 'E-posta adresi veya Kullanıcı adı',
invalidEmailOrUsername: 'Geçersiz e-posta adresi veya kullanıcı adı',
invalidPassword: 'Hatalı Şifre',
logInToPlanka: 'Giriş Yap',
noInternetConnection: 'Internet bağlantısı yok',
pageNotFound_title: 'Sayfa bulunamadı',
password: 'Şifre',
projectManagement: 'Proje Yönetimi',
serverConnectionFailed: 'Sunucu bağlantı hatası',
unknownError: 'Bilinmeyen Hata, daha sonra tekrar deneyin',
},
action: {
logIn: 'Giriş Yap',
},
},
};
+1 -1
View File
@@ -177,7 +177,7 @@ export default {
editDescription_title: '编辑描述',
editEmail_title: '编辑邮箱',
editPassword_title: '编辑密码',
editStopwatch_title: '编辑实践',
editStopwatch_title: '编辑时间',
editTitle_title: '编辑标题',
editUsername_title: '编辑用户名',
leaveBoard: '离开面板',
+3
View File
@@ -12,6 +12,9 @@ export default class extends BaseModel {
coverUrl: attr(),
image: attr(),
name: attr(),
createdAt: attr({
getDefault: () => new Date(),
}),
cardId: fk({
to: 'Card',
as: 'card',
+1 -1
View File
@@ -172,7 +172,7 @@ export default class extends BaseModel {
}
getOrderedMembershipsQuerySet() {
return this.memberships.orderBy('id');
return this.memberships.orderBy('createdAt');
}
getOrderedLabelsQuerySet() {
+3
View File
@@ -10,6 +10,9 @@ export default class extends BaseModel {
id: attr(),
role: attr(),
canComment: attr(),
createdAt: attr({
getDefault: () => new Date(),
}),
boardId: fk({
to: 'Board',
as: 'board',
+2 -2
View File
@@ -244,7 +244,7 @@ export default class extends BaseModel {
}
getOrderedAttachmentsQuerySet() {
return this.attachments.orderBy('id', false);
return this.attachments.orderBy('createdAt', false);
}
getFilteredOrderedInCardActivitiesQuerySet() {
@@ -256,7 +256,7 @@ export default class extends BaseModel {
filter.type = ActivityTypes.COMMENT_CARD;
}
return this.activities.filter(filter).orderBy('id', false);
return this.activities.filter(filter).orderBy('createdAt', false);
}
getUnreadNotificationsQuerySet() {
+1 -1
View File
@@ -137,7 +137,7 @@ export default class extends BaseModel {
}
getOrderedManagersQuerySet() {
return this.managers.orderBy('id');
return this.managers.orderBy('createdAt');
}
getOrderedBoardsQuerySet() {
+3
View File
@@ -8,6 +8,9 @@ export default class extends BaseModel {
static fields = {
id: attr(),
createdAt: attr({
getDefault: () => new Date(),
}),
projectId: fk({
to: 'Project',
as: 'project',
+9 -6
View File
@@ -43,9 +43,12 @@ export default class extends BaseModel {
organization: attr(),
language: attr(),
subscribeToOwnCards: attr(),
isAdmin: attr(),
isLocked: attr(),
isLockedAdmin: attr(),
deletedAt: attr(),
isAdmin: attr({
getDefault: () => false,
createdAt: attr({
getDefault: () => new Date(),
}),
isAvatarUpdating: attr({
getDefault: () => false,
@@ -293,15 +296,15 @@ export default class extends BaseModel {
static getOrderedUndeletedQuerySet() {
return this.filter({
deletedAt: null,
}).orderBy('id');
}).orderBy('createdAt');
}
getOrderedProjectManagersQuerySet() {
return this.projectManagers.orderBy('id');
return this.projectManagers.orderBy('createdAt');
}
getOrderedBoardMembershipsQuerySet() {
return this.boardMemberships.orderBy('id');
return this.boardMemberships.orderBy('createdAt');
}
getOrderedUnreadNotificationsQuerySet() {
@@ -309,7 +312,7 @@ export default class extends BaseModel {
.filter({
isRead: false,
})
.orderBy('id', false);
.orderBy('createdAt', false);
}
getOrderedAvailableProjectsModelArray() {
+1
View File
@@ -10,6 +10,7 @@ const initialState = {
export default (state = initialState, { type, payload }) => {
switch (type) {
case ActionTypes.AUTHENTICATE__SUCCESS:
case ActionTypes.USING_OIDC_AUTHENTICATE__SUCCESS:
return {
...state,
accessToken: payload.accessToken,
-6
View File
@@ -4,7 +4,6 @@ import ActionTypes from '../constants/ActionTypes';
import ModalTypes from '../constants/ModalTypes';
const initialState = {
isInitializing: true,
isLogouting: false,
currentModal: null,
};
@@ -18,11 +17,6 @@ export default (state = initialState, { type, payload }) => {
...state,
currentModal: null,
};
case ActionTypes.CORE_INITIALIZE:
return {
...state,
isInitializing: false,
};
case ActionTypes.LOGOUT__ACCESS_TOKEN_INVALIDATE:
return {
...state,
+2
View File
@@ -3,6 +3,7 @@ import { combineReducers } from 'redux';
import router from './router';
import socket from './socket';
import orm from './orm';
import root from './root';
import auth from './auth';
import core from './core';
import ui from './ui';
@@ -11,6 +12,7 @@ export default combineReducers({
router,
socket,
orm,
root,
auth,
core,
ui,
+36
View File
@@ -0,0 +1,36 @@
import ActionTypes from '../constants/ActionTypes';
const initialState = {
isInitializing: true,
config: null,
};
// eslint-disable-next-line default-param-last
export default (state = initialState, { type, payload }) => {
switch (type) {
case ActionTypes.LOGIN_INITIALIZE:
return {
...state,
isInitializing: false,
config: payload.config,
};
case ActionTypes.AUTHENTICATE__SUCCESS:
case ActionTypes.USING_OIDC_AUTHENTICATE__SUCCESS:
return {
...state,
isInitializing: true,
};
case ActionTypes.CORE_INITIALIZE:
return {
...state,
isInitializing: false,
};
case ActionTypes.CORE_INITIALIZE__CONFIG_FETCH:
return {
...state,
config: payload.config,
};
default:
return state;
}
};
@@ -1,4 +1,7 @@
import { LOCATION_CHANGE_HANDLE } from '../../lib/redux-router';
import ActionTypes from '../../constants/ActionTypes';
import Paths from '../../constants/Paths';
const initialState = {
data: {
@@ -6,12 +9,22 @@ const initialState = {
password: '',
},
isSubmitting: false,
isSubmittingUsingOidc: false,
error: null,
};
// eslint-disable-next-line default-param-last
export default (state = initialState, { type, payload }) => {
switch (type) {
case LOCATION_CHANGE_HANDLE:
if (payload.location.pathname === Paths.OIDC_CALLBACK) {
return {
...state,
isSubmittingUsingOidc: true,
};
}
return state;
case ActionTypes.AUTHENTICATE:
return {
...state,
@@ -22,6 +35,7 @@ export default (state = initialState, { type, payload }) => {
isSubmitting: true,
};
case ActionTypes.AUTHENTICATE__SUCCESS:
case ActionTypes.USING_OIDC_AUTHENTICATE__SUCCESS:
return initialState;
case ActionTypes.AUTHENTICATE__FAILURE:
return {
@@ -29,6 +43,12 @@ export default (state = initialState, { type, payload }) => {
isSubmitting: false,
error: payload.error,
};
case ActionTypes.USING_OIDC_AUTHENTICATE__FAILURE:
return {
...state,
isSubmittingUsingOidc: false,
error: payload.error,
};
case ActionTypes.AUTHENTICATE_ERROR_CLEAR:
return {
...state,
+10 -2
View File
@@ -1,7 +1,8 @@
import { all, apply, fork, take } from 'redux-saga/effects';
import { all, apply, fork, select, take } from 'redux-saga/effects';
import watchers from './watchers';
import services from './services';
import selectors from '../../selectors';
import { socket } from '../../api';
import ActionTypes from '../../constants/ActionTypes';
import Paths from '../../constants/Paths';
@@ -14,5 +15,12 @@ export default function* coreSaga() {
yield take(ActionTypes.LOGOUT);
window.location.href = Paths.LOGIN;
const oidcConfig = yield select(selectors.selectOidcConfig);
if (oidcConfig && oidcConfig.endSessionUrl !== null) {
// Redirect the user to the IDP to log out.
window.location.href = oidcConfig.endSessionUrl;
} else {
window.location.href = Paths.LOGIN;
}
}
+11 -1
View File
@@ -1,13 +1,23 @@
import { call, put, take } from 'redux-saga/effects';
import { call, put, select, take } from 'redux-saga/effects';
import request from '../request';
import requests from '../requests';
import selectors from '../../../selectors';
import actions from '../../../actions';
import api from '../../../api';
import i18n from '../../../i18n';
import { removeAccessToken } from '../../../utils/access-token-storage';
export function* initializeCore() {
const currentConfig = yield select(selectors.selectConfig); // TODO: add boolean selector?
let config;
if (!currentConfig) {
({ item: config } = yield call(api.getConfig)); // TODO: handle error
yield put(actions.initializeCore.fetchConfig(config));
}
const {
user,
board,
+4 -3
View File
@@ -33,15 +33,16 @@ export function* handleLocationChange() {
switch (pathsMatch.pattern.path) {
case Paths.LOGIN:
case Paths.OIDC_CALLBACK:
yield call(goToRoot);
break;
return;
default:
}
const isCoreInitializing = yield select(selectors.selectIsCoreInitializing);
const isInitializing = yield select(selectors.selectIsInitializing);
if (isCoreInitializing) {
if (isInitializing) {
yield take(ActionTypes.CORE_INITIALIZE);
}
+1 -4
View File
@@ -4,8 +4,5 @@ import services from '../services';
import EntryActionTypes from '../../../constants/EntryActionTypes';
export default function* coreWatchers() {
yield all([
takeEvery(EntryActionTypes.CORE_INITIALIZE, () => services.initializeCore()),
takeEvery(EntryActionTypes.LOGOUT, () => services.logout()),
]);
yield all([takeEvery(EntryActionTypes.LOGOUT, () => services.logout())]);
}
+16 -16
View File
@@ -16,17 +16,17 @@ const createSocketEventsChannel = () =>
emit(entryActions.handleSocketReconnect());
};
const handleUserCreate = ({ item }) => {
const handleUserCreate = api.makeHandleUserCreate(({ item }) => {
emit(entryActions.handleUserCreate(item));
};
});
const handleUserUpdate = ({ item }) => {
const handleUserUpdate = api.makeHandleUserUpdate(({ item }) => {
emit(entryActions.handleUserUpdate(item));
};
});
const handleUserDelete = ({ item }) => {
const handleUserDelete = api.makeHandleUserDelete(({ item }) => {
emit(entryActions.handleUserDelete(item));
};
});
const handleProjectCreate = ({ item }) => {
emit(entryActions.handleProjectCreate(item));
@@ -40,13 +40,13 @@ const createSocketEventsChannel = () =>
emit(entryActions.handleProjectDelete(item));
};
const handleProjectManagerCreate = ({ item }) => {
const handleProjectManagerCreate = api.makeHandleProjectManagerCreate(({ item }) => {
emit(entryActions.handleProjectManagerCreate(item));
};
});
const handleProjectManagerDelete = ({ item }) => {
const handleProjectManagerDelete = api.makeHandleProjectManagerDelete(({ item }) => {
emit(entryActions.handleProjectManagerDelete(item));
};
});
const handleBoardCreate = ({ item, requestId }) => {
emit(entryActions.handleBoardCreate(item, requestId));
@@ -60,17 +60,17 @@ const createSocketEventsChannel = () =>
emit(entryActions.handleBoardDelete(item));
};
const handleBoardMembershipCreate = ({ item }) => {
const handleBoardMembershipCreate = api.makeHandleBoardMembershipCreate(({ item }) => {
emit(entryActions.handleBoardMembershipCreate(item));
};
});
const handleBoardMembershipUpdate = ({ item }) => {
const handleBoardMembershipUpdate = api.makeHandleBoardMembershipUpdate(({ item }) => {
emit(entryActions.handleBoardMembershipUpdate(item));
};
});
const handleBoardMembershipDelete = ({ item }) => {
const handleBoardMembershipDelete = api.makeHandleBoardMembershipDelete(({ item }) => {
emit(entryActions.handleBoardMembershipDelete(item));
};
});
const handleListCreate = ({ item }) => {
emit(entryActions.handleListCreate(item));
+3 -1
View File
@@ -7,7 +7,9 @@ import ActionTypes from '../../constants/ActionTypes';
export default function* loginSaga() {
const watcherTasks = yield all(watchers.map((watcher) => fork(watcher)));
yield take(ActionTypes.AUTHENTICATE__SUCCESS);
yield fork(services.initializeLogin);
yield take([ActionTypes.AUTHENTICATE__SUCCESS, ActionTypes.USING_OIDC_AUTHENTICATE__SUCCESS]);
yield cancel(watcherTasks);
yield call(services.goToRoot);
+76 -1
View File
@@ -1,8 +1,18 @@
import { call, put } from 'redux-saga/effects';
import { nanoid } from 'nanoid';
import { call, put, select } from 'redux-saga/effects';
import { replace } from '../../../lib/redux-router';
import selectors from '../../../selectors';
import actions from '../../../actions';
import api from '../../../api';
import { setAccessToken } from '../../../utils/access-token-storage';
import Paths from '../../../constants/Paths';
export function* initializeLogin() {
const { item: config } = yield call(api.getConfig); // TODO: handle error
yield put(actions.initializeLogin(config));
}
export function* authenticate(data) {
yield put(actions.authenticate(data));
@@ -19,11 +29,76 @@ export function* authenticate(data) {
yield put(actions.authenticate.success(accessToken));
}
export function* authenticateUsingOidc() {
const oidcConfig = yield select(selectors.selectOidcConfig);
const nonce = nanoid();
window.sessionStorage.setItem('oidc-nonce', nonce);
window.location.href = `${oidcConfig.authorizationUrl}&nonce=${encodeURIComponent(nonce)}`;
}
export function* authenticateUsingOidcCallback() {
// https://github.com/plankanban/planka/issues/511#issuecomment-1771385639
const params = new URLSearchParams(window.location.hash.substring(1) || window.location.search);
yield put(replace(Paths.LOGIN));
if (params.get('error') !== null) {
yield put(
actions.authenticateUsingOidc.failure(
new Error(
`OIDC Authorization error: ${params.get('error')}: ${params.get('error_description')}`,
),
),
);
return;
}
const nonce = window.sessionStorage.getItem('oidc-nonce');
if (nonce === null) {
yield put(
actions.authenticateUsingOidc.failure(
new Error('Unable to process OIDC response: no nonce issued'),
),
);
return;
}
const code = params.get('code');
if (code === null) {
yield put(
actions.authenticateUsingOidc.failure(new Error('Invalid OIDC response: no code parameter')),
);
return;
}
window.sessionStorage.removeItem('oidc-nonce');
if (code !== null) {
let accessToken;
try {
({ item: accessToken } = yield call(api.exchangeForAccessTokenUsingOidc, {
code,
nonce,
}));
} catch (error) {
yield put(actions.authenticateUsingOidc.failure(error));
return;
}
yield call(setAccessToken, accessToken);
yield put(actions.authenticateUsingOidc.success(accessToken));
}
}
export function* clearAuthenticateError() {
yield put(actions.clearAuthenticateError());
}
export default {
initializeLogin,
authenticate,
authenticateUsingOidc,
authenticateUsingOidcCallback,
clearAuthenticateError,
};
+14 -1
View File
@@ -1,7 +1,9 @@
import { call, put, select } from 'redux-saga/effects';
import { call, put, select, take } from 'redux-saga/effects';
import { push } from '../../../lib/redux-router';
import { authenticateUsingOidcCallback } from './login';
import selectors from '../../../selectors';
import ActionTypes from '../../../constants/ActionTypes';
import Paths from '../../../constants/Paths';
export function* goToLogin() {
@@ -27,6 +29,17 @@ export function* handleLocationChange() {
yield call(goToLogin);
break;
case Paths.OIDC_CALLBACK: {
const isInitializing = yield select(selectors.selectIsInitializing);
if (isInitializing) {
yield take(ActionTypes.LOGIN_INITIALIZE);
}
yield call(authenticateUsingOidcCallback);
break;
}
default:
}
}
+1
View File
@@ -8,6 +8,7 @@ export default function* loginWatchers() {
takeEvery(EntryActionTypes.AUTHENTICATE, ({ payload: { data } }) =>
services.authenticate(data),
),
takeEvery(EntryActionTypes.USING_OIDC_AUTHENTICATE, () => services.authenticateUsingOidc()),
takeEvery(EntryActionTypes.AUTHENTICATE_ERROR_CLEAR, () => services.clearAuthenticateError()),
]);
}
-3
View File
@@ -6,8 +6,6 @@ import Config from '../constants/Config';
export const selectAccessToken = ({ auth: { accessToken } }) => accessToken;
export const selectIsCoreInitializing = ({ core: { isInitializing } }) => isInitializing;
export const selectIsLogouting = ({ core: { isLogouting } }) => isLogouting;
const nextPosition = (items, index, excludedId) => {
@@ -115,7 +113,6 @@ export const selectNextTaskPosition = createSelector(
export default {
selectAccessToken,
selectIsCoreInitializing,
selectIsLogouting,
selectNextBoardPosition,
selectNextLabelPosition,
+2
View File
@@ -1,5 +1,6 @@
import router from './router';
import socket from './socket';
import root from './root';
import core from './core';
import modals from './modals';
import users from './users';
@@ -16,6 +17,7 @@ import attachments from './attachments';
export default {
...router,
...socket,
...root,
...core,
...modals,
...users,
+11
View File
@@ -0,0 +1,11 @@
export const selectIsInitializing = ({ root: { isInitializing } }) => isInitializing;
export const selectConfig = ({ root: { config } }) => config;
export const selectOidcConfig = (state) => selectConfig(state).oidc;
export default {
selectIsInitializing,
selectConfig,
selectOidcConfig,
};
+15 -1
View File
@@ -20,10 +20,12 @@ services:
- 3000:1337
environment:
- BASE_URL=http://localhost:3000
- TRUST_PROXY=0
- DATABASE_URL=postgresql://postgres@postgres/planka
- SECRET_KEY=notsecretkey
# - TRUST_PROXY=0
# - TOKEN_EXPIRES_IN=365 # In days
# related: https://github.com/knex/knex/issues/2354
# As knex does not pass query parameters from the connection string we
# have to use environment variables in order to pass the desired values, e.g.
@@ -31,6 +33,18 @@ services:
# Configure knex to accept SSL certificates
# - KNEX_REJECT_UNAUTHORIZED_SSL_CERTIFICATE=false
# - DEFAULT_ADMIN_EMAIL=demo@demo.demo # Do not remove if you want to prevent this user from being edited/deleted
# - DEFAULT_ADMIN_PASSWORD=demo
# - DEFAULT_ADMIN_NAME=Demo Demo
# - DEFAULT_ADMIN_USERNAME=demo
# - OIDC_ISSUER=
# - OIDC_CLIENT_ID=
# - OIDC_CLIENT_SECRET=
# - OIDC_SCOPES=openid email profile
# - OIDC_ADMIN_ROLES=admin
# - OIDC_ROLES_ATTRIBUTE=groups
depends_on:
- postgres
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "planka",
"version": "1.11.0",
"version": "1.14.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "planka",
"version": "1.11.0",
"version": "1.14.2",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "planka",
"version": "1.11.0",
"version": "1.14.2",
"private": true,
"homepage": "https://plankanban.github.io/planka",
"repository": {
+12
View File
@@ -17,6 +17,18 @@ SECRET_KEY=notsecretkey
# Configure knex to accept SSL certificates
# KNEX_REJECT_UNAUTHORIZED_SSL_CERTIFICATE=false
# DEFAULT_ADMIN_EMAIL=demo@demo.demo # Do not remove if you want to prevent this user from being edited/deleted
# DEFAULT_ADMIN_PASSWORD=demo
# DEFAULT_ADMIN_NAME=Demo Demo
# DEFAULT_ADMIN_USERNAME=demo
# OIDC_ISSUER=
# OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET=
# OIDC_SCOPES=openid email profile
# OIDC_ADMIN_ROLES=admin
# OIDC_ROLES_ATTRIBUTE=groups
## Do not edit this
TZ=UTC
@@ -10,6 +10,9 @@ const Errors = {
INVALID_PASSWORD: {
invalidPassword: 'Invalid password',
},
USE_SINGLE_SIGN_ON: {
useSingleSignOn: 'Use single sign-on',
},
};
const emailOrUsernameValidator = (value) =>
@@ -37,6 +40,9 @@ module.exports = {
invalidPassword: {
responseType: 'unauthorized',
},
useSingleSignOn: {
responseType: 'forbidden',
},
},
async fn(inputs) {
@@ -51,6 +57,10 @@ module.exports = {
throw Errors.INVALID_EMAIL_OR_USERNAME;
}
if (user.isSso) {
throw Errors.USE_SINGLE_SIGN_ON;
}
if (!bcrypt.compareSync(inputs.password, user.password)) {
sails.log.warn(`Invalid password! (IP: ${remoteAddress})`);
throw Errors.INVALID_PASSWORD;
@@ -6,7 +6,7 @@ module.exports = {
accessToken,
deletedAt: null,
}).set({
deletedAt: new Date().toUTCString(),
deletedAt: new Date().toISOString(),
});
return {
@@ -0,0 +1,71 @@
const { getRemoteAddress } = require('../../../utils/remoteAddress');
const Errors = {
INVALID_CODE_OR_NONCE: {
invalidCodeOrNonce: 'Invalid code or nonce',
},
EMAIL_ALREADY_IN_USE: {
emailAlreadyInUse: 'Email already in use',
},
USERNAME_ALREADY_IN_USE: {
usernameAlreadyInUse: 'Username already in use',
},
MISSING_VALUES: {
missingValues: 'Unable to retrieve required values (email, name)',
},
};
module.exports = {
inputs: {
code: {
type: 'string',
required: true,
},
nonce: {
type: 'string',
required: true,
},
},
exits: {
invalidCodeOrNonce: {
responseType: 'unauthorized',
},
emailAlreadyInUse: {
responseType: 'conflict',
},
usernameAlreadyInUse: {
responseType: 'conflict',
},
missingValues: {
responseType: 'unprocessableEntity',
},
},
async fn(inputs) {
const remoteAddress = getRemoteAddress(this.req);
const user = await sails.helpers.users
.getOrCreateOneUsingOidc(inputs.code, inputs.nonce)
.intercept('invalidCodeOrNonce', () => {
sails.log.warn(`Invalid code or nonce! (IP: ${remoteAddress})`);
return Errors.INVALID_CODE_OR_NONCE;
})
.intercept('emailAlreadyInUse', () => Errors.EMAIL_ALREADY_IN_USE)
.intercept('usernameAlreadyInUse', () => Errors.USERNAME_ALREADY_IN_USE)
.intercept('missingValues', () => Errors.MISSING_VALUES);
const accessToken = sails.helpers.utils.createToken(user.id);
await Session.create({
accessToken,
remoteAddress,
userId: user.id,
userAgent: this.req.headers['user-agent'],
});
return {
item: accessToken,
};
},
};
+22
View File
@@ -0,0 +1,22 @@
module.exports = {
fn() {
let oidc = null;
if (sails.hooks.oidc.isActive()) {
const oidcClient = sails.hooks.oidc.getClient();
oidc = {
authorizationUrl: oidcClient.authorizationUrl({
scope: sails.config.custom.oidcScopes,
response_mode: 'fragment',
}),
endSessionUrl: oidcClient.issuer.end_session_endpoint ? oidcClient.endSessionUrl({}) : null,
};
}
return {
item: {
oidc,
},
};
},
};
+10
View File
@@ -1,4 +1,7 @@
const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
},
USER_NOT_FOUND: {
userNotFound: 'User not found',
},
@@ -14,6 +17,9 @@ module.exports = {
},
exits: {
notEnoughRights: {
responseType: 'forbidden',
},
userNotFound: {
responseType: 'notFound',
},
@@ -26,6 +32,10 @@ module.exports = {
throw Errors.USER_NOT_FOUND;
}
if (user.email === sails.config.custom.defaultAdminEmail) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
user = await sails.helpers.users.deleteOne.with({
record: user,
request: this.req,
@@ -1,6 +1,9 @@
const bcrypt = require('bcrypt');
const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
},
USER_NOT_FOUND: {
userNotFound: 'User not found',
},
@@ -31,6 +34,9 @@ module.exports = {
},
exits: {
notEnoughRights: {
responseType: 'forbidden',
},
userNotFound: {
responseType: 'notFound',
},
@@ -59,6 +65,10 @@ module.exports = {
throw Errors.USER_NOT_FOUND;
}
if (user.email === sails.config.custom.defaultAdminEmail || user.isSso) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
if (
inputs.id === currentUser.id &&
!bcrypt.compareSync(inputs.currentPassword, user.password)
@@ -4,6 +4,9 @@ const zxcvbn = require('zxcvbn');
const { getRemoteAddress } = require('../../../utils/remoteAddress');
const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
},
USER_NOT_FOUND: {
userNotFound: 'User not found',
},
@@ -33,6 +36,9 @@ module.exports = {
},
exits: {
notEnoughRights: {
responseType: 'forbidden',
},
userNotFound: {
responseType: 'notFound',
},
@@ -58,6 +64,10 @@ module.exports = {
throw Errors.USER_NOT_FOUND;
}
if (user.email === sails.config.custom.defaultAdminEmail || user.isSso) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
if (
inputs.id === currentUser.id &&
!bcrypt.compareSync(inputs.currentPassword, user.password)

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