diff --git a/.github/workflows/build-and-push-docker-base-image.yml b/.github/workflows/build-and-push-docker-base-image.yml
index 427435d..2cb2dac 100644
--- a/.github/workflows/build-and-push-docker-base-image.yml
+++ b/.github/workflows/build-and-push-docker-base-image.yml
@@ -40,5 +40,5 @@ jobs:
build-args: ALPINE_VERSION=${{ env.ALPINE_VERSION }}
push: true
tags: |
- ghcr.io/plankanban/planka:base-latest
- ghcr.io/plankanban/planka:base-${{ env.ALPINE_VERSION }}
+ ghcr.io/${{ github.repository_owner }}/${{ github.repository }}:base-latest
+ ghcr.io/${{ github.repository_owner }}/${{ github.repository }}:base-${{ env.ALPINE_VERSION }}
diff --git a/.github/workflows/build-and-push-docker-image-dev.yml b/.github/workflows/build-and-push-docker-image-dev.yml
index 7b591f2..fa5b98d 100644
--- a/.github/workflows/build-and-push-docker-image-dev.yml
+++ b/.github/workflows/build-and-push-docker-image-dev.yml
@@ -11,7 +11,7 @@ on:
branches: [master]
env:
- REGISTRY_IMAGE: ghcr.io/plankanban/planka
+ REGISTRY_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.repository }}
jobs:
build:
diff --git a/.github/workflows/build-and-push-docker-image.yml b/.github/workflows/build-and-push-docker-image.yml
index 47bb737..51d2bde 100644
--- a/.github/workflows/build-and-push-docker-image.yml
+++ b/.github/workflows/build-and-push-docker-image.yml
@@ -31,12 +31,23 @@ jobs:
result-encoding: string
script: return context.payload.release.tag_name.replace('v', '')
+ - name: Generate docker image tags
+ id: metadata
+ uses: docker/metadata-action@v5
+ with:
+ images: |
+ name=ghcr.io/${{ github.repository_owner }}/${{ github.repository }}
+ tags: |
+ type=raw,value=${{ steps.set-version.outputs.result }}
+ type=raw,value=latest
+
- 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:latest
- ghcr.io/plankanban/planka:${{ steps.set-version.outputs.result }}
+ tags: ${{ steps.metadata.outputs.tags }}
+ labels: ${{ steps.metadata.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/Dockerfile b/Dockerfile
index b077541..31a5368 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,7 +6,7 @@ RUN apk -U upgrade \
WORKDIR /app
-COPY server/package.json server/package-lock.json .
+COPY server/package.json server/package-lock.json ./
RUN npm install npm@latest --global \
&& npm install pnpm --global \
@@ -17,7 +17,7 @@ FROM node:lts AS client
WORKDIR /app
-COPY client/package.json client/package-lock.json .
+COPY client/package.json client/package-lock.json ./
RUN npm install npm@latest --global \
&& npm install pnpm --global \
@@ -38,6 +38,7 @@ WORKDIR /app
COPY --chown=node:node start.sh .
COPY --chown=node:node server .
+COPY --chown=node:node healthcheck.js .
RUN mv .env.sample .env
@@ -52,4 +53,8 @@ VOLUME /app/private/attachments
EXPOSE 1337
-CMD ["./start.sh"]
+HEALTHCHECK --interval=10s --timeout=2s --start-period=15s \
+ CMD node ./healthcheck.js
+
+
+CMD [ "bash", "start.sh" ]
diff --git a/README.md b/README.md
index 9b273c3..5ff3bba 100644
--- a/README.md
+++ b/README.md
@@ -10,13 +10,14 @@
## Features
- Create projects, boards, lists, cards, labels and tasks
-- Add card members, track time, set a due date, add attachments, write comments
-- Markdown support in a card description and comment
+- Add card members, track time, set due dates, add attachments, write comments
+- Markdown support in card description and comments
- Filter by members and labels
-- Customize project background
+- Customize project backgrounds
- Real-time updates
-- User notifications
-- Internationalization
+- Internal notifications
+- Multiple interface languages
+- Single sign-on via OpenID Connect
## How to deploy Planka
@@ -44,3 +45,7 @@ See the [development section](https://docs.planka.cloud/docs/Development).
## License
Planka is [AGPL-3.0 licensed](https://github.com/plankanban/planka/blob/master/LICENSE).
+
+## Contributors
+
+[](https://github.com/plankanban/planka/graphs/contributors)
diff --git a/charts/planka/Chart.yaml b/charts/planka/Chart.yaml
index 1c32bf7..02158c7 100644
--- a/charts/planka/Chart.yaml
+++ b/charts/planka/Chart.yaml
@@ -15,13 +15,13 @@ 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.15
+version: 0.1.26
# 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.15.4"
+appVersion: "1.16.4"
dependencies:
- alias: postgresql
diff --git a/charts/planka/README.md b/charts/planka/README.md
index ed305b0..f9250e1 100644
--- a/charts/planka/README.md
+++ b/charts/planka/README.md
@@ -46,7 +46,7 @@ helm install planka . --set secretkey=$SECRETKEY \
--set admin_email="demo@demo.demo" \
--set admin_password="demo" \
--set admin_name="Demo Demo" \
---set admin_username="demo"
+--set admin_username="demo" \
--set ingress.enabled=true \
--set ingress.hosts[0].host=planka.example.dev \
@@ -55,7 +55,7 @@ helm install planka . --set secretkey=$SECRETKEY \
--set admin_email="demo@demo.demo" \
--set admin_password="demo" \
--set admin_name="Demo Demo" \
---set admin_username="demo"
+--set admin_username="demo" \
--set ingress.enabled=true \
--set ingress.hosts[0].host=planka.example.dev \
--set ingress.tls[0].secretName=planka-tls \
@@ -76,6 +76,7 @@ admin_name: "Demo Demo"
admin_username: "demo"
# Admin user
+# Ingress
ingress:
enabled: true
hosts:
diff --git a/charts/planka/templates/deployment.yaml b/charts/planka/templates/deployment.yaml
index 06d56b9..2ffe596 100644
--- a/charts/planka/templates/deployment.yaml
+++ b/charts/planka/templates/deployment.yaml
@@ -55,14 +55,24 @@ spec:
- mountPath: /app/private/attachments
subPath: attachments
name: planka
+ {{- if .Values.securityContext.readOnlyRootFilesystem }}
+ - mountPath: /app/logs
+ subPath: app-logs
+ name: emptydir
+ {{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
env:
+ {{- if not .Values.postgresql.enabled }}
+ - name: DATABASE_URL
+ value: {{ required "If the included postgresql deployment is disabled you need to define a Database URL in 'dburl'" .Values.dburl }}
+ {{- else }}
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: planka-postgresql-svcbind-custom-user
key: uri
+ {{- end }}
- name: BASE_URL
{{- if .Values.baseUrl }}
value: {{ .Values.baseUrl }}
@@ -134,3 +144,7 @@ spec:
{{- else }}
emptyDir: {}
{{- end }}
+ {{- if .Values.securityContext.readOnlyRootFilesystem }}
+ - name: emptydir
+ emptyDir: {}
+ {{- end }}
diff --git a/charts/planka/templates/hpa.yaml b/charts/planka/templates/hpa.yaml
index 09e4c39..257a09d 100644
--- a/charts/planka/templates/hpa.yaml
+++ b/charts/planka/templates/hpa.yaml
@@ -1,5 +1,5 @@
{{- if .Values.autoscaling.enabled }}
-apiVersion: autoscaling/v2beta1
+apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "planka.fullname" . }}
@@ -17,12 +17,16 @@ spec:
- type: Resource
resource:
name: cpu
- targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
+ target:
+ type: Utilization
+ averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
- targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
+ target:
+ type: Utilization
+ averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}
diff --git a/charts/planka/values.yaml b/charts/planka/values.yaml
index 04cc5a8..8adf94f 100644
--- a/charts/planka/values.yaml
+++ b/charts/planka/values.yaml
@@ -105,12 +105,14 @@ postgresql:
serviceBindings:
enabled: true
+## Set this if you disable the built-in postgresql deployment
+dburl:
+
## PVC-based data storage configuration
persistence:
enabled: false
# existingClaim: netbox-data
# storageClass: "-"
-
accessMode: ReadWriteOnce
size: 10Gi
diff --git a/client/.env b/client/.env
index 4d4ab73..1b48387 100644
--- a/client/.env
+++ b/client/.env
@@ -1 +1 @@
-REACT_APP_VERSION=1.15.4
+REACT_APP_VERSION=1.16.4
diff --git a/client/package-lock.json b/client/package-lock.json
index 62cebb9..b1e6907 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -17,6 +17,8 @@
"initials": "^3.1.2",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
+ "linkify-react": "^4.1.3",
+ "linkifyjs": "^4.1.3",
"lodash": "^4.17.21",
"nanoid": "^5.0.3",
"node-sass": "^9.0.0",
@@ -11911,6 +11913,20 @@
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
},
+ "node_modules/linkify-react": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/linkify-react/-/linkify-react-4.1.3.tgz",
+ "integrity": "sha512-rhI3zM/fxn5BfRPHfi4r9N7zgac4vOIxub1wHIWXLA5ENTMs+BGaIaFO1D1PhmxgwhIKmJz3H7uCP0Dg5JwSlA==",
+ "peerDependencies": {
+ "linkifyjs": "^4.0.0",
+ "react": ">= 15.0.0"
+ }
+ },
+ "node_modules/linkifyjs": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz",
+ "integrity": "sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg=="
+ },
"node_modules/loader-runner": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
diff --git a/client/package.json b/client/package.json
index 25cdce0..3f3c4e1 100755
--- a/client/package.json
+++ b/client/package.json
@@ -70,6 +70,8 @@
"initials": "^3.1.2",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
+ "linkify-react": "^4.1.3",
+ "linkifyjs": "^4.1.3",
"lodash": "^4.17.21",
"nanoid": "^5.0.3",
"node-sass": "^9.0.0",
diff --git a/client/src/actions/cards.js b/client/src/actions/cards.js
index 0969910..2bbe194 100644
--- a/client/src/actions/cards.js
+++ b/client/src/actions/cards.js
@@ -23,10 +23,14 @@ createCard.failure = (localId, error) => ({
},
});
-const handleCardCreate = (card) => ({
+const handleCardCreate = (card, cardMemberships, cardLabels, tasks, attachments) => ({
type: ActionTypes.CARD_CREATE_HANDLE,
payload: {
card,
+ cardMemberships,
+ cardLabels,
+ tasks,
+ attachments,
},
});
@@ -60,6 +64,34 @@ const handleCardUpdate = (card) => ({
},
});
+const duplicateCard = (id, card, taskIds) => ({
+ type: ActionTypes.CARD_DUPLICATE,
+ payload: {
+ id,
+ card,
+ taskIds,
+ },
+});
+
+duplicateCard.success = (localId, card, cardMemberships, cardLabels, tasks) => ({
+ type: ActionTypes.CARD_DUPLICATE__SUCCESS,
+ payload: {
+ localId,
+ card,
+ cardMemberships,
+ cardLabels,
+ tasks,
+ },
+});
+
+duplicateCard.failure = (id, error) => ({
+ type: ActionTypes.CARD_DUPLICATE__FAILURE,
+ payload: {
+ id,
+ error,
+ },
+});
+
const deleteCard = (id) => ({
type: ActionTypes.CARD_DELETE,
payload: {
@@ -94,6 +126,7 @@ export default {
handleCardCreate,
updateCard,
handleCardUpdate,
+ duplicateCard,
deleteCard,
handleCardDelete,
};
diff --git a/client/src/actions/core.js b/client/src/actions/core.js
index 2d58587..92f081a 100644
--- a/client/src/actions/core.js
+++ b/client/src/actions/core.js
@@ -47,9 +47,11 @@ initializeCore.fetchConfig = (config) => ({
},
});
-const logout = () => ({
+const logout = (invalidateAccessToken) => ({
type: ActionTypes.LOGOUT,
- payload: {},
+ payload: {
+ invalidateAccessToken,
+ },
});
logout.invalidateAccessToken = () => ({
diff --git a/client/src/api/cards.js b/client/src/api/cards.js
index 5240038..568ca8e 100755
--- a/client/src/api/cards.js
+++ b/client/src/api/cards.js
@@ -57,6 +57,12 @@ const updateCard = (id, data, headers) =>
item: transformCard(body.item),
}));
+const duplicateCard = (id, data, headers) =>
+ socket.post(`/cards/${id}/duplicate`, data, headers).then((body) => ({
+ ...body,
+ item: transformCard(body.item),
+ }));
+
const deleteCard = (id, headers) =>
socket.delete(`/cards/${id}`, undefined, headers).then((body) => ({
...body,
@@ -81,6 +87,7 @@ export default {
getCard,
updateCard,
deleteCard,
+ duplicateCard,
makeHandleCardCreate,
makeHandleCardUpdate,
makeHandleCardDelete,
diff --git a/client/src/components/BoardMembershipsStep/BoardMembershipsStep.module.scss b/client/src/components/BoardMembershipsStep/BoardMembershipsStep.module.scss
index deec2a3..6d50f19 100644
--- a/client/src/components/BoardMembershipsStep/BoardMembershipsStep.module.scss
+++ b/client/src/components/BoardMembershipsStep/BoardMembershipsStep.module.scss
@@ -4,7 +4,6 @@
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
- scrollbar-width: thin;
width: 100%;
&::-webkit-scrollbar {
diff --git a/client/src/components/Boards/Boards.module.scss b/client/src/components/Boards/Boards.module.scss
index 848dbe5..e38de2d 100644
--- a/client/src/components/Boards/Boards.module.scss
+++ b/client/src/components/Boards/Boards.module.scss
@@ -85,7 +85,6 @@
height: 56px;
overflow-x: auto;
overflow-y: hidden;
- scrollbar-width: thin;
&:hover {
height: 38px;
diff --git a/client/src/components/Card/ActionsStep.jsx b/client/src/components/Card/ActionsStep.jsx
index dbd349e..bd9db9e 100644
--- a/client/src/components/Card/ActionsStep.jsx
+++ b/client/src/components/Card/ActionsStep.jsx
@@ -36,6 +36,7 @@ const ActionsStep = React.memo(
onUpdate,
onMove,
onTransfer,
+ onDuplicate,
onDelete,
onUserAdd,
onUserRemove,
@@ -76,6 +77,11 @@ const ActionsStep = React.memo(
openStep(StepTypes.MOVE);
}, [openStep]);
+ const handleDuplicateClick = useCallback(() => {
+ onDuplicate();
+ onClose();
+ }, [onDuplicate, onClose]);
+
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
@@ -207,6 +213,11 @@ const ActionsStep = React.memo(
context: 'title',
})}
+
+ {t('action.duplicateCard', {
+ context: 'title',
+ })}
+
{t('action.deleteCard', {
context: 'title',
@@ -232,6 +243,7 @@ ActionsStep.propTypes = {
onUpdate: PropTypes.func.isRequired,
onMove: PropTypes.func.isRequired,
onTransfer: PropTypes.func.isRequired,
+ onDuplicate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,
diff --git a/client/src/components/Card/Card.jsx b/client/src/components/Card/Card.jsx
index 37521a0..4adddbc 100755
--- a/client/src/components/Card/Card.jsx
+++ b/client/src/components/Card/Card.jsx
@@ -41,6 +41,7 @@ const Card = React.memo(
onUpdate,
onMove,
onTransfer,
+ onDuplicate,
onDelete,
onUserAdd,
onUserRemove,
@@ -185,6 +186,7 @@ const Card = React.memo(
onUpdate={onUpdate}
onMove={onMove}
onTransfer={onTransfer}
+ onDuplicate={onDuplicate}
onDelete={onDelete}
onUserAdd={onUserAdd}
onUserRemove={onUserRemove}
@@ -238,6 +240,7 @@ Card.propTypes = {
onUpdate: PropTypes.func.isRequired,
onMove: PropTypes.func.isRequired,
onTransfer: PropTypes.func.isRequired,
+ onDuplicate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,
diff --git a/client/src/components/Card/Tasks.jsx b/client/src/components/Card/Tasks.jsx
index d76340f..c530474 100644
--- a/client/src/components/Card/Tasks.jsx
+++ b/client/src/components/Card/Tasks.jsx
@@ -4,6 +4,8 @@ import classNames from 'classnames';
import { Progress } from 'semantic-ui-react';
import { useToggle } from '../../lib/hooks';
+import Linkify from '../Linkify';
+
import styles from './Tasks.module.scss';
const Tasks = React.memo(({ items }) => {
@@ -48,7 +50,7 @@ const Tasks = React.memo(({ items }) => {
key={item.id}
className={classNames(styles.task, item.isCompleted && styles.taskCompleted)}
>
- {item.name}
+ {item.name}
))}
diff --git a/client/src/components/Card/Tasks.module.scss b/client/src/components/Card/Tasks.module.scss
index 508ac89..8f8a7ec 100644
--- a/client/src/components/Card/Tasks.module.scss
+++ b/client/src/components/Card/Tasks.module.scss
@@ -55,8 +55,10 @@
display: block;
font-size: 12px;
line-height: 14px;
+ overflow: hidden;
padding-bottom: 6px;
padding-left: 14px;
+ text-overflow: ellipsis;
&:before {
content: "–";
diff --git a/client/src/components/CardModal/AttachmentAddZone/AttachmentAddZone.jsx b/client/src/components/CardModal/AttachmentAddZone/AttachmentAddZone.jsx
index 3236d92..66a0e89 100644
--- a/client/src/components/CardModal/AttachmentAddZone/AttachmentAddZone.jsx
+++ b/client/src/components/CardModal/AttachmentAddZone/AttachmentAddZone.jsx
@@ -94,7 +94,7 @@ const AttachmentAddZone = React.memo(({ children, onCreate }) => {
return (
<>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
-
+
{isDragActive &&
{t('common.dropFileToUpload')}
}
{children}
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
diff --git a/client/src/components/CardModal/AttachmentAddZone/AttachmentAddZone.module.scss b/client/src/components/CardModal/AttachmentAddZone/AttachmentAddZone.module.scss
index b0489dd..782f487 100644
--- a/client/src/components/CardModal/AttachmentAddZone/AttachmentAddZone.module.scss
+++ b/client/src/components/CardModal/AttachmentAddZone/AttachmentAddZone.module.scss
@@ -12,8 +12,4 @@
width: 100%;
z-index: 2001;
}
-
- .wrapper {
- overflow: hidden;
- }
}
diff --git a/client/src/components/CardModal/CardModal.jsx b/client/src/components/CardModal/CardModal.jsx
index 76fa5aa..ff77aa9 100755
--- a/client/src/components/CardModal/CardModal.jsx
+++ b/client/src/components/CardModal/CardModal.jsx
@@ -55,6 +55,7 @@ const CardModal = React.memo(
onUpdate,
onMove,
onTransfer,
+ onDuplicate,
onDelete,
onUserAdd,
onUserRemove,
@@ -140,6 +141,11 @@ const CardModal = React.memo(
});
}, [isSubscribed, onUpdate]);
+ const handleDuplicateClick = useCallback(() => {
+ onDuplicate();
+ onClose();
+ }, [onDuplicate, onClose]);
+
const handleGalleryOpen = useCallback(() => {
isGalleryOpened.current = true;
}, []);
@@ -496,6 +502,10 @@ const CardModal = React.memo(
{t('action.move')}
+
+
+ {t('action.duplicate')}
+
- {name}
+ {name}
{isPersisted && canEdit && (
diff --git a/client/src/components/Header/NotificationsStep.module.scss b/client/src/components/Header/NotificationsStep.module.scss
index cdad65b..a86a0e9 100644
--- a/client/src/components/Header/NotificationsStep.module.scss
+++ b/client/src/components/Header/NotificationsStep.module.scss
@@ -49,7 +49,6 @@
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
- scrollbar-width: thin;
&::-webkit-scrollbar {
width: 5px;
diff --git a/client/src/components/LabelsStep/LabelsStep.module.scss b/client/src/components/LabelsStep/LabelsStep.module.scss
index c5ec023..1be274c 100644
--- a/client/src/components/LabelsStep/LabelsStep.module.scss
+++ b/client/src/components/LabelsStep/LabelsStep.module.scss
@@ -25,7 +25,6 @@
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
- scrollbar-width: thin;
&::-webkit-scrollbar {
width: 5px;
diff --git a/client/src/components/Linkify.jsx b/client/src/components/Linkify.jsx
new file mode 100644
index 0000000..71b01fd
--- /dev/null
+++ b/client/src/components/Linkify.jsx
@@ -0,0 +1,68 @@
+import React, { useCallback } from 'react';
+import PropTypes from 'prop-types';
+import LinkifyReact from 'linkify-react';
+
+import history from '../history';
+
+const Linkify = React.memo(({ children, linkStopPropagation, ...props }) => {
+ const handleLinkClick = useCallback(
+ (event) => {
+ if (linkStopPropagation) {
+ event.stopPropagation();
+ }
+
+ if (!event.target.getAttribute('target')) {
+ event.preventDefault();
+ history.push(event.target.href);
+ }
+ },
+ [linkStopPropagation],
+ );
+
+ const linkRenderer = useCallback(
+ ({ attributes: { href, ...linkProps }, content }) => {
+ let url;
+ try {
+ url = new URL(href, window.location);
+ } catch (error) {} // eslint-disable-line no-empty
+
+ const isSameSite = !!url && url.origin === window.location.origin;
+
+ return (
+
+ {isSameSite ? url.pathname : content}
+
+ );
+ },
+ [handleLinkClick],
+ );
+
+ return (
+
+ {children}
+
+ );
+});
+
+Linkify.propTypes = {
+ children: PropTypes.string.isRequired,
+ linkStopPropagation: PropTypes.bool,
+};
+
+Linkify.defaultProps = {
+ linkStopPropagation: false,
+};
+
+export default Linkify;
diff --git a/client/src/components/List/List.module.scss b/client/src/components/List/List.module.scss
index 7b47c89..b1a7096 100644
--- a/client/src/components/List/List.module.scss
+++ b/client/src/components/List/List.module.scss
@@ -43,7 +43,6 @@
max-height: calc(100vh - 268px);
overflow-x: hidden;
overflow-y: auto;
- scrollbar-width: thin;
width: 290px;
&:hover {
diff --git a/client/src/components/Login/Login.jsx b/client/src/components/Login/Login.jsx
index 84e6a9d..6f547ee 100755
--- a/client/src/components/Login/Login.jsx
+++ b/client/src/components/Login/Login.jsx
@@ -68,6 +68,7 @@ const Login = React.memo(
isSubmittingUsingOidc,
error,
withOidc,
+ isOidcEnforced,
onAuthenticate,
onAuthenticateUsingOidc,
onMessageDismiss,
@@ -107,8 +108,10 @@ const Login = React.memo(
}, [onAuthenticate, data]);
useEffect(() => {
- emailOrUsernameField.current.focus();
- }, []);
+ if (!isOidcEnforced) {
+ emailOrUsernameField.current.focus();
+ }
+ }, [isOidcEnforced]);
useEffect(() => {
if (wasSubmitting && !isSubmitting && error) {
@@ -159,51 +162,57 @@ const Login = React.memo(
onDismiss={onMessageDismiss}
/>
)}
-
-
+
+ )}
{withOidc && (
- {t('action.logInWithSSO')}
-
+ />
)}
@@ -242,6 +251,7 @@ Login.propTypes = {
isSubmittingUsingOidc: PropTypes.bool.isRequired,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
withOidc: PropTypes.bool.isRequired,
+ isOidcEnforced: PropTypes.bool.isRequired,
onAuthenticate: PropTypes.func.isRequired,
onAuthenticateUsingOidc: PropTypes.func.isRequired,
onMessageDismiss: PropTypes.func.isRequired,
diff --git a/client/src/components/Memberships/AddStep/AddStep.module.scss b/client/src/components/Memberships/AddStep/AddStep.module.scss
index 95be8d0..917142f 100644
--- a/client/src/components/Memberships/AddStep/AddStep.module.scss
+++ b/client/src/components/Memberships/AddStep/AddStep.module.scss
@@ -4,7 +4,6 @@
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
- scrollbar-width: thin;
&::-webkit-scrollbar {
width: 5px;
diff --git a/client/src/components/UserSettingsModal/AccountPane/AccountPane.jsx b/client/src/components/UserSettingsModal/AccountPane/AccountPane.jsx
index 7333cf5..eed1326 100644
--- a/client/src/components/UserSettingsModal/AccountPane/AccountPane.jsx
+++ b/client/src/components/UserSettingsModal/AccountPane/AccountPane.jsx
@@ -24,6 +24,7 @@ const AccountPane = React.memo(
organization,
language,
isLocked,
+ isUsernameLocked,
isAvatarUpdating,
usernameUpdateForm,
emailUpdateForm,
@@ -104,7 +105,7 @@ const AccountPane = React.memo(
value={language || 'auto'}
onChange={handleLanguageChange}
/>
- {!isLocked && (
+ {(!isLocked || !isUsernameLocked) && (
<>
@@ -113,56 +114,62 @@ const AccountPane = React.memo(
})}
-
-
-
- {t('action.editUsername', {
- context: 'title',
- })}
-
-
-
-
-
-
- {t('action.editEmail', {
- context: 'title',
- })}
-
-
-
-
-
-
- {t('action.editPassword', {
- context: 'title',
- })}
-
-
-
+ {!isUsernameLocked && (
+
+
+
+ {t('action.editUsername', {
+ context: 'title',
+ })}
+
+
+
+ )}
+ {!isLocked && (
+ <>
+
+
+
+ {t('action.editEmail', {
+ context: 'title',
+ })}
+
+
+
+
+
+
+ {t('action.editPassword', {
+ context: 'title',
+ })}
+
+
+
+ >
+ )}
>
)}
@@ -179,6 +186,7 @@ AccountPane.propTypes = {
organization: PropTypes.string,
language: PropTypes.string,
isLocked: PropTypes.bool.isRequired,
+ isUsernameLocked: PropTypes.bool.isRequired,
isAvatarUpdating: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */
usernameUpdateForm: PropTypes.object.isRequired,
diff --git a/client/src/components/UserSettingsModal/UserSettingsModal.jsx b/client/src/components/UserSettingsModal/UserSettingsModal.jsx
index fc8ac9c..8767ac4 100644
--- a/client/src/components/UserSettingsModal/UserSettingsModal.jsx
+++ b/client/src/components/UserSettingsModal/UserSettingsModal.jsx
@@ -18,6 +18,7 @@ const UserSettingsModal = React.memo(
language,
subscribeToOwnCards,
isLocked,
+ isUsernameLocked,
isAvatarUpdating,
usernameUpdateForm,
emailUpdateForm,
@@ -50,6 +51,7 @@ const UserSettingsModal = React.memo(
organization={organization}
language={language}
isLocked={isLocked}
+ isUsernameLocked={isUsernameLocked}
isAvatarUpdating={isAvatarUpdating}
usernameUpdateForm={usernameUpdateForm}
emailUpdateForm={emailUpdateForm}
@@ -108,6 +110,7 @@ UserSettingsModal.propTypes = {
language: PropTypes.string,
subscribeToOwnCards: PropTypes.bool.isRequired,
isLocked: PropTypes.bool.isRequired,
+ isUsernameLocked: PropTypes.bool.isRequired,
isAvatarUpdating: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */
usernameUpdateForm: PropTypes.object.isRequired,
diff --git a/client/src/components/UsersModal/Item/ActionsStep.jsx b/client/src/components/UsersModal/Item/ActionsStep.jsx
index f8a2959..7b0eaed 100644
--- a/client/src/components/UsersModal/Item/ActionsStep.jsx
+++ b/client/src/components/UsersModal/Item/ActionsStep.jsx
@@ -136,13 +136,15 @@ const ActionsStep = React.memo(
context: 'title',
})}
+ {!user.isUsernameLocked && (
+
+ {t('action.editUsername', {
+ context: 'title',
+ })}
+
+ )}
{!user.isLocked && (
<>
-
- {t('action.editUsername', {
- context: 'title',
- })}
-
{t('action.editEmail', {
context: 'title',
diff --git a/client/src/components/UsersModal/Item/Item.jsx b/client/src/components/UsersModal/Item/Item.jsx
index 80db356..ed68da3 100755
--- a/client/src/components/UsersModal/Item/Item.jsx
+++ b/client/src/components/UsersModal/Item/Item.jsx
@@ -19,6 +19,7 @@ const Item = React.memo(
isAdmin,
isLocked,
isRoleLocked,
+ isUsernameLocked,
isDeletionLocked,
emailUpdateForm,
passwordUpdateForm,
@@ -61,6 +62,7 @@ const Item = React.memo(
phone,
isAdmin,
isLocked,
+ isUsernameLocked,
isDeletionLocked,
emailUpdateForm,
passwordUpdateForm,
@@ -95,6 +97,7 @@ Item.propTypes = {
isAdmin: PropTypes.bool.isRequired,
isLocked: PropTypes.bool.isRequired,
isRoleLocked: PropTypes.bool.isRequired,
+ isUsernameLocked: PropTypes.bool.isRequired,
isDeletionLocked: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */
emailUpdateForm: PropTypes.object.isRequired,
diff --git a/client/src/components/UsersModal/UsersModal.jsx b/client/src/components/UsersModal/UsersModal.jsx
index 0b55778..68f0f82 100755
--- a/client/src/components/UsersModal/UsersModal.jsx
+++ b/client/src/components/UsersModal/UsersModal.jsx
@@ -10,6 +10,7 @@ import Item from './Item';
const UsersModal = React.memo(
({
items,
+ canAdd,
onUpdate,
onUsernameUpdate,
onUsernameUpdateMessageDismiss,
@@ -112,6 +113,7 @@ const UsersModal = React.memo(
isAdmin={item.isAdmin}
isLocked={item.isLocked}
isRoleLocked={item.isRoleLocked}
+ isUsernameLocked={item.isUsernameLocked}
isDeletionLocked={item.isDeletionLocked}
emailUpdateForm={item.emailUpdateForm}
passwordUpdateForm={item.passwordUpdateForm}
@@ -129,11 +131,13 @@ const UsersModal = React.memo(
-
-
-
-
-
+ {canAdd && (
+
+
+
+
+
+ )}
);
},
@@ -141,6 +145,7 @@ const UsersModal = React.memo(
UsersModal.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
+ canAdd: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onUsernameUpdate: PropTypes.func.isRequired,
onUsernameUpdateMessageDismiss: PropTypes.func.isRequired,
diff --git a/client/src/constants/ActionTypes.js b/client/src/constants/ActionTypes.js
index 02483d9..d5724e1 100644
--- a/client/src/constants/ActionTypes.js
+++ b/client/src/constants/ActionTypes.js
@@ -194,6 +194,9 @@ export default {
CARD_TRANSFER: 'CARD_TRANSFER',
CARD_TRANSFER__SUCCESS: 'CARD_TRANSFER__SUCCESS',
CARD_TRANSFER__FAILURE: 'CARD_TRANSFER__FAILURE',
+ CARD_DUPLICATE: 'CARD_DUPLICATE',
+ CARD_DUPLICATE__SUCCESS: 'CARD_DUPLICATE__SUCCESS',
+ CARD_DUPLICATE__FAILURE: 'CARD_DUPLICATE__FAILURE',
CARD_DELETE: 'CARD_DELETE',
CARD_DELETE__SUCCESS: 'CARD_DELETE__SUCCESS',
CARD_DELETE__FAILURE: 'CARD_DELETE__FAILURE',
diff --git a/client/src/constants/EntryActionTypes.js b/client/src/constants/EntryActionTypes.js
index 5a66c0f..9cd0694 100755
--- a/client/src/constants/EntryActionTypes.js
+++ b/client/src/constants/EntryActionTypes.js
@@ -132,6 +132,8 @@ export default {
CURRENT_CARD_MOVE: `${PREFIX}/CURRENT_CARD_MOVE`,
CARD_TRANSFER: `${PREFIX}/CARD_TRANSFER`,
CURRENT_CARD_TRANSFER: `${PREFIX}/CURRENT_CARD_TRANSFER`,
+ CARD_DUPLICATE: `${PREFIX}/CARD_DUPLICATE`,
+ CURRENT_CARD_DUPLICATE: `${PREFIX}/CURRENT_CARD_DUPLICATE`,
CARD_DELETE: `${PREFIX}/CARD_DELETE`,
CURRENT_CARD_DELETE: `${PREFIX}/CURRENT_CARD_DELETE`,
CARD_DELETE_HANDLE: `${PREFIX}/CARD_DELETE_HANDLE`,
diff --git a/client/src/containers/CardContainer.js b/client/src/containers/CardContainer.js
index 127d443..e174564 100755
--- a/client/src/containers/CardContainer.js
+++ b/client/src/containers/CardContainer.js
@@ -62,6 +62,7 @@ const mapDispatchToProps = (dispatch, { id }) =>
onUpdate: (data) => entryActions.updateCard(id, data),
onMove: (listId, index) => entryActions.moveCard(id, listId, index),
onTransfer: (boardId, listId) => entryActions.transferCard(id, boardId, listId),
+ onDuplicate: () => entryActions.duplicateCard(id),
onDelete: () => entryActions.deleteCard(id),
onUserAdd: (userId) => entryActions.addUserToCard(userId, id),
onUserRemove: (userId) => entryActions.removeUserFromCard(userId, id),
diff --git a/client/src/containers/CardModalContainer.js b/client/src/containers/CardModalContainer.js
index ceae984..a8df6ea 100755
--- a/client/src/containers/CardModalContainer.js
+++ b/client/src/containers/CardModalContainer.js
@@ -78,6 +78,7 @@ const mapDispatchToProps = (dispatch) =>
onUpdate: entryActions.updateCurrentCard,
onMove: entryActions.moveCurrentCard,
onTransfer: entryActions.transferCurrentCard,
+ onDuplicate: entryActions.duplicateCurrentCard,
onDelete: entryActions.deleteCurrentCard,
onUserAdd: entryActions.addUserToCurrentCard,
onUserRemove: entryActions.removeUserFromCurrentCard,
diff --git a/client/src/containers/LoginContainer.js b/client/src/containers/LoginContainer.js
index fc7ec21..1a16788 100755
--- a/client/src/containers/LoginContainer.js
+++ b/client/src/containers/LoginContainer.js
@@ -20,6 +20,7 @@ const mapStateToProps = (state) => {
isSubmittingUsingOidc,
error,
withOidc: !!oidcConfig,
+ isOidcEnforced: !!oidcConfig && oidcConfig.isEnforced,
};
};
diff --git a/client/src/containers/UserSettingsModalContainer.js b/client/src/containers/UserSettingsModalContainer.js
index 620632e..8c3b1c3 100644
--- a/client/src/containers/UserSettingsModalContainer.js
+++ b/client/src/containers/UserSettingsModalContainer.js
@@ -16,6 +16,7 @@ const mapStateToProps = (state) => {
language,
subscribeToOwnCards,
isLocked,
+ isUsernameLocked,
isAvatarUpdating,
emailUpdateForm,
passwordUpdateForm,
@@ -32,6 +33,7 @@ const mapStateToProps = (state) => {
language,
subscribeToOwnCards,
isLocked,
+ isUsernameLocked,
isAvatarUpdating,
emailUpdateForm,
passwordUpdateForm,
diff --git a/client/src/containers/UsersModalContainer.js b/client/src/containers/UsersModalContainer.js
index 53503f5..0e29421 100755
--- a/client/src/containers/UsersModalContainer.js
+++ b/client/src/containers/UsersModalContainer.js
@@ -6,10 +6,12 @@ import entryActions from '../entry-actions';
import UsersModal from '../components/UsersModal';
const mapStateToProps = (state) => {
+ const oidcConfig = selectors.selectOidcConfig(state);
const users = selectors.selectUsersExceptCurrent(state);
return {
items: users,
+ canAdd: !oidcConfig || !oidcConfig.isEnforced,
};
};
diff --git a/client/src/entry-actions/cards.js b/client/src/entry-actions/cards.js
index d73b247..d3dd849 100755
--- a/client/src/entry-actions/cards.js
+++ b/client/src/entry-actions/cards.js
@@ -38,7 +38,7 @@ const handleCardUpdate = (card) => ({
},
});
-const moveCard = (id, listId, index = 0) => ({
+const moveCard = (id, listId, index) => ({
type: EntryActionTypes.CARD_MOVE,
payload: {
id,
@@ -47,7 +47,7 @@ const moveCard = (id, listId, index = 0) => ({
},
});
-const moveCurrentCard = (listId, index = 0) => ({
+const moveCurrentCard = (listId, index) => ({
type: EntryActionTypes.CURRENT_CARD_MOVE,
payload: {
listId,
@@ -55,7 +55,7 @@ const moveCurrentCard = (listId, index = 0) => ({
},
});
-const transferCard = (id, boardId, listId, index = 0) => ({
+const transferCard = (id, boardId, listId, index) => ({
type: EntryActionTypes.CARD_TRANSFER,
payload: {
id,
@@ -65,7 +65,7 @@ const transferCard = (id, boardId, listId, index = 0) => ({
},
});
-const transferCurrentCard = (boardId, listId, index = 0) => ({
+const transferCurrentCard = (boardId, listId, index) => ({
type: EntryActionTypes.CURRENT_CARD_TRANSFER,
payload: {
boardId,
@@ -74,6 +74,18 @@ const transferCurrentCard = (boardId, listId, index = 0) => ({
},
});
+const duplicateCard = (id) => ({
+ type: EntryActionTypes.CARD_DUPLICATE,
+ payload: {
+ id,
+ },
+});
+
+const duplicateCurrentCard = () => ({
+ type: EntryActionTypes.CURRENT_CARD_DUPLICATE,
+ payload: {},
+});
+
const deleteCard = (id) => ({
type: EntryActionTypes.CARD_DELETE,
payload: {
@@ -103,6 +115,8 @@ export default {
moveCurrentCard,
transferCard,
transferCurrentCard,
+ duplicateCard,
+ duplicateCurrentCard,
deleteCard,
deleteCurrentCard,
handleCardDelete,
diff --git a/client/src/entry-actions/core.js b/client/src/entry-actions/core.js
index c1e03ff..b5a3002 100644
--- a/client/src/entry-actions/core.js
+++ b/client/src/entry-actions/core.js
@@ -1,8 +1,10 @@
import EntryActionTypes from '../constants/EntryActionTypes';
-const logout = () => ({
+const logout = (invalidateAccessToken) => ({
type: EntryActionTypes.LOGOUT,
- payload: {},
+ payload: {
+ invalidateAccessToken,
+ },
});
export default {
diff --git a/client/src/locales/cs/core.js b/client/src/locales/cs/core.js
index 04dfc8d..4001513 100644
--- a/client/src/locales/cs/core.js
+++ b/client/src/locales/cs/core.js
@@ -39,9 +39,9 @@ export default {
areYouSureYouWantToLeaveBoard: 'Opravdu chcete opustit tuto tabuli?',
areYouSureYouWantToLeaveProject: 'Opravdu chcete opustit projekt?',
areYouSureYouWantToRemoveThisManagerFromProject:
- 'Are you sure you want to remove this manager from the project?',
+ 'Jste si jisti, že chcete tohoto správce z projektu odebrat?',
areYouSureYouWantToRemoveThisMemberFromBoard:
- 'Opravdu chcete odstranit tohoto člena z tabule?',
+ 'Jste si jisti, že chcete tohoto člena odebrat z tabule?',
attachment: 'Příloha',
attachments: 'Přílohy',
authentication: 'Ověření',
@@ -55,6 +55,7 @@ export default {
cardNotFound_title: 'Karta nenalezena',
cardOrActionAreDeleted: 'Karta nebo akce je smazána.',
color: 'Barva',
+ copy_inline: 'copy',
createBoard_title: 'Vytvořit tabuli',
createLabel_title: 'Vytvořit štítek',
createNewOneOrSelectExistingOne: 'Vytvořit nový nebo vyberte již existující.',
@@ -130,7 +131,7 @@ export default {
phone: 'Telefon',
preferences: 'Volby',
pressPasteShortcutToAddAttachmentFromClipboard:
- 'Tip: dejte Ctrl-V (Cmd-V na Mac) pro vložení přílohy ze schránky.',
+ 'Tip: stisknutím Ctrl-V (Cmd-V na Mac) přidáte přílohu ze schránky.',
project: 'Projekt',
projectNotFound_title: 'Projekt nenalezen',
removeManager_title: 'Odstranit vedoucího',
@@ -199,6 +200,8 @@ export default {
deleteTask: 'Smazat úkol',
deleteTask_title: 'Smazat úkol',
deleteUser: 'Smazat uživatele',
+ duplicate: 'Duplikovat',
+ duplicateCard_title: 'Duplikovat kartu',
edit: 'Upravit',
editDueDate_title: 'Upravit termín do',
editDescription_title: 'Upravit popis',
@@ -226,13 +229,13 @@ export default {
removeMember: 'Odstranit člena',
save: 'Uložit',
showAllAttachments: 'Zozbrazit všechny přílohy ({{hidden}} skryté)',
- showFewerAttachments: 'Zobrazit méně příloh',
showDetails: 'Zobrazit detaily',
+ showFewerAttachments: 'Zobrazit méně příloh',
start: 'Start',
stop: 'Stop',
subscribe: 'Odebírat',
unsubscribe: 'Neodebírat',
- uploadNewAvatar: 'Nahrát nového avatara',
+ uploadNewAvatar: 'Nahrát nový avatar',
uploadNewImage: 'Nahrát nový obrázek',
},
},
diff --git a/client/src/locales/en/core.js b/client/src/locales/en/core.js
index 83b0116..41e4494 100644
--- a/client/src/locales/en/core.js
+++ b/client/src/locales/en/core.js
@@ -51,6 +51,7 @@ export default {
cardNotFound_title: 'Card Not Found',
cardOrActionAreDeleted: 'Card or action are deleted.',
color: 'Color',
+ copy_inline: 'copy',
createBoard_title: 'Create Board',
createLabel_title: 'Create Label',
createNewOneOrSelectExistingOne: 'Create a new one or select an existing one.',
@@ -196,6 +197,8 @@ export default {
deleteTask: 'Delete task',
deleteTask_title: 'Delete Task',
deleteUser: 'Delete user',
+ duplicate: 'Duplicate',
+ duplicateCard_title: 'Duplicate Card',
edit: 'Edit',
editDueDate_title: 'Edit Due Date',
editDescription_title: 'Edit Description',
diff --git a/client/src/locales/fr/core.js b/client/src/locales/fr/core.js
index e20e291..1b9296f 100644
--- a/client/src/locales/fr/core.js
+++ b/client/src/locales/fr/core.js
@@ -169,6 +169,7 @@ export default {
deleteTask: 'Supprimer la tâche',
deleteTask_title: 'Supprimer la tâche',
deleteUser: "Supprimer l'utilisateur",
+ duplicate: 'Dupliquer',
edit: 'Modifier',
editDueDate_title: "Modifier la date d'échéance",
editDescription_title: 'Éditer la description',
diff --git a/client/src/locales/id/core.js b/client/src/locales/id/core.js
new file mode 100644
index 0000000..b2de922
--- /dev/null
+++ b/client/src/locales/id/core.js
@@ -0,0 +1,241 @@
+import dateFns from 'date-fns/locale/id';
+
+export default {
+ dateFns,
+
+ format: {
+ date: 'dd MMM yyyy',
+ time: 'p',
+ dateTime: '$t(format:date) $t(format:time)',
+ longDate: 'd MMM',
+ longDateTime: "d MMMM 'pada' p",
+ fullDate: 'd MMM, y',
+ fullDateTime: "d MMM, y 'pada' p",
+ },
+
+ translation: {
+ common: {
+ aboutPlanka: 'Tentang Planka',
+ account: 'Akun',
+ actions: 'Tindakan',
+ addAttachment_title: 'Tambah Lampiran',
+ addComment: 'Tambahkan komentar',
+ addManager_title: 'Tambahkan Manager',
+ addMember_title: 'Tambahkan Anggota',
+ addUser_title: 'Tambahkan Pengguna',
+ administrator: 'Administrator',
+ all: 'Semua',
+ allChangesWillBeAutomaticallySavedAfterConnectionRestored:
+ 'Semua perubahan akan disimpan setelah koneksi pulih.',
+ areYouSureYouWantToDeleteThisAttachment: 'Apakah anda ingin menghapus lampiran ini?',
+ areYouSureYouWantToDeleteThisBoard: 'Apakah anda ingin menghapus papan ini?',
+ areYouSureYouWantToDeleteThisCard: 'Apakah anda ingin menghapus kartu ini?',
+ areYouSureYouWantToDeleteThisComment: 'Apakah anda ingin menghapus komentar ini?',
+ areYouSureYouWantToDeleteThisLabel: 'Apakah anda ingin menghapus label ini?',
+ areYouSureYouWantToDeleteThisList: 'Apakah anda ingin menghapus daftar ini?',
+ areYouSureYouWantToDeleteThisProject: 'Apakah anda ingin menghapus proyek ini?',
+ areYouSureYouWantToDeleteThisTask: 'Apakah anda ingin menghapus tugas ini?',
+ areYouSureYouWantToDeleteThisUser: 'Apakah anda ingin menghapus pengguna ini?',
+ areYouSureYouWantToLeaveBoard: 'Apakah anda ingin keluar dari papan ini?',
+ areYouSureYouWantToLeaveProject: 'Apakah anda ingin keluar dari proyek ini?',
+ areYouSureYouWantToRemoveThisManagerFromProject:
+ 'Apakah anda ingin menghapus manajer ini dari papan ini?',
+ areYouSureYouWantToRemoveThisMemberFromBoard:
+ 'Apakah anda ingin menghapus anggota ini dari papan ini?',
+ attachment: 'Lampiran',
+ attachments: 'Lampiran-lampiran',
+ authentication: 'Autentikasi',
+ background: 'Latar belakang',
+ board: 'Papan',
+ boardNotFound_title: 'Papan Tidak Ditemukan',
+ canComment: 'Bisa berkomentar',
+ canEditContentOfBoard: 'Bisa mengubah isi papan.',
+ canOnlyViewBoard: 'Hanya dapat menglihat isi papan.',
+ cardActions_title: 'Aksi Kartu',
+ cardNotFound_title: 'Kartu Tidak Ditemukan',
+ cardOrActionAreDeleted: 'Kartu atau aksi telah dihapus.',
+ color: 'Warna',
+ createBoard_title: 'Buat Papan',
+ createLabel_title: 'Buat Label',
+ createNewOneOrSelectExistingOne: 'Create a new one or select an existing one.',
+ createProject_title: 'Buat Proyek',
+ createTextFile_title: 'Buat Berkas Teks',
+ currentPassword: 'Kata sandi sekarang',
+ dangerZone_title: 'Zona Berbahaya',
+ date: 'Tanggal',
+ dueDate_title: 'Tenggat Waktu',
+ deleteAttachment_title: 'Hapus Lampiran',
+ deleteBoard_title: 'Hapus Papan',
+ deleteCard_title: 'Hapus Kartu',
+ deleteComment_title: 'Hapus Komentar',
+ deleteLabel_title: 'Hapus Label',
+ deleteList_title: 'Hapus Daftar',
+ deleteProject_title: 'Hapus Proyek',
+ deleteTask_title: 'Hapus Tugas',
+ deleteUser_title: 'Hapus Pengguna',
+ description: 'Deskripsi',
+ detectAutomatically: 'Deteksi otomatis',
+ dropFileToUpload: 'Tarik berkas untuk menggungah',
+ editor: 'Pengubah',
+ editAttachment_title: 'Ubah Lampiran',
+ editAvatar_title: 'Ubah Avatar',
+ editBoard_title: 'Ubah Papan',
+ editDueDate_title: 'Ubah Tenggat Waktu',
+ editEmail_title: 'Ubah E-mail',
+ editInformation_title: 'Ubah Informasi',
+ editLabel_title: 'Ubah Label',
+ editPassword_title: 'Ubah Kata Sandi',
+ editPermissions_title: 'Ubah Izin',
+ editStopwatch_title: 'Ubah Stopwatch',
+ editUsername_title: 'Ubah Username',
+ email: 'E-mail',
+ emailAlreadyInUse: 'E-mail telah digunakan',
+ enterCardTitle: 'Masukkan judul kartu... [Ctrl+Enter] untuk membuka otomatis.',
+ enterDescription: 'Masukkan deskripsi...',
+ enterFilename: 'Masukkan nama berkas...',
+ enterListTitle: 'Masukkan judul daftar...',
+ enterProjectTitle: 'Masukkan judul proyek',
+ enterTaskDescription: 'Masukkan deskripsi tugas...',
+ filterByLabels_title: 'Saring berdasarkan Label',
+ filterByMembers_title: 'Saring berdasarkan Anggota',
+ fromComputer_title: 'Dari Komputer',
+ fromTrello: 'Dari Trello',
+ general: 'Umum',
+ hours: 'Jam',
+ importBoard_title: 'Impor Papan',
+ invalidCurrentPassword: 'Kata sandi saat ini tidak valid',
+ labels: 'Label',
+ language: 'Bahasa',
+ leaveBoard_title: 'Keluar dari Papan',
+ leaveProject_title: 'Keluar dari Proyek',
+ list: 'Daftar',
+ listActions_title: 'Aksi Daftar',
+ managers: 'Manager',
+ members: 'Anggota',
+ minutes: 'Menit',
+ moveCard_title: 'Pindahkan Kartu',
+ name: 'Nama',
+ newEmail: 'E-mail baru',
+ newPassword: 'Kata sandi baru',
+ newUsername: 'Username baru',
+ noConnectionToServer: 'Tidak ada koneksi ke server',
+ noBoards: 'Tidak ada papan',
+ noLists: 'Tidak ada daftar',
+ noProjects: 'Tidak ada projek',
+ notifications: 'Notifikasi',
+ noUnreadNotifications: 'Tiada notifikasi yang belum dibaca.',
+ openBoard_title: 'Buka Papan',
+ optional_inline: 'opsional',
+ organization: 'Organisasi',
+ phone: 'Ponsel',
+ preferences: 'Preferensi',
+ pressPasteShortcutToAddAttachmentFromClipboard:
+ 'Tip: tekan Ctrl-V (Cmd-V di Mac) untuk menambahkan lampiran dari papan klip.',
+ project: 'Proyek',
+ projectNotFound_title: 'Proyek Tidak Ditemukan',
+ removeManager_title: 'Hapus Manager',
+ removeMember_title: 'Hapus Anggota',
+ searchLabels: 'Cari label...',
+ searchMembers: 'Cari anggota...',
+ searchUsers: 'Cari pengguna...',
+ seconds: 'Detik',
+ selectBoard: 'Pilih papan',
+ selectList: 'Pilih daftar',
+ selectPermissions_title: 'Pilih Izin',
+ selectProject: 'Pilih proyek',
+ settings: 'Setelan',
+ stopwatch: 'Stopwatch',
+ subscribeToMyOwnCardsByDefault: 'Berlangganan kartu saya sendiri secara default',
+ taskActions_title: 'Aksi Tugas',
+ tasks: 'Tugas',
+ thereIsNoPreviewAvailableForThisAttachment:
+ 'Tidak ada pratinjau yang tersedia untuk lampiran ini.',
+ time: 'Waktu',
+ title: 'Judul',
+ userActions_title: 'Aksi Pengguna',
+ userAddedThisCardToList: '<0>{{user}}0><1> menambahkan kartu ini ke {{list}}1>',
+ userLeftNewCommentToCard: '{{user}} mengomentari «{{comment}}» di <2>{{card}}2>',
+ userMovedCardFromListToList:
+ '{{user}} memindahkan <2>{{card}}2> dari {{fromList}} ke {{toList}}',
+ userMovedThisCardFromListToList:
+ '<0>{{user}}0><1> memindahkan kartu ini dari {{fromList}} ke {{toList}}1>',
+ username: 'Username',
+ usernameAlreadyInUse: 'Username telah digunakan',
+ users: 'Pengguna',
+ version: 'Versi',
+ viewer: 'Penglihat',
+ writeComment: 'Tuliskan komentar...',
+ },
+
+ action: {
+ addAnotherCard: 'Tambahkan kartu lain',
+ addAnotherList: 'Tambahkan daftar lain',
+ addAnotherTask: 'Tambahkan aksi lain',
+ addCard: 'Tambah kartu',
+ addCard_title: 'Tambah Kartu',
+ addComment: 'Tambah komentar',
+ addList: 'Tambah daftar',
+ addMember: 'Tambah anggota',
+ addMoreDetailedDescription: 'Tambahkan deskripsi yang lebih detail',
+ addTask: 'Tambah tugas',
+ addToCard: 'Tambahkan ke kartu',
+ addUser: 'Tambah pengguna',
+ createBoard: 'Tambah papan',
+ createFile: 'Tambah berkas',
+ createLabel: 'Tambah label',
+ createNewLabel: 'Tambah label baru',
+ createProject: 'Tambah proyek',
+ delete: 'Hapus',
+ deleteAttachment: 'Hapus lampiran',
+ deleteAvatar: 'Hapus avatar',
+ deleteBoard: 'Hapus papan',
+ deleteCard: 'Hapus kartu',
+ deleteCard_title: 'Hapus Kartu',
+ deleteComment: 'Hapus komentar',
+ deleteImage: 'Hapus gambar',
+ deleteLabel: 'Hapus labek',
+ deleteList: 'Hapus daftar',
+ deleteList_title: 'Hapus Daftar',
+ deleteProject: 'Hapus proyek',
+ deleteProject_title: 'Hapus Proyek',
+ deleteTask: 'Hapus tugas',
+ deleteTask_title: 'Hapus Tugas',
+ deleteUser: 'Hapus pengguna',
+ edit: 'Ubah',
+ editDueDate_title: 'Ubah Tenggat Waktu',
+ editDescription_title: 'Ubdah Deskripsi',
+ editEmail_title: 'Ubah E-mail',
+ editInformation_title: 'Ubah Informasi',
+ editPassword_title: 'Ubah Kata Sandi',
+ editPermissions: 'Ubah izin',
+ editStopwatch_title: 'Ubah Stopwatch',
+ editTitle_title: 'Ubah Judul',
+ editUsername_title: 'Ubah Username',
+ hideDetails: 'Sembunyikan detail',
+ import: 'Impor',
+ leaveBoard: 'Keluar dari papan',
+ leaveProject: 'Keluar dari proyek',
+ logOut_title: 'Keluar',
+ makeCover_title: 'Buat Cover',
+ move: 'Pindah',
+ moveCard_title: 'Pindahkan Kartu',
+ remove: 'Hapus',
+ removeBackground: 'Hapus latar belakang',
+ removeCover_title: 'Hapus Cover',
+ removeFromBoard: 'Hapus dari papan',
+ removeFromProject: 'Hapus dari proyek',
+ removeManager: 'Hapus manager',
+ removeMember: 'Hapus papan',
+ save: 'Simpan',
+ showAllAttachments: 'Tampilkan semua lampiran ({{hidden}} tersembunyi)',
+ showDetails: 'Tampilkan detail',
+ showFewerAttachments: 'Tampilkan lampiran lebih sedikit',
+ start: 'Mulai',
+ stop: 'Berhenti',
+ subscribe: 'Berlanggan',
+ unsubscribe: 'Berhenti berlangganan',
+ uploadNewAvatar: 'Unggah avatar baru',
+ uploadNewImage: 'Unggah gambar baru',
+ },
+ },
+};
diff --git a/client/src/locales/id/index.js b/client/src/locales/id/index.js
new file mode 100644
index 0000000..42801f3
--- /dev/null
+++ b/client/src/locales/id/index.js
@@ -0,0 +1,8 @@
+import login from './login';
+
+export default {
+ language: 'id',
+ country: 'id',
+ name: 'Bahasa Indonesia',
+ embeddedLocale: login,
+};
diff --git a/client/src/locales/id/login.js b/client/src/locales/id/login.js
new file mode 100644
index 0000000..2660d45
--- /dev/null
+++ b/client/src/locales/id/login.js
@@ -0,0 +1,22 @@
+export default {
+ translation: {
+ common: {
+ emailOrUsername: 'E-mail atau username',
+ invalidEmailOrUsername: 'E-mail atau username salah',
+ invalidPassword: 'Kata sandi salah',
+ logInToPlanka: 'Masuk ke Planka',
+ noInternetConnection: 'Tidak ada koneksi internet',
+ pageNotFound_title: 'Halaman Tidak Ditemukan',
+ password: 'Kata sandi',
+ projectManagement: 'Manajemen projek',
+ serverConnectionFailed: 'Koneksi server gagal',
+ unknownError: 'Kesalahan tidak diketahui, coba lagi nanti.',
+ useSingleSignOn: 'Gunakan single sign-on',
+ },
+
+ action: {
+ logIn: 'Masuk',
+ logInWithSSO: 'Masuk dengan SSO',
+ },
+ },
+};
diff --git a/client/src/locales/index.js b/client/src/locales/index.js
index cfbd9c7..7b3e784 100644
--- a/client/src/locales/index.js
+++ b/client/src/locales/index.js
@@ -5,10 +5,13 @@ import en from './en';
import es from './es';
import fr from './fr';
import hu from './hu';
+import id from './id';
import it from './it';
import ja from './ja';
import ko from './ko';
+import nl from './nl';
import pl from './pl';
+import pt from './pt';
import ro from './ro';
import ru from './ru';
import sk from './sk';
@@ -18,7 +21,30 @@ import ua from './ua';
import uz from './uz';
import zh from './zh';
-const locales = [cs, da, de, en, es, fr, hu, it, ja, ko, pl, ro, ru, sk, sv, tr, ua, uz, zh];
+const locales = [
+ cs,
+ da,
+ de,
+ en,
+ es,
+ fr,
+ hu,
+ id,
+ it,
+ ja,
+ ko,
+ nl,
+ pl,
+ pt,
+ ro,
+ ru,
+ sk,
+ sv,
+ tr,
+ ua,
+ uz,
+ zh,
+];
export default locales;
diff --git a/client/src/locales/it/core.js b/client/src/locales/it/core.js
index f08a946..1f605b1 100644
--- a/client/src/locales/it/core.js
+++ b/client/src/locales/it/core.js
@@ -3,10 +3,10 @@ export default {
date: 'd/M/yyyy',
time: 'p',
dateTime: '$t(format:date) $t(format:time)',
- longDate: 'MMM d',
- longDateTime: "MMMM d 'at' p",
- fullDate: 'MMM d, y',
- fullDateTime: "MMMM d, y 'at' p",
+ longDate: 'd MMM',
+ longDateTime: "d MMMM 'alle' p",
+ fullDate: 'd MMM, y',
+ fullDateTime: "d MMMM, y 'alle' p",
},
translation: {
@@ -21,7 +21,7 @@ export default {
administrator: 'Amministratore',
all: 'tutto',
allChangesWillBeAutomaticallySavedAfterConnectionRestored:
- 'All changes will be automatically saved after connection restored.',
+ 'Tutte le modifiche verranno salvate al ripristino della connessione.',
areYouSureYouWantToDeleteThisAttachment: 'Sei sicuro di voler eliminare questo allegato?',
areYouSureYouWantToDeleteThisBoard: 'Sei sicuro di voler eliminare questa bacheca?',
areYouSureYouWantToDeleteThisCard: 'Sei sicuro di voler eliminare questa card?',
@@ -50,6 +50,7 @@ export default {
cardNotFound_title: 'Card non trovata',
cardOrActionAreDeleted: "La card o l'azione vengono eliminate.",
color: 'Colore',
+ copy_inline: 'copia',
createBoard_title: 'Crea Bacheca',
createLabel_title: 'Crea Etichetta',
createNewOneOrSelectExistingOne: 'Crea nuovo o seleziona esistente.',
@@ -94,8 +95,10 @@ export default {
filterByLabels_title: 'Filtra per Etichetta',
filterByMembers_title: 'Filtra per Membro',
fromComputer_title: 'Dal Computer',
+ fromTrello: 'Da Trello',
general: 'Generale',
hours: 'Ore',
+ importBoard_title: 'Importa Board',
invalidCurrentPassword: 'Password corrente non valida',
labels: 'Etichette',
language: 'Lingua',
@@ -123,7 +126,7 @@ export default {
phone: 'Telefono',
preferences: 'Preferenze',
pressPasteShortcutToAddAttachmentFromClipboard:
- 'Tip: prmi Ctrl-V (Cmd-V on Mac) per aggiungere un allegato dalla clipboard.',
+ 'Consiglio: premi Ctrl-V (Cmd-V on Mac) per aggiungere un allegato dalla clipboard.',
project: 'Progetto',
projectNotFound_title: 'Progetto non trovato',
removeManager_title: 'Rimuovi Manager',
@@ -193,6 +196,8 @@ export default {
deleteTask: 'Elimina task',
deleteTask_title: 'Elimina Task',
deleteUser: 'Elimina utente',
+ duplicate: 'Duplica',
+ duplicateCard_title: 'Duplica Card',
edit: 'Modifica',
editDueDate_title: 'Modifica data di scadenza',
editDescription_title: 'Modifica Descrizione',
@@ -204,9 +209,10 @@ export default {
editTitle_title: 'Modifica Titolo',
editUsername_title: 'Modifica Username',
hideDetails: 'Nascondi dettagli',
+ import: 'Importa',
leaveBoard: 'Lascia bacheca',
leaveProject: 'Lascia progetto',
- logOut_title: 'Log Out',
+ logOut_title: 'Disconnettiti',
makeCover_title: 'Crea Cover',
move: 'Muovi',
moveCard_title: 'Muovi Card',
diff --git a/client/src/locales/nl/core.js b/client/src/locales/nl/core.js
new file mode 100644
index 0000000..a012193
--- /dev/null
+++ b/client/src/locales/nl/core.js
@@ -0,0 +1,242 @@
+import dateFns from 'date-fns/locale/nl';
+
+export default {
+ dateFns,
+
+ format: {
+ date: 'd-M-yyyy',
+ time: 'p',
+ dateTime: '$t(format:date) $t(format:time)',
+ longDate: 'd MMM',
+ longDateTime: "d MMMM 'om' p",
+ fullDate: 'd MMM y',
+ fullDateTime: "d MMMM y 'om' p",
+ },
+
+ translation: {
+ common: {
+ aboutPlanka: 'Over Planka',
+ account: 'Account',
+ actions: 'Acties',
+ addAttachment_title: 'Bijlage toevoegen',
+ addComment: 'Opmerking toevoegen',
+ addManager_title: 'Manager toevoegen',
+ addMember_title: 'Lid toevoegen',
+ addUser_title: 'Gebruiker toevoegen',
+ administrator: 'Beheerder',
+ all: 'Alle',
+ allChangesWillBeAutomaticallySavedAfterConnectionRestored:
+ 'Alle wijzigingen worden automatisch opgeslagen nadat de verbinding is hersteld.',
+ areYouSureYouWantToDeleteThisAttachment: 'Weet u zeker dat u deze bijlage wilt verwijderen?',
+ areYouSureYouWantToDeleteThisBoard: 'Weet u zeker dat u dit bord wilt verwijderen?',
+ areYouSureYouWantToDeleteThisCard: 'Weet u zeker dat u deze kaart wilt verwijderen?',
+ areYouSureYouWantToDeleteThisComment: 'Weet u zeker dat u deze opmerking wilt verwijderen?',
+ areYouSureYouWantToDeleteThisLabel: 'Weet u zeker dat u dit label wilt verwijderen?',
+ areYouSureYouWantToDeleteThisList: 'Weet u zeker dat u deze lijst wilt verwijderen?',
+ areYouSureYouWantToDeleteThisProject: 'Weet u zeker dat u dit project wilt verwijderen?',
+ areYouSureYouWantToDeleteThisTask: 'Weet u zeker dat u deze taak wilt verwijderen?',
+ areYouSureYouWantToDeleteThisUser: 'Weet u zeker dat u deze gebruiker wilt verwijderen?',
+ areYouSureYouWantToLeaveBoard: 'Weet u zeker dat u het bord wilt verlaten?',
+ areYouSureYouWantToLeaveProject: 'Weet u zeker dat u het project wilt verlaten?',
+ areYouSureYouWantToRemoveThisManagerFromProject:
+ 'Weet u zeker dat u deze manager uit het project wilt verwijderen?',
+ areYouSureYouWantToRemoveThisMemberFromBoard:
+ 'Weet u zeker dat u dit lid uit het bord wilt verwijderen?',
+ attachment: 'Bijlage',
+ attachments: 'Bijlagen',
+ authentication: 'Authenticatie',
+ background: 'Achtergrond',
+ board: 'Bord',
+ boardNotFound_title: 'Bord niet gevonden',
+ canComment: 'Kan opmerking plaatsen',
+ canEditContentOfBoard: 'Kan de inhoud van het bord bewerken.',
+ canOnlyViewBoard: 'Kan alleen het bord bekijken.',
+ cardActions_title: 'Kaartacties',
+ cardNotFound_title: 'Kaart niet gevonden',
+ cardOrActionAreDeleted: 'Kaart of actie zijn verwijderd.',
+ color: 'Kleur',
+ createBoard_title: 'Bord aanmaken',
+ createLabel_title: 'Label aanmaken',
+ createNewOneOrSelectExistingOne: 'Maak een nieuwe of selecteer een bestaande.',
+ createProject_title: 'Project aanmaken',
+ createTextFile_title: 'Tekstbestand aanmaken',
+ currentPassword: 'Huidig wachtwoord',
+ dangerZone_title: 'Gevaarlijke zone',
+ date: 'Datum',
+ dueDate_title: 'Vervaldatum',
+ deleteAttachment_title: 'Bijlage verwijderen',
+ deleteBoard_title: 'Bord verwijderen',
+ deleteCard_title: 'Kaart verwijderen',
+ deleteComment_title: 'Opmerking verwijderen',
+ deleteLabel_title: 'Label verwijderen',
+ deleteList_title: 'Lijst verwijderen',
+ deleteProject_title: 'Project verwijderen',
+ deleteTask_title: 'Taak verwijderen',
+ deleteUser_title: 'Gebruiker verwijderen',
+ description: 'Beschrijving',
+ detectAutomatically: 'Automatisch detecteren',
+ dropFileToUpload: 'Sleep bestand om te uploaden',
+ editor: 'Editor',
+ editAttachment_title: 'Bijlage bewerken',
+ editAvatar_title: 'Avatar bewerken',
+ editBoard_title: 'Bord bewerken',
+ editDueDate_title: 'Vervaldatum bewerken',
+ editEmail_title: 'E-mail bewerken',
+ editInformation_title: 'Informatie bewerken',
+ editLabel_title: 'Label bewerken',
+ editPassword_title: 'Wachtwoord bewerken',
+ editPermissions_title: 'Machtigingen bewerken',
+ editStopwatch_title: 'Stopwatch bewerken',
+ editUsername_title: 'Gebruikersnaam bewerken',
+ email: 'E-mail',
+ emailAlreadyInUse: 'E-mail is al in gebruik',
+ enterCardTitle: 'Voer kaarttitel in... [Ctrl+Enter] om automatisch te openen.',
+ enterDescription: 'Beschrijving invoeren...',
+ enterFilename: 'Bestandsnaam invoeren',
+ enterListTitle: 'Voer lijsttitel in...',
+ enterProjectTitle: 'Voer projecttitel in',
+ enterTaskDescription: 'Taakbeschrijving invoeren...',
+ filterByLabels_title: 'Filteren op labels',
+ filterByMembers_title: 'Filteren op leden',
+ fromComputer_title: 'Van computer',
+ fromTrello: 'Van Trello',
+ general: 'Algemeen',
+ hours: 'Uren',
+ importBoard_title: 'Bord importeren',
+ invalidCurrentPassword: 'Ongeldig huidig wachtwoord',
+ labels: 'Labels',
+ language: 'Taal',
+ leaveBoard_title: 'Bord verlaten',
+ leaveProject_title: 'Project verlaten',
+ list: 'Lijst',
+ listActions_title: 'Lijstacties',
+ managers: 'Managers',
+ members: 'Leden',
+ minutes: 'Minuten',
+ moveCard_title: 'Kaart verplaatsen',
+ name: 'Naam',
+ newEmail: 'Nieuwe e-mail',
+ newPassword: 'Nieuw wachtwoord',
+ newUsername: 'Nieuwe gebruikersnaam',
+ noConnectionToServer: 'Geen verbinding met server',
+ noBoards: 'Geen borden',
+ noLists: 'Geen lijsten',
+ noProjects: 'Geen projecten',
+ notifications: 'Meldingen',
+ noUnreadNotifications: 'Geen ongelezen meldingen.',
+ openBoard_title: 'Bord openen',
+ optional_inline: 'optioneel',
+ organization: 'Organisatie',
+ phone: 'Telefoon',
+ preferences: 'Voorkeuren',
+ pressPasteShortcutToAddAttachmentFromClipboard:
+ 'Tip: druk op Ctrl-V (Cmd-V op Mac) om een bijlage van het klembord toe te voegen.',
+ project: 'Project',
+ projectNotFound_title: 'Project niet gevonden',
+ removeManager_title: 'Manager verwijderen',
+ removeMember_title: 'Lid verwijderen',
+ searchLabels: 'Labels zoeken...',
+ searchMembers: 'Leden zoeken...',
+ searchUsers: 'Gebruikers zoeken...',
+ seconds: 'Seconden',
+ selectBoard: 'Bord selecteren',
+ selectList: 'Lijst selecteren',
+ selectPermissions_title: 'Machtigingen selecteren',
+ selectProject: 'Project selecteren',
+ settings: 'Instellingen',
+ stopwatch: 'Stopwatch',
+ subscribeToMyOwnCardsByDefault: 'Standaard abonneren op mijn eigen kaarten',
+ taskActions_title: 'Takenacties',
+ tasks: 'Taken',
+ thereIsNoPreviewAvailableForThisAttachment:
+ 'Er is geen voorbeeld beschikbaar voor deze bijlage.',
+ time: 'Tijd',
+ title: 'Titel',
+ userActions_title: 'Gebruikersacties',
+ userAddedThisCardToList: '<0>{{user}}0><1> heeft deze kaart toegevoegd aan {{list}}1>',
+ userLeftNewCommentToCard:
+ '{{user}} heeft een nieuwe opmerking achtergelaten «{{comment}}» bij <2>{{card}}2>',
+ userMovedCardFromListToList:
+ '{{user}} heeft <2>{{card}}2> verplaatst van {{fromList}} naar {{toList}}',
+ userMovedThisCardFromListToList:
+ '<0>{{user}}0><1> heeft deze kaart verplaatst van {{fromList}} naar {{toList}}1>',
+ username: 'Gebruikersnaam',
+ usernameAlreadyInUse: 'Gebruikersnaam is al in gebruik',
+ users: 'Gebruikers',
+ version: 'Versie',
+ viewer: 'Kijker',
+ writeComment: 'Schrijf een opmerking...',
+ },
+
+ action: {
+ addAnotherCard: 'Voeg nog een kaart toe',
+ addAnotherList: 'Voeg nog een lijst toe',
+ addAnotherTask: 'Voeg nog een taak toe',
+ addCard: 'Kaart toevoegen',
+ addCard_title: 'Kaart toevoegen',
+ addComment: 'Opmerking toevoegen',
+ addList: 'Lijst toevoegen',
+ addMember: 'Lid toevoegen',
+ addMoreDetailedDescription: 'Meer gedetailleerde beschrijving toevoegen',
+ addTask: 'Taak toevoegen',
+ addToCard: 'Toevoegen aan kaart',
+ addUser: 'Gebruiker toevoegen',
+ createBoard: 'Bord aanmaken',
+ createFile: 'Bestand aanmaken',
+ createLabel: 'Label aanmaken',
+ createNewLabel: 'Nieuw label aanmaken',
+ createProject: 'Project aanmaken',
+ delete: 'Verwijderen',
+ deleteAttachment: 'Bijlage verwijderen',
+ deleteAvatar: 'Avatar verwijderen',
+ deleteBoard: 'Bord verwijderen',
+ deleteCard: 'Kaart verwijderen',
+ deleteCard_title: 'Kaart verwijderen',
+ deleteComment: 'Opmerking verwijderen',
+ deleteImage: 'Afbeelding verwijderen',
+ deleteLabel: 'Label verwijderen',
+ deleteList: 'Lijst verwijderen',
+ deleteList_title: 'Lijst verwijderen',
+ deleteProject: 'Project verwijderen',
+ deleteProject_title: 'Project verwijderen',
+ deleteTask: 'Taak verwijderen',
+ deleteTask_title: 'Taak verwijderen',
+ deleteUser: 'Gebruiker verwijderen',
+ edit: 'Bewerken',
+ editDueDate_title: 'Vervaldatum bewerken',
+ editDescription_title: 'Beschrijving bewerken',
+ editEmail_title: 'E-mail bewerken',
+ editInformation_title: 'Informatie bewerken',
+ editPassword_title: 'Wachtwoord bewerken',
+ editPermissions: 'Machtigingen bewerken',
+ editStopwatch_title: 'Stopwatch bewerken',
+ editTitle_title: 'Titel bewerken',
+ editUsername_title: 'Gebruikersnaam bewerken',
+ hideDetails: 'Details verbergen',
+ import: 'Importeren',
+ leaveBoard: 'Bord verlaten',
+ leaveProject: 'Project verlaten',
+ logOut_title: 'Uitloggen',
+ makeCover_title: 'Omslag maken',
+ move: 'Verplaatsen',
+ moveCard_title: 'Kaart verplaatsen',
+ remove: 'Verwijderen',
+ removeBackground: 'Achtergrond verwijderen',
+ removeCover_title: 'Omslag verwijderen',
+ removeFromBoard: 'Verwijderen van bord',
+ removeFromProject: 'Verwijderen van project',
+ removeManager: 'Manager verwijderen',
+ removeMember: 'Lid verwijderen',
+ save: 'Opslaan',
+ showAllAttachments: 'Alle bijlagen weergeven ({{hidden}} verbergen)',
+ showDetails: 'Details weergeven',
+ showFewerAttachments: 'Minder bijlagen weergeven',
+ start: 'Start',
+ stop: 'Stop',
+ subscribe: 'Abonneren',
+ unsubscribe: 'Afmelden',
+ uploadNewAvatar: 'Nieuwe avatar uploaden',
+ uploadNewImage: 'Nieuwe afbeelding uploaden',
+ },
+ },
+};
diff --git a/client/src/locales/nl/index.js b/client/src/locales/nl/index.js
new file mode 100644
index 0000000..6e24c66
--- /dev/null
+++ b/client/src/locales/nl/index.js
@@ -0,0 +1,8 @@
+import login from './login';
+
+export default {
+ language: 'nl',
+ country: 'nl',
+ name: 'Nederlands',
+ embeddedLocale: login,
+};
diff --git a/client/src/locales/nl/login.js b/client/src/locales/nl/login.js
new file mode 100644
index 0000000..43ac4b1
--- /dev/null
+++ b/client/src/locales/nl/login.js
@@ -0,0 +1,22 @@
+export default {
+ translation: {
+ common: {
+ emailOrUsername: 'E-mail of gebruikersnaam',
+ invalidEmailOrUsername: 'Ongeldig e-mailadres of gebruikersnaam',
+ invalidPassword: 'Ongeldig wachtwoord',
+ logInToPlanka: 'Inloggen bij Planka',
+ noInternetConnection: 'Geen internetverbinding',
+ pageNotFound_title: 'Pagina niet gevonden',
+ password: 'Wachtwoord',
+ projectManagement: 'Projectbeheer',
+ serverConnectionFailed: 'Verbinding met de server mislukt',
+ unknownError: 'Onbekende fout, probeer het later opnieuw',
+ useSingleSignOn: 'Gebruik single sign-on',
+ },
+
+ action: {
+ logIn: 'Inloggen',
+ logInWithSSO: 'Inloggen met SSO',
+ },
+ },
+};
diff --git a/client/src/locales/pt/core.js b/client/src/locales/pt/core.js
new file mode 100644
index 0000000..3d557e8
--- /dev/null
+++ b/client/src/locales/pt/core.js
@@ -0,0 +1,241 @@
+import dateFns from 'date-fns/locale/pt-BR';
+
+export default {
+ dateFns,
+
+ format: {
+ date: 'dd/MM/yyyy',
+ time: 'p',
+ dateTime: '$t(format:date) $t(format:time)',
+ longDate: 'd MMM',
+ longDateTime: "d 'de' MMMM 'às' p",
+ fullDate: 'd MMM, y',
+ fullDateTime: "d 'de' MMMM, y 'às' p",
+ },
+
+ translation: {
+ common: {
+ aboutPlanka: 'Sobre o Planka',
+ account: 'Conta',
+ actions: 'Ações',
+ addAttachment_title: 'Adicionar Anexo',
+ addComment: 'Adicionar comentário',
+ addManager_title: 'Adicionar Gerente',
+ addMember_title: 'Adicionar Membro',
+ addUser_title: 'Adicionar Usuário',
+ administrator: 'Administrador',
+ all: 'Todos',
+ allChangesWillBeAutomaticallySavedAfterConnectionRestored:
+ 'Todas as alterações serão salvas automaticamente após a conexão ser restaurada.',
+ areYouSureYouWantToDeleteThisAttachment: 'Tem certeza de que deseja excluir este anexo?',
+ areYouSureYouWantToDeleteThisBoard: 'Tem certeza de que deseja excluir este quadro?',
+ areYouSureYouWantToDeleteThisCard: 'Tem certeza de que deseja excluir este cartão?',
+ areYouSureYouWantToDeleteThisComment: 'Tem certeza de que deseja excluir este comentário?',
+ areYouSureYouWantToDeleteThisLabel: 'Tem certeza de que deseja excluir este rótulo?',
+ areYouSureYouWantToDeleteThisList: 'Tem certeza de que deseja excluir esta lista?',
+ areYouSureYouWantToDeleteThisProject: 'Tem certeza de que deseja excluir este projeto?',
+ areYouSureYouWantToDeleteThisTask: 'Tem certeza de que deseja excluir esta tarefa?',
+ areYouSureYouWantToDeleteThisUser: 'Tem certeza de que deseja excluir este usuário?',
+ areYouSureYouWantToLeaveBoard: 'Tem certeza de que deseja sair do quadro?',
+ areYouSureYouWantToLeaveProject: 'Tem certeza de que deseja sair do projeto?',
+ areYouSureYouWantToRemoveThisManagerFromProject:
+ 'Tem certeza de que deseja remover este gerente do projeto?',
+ areYouSureYouWantToRemoveThisMemberFromBoard:
+ 'Tem certeza de que deseja remover este membro do quadro?',
+ attachment: 'Anexo',
+ attachments: 'Anexos',
+ authentication: 'Autenticação',
+ background: 'Fundo',
+ board: 'Quadro',
+ boardNotFound_title: 'Quadro não encontrado',
+ canComment: 'Pode comentar',
+ canEditContentOfBoard: 'Pode editar o conteúdo do quadro.',
+ canOnlyViewBoard: 'Só pode visualizar o quadro.',
+ cardActions_title: 'Ações do Cartão',
+ cardNotFound_title: 'Cartão não encontrado',
+ cardOrActionAreDeleted: 'Cartão ou ação foram excluídos.',
+ color: 'Cor',
+ createBoard_title: 'Criar Quadro',
+ createLabel_title: 'Criar Rótulo',
+ createNewOneOrSelectExistingOne: 'Criar um novo ou selecionar um existente.',
+ createProject_title: 'Criar Projeto',
+ createTextFile_title: 'Criar Arquivo de Texto',
+ currentPassword: 'Senha atual',
+ dangerZone_title: 'Zona de Perigo',
+ date: 'Data',
+ dueDate_title: 'Data de Vencimento',
+ deleteAttachment_title: 'Excluir Anexo',
+ deleteBoard_title: 'Excluir Quadro',
+ deleteCard_title: 'Excluir Cartão',
+ deleteComment_title: 'Excluir Comentário',
+ deleteLabel_title: 'Excluir Rótulo',
+ deleteList_title: 'Excluir Lista',
+ deleteProject_title: 'Excluir Projeto',
+ deleteTask_title: 'Excluir Tarefa',
+ deleteUser_title: 'Excluir Usuário',
+ description: 'Descrição',
+ detectAutomatically: 'Detectar automaticamente',
+ dropFileToUpload: 'Solte o arquivo para enviar',
+ editor: 'Editor',
+ editAttachment_title: 'Editar Anexo',
+ editAvatar_title: 'Editar Avatar',
+ editBoard_title: 'Editar Quadro',
+ editDueDate_title: 'Editar Data de Vencimento',
+ editEmail_title: 'Editar E-mail',
+ editInformation_title: 'Editar Informações',
+ editLabel_title: 'Editar Rótulo',
+ editPassword_title: 'Editar Senha',
+ editPermissions_title: 'Editar Permissões',
+ editStopwatch_title: 'Editar Cronômetro',
+ editUsername_title: 'Editar Nome de Usuário',
+ email: 'E-mail',
+ emailAlreadyInUse: 'E-mail já está em uso',
+ enterCardTitle: 'Digite o título do cartão... [Ctrl+Enter] para abrir automaticamente.',
+ enterDescription: 'Digite a descrição...',
+ enterFilename: 'Digite o nome do arquivo',
+ enterListTitle: 'Digite o título da lista...',
+ enterProjectTitle: 'Digite o título do projeto',
+ enterTaskDescription: 'Digite a descrição da tarefa...',
+ filterByLabels_title: 'Filtrar por Rótulos',
+ filterByMembers_title: 'Filtrar por Membros',
+ fromComputer_title: 'Do Computador',
+ fromTrello: 'Do Trello',
+ general: 'Geral',
+ hours: 'Horas',
+ importBoard_title: 'Importar Quadro',
+ invalidCurrentPassword: 'Senha atual inválida',
+ labels: 'Rótulos',
+ language: 'Idioma',
+ leaveBoard_title: 'Sair do Quadro',
+ leaveProject_title: 'Sair do Projeto',
+ list: 'Lista',
+ listActions_title: 'Ações da Lista',
+ managers: 'Gerentes',
+ members: 'Membros',
+ minutes: 'Minutos',
+ moveCard_title: 'Mover Cartão',
+ name: 'Nome',
+ newEmail: 'Novo e-mail',
+ newPassword: 'Nova senha',
+ newUsername: 'Novo nome de usuário',
+ noConnectionToServer: 'Sem conexão com o servidor',
+ noBoards: 'Sem quadros',
+ noLists: 'Sem listas',
+ noProjects: 'Sem projetos',
+ notifications: 'Notificações',
+ noUnreadNotifications: 'Nenhuma notificação não lida.',
+ openBoard_title: 'Abrir Quadro',
+ optional_inline: 'opcional',
+ organization: 'Organização',
+ phone: 'Telefone',
+ preferences: 'Preferências',
+ pressPasteShortcutToAddAttachmentFromClipboard:
+ 'Dica: pressione Ctrl-V (Cmd-V no Mac) para adicionar um anexo da área de transferência.',
+ project: 'Projeto',
+ projectNotFound_title: 'Projeto não encontrado',
+ removeManager_title: 'Remover Gerente',
+ removeMember_title: 'Remover Membro',
+ searchLabels: 'Pesquisar rótulos...',
+ searchMembers: 'Pesquisar membros...',
+ searchUsers: 'Pesquisar usuários...',
+ seconds: 'Segundos',
+ selectBoard: 'Selecionar quadro',
+ selectList: 'Selecionar lista',
+ selectPermissions_title: 'Selecionar Permissões',
+ selectProject: 'Selecionar projeto',
+ settings: 'Configurações',
+ stopwatch: 'Cronômetro',
+ subscribeToMyOwnCardsByDefault: 'Inscrever-se automaticamente em meus próprios cartões',
+ taskActions_title: 'Ações da Tarefa',
+ tasks: 'Tarefas',
+ thereIsNoPreviewAvailableForThisAttachment:
+ 'Não há pré-visualização disponível para este anexo.',
+ time: 'Tempo',
+ title: 'Título',
+ userActions_title: 'Ações do Usuário',
+ userAddedThisCardToList: '<0>{{user}}0><1> adicionou este cartão a {{list}}1>',
+ userLeftNewCommentToCard:
+ '{{user}} deixou um novo comentário «{{comment}}» em <2>{{card}}2>',
+ userMovedCardFromListToList: '{{user}} moveu <2>{{card}}2> de {{fromList}} para {{toList}}',
+ userMovedThisCardFromListToList:
+ '<0>{{user}}0><1> moveu este cartão de {{fromList}} para {{toList}}1>',
+ username: 'Nome de usuário',
+ usernameAlreadyInUse: 'Nome de usuário já está em uso',
+ users: 'Usuários',
+ version: 'Versão',
+ viewer: 'Visualizador',
+ writeComment: 'Escreva um comentário...',
+ },
+
+ action: {
+ addAnotherCard: 'Adicionar outro cartão',
+ addAnotherList: 'Adicionar outra lista',
+ addAnotherTask: 'Adicionar outra tarefa',
+ addCard: 'Adicionar cartão',
+ addCard_title: 'Adicionar Cartão',
+ addComment: 'Adicionar comentário',
+ addList: 'Adicionar lista',
+ addMember: 'Adicionar membro',
+ addMoreDetailedDescription: 'Adicionar descrição mais detalhada',
+ addTask: 'Adicionar tarefa',
+ addToCard: 'Adicionar ao cartão',
+ addUser: 'Adicionar usuário',
+ createBoard: 'Criar quadro',
+ createFile: 'Criar arquivo',
+ createLabel: 'Criar rótulo',
+ createNewLabel: 'Criar novo rótulo',
+ createProject: 'Criar projeto',
+ delete: 'Excluir',
+ deleteAttachment: 'Excluir anexo',
+ deleteAvatar: 'Excluir avatar',
+ deleteBoard: 'Excluir quadro',
+ deleteCard: 'Excluir cartão',
+ deleteCard_title: 'Excluir Cartão',
+ deleteComment: 'Excluir comentário',
+ deleteImage: 'Excluir imagem',
+ deleteLabel: 'Excluir rótulo',
+ deleteList: 'Excluir lista',
+ deleteList_title: 'Excluir Lista',
+ deleteProject: 'Excluir projeto',
+ deleteProject_title: 'Excluir Projeto',
+ deleteTask: 'Excluir tarefa',
+ deleteTask_title: 'Excluir Tarefa',
+ deleteUser: 'Excluir usuário',
+ edit: 'Editar',
+ editDueDate_title: 'Editar Data de Vencimento',
+ editDescription_title: 'Editar Descrição',
+ editEmail_title: 'Editar E-mail',
+ editInformation_title: 'Editar Informações',
+ editPassword_title: 'Editar Senha',
+ editPermissions: 'Editar permissões',
+ editStopwatch_title: 'Editar Cronômetro',
+ editTitle_title: 'Editar Título',
+ editUsername_title: 'Editar Nome de Usuário',
+ hideDetails: 'Ocultar detalhes',
+ import: 'Importar',
+ leaveBoard: 'Sair do quadro',
+ leaveProject: 'Sair do projeto',
+ logOut_title: 'Sair',
+ makeCover_title: 'Tornar Capa',
+ move: 'Mover',
+ moveCard_title: 'Mover Cartão',
+ remove: 'Remover',
+ removeBackground: 'Remover fundo',
+ removeCover_title: 'Remover Capa',
+ removeFromBoard: 'Remover do quadro',
+ removeFromProject: 'Remover do projeto',
+ removeManager: 'Remover gerente',
+ removeMember: 'Remover membro',
+ save: 'Salvar',
+ showAllAttachments: 'Mostrar todos os anexos ({{hidden}} ocultos)',
+ showDetails: 'Mostrar detalhes',
+ showFewerAttachments: 'Mostrar menos anexos',
+ start: 'Iniciar',
+ stop: 'Parar',
+ subscribe: 'Inscrever-se',
+ unsubscribe: 'Cancelar inscrição',
+ uploadNewAvatar: 'Enviar novo avatar',
+ uploadNewImage: 'Enviar nova imagem',
+ },
+ },
+};
diff --git a/client/src/locales/pt/index.js b/client/src/locales/pt/index.js
new file mode 100644
index 0000000..b3c867f
--- /dev/null
+++ b/client/src/locales/pt/index.js
@@ -0,0 +1,8 @@
+import login from './login';
+
+export default {
+ language: 'pt',
+ country: 'br',
+ name: 'Português',
+ embeddedLocale: login,
+};
diff --git a/client/src/locales/pt/login.js b/client/src/locales/pt/login.js
new file mode 100644
index 0000000..07a4489
--- /dev/null
+++ b/client/src/locales/pt/login.js
@@ -0,0 +1,22 @@
+export default {
+ translation: {
+ common: {
+ emailOrUsername: 'E-mail ou nome de usuário',
+ invalidEmailOrUsername: 'E-mail ou nome de usuário inválido',
+ invalidPassword: 'Senha inválida',
+ logInToPlanka: 'Entrar no Planka',
+ noInternetConnection: 'Sem conexão com a internet',
+ pageNotFound_title: 'Página não encontrada',
+ password: 'Senha',
+ projectManagement: 'Gerenciamento de projetos',
+ serverConnectionFailed: 'Falha na conexão com o servidor',
+ unknownError: 'Erro desconhecido, tente novamente mais tarde',
+ useSingleSignOn: 'Usar login único',
+ },
+
+ action: {
+ logIn: 'Entrar',
+ logInWithSSO: 'Entrar com SSO',
+ },
+ },
+};
diff --git a/client/src/locales/ru/core.js b/client/src/locales/ru/core.js
index 138b893..06520c6 100644
--- a/client/src/locales/ru/core.js
+++ b/client/src/locales/ru/core.js
@@ -199,6 +199,8 @@ export default {
deleteProject: 'Удалить проект',
deleteTask: 'Удалить задачу',
deleteUser: 'Удалить пользователя',
+ duplicate: 'Дублировать',
+ duplicateCard_title: 'Дублировать карточку',
edit: 'Изменить',
editBackground: 'Изменить фон',
editDueDate: 'Изменить срок',
diff --git a/client/src/locales/zh/core.js b/client/src/locales/zh/core.js
index 486cfcd..95ae46c 100644
--- a/client/src/locales/zh/core.js
+++ b/client/src/locales/zh/core.js
@@ -67,7 +67,7 @@ export default {
deleteTask_title: '删除任务',
deleteUser_title: '删除用户',
description: '描述',
- detectAutomatically: '自动删除',
+ detectAutomatically: '自动检测',
dropFileToUpload: '拖放文件以上传',
editor: '编辑器',
editAttachment_title: '编辑附件',
diff --git a/client/src/models/Card.js b/client/src/models/Card.js
index 78c682c..3c45256 100755
--- a/client/src/models/Card.js
+++ b/client/src/models/Card.js
@@ -1,3 +1,4 @@
+import pick from 'lodash/pick';
import { attr, fk, many, oneToOne } from 'redux-orm';
import BaseModel from './BaseModel';
@@ -165,7 +166,6 @@ export default class extends BaseModel {
break;
case ActionTypes.CARD_CREATE:
- case ActionTypes.CARD_CREATE_HANDLE:
case ActionTypes.CARD_UPDATE__SUCCESS:
case ActionTypes.CARD_UPDATE_HANDLE:
Card.upsert(payload.card);
@@ -176,10 +176,63 @@ export default class extends BaseModel {
Card.upsert(payload.card);
break;
+ case ActionTypes.CARD_CREATE_HANDLE: {
+ const cardModel = Card.upsert(payload.card);
+
+ payload.cardMemberships.forEach(({ userId }) => {
+ cardModel.users.add(userId);
+ });
+
+ payload.cardLabels.forEach(({ labelId }) => {
+ cardModel.labels.add(labelId);
+ });
+
+ break;
+ }
case ActionTypes.CARD_UPDATE:
Card.withId(payload.id).update(payload.data);
break;
+ case ActionTypes.CARD_DUPLICATE: {
+ const cardModel = Card.withId(payload.id);
+
+ const nextCardModel = Card.upsert({
+ ...pick(cardModel.ref, [
+ 'boardId',
+ 'listId',
+ 'position',
+ 'name',
+ 'description',
+ 'dueDate',
+ 'stopwatch',
+ ]),
+ ...payload.card,
+ });
+
+ cardModel.users.toRefArray().forEach(({ id }) => {
+ nextCardModel.users.add(id);
+ });
+
+ cardModel.labels.toRefArray().forEach(({ id }) => {
+ nextCardModel.labels.add(id);
+ });
+
+ break;
+ }
+ case ActionTypes.CARD_DUPLICATE__SUCCESS: {
+ Card.withId(payload.localId).deleteWithRelated();
+ const cardModel = Card.upsert(payload.card);
+
+ payload.cardMemberships.forEach(({ userId }) => {
+ cardModel.users.add(userId);
+ });
+
+ payload.cardLabels.forEach(({ labelId }) => {
+ cardModel.labels.add(labelId);
+ });
+
+ break;
+ }
case ActionTypes.CARD_DELETE:
Card.withId(payload.id).deleteWithRelated();
diff --git a/client/src/models/Task.js b/client/src/models/Task.js
index 1a47271..b990fe2 100755
--- a/client/src/models/Task.js
+++ b/client/src/models/Task.js
@@ -1,5 +1,6 @@
import { attr, fk } from 'redux-orm';
+import { createLocalId } from '../utils/local-id';
import BaseModel from './BaseModel';
import ActionTypes from '../constants/ActionTypes';
@@ -44,10 +45,24 @@ export default class extends BaseModel {
break;
case ActionTypes.BOARD_FETCH__SUCCESS:
+ case ActionTypes.CARD_CREATE_HANDLE:
+ case ActionTypes.CARD_DUPLICATE__SUCCESS:
payload.tasks.forEach((task) => {
Task.upsert(task);
});
+ break;
+ case ActionTypes.CARD_DUPLICATE:
+ payload.taskIds.forEach((taskId, index) => {
+ const taskModel = Task.withId(taskId);
+
+ Task.upsert({
+ ...taskModel.ref,
+ id: `${createLocalId()}-${index}`, // TODO: hack?
+ cardId: payload.card.id,
+ });
+ });
+
break;
case ActionTypes.TASK_CREATE:
case ActionTypes.TASK_CREATE_HANDLE:
diff --git a/client/src/models/User.js b/client/src/models/User.js
index 8455530..9bf8208 100755
--- a/client/src/models/User.js
+++ b/client/src/models/User.js
@@ -46,6 +46,7 @@ export default class extends BaseModel {
isAdmin: attr(),
isLocked: attr(),
isRoleLocked: attr(),
+ isUsernameLocked: attr(),
isDeletionLocked: attr(),
deletedAt: attr(),
createdAt: attr({
diff --git a/client/src/sagas/core/request.js b/client/src/sagas/core/request.js
index 2cfd994..f905c7a 100755
--- a/client/src/sagas/core/request.js
+++ b/client/src/sagas/core/request.js
@@ -1,8 +1,7 @@
import { call, fork, join, put, select, take } from 'redux-saga/effects';
import selectors from '../../selectors';
-import actions from '../../actions';
-import { removeAccessToken } from '../../utils/access-token-storage';
+import entryActions from '../../entry-actions';
import ErrorCodes from '../../constants/ErrorCodes';
let lastRequestTask;
@@ -22,8 +21,7 @@ function* queueRequest(method, ...args) {
});
} catch (error) {
if (error.code === ErrorCodes.UNAUTHORIZED) {
- yield call(removeAccessToken);
- yield put(actions.logout()); // TODO: next url
+ yield put(entryActions.logout(false));
yield take();
}
diff --git a/client/src/sagas/core/services/cards.js b/client/src/sagas/core/services/cards.js
index 40c3db8..7232c3f 100644
--- a/client/src/sagas/core/services/cards.js
+++ b/client/src/sagas/core/services/cards.js
@@ -5,6 +5,7 @@ import request from '../request';
import selectors from '../../../selectors';
import actions from '../../../actions';
import api from '../../../api';
+import i18n from '../../../i18n';
import { createLocalId } from '../../../utils/local-id';
export function* createCard(listId, data, autoOpen) {
@@ -41,8 +42,23 @@ export function* createCard(listId, data, autoOpen) {
}
}
-export function* handleCardCreate(card) {
- yield put(actions.handleCardCreate(card));
+export function* handleCardCreate({ id }) {
+ let card;
+ let cardMemberships;
+ let cardLabels;
+ let tasks;
+ let attachments;
+
+ try {
+ ({
+ item: card,
+ included: { cardMemberships, cardLabels, tasks, attachments },
+ } = yield call(request, api.getCard, id));
+ } catch (error) {
+ return;
+ }
+
+ yield put(actions.handleCardCreate(card, cardMemberships, cardLabels, tasks, attachments));
}
export function* updateCard(id, data) {
@@ -70,7 +86,7 @@ export function* handleCardUpdate(card) {
yield put(actions.handleCardUpdate(card));
}
-export function* moveCard(id, listId, index) {
+export function* moveCard(id, listId, index = 0) {
const position = yield select(selectors.selectNextCardPosition, listId, index, id);
yield call(updateCard, id, {
@@ -85,7 +101,7 @@ export function* moveCurrentCard(listId, index) {
yield call(moveCard, cardId, listId, index);
}
-export function* transferCard(id, boardId, listId, index) {
+export function* transferCard(id, boardId, listId, index = 0) {
const { cardId: currentCardId, boardId: currentBoardId } = yield select(selectors.selectPath);
const position = yield select(selectors.selectNextCardPosition, listId, index, id);
@@ -106,6 +122,55 @@ export function* transferCurrentCard(boardId, listId, index) {
yield call(transferCard, cardId, boardId, listId, index);
}
+export function* duplicateCard(id) {
+ const { listId, name } = yield select(selectors.selectCardById, id);
+ const index = yield select(selectors.selectCardIndexById, id);
+
+ const nextData = {
+ position: yield select(selectors.selectNextCardPosition, listId, index + 1),
+ name: `${name} (${i18n.t('common.copy', {
+ context: 'inline',
+ })})`,
+ };
+
+ const localId = yield call(createLocalId);
+ const taskIds = yield select(selectors.selectTaskIdsByCardId, id);
+
+ yield put(
+ actions.duplicateCard(
+ id,
+ {
+ ...nextData,
+ id: localId,
+ },
+ taskIds,
+ ),
+ );
+
+ let card;
+ let cardMemberships;
+ let cardLabels;
+ let tasks;
+
+ try {
+ ({
+ item: card,
+ included: { cardMemberships, cardLabels, tasks },
+ } = yield call(request, api.duplicateCard, id, nextData));
+ } catch (error) {
+ yield put(actions.duplicateCard.failure(localId, error));
+ return;
+ }
+
+ yield put(actions.duplicateCard.success(localId, card, cardMemberships, cardLabels, tasks));
+}
+
+export function* duplicateCurrentCard() {
+ const { cardId } = yield select(selectors.selectPath);
+
+ yield call(duplicateCard, cardId);
+}
+
export function* deleteCard(id) {
const { cardId, boardId } = yield select(selectors.selectPath);
@@ -147,11 +212,13 @@ export default {
handleCardCreate,
updateCard,
updateCurrentCard,
+ handleCardUpdate,
moveCard,
moveCurrentCard,
transferCard,
transferCurrentCard,
- handleCardUpdate,
+ duplicateCard,
+ duplicateCurrentCard,
deleteCard,
deleteCurrentCard,
handleCardDelete,
diff --git a/client/src/sagas/core/services/core.js b/client/src/sagas/core/services/core.js
index cd69413..9296c1c 100644
--- a/client/src/sagas/core/services/core.js
+++ b/client/src/sagas/core/services/core.js
@@ -1,4 +1,4 @@
-import { call, put, select, take } from 'redux-saga/effects';
+import { call, put, select } from 'redux-saga/effects';
import request from '../request';
import requests from '../requests';
@@ -84,8 +84,7 @@ export function* logout(invalidateAccessToken = true) {
} catch (error) {} // eslint-disable-line no-empty
}
- yield put(actions.logout());
- yield take();
+ yield put(actions.logout()); // TODO: next url
}
export default {
diff --git a/client/src/sagas/core/services/router.js b/client/src/sagas/core/services/router.js
index 7b981e4..3f6ab57 100644
--- a/client/src/sagas/core/services/router.js
+++ b/client/src/sagas/core/services/router.js
@@ -1,10 +1,12 @@
import { call, put, select, take } from 'redux-saga/effects';
import { push } from '../../../lib/redux-router';
+import { logout } from './core';
import request from '../request';
import selectors from '../../../selectors';
import actions from '../../../actions';
import api from '../../../api';
+import { getAccessToken } from '../../../utils/access-token-storage';
import ActionTypes from '../../../constants/ActionTypes';
import Paths from '../../../constants/Paths';
@@ -25,6 +27,13 @@ export function* goToCard(cardId) {
}
export function* handleLocationChange() {
+ const accessToken = yield call(getAccessToken);
+
+ if (!accessToken) {
+ yield call(logout, false);
+ return;
+ }
+
const pathsMatch = yield select(selectors.selectPathsMatch);
if (!pathsMatch) {
diff --git a/client/src/sagas/core/services/users.js b/client/src/sagas/core/services/users.js
index 8b6b865..2f6008c 100644
--- a/client/src/sagas/core/services/users.js
+++ b/client/src/sagas/core/services/users.js
@@ -218,6 +218,7 @@ export function* handleUserDelete(user) {
if (user.id === currentUserId) {
yield call(logout, false);
+ return;
}
yield put(actions.handleUserDelete(user));
diff --git a/client/src/sagas/core/watchers/cards.js b/client/src/sagas/core/watchers/cards.js
index 0290515..3fcb599 100644
--- a/client/src/sagas/core/watchers/cards.js
+++ b/client/src/sagas/core/watchers/cards.js
@@ -32,6 +32,8 @@ export default function* cardsWatchers() {
takeEvery(EntryActionTypes.CURRENT_CARD_TRANSFER, ({ payload: { boardId, listId, index } }) =>
services.transferCurrentCard(boardId, listId, index),
),
+ takeEvery(EntryActionTypes.CARD_DUPLICATE, ({ payload: { id } }) => services.duplicateCard(id)),
+ takeEvery(EntryActionTypes.CURRENT_CARD_DUPLICATE, () => services.duplicateCurrentCard()),
takeEvery(EntryActionTypes.CARD_DELETE, ({ payload: { id } }) => services.deleteCard(id)),
takeEvery(EntryActionTypes.CURRENT_CARD_DELETE, () => services.deleteCurrentCard()),
takeEvery(EntryActionTypes.CARD_DELETE_HANDLE, ({ payload: { card } }) =>
diff --git a/client/src/sagas/core/watchers/core.js b/client/src/sagas/core/watchers/core.js
index cab4704..dd58668 100644
--- a/client/src/sagas/core/watchers/core.js
+++ b/client/src/sagas/core/watchers/core.js
@@ -4,5 +4,9 @@ import services from '../services';
import EntryActionTypes from '../../../constants/EntryActionTypes';
export default function* coreWatchers() {
- yield all([takeEvery(EntryActionTypes.LOGOUT, () => services.logout())]);
+ yield all([
+ takeEvery(EntryActionTypes.LOGOUT, ({ payload: { invalidateAccessToken } }) =>
+ services.logout(invalidateAccessToken),
+ ),
+ ]);
}
diff --git a/client/src/sagas/core/watchers/socket.js b/client/src/sagas/core/watchers/socket.js
index 169de93..ddb65cf 100644
--- a/client/src/sagas/core/watchers/socket.js
+++ b/client/src/sagas/core/watchers/socket.js
@@ -16,6 +16,10 @@ const createSocketEventsChannel = () =>
emit(entryActions.handleSocketReconnect());
};
+ const handleLogout = () => {
+ emit(entryActions.logout(false));
+ };
+
const handleUserCreate = api.makeHandleUserCreate(({ item }) => {
emit(entryActions.handleUserCreate(item));
});
@@ -171,6 +175,8 @@ const createSocketEventsChannel = () =>
socket.on('disconnect', handleDisconnect);
socket.on('reconnect', handleReconnect);
+ socket.on('logout', handleLogout);
+
socket.on('userCreate', handleUserCreate);
socket.on('userUpdate', handleUserUpdate);
socket.on('userDelete', handleUserDelete);
@@ -227,6 +233,8 @@ const createSocketEventsChannel = () =>
socket.off('disconnect', handleDisconnect);
socket.off('reconnect', handleReconnect);
+ socket.off('logout', handleLogout);
+
socket.off('userCreate', handleUserCreate);
socket.off('userUpdate', handleUserUpdate);
socket.off('userDelete', handleUserDelete);
diff --git a/client/src/sagas/login/services/login.js b/client/src/sagas/login/services/login.js
index ef02a38..7340307 100644
--- a/client/src/sagas/login/services/login.js
+++ b/client/src/sagas/login/services/login.js
@@ -33,10 +33,10 @@ export function* authenticateUsingOidc() {
const oidcConfig = yield select(selectors.selectOidcConfig);
const state = nanoid();
- window.sessionStorage.setItem('oidc-state', state);
+ window.localStorage.setItem('oidc-state', state);
const nonce = nanoid();
- window.sessionStorage.setItem('oidc-nonce', nonce);
+ window.localStorage.setItem('oidc-nonce', nonce);
let redirectUrl = `${oidcConfig.authorizationUrl}`;
redirectUrl += `&state=${encodeURIComponent(state)}`;
@@ -49,11 +49,11 @@ export function* authenticateUsingOidcCallback() {
// https://github.com/plankanban/planka/issues/511#issuecomment-1771385639
const params = new URLSearchParams(window.location.hash.substring(1) || window.location.search);
- const state = window.sessionStorage.getItem('oidc-state');
- window.sessionStorage.removeItem('oidc-state');
+ const state = window.localStorage.getItem('oidc-state');
+ window.localStorage.removeItem('oidc-state');
- const nonce = window.sessionStorage.getItem('oidc-nonce');
- window.sessionStorage.removeItem('oidc-nonce');
+ const nonce = window.localStorage.getItem('oidc-nonce');
+ window.localStorage.removeItem('oidc-nonce');
yield put(replace(Paths.LOGIN));
diff --git a/client/src/selectors/cards.js b/client/src/selectors/cards.js
index 8f100d5..d1137dc 100644
--- a/client/src/selectors/cards.js
+++ b/client/src/selectors/cards.js
@@ -26,6 +26,24 @@ export const makeSelectCardById = () =>
export const selectCardById = makeSelectCardById();
+export const makeSelectCardIndexById = () =>
+ createSelector(
+ orm,
+ (_, id) => id,
+ ({ Card }, id) => {
+ const cardModel = Card.withId(id);
+
+ if (!cardModel) {
+ return cardModel;
+ }
+
+ const cardModels = cardModel.list.getFilteredOrderedCardsModelArray();
+ return cardModels.findIndex((cardModelItem) => cardModelItem.id === cardModel.id);
+ },
+ );
+
+export const selectCardIndexById = makeSelectCardIndexById();
+
export const makeSelectUsersByCardId = () =>
createSelector(
orm,
@@ -60,6 +78,26 @@ export const makeSelectLabelsByCardId = () =>
export const selectLabelsByCardId = makeSelectLabelsByCardId();
+export const makeSelectTaskIdsByCardId = () =>
+ createSelector(
+ orm,
+ (_, id) => id,
+ ({ Card }, id) => {
+ const cardModel = Card.withId(id);
+
+ if (!cardModel) {
+ return cardModel;
+ }
+
+ return cardModel
+ .getOrderedTasksQuerySet()
+ .toRefArray()
+ .map((task) => task.id);
+ },
+ );
+
+export const selectTaskIdsByCardId = makeSelectTaskIdsByCardId();
+
export const makeSelectTasksByCardId = () =>
createSelector(
orm,
@@ -286,10 +324,14 @@ export const selectNotificationIdsForCurrentCard = createSelector(
export default {
makeSelectCardById,
selectCardById,
+ makeSelectCardIndexById,
+ selectCardIndexById,
makeSelectUsersByCardId,
selectUsersByCardId,
makeSelectLabelsByCardId,
selectLabelsByCardId,
+ makeSelectTaskIdsByCardId,
+ selectTaskIdsByCardId,
makeSelectTasksByCardId,
selectTasksByCardId,
makeSelectLastActivityIdByCardId,
diff --git a/config/development/Dockerfile.client b/config/development/Dockerfile.client
new file mode 100644
index 0000000..8a5ed92
--- /dev/null
+++ b/config/development/Dockerfile.client
@@ -0,0 +1,18 @@
+FROM node:18-alpine as server-dependencies
+
+RUN apk -U upgrade \
+ && apk add build-base python3 \
+ --no-cache
+
+WORKDIR /app/client
+COPY package.json package-lock.json /app/client/
+RUN npm install npm@latest --global \
+ && npm install pnpm --global \
+ && pnpm import \
+ && pnpm install
+
+
+WORKDIR /app/
+COPY ../../package.json ../../package-lock.json /app/
+RUN pnpm import \
+ && pnpm install
diff --git a/config/development/Dockerfile.server b/config/development/Dockerfile.server
new file mode 100644
index 0000000..0f51abe
--- /dev/null
+++ b/config/development/Dockerfile.server
@@ -0,0 +1,14 @@
+FROM node:18-alpine as server-dependencies
+
+RUN apk -U upgrade \
+ && apk add build-base python3 \
+ --no-cache
+
+WORKDIR /app
+
+COPY package.json package-lock.json ./
+
+RUN npm install npm@latest --global \
+ && npm install pnpm --global \
+ && pnpm import \
+ && pnpm install
diff --git a/config/development/nginx.conf b/config/development/nginx.conf
new file mode 100644
index 0000000..94fc7d7
--- /dev/null
+++ b/config/development/nginx.conf
@@ -0,0 +1,47 @@
+user nginx;
+worker_processes 1;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ sendfile on;
+ keepalive_timeout 65;
+
+ server {
+ listen 80;
+
+ location /api/ {
+ proxy_pass http://server:1337;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host $host;
+ proxy_cache_bypass $http_upgrade;
+ }
+
+ location /socket.io {
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_pass http://server:1337/socket.io;
+ }
+
+ location / {
+ proxy_pass http://client:3000;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host $host;
+ proxy_cache_bypass $http_upgrade;
+ }
+ }
+}
diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml
index 4f867d6..b3eccfd 100644
--- a/docker-compose-dev.yml
+++ b/docker-compose-dev.yml
@@ -1,28 +1,18 @@
-version: '3'
+version: '3.8'
services:
- planka:
- image: ghcr.io/plankanban/planka:master
- command: >
- bash -c
- "for i in `seq 1 30`; do
- ./start.sh &&
- s=$$? && break || s=$$?;
- echo \"Tried $$i times. Waiting 5 seconds...\";
- sleep 5;
- done; (exit $$s)"
- restart: unless-stopped
+
+ server:
+ build:
+ context: ./server
+ dockerfile: ../config/development/Dockerfile.server
volumes:
- - user-avatars:/app/public/user-avatars
- - project-background-images:/app/public/project-background-images
- - attachments:/app/private/attachments
- ports:
- - 3000:1337
+ - ./server:/app
+ - /app/node_modules
environment:
- - BASE_URL=http://localhost:3000
- - DATABASE_URL=postgresql://postgres@postgres/planka
+ - NODE_ENV=development
+ - DATABASE_URL=postgresql://user:password@postgres:5432/planka_db
- SECRET_KEY=notsecretkey
-
# - TRUST_PROXY=0
# - TOKEN_EXPIRES_IN=365 # In days
@@ -33,33 +23,99 @@ 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_EMAIL_ATTRIBUTE=email
+ # - OIDC_NAME_ATTRIBUTE=name
+ # - OIDC_USERNAME_ATTRIBUTE=preferred_username
# - OIDC_ROLES_ATTRIBUTE=groups
+ # - OIDC_IGNORE_USERNAME=true
# - OIDC_IGNORE_ROLES=true
+ # - OIDC_ENFORCED=true
+
+ # Email Notifications (https://nodemailer.com/smtp/)
+ # - SMTP_HOST=
+ # - SMTP_PORT=587
+ # - SMTP_SECURE=true
+ # - SMTP_USER=
+ # - SMTP_PASSWORD=
+ # - SMTP_FROM="Demo Demo"
+
+ # - SLACK_BOT_TOKEN=
+ # - SLACK_CHANNEL_ID=
+
+ working_dir: /app
+ command: ["sh", "-c", "npm run start"]
depends_on:
- - postgres
+ postgres:
+ condition: service_healthy
+
+ client:
+ build:
+ context: ./client
+ dockerfile: ../config/development/Dockerfile.client
+ volumes:
+ - ./client:/app/client
+ - /app/client/node_modules
+ - /app/node_modules
+ environment:
+ - NODE_ENV=development
+ - CHOKIDAR_USEPOLLING=true
+ - BASE_URL=http://localhost:3000
+ - REACT_APP_SERVER_BASE_URL=http://localhost:3000
+ working_dir: /app/client
+ command: npm start
+
+ init-db:
+ build:
+ context: ./server
+ dockerfile: ../config/development/Dockerfile.server
+ environment:
+ - DATABASE_URL=postgresql://user:password@postgres:5432/planka_db
+ # - 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
+
+ working_dir: /app
+ command: ["sh", "-c", "npm run db:init"]
+ volumes:
+ - ./server:/app
+ - /app/node_modules
+ depends_on:
+ postgres:
+ condition: service_healthy
postgres:
- image: postgres:14-alpine
- restart: unless-stopped
+ image: postgres:latest
volumes:
- db-data:/var/lib/postgresql/data
environment:
- - POSTGRES_DB=planka
- - POSTGRES_HOST_AUTH_METHOD=trust
+ POSTGRES_DB: planka_db
+ POSTGRES_USER: user
+ POSTGRES_PASSWORD: password
+ ports:
+ - "5432:5432"
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres -d planka"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ proxy:
+ image: nginx:alpine
+ ports:
+ - "3000:80"
+ volumes:
+ - ./config/development/nginx.conf:/etc/nginx/nginx.conf
+ depends_on:
+ - server
+ - client
+
volumes:
- user-avatars:
- project-background-images:
- attachments:
db-data:
diff --git a/docker-compose.yml b/docker-compose.yml
index 9d8fef1..f7935aa 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,15 +3,7 @@ version: '3'
services:
planka:
image: ghcr.io/plankanban/planka:latest
- command: >
- bash -c
- "for i in `seq 1 30`; do
- ./start.sh &&
- s=$$? && break || s=$$?;
- echo \"Tried $$i times. Waiting 5 seconds...\";
- sleep 5;
- done; (exit $$s)"
- restart: unless-stopped
+ restart: on-failure
volumes:
- user-avatars:/app/public/user-avatars
- project-background-images:/app/public/project-background-images
@@ -44,19 +36,41 @@ services:
# - OIDC_CLIENT_SECRET=
# - OIDC_SCOPES=openid email profile
# - OIDC_ADMIN_ROLES=admin
+ # - OIDC_EMAIL_ATTRIBUTE=email
+ # - OIDC_NAME_ATTRIBUTE=name
+ # - OIDC_USERNAME_ATTRIBUTE=preferred_username
# - OIDC_ROLES_ATTRIBUTE=groups
+ # - OIDC_IGNORE_USERNAME=true
# - OIDC_IGNORE_ROLES=true
+ # - OIDC_ENFORCED=true
+
+ # Email Notifications (https://nodemailer.com/smtp/)
+ # - SMTP_HOST=
+ # - SMTP_PORT=587
+ # - SMTP_SECURE=true
+ # - SMTP_USER=
+ # - SMTP_PASSWORD=
+ # - SMTP_FROM="Demo Demo"
+
+ # - SLACK_BOT_TOKEN=
+ # - SLACK_CHANNEL_ID=
depends_on:
- - postgres
+ postgres:
+ condition: service_healthy
postgres:
image: postgres:14-alpine
- restart: unless-stopped
+ restart: on-failure
volumes:
- db-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=planka
- POSTGRES_HOST_AUTH_METHOD=trust
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres -d planka"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
volumes:
user-avatars:
diff --git a/healthcheck.js b/healthcheck.js
new file mode 100644
index 0000000..ed3dbce
--- /dev/null
+++ b/healthcheck.js
@@ -0,0 +1,23 @@
+const http = require('http');
+const options = {
+ host: 'localhost',
+ port: 1337,
+ timeout: 2000
+};
+
+const healthCheck = http.request(options, (res) => {
+ console.log(`HEALTHCHECK STATUS: ${res.statusCode}`);
+ if (res.statusCode == 200) {
+ process.exit(0);
+ }
+ else {
+ process.exit(1);
+ }
+});
+
+healthCheck.on('error', function (err) {
+ console.error('ERROR');
+ process.exit(1);
+});
+
+healthCheck.end();
diff --git a/package-lock.json b/package-lock.json
index 51e7931..9c2bff2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "planka",
- "version": "1.15.4",
+ "version": "1.16.4",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "planka",
- "version": "1.15.4",
+ "version": "1.16.4",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
diff --git a/package.json b/package.json
index 87a41e4..dab2865 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "planka",
- "version": "1.15.4",
+ "version": "1.16.4",
"private": true,
"homepage": "https://plankanban.github.io/planka",
"repository": {
diff --git a/server/.env.sample b/server/.env.sample
index f06b647..15d66ed 100644
--- a/server/.env.sample
+++ b/server/.env.sample
@@ -27,8 +27,24 @@ SECRET_KEY=notsecretkey
# OIDC_CLIENT_SECRET=
# OIDC_SCOPES=openid email profile
# OIDC_ADMIN_ROLES=admin
+# OIDC_EMAIL_ATTRIBUTE=email
+# OIDC_NAME_ATTRIBUTE=name
+# OIDC_USERNAME_ATTRIBUTE=preferred_username
# OIDC_ROLES_ATTRIBUTE=groups
+# OIDC_IGNORE_USERNAME=true
# OIDC_IGNORE_ROLES=true
+# OIDC_ENFORCED=true
+
+# Email Notifications (https://nodemailer.com/smtp/)
+# SMTP_HOST=
+# SMTP_PORT=587
+# SMTP_SECURE=true
+# SMTP_USER=
+# SMTP_PASSWORD=
+# SMTP_FROM="Demo Demo"
+
+# SLACK_BOT_TOKEN=
+# SLACK_CHANNEL_ID=
## Do not edit this
diff --git a/server/api/controllers/access-tokens/create.js b/server/api/controllers/access-tokens/create.js
index c12c4f8..ed9eb8c 100755
--- a/server/api/controllers/access-tokens/create.js
+++ b/server/api/controllers/access-tokens/create.js
@@ -46,8 +46,11 @@ module.exports = {
},
async fn(inputs) {
- const remoteAddress = getRemoteAddress(this.req);
+ if (sails.config.custom.oidcEnforced) {
+ throw Errors.USE_SINGLE_SIGN_ON;
+ }
+ const remoteAddress = getRemoteAddress(this.req);
const user = await sails.helpers.users.getOneByEmailOrUsername(inputs.emailOrUsername);
if (!user) {
diff --git a/server/api/controllers/access-tokens/delete.js b/server/api/controllers/access-tokens/delete.js
index e3889e2..b252a0c 100644
--- a/server/api/controllers/access-tokens/delete.js
+++ b/server/api/controllers/access-tokens/delete.js
@@ -9,6 +9,10 @@ module.exports = {
deletedAt: new Date().toISOString(),
});
+ if (this.req.isSocket) {
+ sails.sockets.leaveAll(`@accessToken:${accessToken}`);
+ }
+
return {
item: accessToken,
};
diff --git a/server/api/controllers/board-memberships/create.js b/server/api/controllers/board-memberships/create.js
index fa4ec89..7c70fa5 100755
--- a/server/api/controllers/board-memberships/create.js
+++ b/server/api/controllers/board-memberships/create.js
@@ -29,6 +29,7 @@ module.exports = {
},
canComment: {
type: 'boolean',
+ allowNull: true,
},
},
diff --git a/server/api/controllers/board-memberships/update.js b/server/api/controllers/board-memberships/update.js
index ffbf2d5..695b2b8 100644
--- a/server/api/controllers/board-memberships/update.js
+++ b/server/api/controllers/board-memberships/update.js
@@ -17,6 +17,7 @@ module.exports = {
},
canComment: {
type: 'boolean',
+ allowNull: true,
},
},
diff --git a/server/api/controllers/cards/create.js b/server/api/controllers/cards/create.js
index ab0c444..b847c3e 100755
--- a/server/api/controllers/cards/create.js
+++ b/server/api/controllers/cards/create.js
@@ -78,12 +78,12 @@ module.exports = {
async fn(inputs) {
const { currentUser } = this.req;
- const { list } = await sails.helpers.lists
+ const { board, list } = await sails.helpers.lists
.getProjectPath(inputs.listId)
.intercept('pathNotFound', () => Errors.LIST_NOT_FOUND);
const boardMembership = await BoardMembership.findOne({
- boardId: list.boardId,
+ boardId: board.id,
userId: currentUser.id,
});
@@ -99,6 +99,7 @@ module.exports = {
const card = await sails.helpers.cards.createOne
.with({
+ board,
values: {
...values,
list,
diff --git a/server/api/controllers/cards/delete.js b/server/api/controllers/cards/delete.js
index e2b6d00..dad82de 100755
--- a/server/api/controllers/cards/delete.js
+++ b/server/api/controllers/cards/delete.js
@@ -47,6 +47,7 @@ module.exports = {
card = await sails.helpers.cards.deleteOne.with({
record: card,
+ user: currentUser,
request: this.req,
});
diff --git a/server/api/controllers/cards/duplicate.js b/server/api/controllers/cards/duplicate.js
new file mode 100755
index 0000000..918a8ec
--- /dev/null
+++ b/server/api/controllers/cards/duplicate.js
@@ -0,0 +1,82 @@
+const Errors = {
+ NOT_ENOUGH_RIGHTS: {
+ notEnoughRights: 'Not enough rights',
+ },
+ CARD_NOT_FOUND: {
+ cardNotFound: 'Card not found',
+ },
+};
+
+module.exports = {
+ inputs: {
+ id: {
+ type: 'string',
+ regex: /^[0-9]+$/,
+ required: true,
+ },
+ position: {
+ type: 'number',
+ required: true,
+ },
+ name: {
+ type: 'string',
+ },
+ },
+
+ exits: {
+ notEnoughRights: {
+ responseType: 'forbidden',
+ },
+ cardNotFound: {
+ responseType: 'notFound',
+ },
+ },
+
+ async fn(inputs) {
+ const { currentUser } = this.req;
+
+ const { card, list, board } = await sails.helpers.cards
+ .getProjectPath(inputs.id)
+ .intercept('pathNotFound', () => Errors.CARD_NOT_FOUND);
+
+ const boardMembership = await BoardMembership.findOne({
+ boardId: card.boardId,
+ userId: currentUser.id,
+ });
+
+ if (!boardMembership) {
+ throw Errors.CARD_NOT_FOUND; // Forbidden
+ }
+
+ if (boardMembership.role !== BoardMembership.Roles.EDITOR) {
+ throw Errors.NOT_ENOUGH_RIGHTS;
+ }
+
+ const values = _.pick(inputs, ['position', 'name']);
+
+ const {
+ card: nextCard,
+ cardMemberships,
+ cardLabels,
+ tasks,
+ } = await sails.helpers.cards.duplicateOne.with({
+ board,
+ list,
+ record: card,
+ values: {
+ ...values,
+ creatorUser: currentUser,
+ },
+ request: this.req,
+ });
+
+ return {
+ item: nextCard,
+ included: {
+ cardMemberships,
+ cardLabels,
+ tasks,
+ },
+ };
+ },
+};
diff --git a/server/api/controllers/comment-actions/create.js b/server/api/controllers/comment-actions/create.js
index ddf4b99..7712a3a 100755
--- a/server/api/controllers/comment-actions/create.js
+++ b/server/api/controllers/comment-actions/create.js
@@ -32,12 +32,12 @@ module.exports = {
async fn(inputs) {
const { currentUser } = this.req;
- const { card } = await sails.helpers.cards
+ const { board, card } = await sails.helpers.cards
.getProjectPath(inputs.cardId)
.intercept('pathNotFound', () => Errors.CARD_NOT_FOUND);
const boardMembership = await BoardMembership.findOne({
- boardId: card.boardId,
+ boardId: board.id,
userId: currentUser.id,
});
@@ -55,6 +55,7 @@ module.exports = {
};
const action = await sails.helpers.actions.createOne.with({
+ board,
values: {
...values,
card,
diff --git a/server/api/controllers/show-config.js b/server/api/controllers/show-config.js
index a580a7c..5349149 100644
--- a/server/api/controllers/show-config.js
+++ b/server/api/controllers/show-config.js
@@ -10,6 +10,7 @@ module.exports = {
response_mode: 'fragment',
}),
endSessionUrl: oidcClient.issuer.end_session_endpoint ? oidcClient.endSessionUrl({}) : null,
+ isEnforced: sails.config.custom.oidcEnforced,
};
}
diff --git a/server/api/controllers/users/create.js b/server/api/controllers/users/create.js
index 5652ad5..7d40c97 100755
--- a/server/api/controllers/users/create.js
+++ b/server/api/controllers/users/create.js
@@ -1,6 +1,9 @@
const zxcvbn = require('zxcvbn');
const Errors = {
+ NOT_ENOUGH_RIGHTS: {
+ notEnoughRights: 'Not enough rights',
+ },
EMAIL_ALREADY_IN_USE: {
emailAlreadyInUse: 'Email already in use',
},
@@ -56,6 +59,9 @@ module.exports = {
},
exits: {
+ notEnoughRights: {
+ responseType: 'forbidden',
+ },
emailAlreadyInUse: {
responseType: 'conflict',
},
@@ -65,6 +71,10 @@ module.exports = {
},
async fn(inputs) {
+ if (sails.config.custom.oidcEnforced) {
+ throw Errors.NOT_ENOUGH_RIGHTS;
+ }
+
const values = _.pick(inputs, [
'email',
'password',
diff --git a/server/api/controllers/users/update-username.js b/server/api/controllers/users/update-username.js
index 5805946..b55529b 100644
--- a/server/api/controllers/users/update-username.js
+++ b/server/api/controllers/users/update-username.js
@@ -53,11 +53,7 @@ module.exports = {
async fn(inputs) {
const { currentUser } = this.req;
- if (inputs.id === currentUser.id) {
- if (!inputs.currentPassword) {
- throw Errors.INVALID_CURRENT_PASSWORD;
- }
- } else if (!currentUser.isAdmin) {
+ if (inputs.id !== currentUser.id && !currentUser.isAdmin) {
throw Errors.USER_NOT_FOUND; // Forbidden
}
@@ -67,15 +63,18 @@ module.exports = {
throw Errors.USER_NOT_FOUND;
}
- if (user.email === sails.config.custom.defaultAdminEmail || user.isSso) {
+ if (user.email === sails.config.custom.defaultAdminEmail) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
- if (
- inputs.id === currentUser.id &&
- !bcrypt.compareSync(inputs.currentPassword, user.password)
- ) {
- throw Errors.INVALID_CURRENT_PASSWORD;
+ if (user.isSso) {
+ if (!sails.config.custom.oidcIgnoreUsername) {
+ throw Errors.NOT_ENOUGH_RIGHTS;
+ }
+ } else if (inputs.id === currentUser.id) {
+ if (!inputs.currentPassword || !bcrypt.compareSync(inputs.currentPassword, user.password)) {
+ throw Errors.INVALID_CURRENT_PASSWORD;
+ }
}
const values = _.pick(inputs, ['username']);
diff --git a/server/api/helpers/actions/create-one.js b/server/api/helpers/actions/create-one.js
index e96df93..b03adfd 100644
--- a/server/api/helpers/actions/create-one.js
+++ b/server/api/helpers/actions/create-one.js
@@ -14,6 +14,30 @@ const valuesValidator = (value) => {
return true;
};
+const buildAndSendSlackMessage = async (user, card, action) => {
+ const cardLink = `<${sails.config.custom.baseUrl}/cards/${card.id}|${card.name}>`;
+
+ let markdown;
+ switch (action.type) {
+ case Action.Types.CREATE_CARD:
+ markdown = `${cardLink} was created by ${user.name} in *${action.data.list.name}*`;
+
+ break;
+ case Action.Types.MOVE_CARD:
+ markdown = `${cardLink} was moved by ${user.name} to *${action.data.toList.name}*`;
+
+ break;
+ case Action.Types.COMMENT_CARD:
+ markdown = `*${user.name}* commented on ${cardLink}:\n>${action.data.text}`;
+
+ break;
+ default:
+ return;
+ }
+
+ await sails.helpers.utils.sendSlackMessage(markdown);
+};
+
module.exports = {
inputs: {
values: {
@@ -21,6 +45,10 @@ module.exports = {
custom: valuesValidator,
required: true,
},
+ board: {
+ type: 'ref',
+ required: true,
+ },
request: {
type: 'ref',
},
@@ -56,10 +84,17 @@ module.exports = {
userId,
action,
},
+ user: values.user,
+ board: inputs.board,
+ card: values.card,
}),
),
);
+ if (sails.config.custom.slackBotToken) {
+ buildAndSendSlackMessage(values.user, values.card, action);
+ }
+
return action;
},
};
diff --git a/server/api/helpers/boards/import-from-trello.js b/server/api/helpers/boards/import-from-trello.js
index f036370..2ab6e0a 100644
--- a/server/api/helpers/boards/import-from-trello.js
+++ b/server/api/helpers/boards/import-from-trello.js
@@ -1,3 +1,5 @@
+const POSITION_GAP = 65535; // TODO: move to config
+
module.exports = {
inputs: {
user: {
@@ -107,6 +109,7 @@ module.exports = {
position: trelloCard.pos,
name: trelloCard.name,
description: trelloCard.desc || null,
+ dueDate: trelloCard.due,
}).fetch();
await importCardLabels(plankaCard, trelloCard);
@@ -123,7 +126,7 @@ module.exports = {
getUsedTrelloLabels().map(async (trelloLabel, index) => {
const plankaLabel = await Label.create({
boardId: inputs.board.id,
- position: 65535 * (index + 1), // TODO: move to config
+ position: POSITION_GAP * (index + 1),
name: trelloLabel.name || null,
color: getPlankaLabelColor(trelloLabel.color),
}).fetch();
diff --git a/server/api/helpers/cards/create-one.js b/server/api/helpers/cards/create-one.js
index e1a96b4..847a00c 100644
--- a/server/api/helpers/cards/create-one.js
+++ b/server/api/helpers/cards/create-one.js
@@ -25,6 +25,10 @@ module.exports = {
custom: valuesValidator,
required: true,
},
+ board: {
+ type: 'ref',
+ required: true,
+ },
request: {
type: 'ref',
},
@@ -104,6 +108,7 @@ module.exports = {
},
user: values.creatorUser,
},
+ board: inputs.board,
});
return card;
diff --git a/server/api/helpers/cards/delete-one.js b/server/api/helpers/cards/delete-one.js
index fb72787..a947f73 100644
--- a/server/api/helpers/cards/delete-one.js
+++ b/server/api/helpers/cards/delete-one.js
@@ -1,9 +1,17 @@
+const buildAndSendSlackMessage = async (user, card) => {
+ await sails.helpers.utils.sendSlackMessage(`*${card.name}* was deleted by ${user.name}`);
+};
+
module.exports = {
inputs: {
record: {
type: 'ref',
required: true,
},
+ user: {
+ type: 'ref',
+ required: true,
+ },
request: {
type: 'ref',
},
@@ -21,6 +29,10 @@ module.exports = {
},
inputs.request,
);
+
+ if (sails.config.custom.slackBotToken) {
+ buildAndSendSlackMessage(inputs.user, card);
+ }
}
return card;
diff --git a/server/api/helpers/cards/duplicate-one.js b/server/api/helpers/cards/duplicate-one.js
new file mode 100644
index 0000000..a63feaa
--- /dev/null
+++ b/server/api/helpers/cards/duplicate-one.js
@@ -0,0 +1,144 @@
+const valuesValidator = (value) => {
+ if (!_.isPlainObject(value)) {
+ return false;
+ }
+
+ if (!_.isUndefined(value.position) && !_.isFinite(value.position)) {
+ return false;
+ }
+
+ if (!_.isPlainObject(value.creatorUser)) {
+ return false;
+ }
+
+ return true;
+};
+
+module.exports = {
+ inputs: {
+ record: {
+ type: 'ref',
+ required: true,
+ },
+ values: {
+ type: 'ref',
+ custom: valuesValidator,
+ required: true,
+ },
+ board: {
+ type: 'ref',
+ required: true,
+ },
+ list: {
+ type: 'ref',
+ required: true,
+ },
+ request: {
+ type: 'ref',
+ },
+ },
+
+ async fn(inputs) {
+ const { values } = inputs;
+
+ const cards = await sails.helpers.lists.getCards(inputs.record.listId);
+
+ const { position, repositions } = sails.helpers.utils.insertToPositionables(
+ values.position,
+ cards,
+ );
+
+ repositions.forEach(async ({ id, position: nextPosition }) => {
+ await Card.update({
+ id,
+ listId: inputs.record.listId,
+ }).set({
+ position: nextPosition,
+ });
+
+ sails.sockets.broadcast(`board:${inputs.record.boardId}`, 'cardUpdate', {
+ item: {
+ id,
+ position: nextPosition,
+ },
+ });
+ });
+
+ const card = await Card.create({
+ ..._.pick(inputs.record, [
+ 'boardId',
+ 'listId',
+ 'name',
+ 'description',
+ 'dueDate',
+ 'stopwatch',
+ ]),
+ ...values,
+ position,
+ creatorUserId: values.creatorUser.id,
+ }).fetch();
+
+ const cardMemberships = await sails.helpers.cards.getCardMemberships(inputs.record.id);
+ const cardMembershipsValues = cardMemberships.map((cardMembership) => ({
+ ..._.pick(cardMembership, ['userId']),
+ cardId: card.id,
+ }));
+ const nextCardMemberships = await CardMembership.createEach(cardMembershipsValues).fetch();
+
+ const cardLabels = await sails.helpers.cards.getCardLabels(inputs.record.id);
+ const cardLabelsValues = cardLabels.map((cardLabel) => ({
+ ..._.pick(cardLabel, ['labelId']),
+ cardId: card.id,
+ }));
+ const nextCardLabels = await CardLabel.createEach(cardLabelsValues).fetch();
+
+ const tasks = await sails.helpers.cards.getTasks(inputs.record.id);
+ const tasksValues = tasks.map((task) => ({
+ ..._.pick(task, ['position', 'name', 'isCompleted']),
+ cardId: card.id,
+ }));
+ const nextTasks = await Task.createEach(tasksValues).fetch();
+
+ sails.sockets.broadcast(
+ `board:${card.boardId}`,
+ 'cardCreate',
+ {
+ item: card,
+ },
+ inputs.request,
+ );
+
+ if (values.creatorUser.subscribeToOwnCards) {
+ await CardSubscription.create({
+ cardId: card.id,
+ userId: card.creatorUserId,
+ }).tolerate('E_UNIQUE');
+
+ sails.sockets.broadcast(`user:${card.creatorUserId}`, 'cardUpdate', {
+ item: {
+ id: card.id,
+ isSubscribed: true,
+ },
+ });
+ }
+
+ await sails.helpers.actions.createOne.with({
+ values: {
+ card,
+ type: Action.Types.CREATE_CARD, // TODO: introduce separate type?
+ data: {
+ list: _.pick(inputs.list, ['id', 'name']),
+ },
+ user: values.creatorUser,
+ },
+ board: inputs.board,
+ });
+
+ return {
+ card,
+ cardMemberships: nextCardMemberships,
+ cardLabels: nextCardLabels,
+ tasks: nextTasks,
+ };
+ },
+};
diff --git a/server/api/helpers/cards/update-one.js b/server/api/helpers/cards/update-one.js
index 1a78f8d..4363f9b 100644
--- a/server/api/helpers/cards/update-one.js
+++ b/server/api/helpers/cards/update-one.js
@@ -232,6 +232,7 @@ module.exports = {
toList: _.pick(values.list, ['id', 'name']),
},
},
+ board: inputs.board,
});
}
diff --git a/server/api/helpers/notifications/create-one.js b/server/api/helpers/notifications/create-one.js
index cc6a0b6..f24e4bf 100644
--- a/server/api/helpers/notifications/create-one.js
+++ b/server/api/helpers/notifications/create-one.js
@@ -14,6 +14,42 @@ const valuesValidator = (value) => {
return true;
};
+// TODO: use templates (views) to build html
+const buildAndSendEmail = async (user, board, card, action, notifiableUser) => {
+ let emailData;
+ switch (action.type) {
+ case Action.Types.MOVE_CARD:
+ emailData = {
+ subject: `${user.name} moved ${card.name} from ${action.data.fromList.name} to ${action.data.toList.name} on ${board.name}`,
+ html:
+ `${user.name} moved ` +
+ `${card.name} ` +
+ `from ${action.data.fromList.name} to ${action.data.toList.name} ` +
+ `on ${board.name}
`,
+ };
+
+ break;
+ case Action.Types.COMMENT_CARD:
+ emailData = {
+ subject: `${user.name} left a new comment to ${card.name} on ${board.name}`,
+ html:
+ `${user.name} left a new comment to ` +
+ `${card.name} ` +
+ `on ${board.name}
` +
+ `${action.data.text}
`,
+ };
+
+ break;
+ default:
+ return;
+ }
+
+ await sails.helpers.utils.sendEmail.with({
+ ...emailData,
+ to: notifiableUser.email,
+ });
+};
+
module.exports = {
inputs: {
values: {
@@ -21,6 +57,18 @@ module.exports = {
custom: valuesValidator,
required: true,
},
+ user: {
+ type: 'ref',
+ required: true,
+ },
+ board: {
+ type: 'ref',
+ required: true,
+ },
+ card: {
+ type: 'ref',
+ required: true,
+ },
},
async fn(inputs) {
@@ -40,6 +88,17 @@ module.exports = {
item: notification,
});
+ if (sails.hooks.smtp.isActive()) {
+ let notifiableUser;
+ if (values.user) {
+ notifiableUser = values.user;
+ } else {
+ notifiableUser = await sails.helpers.users.getOne(notification.userId);
+ }
+
+ buildAndSendEmail(inputs.user, inputs.board, inputs.card, values.action, notifiableUser);
+ }
+
return notification;
},
};
diff --git a/server/api/helpers/users/get-or-create-one-using-oidc.js b/server/api/helpers/users/get-or-create-one-using-oidc.js
index 6d1c49f..2186c09 100644
--- a/server/api/helpers/users/get-or-create-one-using-oidc.js
+++ b/server/api/helpers/users/get-or-create-one-using-oidc.js
@@ -38,7 +38,10 @@ module.exports = {
throw 'invalidCodeOrNonce';
}
- if (!userInfo.email || !userInfo.name) {
+ if (
+ !userInfo[sails.config.custom.oidcEmailAttribute] ||
+ !userInfo[sails.config.custom.oidcNameAttribute]
+ ) {
throw 'missingValues';
}
@@ -56,12 +59,14 @@ module.exports = {
const values = {
isAdmin,
- email: userInfo.email,
+ email: userInfo[sails.config.custom.oidcEmailAttribute],
isSso: true,
- name: userInfo.name,
- username: userInfo.preferred_username,
+ name: userInfo[sails.config.custom.oidcNameAttribute],
subscribeToOwnCards: false,
};
+ if (!sails.config.custom.oidcIgnoreUsername) {
+ values.username = userInfo[sails.config.custom.oidcUsernameAttribute];
+ }
let user;
// This whole block technically needs to be executed in a transaction
@@ -95,7 +100,10 @@ module.exports = {
});
}
- const updateFieldKeys = ['email', 'isSso', 'name', 'username'];
+ const updateFieldKeys = ['email', 'isSso', 'name'];
+ if (!sails.config.custom.oidcIgnoreUsername) {
+ updateFieldKeys.push('username');
+ }
if (!sails.config.custom.oidcIgnoreRoles) {
updateFieldKeys.push('isAdmin');
}
diff --git a/server/api/helpers/utils/send-email.js b/server/api/helpers/utils/send-email.js
new file mode 100644
index 0000000..02593f8
--- /dev/null
+++ b/server/api/helpers/utils/send-email.js
@@ -0,0 +1,31 @@
+module.exports = {
+ inputs: {
+ to: {
+ type: 'string',
+ required: true,
+ },
+ subject: {
+ type: 'string',
+ required: true,
+ },
+ html: {
+ type: 'string',
+ required: true,
+ },
+ },
+
+ async fn(inputs) {
+ const transporter = sails.hooks.smtp.getTransporter(); // TODO: check if active?
+
+ try {
+ const info = await transporter.sendMail({
+ ...inputs,
+ from: sails.config.custom.smtpFrom,
+ });
+
+ sails.log.info(`Email sent: ${info.messageId}`);
+ } catch (error) {
+ sails.log.error(`Error sending email: ${error}`);
+ }
+ },
+};
diff --git a/server/api/helpers/utils/send-slack-message.js b/server/api/helpers/utils/send-slack-message.js
new file mode 100644
index 0000000..510ae1b
--- /dev/null
+++ b/server/api/helpers/utils/send-slack-message.js
@@ -0,0 +1,53 @@
+const POST_MESSAGE_API_URL = 'https://slack.com/api/chat.postMessage';
+
+module.exports = {
+ inputs: {
+ markdown: {
+ type: 'string',
+ required: true,
+ },
+ },
+
+ async fn(inputs) {
+ const headers = {
+ Authorization: `Bearer ${sails.config.custom.slackBotToken}`,
+ 'Content-Type': 'application/json; charset=utf-8',
+ };
+
+ const body = {
+ blocks: [
+ {
+ type: 'section',
+ text: {
+ type: 'mrkdwn',
+ text: inputs.markdown,
+ },
+ },
+ ],
+ channel: sails.config.custom.slackChannelId,
+ };
+
+ let response;
+ try {
+ response = await fetch(POST_MESSAGE_API_URL, {
+ headers,
+ method: 'POST',
+ body: JSON.stringify(body),
+ });
+ } catch (error) {
+ sails.log.error(`Error sending to Slack: ${error}`);
+ return;
+ }
+
+ if (!response.ok) {
+ sails.log.error(`Error sending to Slack: ${response.error}`);
+ return;
+ }
+
+ const responseJson = await response.json();
+
+ if (!responseJson.ok) {
+ sails.log.error(`Error sending to Slack: ${responseJson.error}`);
+ }
+ },
+};
diff --git a/server/api/hooks/current-user/index.js b/server/api/hooks/current-user/index.js
index dd29595..1d3214f 100644
--- a/server/api/hooks/current-user/index.js
+++ b/server/api/hooks/current-user/index.js
@@ -61,6 +61,7 @@ module.exports = function defineCurrentUserHook(sails) {
});
if (req.isSocket) {
+ sails.sockets.join(req, `@accessToken:${accessToken}`);
sails.sockets.join(req, `@user:${currentUser.id}`);
}
}
diff --git a/server/api/hooks/oidc/index.js b/server/api/hooks/oidc/index.js
index 05d9d0b..6dbeddd 100644
--- a/server/api/hooks/oidc/index.js
+++ b/server/api/hooks/oidc/index.js
@@ -1,6 +1,14 @@
const openidClient = require('openid-client');
-module.exports = function oidcServiceHook(sails) {
+/**
+ * oidc hook
+ *
+ * @description :: A hook definition. Extends Sails by adding shadow routes, implicit actions,
+ * and/or initialization logic.
+ * @docs :: https://sailsjs.com/docs/concepts/extending-sails/hooks
+ */
+
+module.exports = function defineOidcHook(sails) {
let client = null;
return {
@@ -9,17 +17,20 @@ module.exports = function oidcServiceHook(sails) {
*/
async initialize() {
- if (sails.config.custom.oidcIssuer) {
- const issuer = await openidClient.Issuer.discover(sails.config.custom.oidcIssuer);
-
- client = new issuer.Client({
- client_id: sails.config.custom.oidcClientId,
- client_secret: sails.config.custom.oidcClientSecret,
- redirect_uris: [sails.config.custom.oidcRedirectUri],
- response_types: ['code'],
- });
- sails.log.info('OIDC hook has been loaded successfully');
+ if (!sails.config.custom.oidcIssuer) {
+ return;
}
+
+ sails.log.info('Initializing custom hook (`oidc`)');
+
+ const issuer = await openidClient.Issuer.discover(sails.config.custom.oidcIssuer);
+
+ client = new issuer.Client({
+ client_id: sails.config.custom.oidcClientId,
+ client_secret: sails.config.custom.oidcClientSecret,
+ redirect_uris: [sails.config.custom.oidcRedirectUri],
+ response_types: ['code'],
+ });
},
getClient() {
diff --git a/server/api/hooks/smtp/index.js b/server/api/hooks/smtp/index.js
new file mode 100644
index 0000000..d6ce84f
--- /dev/null
+++ b/server/api/hooks/smtp/index.js
@@ -0,0 +1,46 @@
+const nodemailer = require('nodemailer');
+
+/**
+ * smtp hook
+ *
+ * @description :: A hook definition. Extends Sails by adding shadow routes, implicit actions,
+ * and/or initialization logic.
+ * @docs :: https://sailsjs.com/docs/concepts/extending-sails/hooks
+ */
+
+module.exports = function defineSmtpHook(sails) {
+ let transporter = null;
+
+ return {
+ /**
+ * Runs when this Sails app loads/lifts.
+ */
+
+ async initialize() {
+ if (!sails.config.custom.smtpHost) {
+ return;
+ }
+
+ sails.log.info('Initializing custom hook (`smtp`)');
+
+ transporter = nodemailer.createTransport({
+ pool: true,
+ host: sails.config.custom.smtpHost,
+ port: sails.config.custom.smtpPort,
+ secure: sails.config.custom.smtpSecure,
+ auth: sails.config.custom.smtpUser && {
+ user: sails.config.custom.smtpUser,
+ pass: sails.config.custom.smtpPassword,
+ },
+ });
+ },
+
+ getTransporter() {
+ return transporter;
+ },
+
+ isActive() {
+ return transporter !== null;
+ },
+ };
+};
diff --git a/server/api/hooks/watcher/index.js b/server/api/hooks/watcher/index.js
new file mode 100644
index 0000000..835b1e8
--- /dev/null
+++ b/server/api/hooks/watcher/index.js
@@ -0,0 +1,38 @@
+/**
+ * watcher hook
+ *
+ * @description :: A hook definition. Extends Sails by adding shadow routes, implicit actions,
+ * and/or initialization logic.
+ * @docs :: https://sailsjs.com/docs/concepts/extending-sails/hooks
+ */
+
+module.exports = function defineWatcherHook(sails) {
+ const checkSocketConnectionsToLogout = () => {
+ Object.keys(sails.io.sockets.adapter.rooms).forEach((room) => {
+ if (!room.startsWith('@accessToken:')) {
+ return;
+ }
+
+ const accessToken = room.split(':')[1];
+
+ try {
+ sails.helpers.utils.verifyToken(accessToken);
+ } catch (error) {
+ sails.sockets.broadcast(room, 'logout');
+ sails.sockets.leaveAll(room);
+ }
+ });
+ };
+
+ return {
+ /**
+ * Runs when this Sails app loads/lifts.
+ */
+
+ async initialize() {
+ sails.log.info('Initializing custom hook (`watcher`)');
+
+ setInterval(checkSocketConnectionsToLogout, 60 * 1000);
+ },
+ };
+};
diff --git a/server/api/models/User.js b/server/api/models/User.js
index 9bf8a29..1d87531 100755
--- a/server/api/models/User.js
+++ b/server/api/models/User.js
@@ -116,6 +116,7 @@ module.exports = {
..._.omit(this, ['password', 'isSso', 'avatar', 'passwordChangedAt']),
isLocked: this.isSso || isDefaultAdmin,
isRoleLocked: (this.isSso && !sails.config.custom.oidcIgnoreRoles) || isDefaultAdmin,
+ isUsernameLocked: (this.isSso && !sails.config.custom.oidcIgnoreUsername) || isDefaultAdmin,
isDeletionLocked: isDefaultAdmin,
avatarUrl:
this.avatar &&
diff --git a/server/config/custom.js b/server/config/custom.js
index cbbc89b..ac344d0 100644
--- a/server/config/custom.js
+++ b/server/config/custom.js
@@ -31,18 +31,34 @@ module.exports.custom = {
attachmentsPath: path.join(sails.config.appPath, 'private', 'attachments'),
attachmentsUrl: `${process.env.BASE_URL}/attachments`,
- defaultAdminEmail: process.env.DEFAULT_ADMIN_EMAIL,
+ defaultAdminEmail:
+ process.env.DEFAULT_ADMIN_EMAIL && process.env.DEFAULT_ADMIN_EMAIL.toLowerCase(),
oidcIssuer: process.env.OIDC_ISSUER,
oidcClientId: process.env.OIDC_CLIENT_ID,
oidcClientSecret: process.env.OIDC_CLIENT_SECRET,
oidcScopes: process.env.OIDC_SCOPES || 'openid email profile',
oidcAdminRoles: process.env.OIDC_ADMIN_ROLES ? process.env.OIDC_ADMIN_ROLES.split(',') : [],
+ oidcEmailAttribute: process.env.OIDC_EMAIL_ATTRIBUTE || 'email',
+ oidcNameAttribute: process.env.OIDC_NAME_ATTRIBUTE || 'name',
+ oidcUsernameAttribute: process.env.OIDC_USERNAME_ATTRIBUTE || 'preferred_username',
oidcRolesAttribute: process.env.OIDC_ROLES_ATTRIBUTE || 'groups',
+ oidcIgnoreUsername: process.env.OIDC_IGNORE_USERNAME === 'true',
oidcIgnoreRoles: process.env.OIDC_IGNORE_ROLES === 'true',
+ oidcEnforced: process.env.OIDC_ENFORCED === 'true',
// TODO: move client base url to environment variable?
oidcRedirectUri: `${
sails.config.environment === 'production' ? process.env.BASE_URL : 'http://localhost:3000'
}/oidc-callback`,
+
+ smtpHost: process.env.SMTP_HOST,
+ smtpPort: process.env.SMTP_PORT || 587,
+ smtpSecure: process.env.SMTP_SECURE === 'true',
+ smtpUser: process.env.SMTP_USER,
+ smtpPassword: process.env.SMTP_PASSWORD,
+ smtpFrom: process.env.SMTP_FROM,
+
+ slackBotToken: process.env.SLACK_BOT_TOKEN,
+ slackChannelId: process.env.SLACK_CHANNEL_ID,
};
diff --git a/server/config/routes.js b/server/config/routes.js
index 77f5c43..8b88df5 100644
--- a/server/config/routes.js
+++ b/server/config/routes.js
@@ -55,6 +55,7 @@ module.exports.routes = {
'POST /api/lists/:listId/cards': 'cards/create',
'GET /api/cards/:id': 'cards/show',
'PATCH /api/cards/:id': 'cards/update',
+ 'POST /api/cards/:id/duplicate': 'cards/duplicate',
'DELETE /api/cards/:id': 'cards/delete',
'POST /api/cards/:cardId/memberships': 'card-memberships/create',
'DELETE /api/cards/:cardId/memberships': 'card-memberships/delete',
diff --git a/server/db/seeds/default.js b/server/db/seeds/default.js
index 137cf56..19c2e53 100644
--- a/server/db/seeds/default.js
+++ b/server/db/seeds/default.js
@@ -13,7 +13,7 @@ const buildData = () => {
data.name = process.env.DEFAULT_ADMIN_NAME;
}
if (process.env.DEFAULT_ADMIN_USERNAME) {
- data.username = process.env.DEFAULT_ADMIN_USERNAME;
+ data.username = process.env.DEFAULT_ADMIN_USERNAME.toLowerCase();
}
return data;
@@ -24,16 +24,17 @@ exports.seed = async (knex) => {
return;
}
+ const email = process.env.DEFAULT_ADMIN_EMAIL.toLowerCase();
const data = buildData();
try {
await knex('user_account').insert({
...data,
- email: process.env.DEFAULT_ADMIN_EMAIL,
+ email,
subscribeToOwnCards: false,
createdAt: new Date().toISOString(),
});
} catch (error) {
- await knex('user_account').update(data).where('email', process.env.DEFAULT_ADMIN_EMAIL);
+ await knex('user_account').update(data).where('email', email);
}
};
diff --git a/server/package-lock.json b/server/package-lock.json
index 05527a7..93453a8 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -15,6 +15,7 @@
"lodash": "^4.17.21",
"moment": "^2.29.4",
"move-file": "^2.1.0",
+ "nodemailer": "^6.9.12",
"openid-client": "^5.6.1",
"rimraf": "^5.0.5",
"sails": "^1.5.7",
@@ -5257,6 +5258,14 @@
}
}
},
+ "node_modules/nodemailer": {
+ "version": "6.9.12",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.12.tgz",
+ "integrity": "sha512-pnLo7g37Br3jXbF0bl5DekBJihm2q+3bB3l2o/B060sWmb5l+VqeScAQCBqaQ+5ezRZFzW5SciZNGdRDEbq89w==",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/nodemon": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.1.tgz",
@@ -12852,6 +12861,11 @@
"whatwg-url": "^5.0.0"
}
},
+ "nodemailer": {
+ "version": "6.9.12",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.12.tgz",
+ "integrity": "sha512-pnLo7g37Br3jXbF0bl5DekBJihm2q+3bB3l2o/B060sWmb5l+VqeScAQCBqaQ+5ezRZFzW5SciZNGdRDEbq89w=="
+ },
"nodemon": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.1.tgz",
diff --git a/server/package.json b/server/package.json
index 59bb2f7..67fc5e0 100644
--- a/server/package.json
+++ b/server/package.json
@@ -36,6 +36,7 @@
"lodash": "^4.17.21",
"moment": "^2.29.4",
"move-file": "^2.1.0",
+ "nodemailer": "^6.9.12",
"openid-client": "^5.6.1",
"rimraf": "^5.0.5",
"sails": "^1.5.7",
diff --git a/start.sh b/start.sh
index 5e10d63..440950c 100755
--- a/start.sh
+++ b/start.sh
@@ -1,4 +1,2 @@
#!/bin/bash
-set -e
-node db/init.js
-exec node app.js --prod $@
+export NODE_ENV=production && set -e && node db/init.js && node app.js --prod