Compare commits

...

27 Commits

Author SHA1 Message Date
Maksim Eltyshev
7580afdb1c chore: Update version 2024-10-22 21:41:07 +02:00
Maksim Eltyshev
2458709031 feat: Attachments icon on front of cards
Closes #225
2024-10-22 21:38:04 +02:00
Maksim Eltyshev
e0d5d7f4ff feat: Description icon on front of cards
Closes #563
2024-10-22 21:22:46 +02:00
Maksim Eltyshev
bedd1f6e2a chore: Bump sails version 2024-10-22 21:00:37 +02:00
Maksim Eltyshev
d9464dc3f9 docs: Add invitation to TestFlight 2024-10-22 20:37:29 +02:00
Maksim Eltyshev
87c1eeb5f8 chore: Bump postgres version to 16 in compose files 2024-10-22 20:22:23 +02:00
Holden Hu
98413d759a chore: Pin postgres version for docker-compose-db (#919)
Closes #918
2024-10-22 20:16:26 +02:00
Maksim Eltyshev
fa3b1d7b28 fix: Format-agnostic time parsing
Closes #916
2024-10-21 14:11:26 +02:00
Maksim Eltyshev
23e5f1a326 ci: Add lint workflow 2024-10-17 23:51:47 +02:00
dependabot[bot]
de44b520af chore(deps): Bump cookie and express in /client (#912)
Bumps [cookie](https://github.com/jshttp/cookie) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together.

Updates `cookie` from 0.6.0 to 0.7.1
- [Release notes](https://github.com/jshttp/cookie/releases)
- [Commits](https://github.com/jshttp/cookie/compare/v0.6.0...v0.7.1)

Updates `express` from 4.21.0 to 4.21.1
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/4.21.1/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.21.0...4.21.1)

---
updated-dependencies:
- dependency-name: cookie
  dependency-type: indirect
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-17 22:15:39 +02:00
Nalem7
e7d7425768 test: Add BDD UI tests using Playwright (#911) 2024-10-17 22:06:48 +02:00
Amrita
df7746f896 test: Setup UI test using BDD approach (#152) 2024-10-17 20:18:31 +02:00
Maksim Eltyshev
01a7e3a57d ci: Omit version completely from prebuild asset 2024-10-09 14:53:16 +02:00
Maksim Eltyshev
234bd1e8b3 ci: Fix naming of prebuild asset 2024-10-09 14:40:22 +02:00
Maksim Eltyshev
2173c3657c chore: Update version 2024-10-09 14:25:19 +02:00
Maksim Eltyshev
a2b81f6d59 feat: Add British English translation 2024-10-09 14:22:22 +02:00
Maksim Eltyshev
1948bd4fbe feat: Ability to upload multiple attachments at once
Closes #908
2024-10-09 12:38:18 +02:00
Maksim Eltyshev
52f8abc1f8 docs: Add information about mobile app 2024-10-03 19:07:59 +02:00
Maksim Eltyshev
dd7574f134 ci: Omit version from prebuild asset label 2024-10-02 15:20:10 +02:00
Maksim Eltyshev
19935e52af chore: Update version 2024-10-02 14:18:54 +02:00
Maksim Eltyshev
89292356db feat: Ability to disable SMTP certificate verification
Closes #744
2024-10-02 14:10:31 +02:00
dependabot[bot]
5f6528fa42 chore(deps): Bump rollup from 2.79.1 to 2.79.2 in /client (#898)
Bumps [rollup](https://github.com/rollup/rollup) from 2.79.1 to 2.79.2.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v2.79.1...v2.79.2)

---
updated-dependencies:
- dependency-name: rollup
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-02 12:32:10 +02:00
JoeKer1
2081b44874 feat: Support for service annotations in Helm (#903)
Closes #902
2024-10-02 12:30:55 +02:00
Maksim Eltyshev
0b729bf4b3 chore: Update version 2024-09-20 20:41:54 +02:00
Matthew Stickney
368ead982e feat: Configurable file storage locations (#886)
* feat: Make logfile location customizable

It may be desirable to log to a more standard location (e.g. in /var/log/),
or in some cases to turn logging to file off. To support these, use a
custom config property to determine the location of the output log file,
and default to the previous location if it is unset.

* feat: Support alternate storage locations for uploaded files

This involves a couple primary changes:
1) to make Sails' temporary file-upload directory a configurable location
   by using a common file-upload-receiving helper;
2) to create custom static routes for the file-upload locations, so they
   can be outside the application's public directory; and
3) to use the file-uploading handler everywhere that receives files, so
   config for the helper is applied to all file uploads consistently.

This is sufficient to allow the application directory to be deployed read-
only, with writable storage used for file uploads. The new config property
for Sails' temporary upload directory, combined with the existing settings
for user-avatar and background-image locations are sufficient to handle
uploads; the new custom routes handle serving those files from external
locations.

The default behavior of the application should be unchanged, with files
uploaded to, and served from, the public directory if the relevant
config properties aren't set to other values.
2024-09-20 20:29:11 +02:00
iMarKoLiGa
1217969e22 feat: Ability to configure OIDC claims source (#888)
Closes #884
2024-09-20 16:19:54 +02:00
Ahmed
e410e21363 feat: Add Yemeni Arabic translation (#880) 2024-09-18 14:17:51 +02:00
59 changed files with 9216 additions and 7165 deletions

View File

@@ -4,7 +4,6 @@ on:
release:
types: [created]
jobs:
build-and-publish-release-package:
runs-on: ubuntu-latest
@@ -48,10 +47,10 @@ jobs:
- name: Dist create .zip file
run: |
mv dist/ planka/
zip -r planka-prebuild-${{ github.event.release.tag_name }}.zip planka
zip -r planka-prebuild.zip planka
- name: Dist upload assets
run: |
gh release upload ${{ github.event.release.tag_name }} planka-prebuild-${{ github.event.release.tag_name }}.zip
gh release upload ${{ github.event.release.tag_name }} planka-prebuild.zip
env:
GH_TOKEN: ${{ github.token }}

73
.github/workflows/build-and-test.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: Build and test
on:
pull_request:
branches:
- master
push:
branches:
- master
jobs:
setup:
runs-on: ubuntu-latest
env:
POSTGRES_DB: planka_db
POSTGRES_USER: user
POSTGRES_PASSWORD: password
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Setup PostgreSQL
uses: ikalnytskyi/action-setup-postgres@v5
with:
database: ${{ env.POSTGRES_DB }}
username: ${{ env.POSTGRES_USER }}
password: ${{ env.POSTGRES_PASSWORD }}
- name: Cache Node.js modules
uses: actions/cache@v3
with:
path: client/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('client/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: |
npm install
cd client
npm run build
- name: Setup server
env:
DEFAULT_ADMIN_EMAIL: demo@demo.demo
DEFAULT_ADMIN_PASSWORD: demo
DEFAULT_ADMIN_NAME: Demo Demo
DEFAULT_ADMIN_USERNAME: demo
run: |
client/tests/setup-symlinks.sh
cd server
cp .env.sample .env
sed -i "s|^DATABASE_URL=.*|DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost/${POSTGRES_DB}|" .env
npm run db:init
npm start --prod &
- name: Wait for development server
run: |
sudo apt-get install wait-for-it -y
wait-for-it -h localhost -p 1337 -t 10
- name: Run UI tests
run: |
cd client
npm install
npx playwright install chromium
npm run test:acceptance tests

33
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Lint
on:
pull_request:
branches:
- master
jobs:
setup:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Cache Node.js modules
uses: actions/cache@v3
with:
path: client/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('client/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm install
- name: Run linter
run: npm run lint

View File

@@ -25,6 +25,14 @@ There are many ways to install Planka, [check them out](https://docs.planka.clou
For configuration, please see the [configuration section](https://docs.planka.cloud/docs/category/configuration).
## Mobile app
Here is the [mobile app repository](https://github.com/LouisHDev/planka_app) maintained by the community, where you can build an app for iOS and Android.
Alternatively, you can download the [Android APK](https://github.com/LouisHDev/planka_app/releases/latest/download/app-release.apk) directly.
If you have an iOS device and would like to test the app, you can join [TestFlight](https://testflight.apple.com/join/Uwn41eY4) (limited to 200 participants).
## Contact
- If you want to get a hosted version of Planka, you can contact us via email contact@planka.cloud

View File

@@ -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.2.8
version: 0.2.12
# 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.22.0"
appVersion: "1.23.3"
dependencies:
- alias: postgresql

View File

@@ -4,6 +4,10 @@ metadata:
name: {{ include "planka.fullname" . }}
labels:
{{- include "planka.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
ports:

View File

@@ -54,6 +54,7 @@ securityContext: {}
# runAsUser: 1000
service:
annotations: {}
type: ClusterIP
port: 1337
## @param service.containerPort Planka HTTP container port

941
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"eject": "react-scripts eject",
"lint": "eslint --ext js,jsx src config-overrides.js",
"start": "react-app-rewired start",
"test": "react-app-rewired test"
"test": "react-app-rewired test",
"test:acceptance": "cucumber-js --require tests/acceptance/cucumber.conf.js --require tests/acceptance/stepDefinitions/**/*.js --format @cucumber/pretty-formatter"
},
"browserslist": {
"production": [
@@ -108,9 +109,13 @@
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@cucumber/cucumber": "^7.3.1",
"@cucumber/pretty-formatter": "^1.0.1",
"@playwright/test": "^1.46.1",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^15.0.7",
"@testing-library/user-event": "^14.5.2",
"axios": "^1.6.2",
"babel-preset-airbnb": "^5.0.0",
"chai": "^4.5.0",
"eslint": "^8.57.0",
@@ -119,6 +124,7 @@
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react": "^7.36.1",
"eslint-plugin-react-hooks": "^4.6.2",
"playwright": "^1.46.1",
"react-test-renderer": "18.2.0"
}
}

View File

@@ -23,6 +23,7 @@ const Card = React.memo(
id,
index,
name,
description,
dueDate,
isDueDateCompleted,
stopwatch,
@@ -31,6 +32,7 @@ const Card = React.memo(
listId,
projectId,
isPersisted,
attachmentsTotal,
notificationsTotal,
users,
labels,
@@ -106,7 +108,11 @@ const Card = React.memo(
)}
<div className={styles.name}>{name}</div>
{tasks.length > 0 && <Tasks items={tasks} />}
{(dueDate || stopwatch || notificationsTotal > 0) && (
{(description ||
dueDate ||
stopwatch ||
attachmentsTotal > 0 ||
notificationsTotal > 0) && (
<span className={styles.attachments}>
{notificationsTotal > 0 && (
<span
@@ -135,6 +141,21 @@ const Card = React.memo(
/>
</span>
)}
{description && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<span className={styles.attachmentContent}>
<Icon name="align left" />
</span>
</span>
)}
{attachmentsTotal > 0 && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<span className={styles.attachmentContent}>
<Icon name="attach" />
{attachmentsTotal}
</span>
</span>
)}
</span>
)}
{users.length > 0 && (
@@ -221,6 +242,7 @@ Card.propTypes = {
id: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
description: PropTypes.string,
dueDate: PropTypes.instanceOf(Date),
isDueDateCompleted: PropTypes.bool,
stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
@@ -229,6 +251,7 @@ Card.propTypes = {
listId: PropTypes.string.isRequired,
projectId: PropTypes.string.isRequired,
isPersisted: PropTypes.bool.isRequired,
attachmentsTotal: PropTypes.number.isRequired,
notificationsTotal: PropTypes.number.isRequired,
/* eslint-disable react/forbid-prop-types */
users: PropTypes.array.isRequired,
@@ -256,6 +279,7 @@ Card.propTypes = {
};
Card.defaultProps = {
description: undefined,
dueDate: undefined,
isDueDateCompleted: undefined,
stopwatch: undefined,

View File

@@ -39,6 +39,13 @@
vertical-align: top;
}
.attachmentContent {
color: #6a808b;
font-size: 12px;
line-height: 20px;
padding: 0px 3px;
}
.attachmentLeft {
margin-right: 4px;
}

View File

@@ -28,7 +28,7 @@ const AttachmentAddStep = React.memo(({ onCreate, onClose }) => {
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<FilePicker onSelect={handleFileSelect}>
<FilePicker multiple onSelect={handleFileSelect}>
<Menu.Item className={styles.menuItem}>
{t('common.fromComputer', {
context: 'title',

View File

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { closePopup } from '../../../lib/popup';
import { useModal } from '../../../hooks';
import { isActiveTextElement } from '../../../utils/element-helpers';
import TextFileAddModal from './TextFileAddModal';
import styles from './AttachmentAddZone.module.scss';
@@ -24,13 +25,14 @@ const AttachmentAddZone = React.memo(({ children, onCreate }) => {
const handleDropAccepted = useCallback(
(files) => {
submit(files[0]);
files.forEach((file) => {
submit(file);
});
},
[submit],
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
multiple: false,
noClick: true,
noKeyboard: true,
onDropAccepted: handleDropAccepted,
@@ -49,38 +51,43 @@ const AttachmentAddZone = React.memo(({ children, onCreate }) => {
return;
}
const file = event.clipboardData.files[0];
const { files, items } = event.clipboardData;
if (file) {
submit(file);
return;
}
const item = event.clipboardData.items[0];
if (!item) {
return;
}
if (item.kind === 'file') {
submit(item.getAsFile());
return;
}
if (
['input', 'textarea'].includes(event.target.tagName.toLowerCase()) &&
event.target === document.activeElement
) {
return;
}
closePopup();
event.preventDefault();
item.getAsString((content) => {
openModal({
content,
if (files.length > 0) {
[...files].forEach((file) => {
submit(file);
});
return;
}
if (items.length === 0) {
return;
}
if (items[0].kind === 'string') {
if (isActiveTextElement(event.target)) {
return;
}
closePopup();
event.preventDefault();
items[0].getAsString((content) => {
openModal({
content,
});
});
return;
}
[...items].forEach((item) => {
if (item.kind !== 'file') {
return;
}
submit(item.getAsFile());
});
};

View File

@@ -369,7 +369,7 @@ const CardModal = React.memo(
{(description || canEdit) && (
<div className={styles.contentModule}>
<div className={styles.moduleWrapper}>
<Icon name="align justify" className={styles.moduleIcon} />
<Icon name="align left" className={styles.moduleIcon} />
<div className={styles.moduleHeader}>{t('common.description')}</div>
{canEdit ? (
<DescriptionEdit defaultValue={description} onUpdate={handleDescriptionUpdate}>

View File

@@ -7,6 +7,7 @@ import { useDidUpdate, useToggle } from '../../lib/hooks';
import { Input, Popup } from '../../lib/custom-ui';
import { useForm } from '../../hooks';
import parseTime from '../../utils/parse-time';
import styles from './DueDateEditStep.module.scss';
@@ -66,10 +67,7 @@ const DueDateEditStep = React.memo(({ defaultValue, onUpdate, onBack, onClose })
return;
}
const value = t('format:dateTime', {
postProcess: 'parseDate',
value: `${data.date} ${data.time}`,
});
const value = parseTime(data.time, nullableDate);
if (Number.isNaN(value.getTime())) {
timeField.current.select();
@@ -81,7 +79,7 @@ const DueDateEditStep = React.memo(({ defaultValue, onUpdate, onBack, onClose })
}
onClose();
}, [defaultValue, onUpdate, onClose, data, nullableDate, t]);
}, [defaultValue, onUpdate, onClose, data, nullableDate]);
const handleClearClick = useCallback(() => {
if (defaultValue) {

View File

@@ -20,12 +20,22 @@ const makeMapStateToProps = () => {
const allLabels = selectors.selectLabelsForCurrentBoard(state);
const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const { name, dueDate, isDueDateCompleted, stopwatch, coverUrl, boardId, listId, isPersisted } =
selectCardById(state, id);
const {
name,
description,
dueDate,
isDueDateCompleted,
stopwatch,
coverUrl,
boardId,
listId,
isPersisted,
} = selectCardById(state, id);
const users = selectUsersByCardId(state, id);
const labels = selectLabelsByCardId(state, id);
const tasks = selectTasksByCardId(state, id);
const attachmentsTotal = selectors.selectAttachmentsTotalByCardId(state, id);
const notificationsTotal = selectNotificationsTotalByCardId(state, id);
const isCurrentUserEditor =
@@ -35,6 +45,7 @@ const makeMapStateToProps = () => {
id,
index,
name,
description,
dueDate,
isDueDateCompleted,
stopwatch,
@@ -43,6 +54,7 @@ const makeMapStateToProps = () => {
listId,
projectId,
isPersisted,
attachmentsTotal,
notificationsTotal,
users,
labels,

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import styles from './FilePicker.module.css';
const FilePicker = React.memo(({ children, accept, onSelect }) => {
const FilePicker = React.memo(({ children, accept, multiple, onSelect }) => {
const field = useRef(null);
const handleTriggerClick = useCallback(() => {
@@ -12,11 +12,11 @@ const FilePicker = React.memo(({ children, accept, onSelect }) => {
const handleFieldChange = useCallback(
({ target }) => {
if (target.files[0]) {
onSelect(target.files[0]);
[...target.files].forEach((file) => {
onSelect(file);
});
target.value = null; // eslint-disable-line no-param-reassign
}
target.value = null; // eslint-disable-line no-param-reassign
},
[onSelect],
);
@@ -32,6 +32,7 @@ const FilePicker = React.memo(({ children, accept, onSelect }) => {
ref={field}
type="file"
accept={accept}
multiple={multiple}
className={styles.field}
onChange={handleFieldChange}
/>
@@ -42,11 +43,13 @@ const FilePicker = React.memo(({ children, accept, onSelect }) => {
FilePicker.propTypes = {
children: PropTypes.element.isRequired,
accept: PropTypes.string,
multiple: PropTypes.bool,
onSelect: PropTypes.func.isRequired,
};
FilePicker.defaultProps = {
accept: undefined,
multiple: false,
};
export default FilePicker;

View File

@@ -0,0 +1,252 @@
import dateFns from 'date-fns/locale/ar';
export default {
dateFns,
format: {
date: 'M/d/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",
},
translation: {
common: {
aboutPlanka: 'حول Planka',
account: 'الحساب',
actions: 'إجراءات',
addAttachment_title: 'إضافة مرفق',
addComment: 'إضافة تعليق',
addManager_title: 'إضافة مدير',
addMember_title: 'إضافة عضو',
addUser_title: 'إضافة مستخدم',
administrator: 'المدير',
all: 'الكل',
allChangesWillBeAutomaticallySavedAfterConnectionRestored:
'سيتم حفظ جميع التغييرات تلقائياً<br />بعد استعادة الإتصال.',
areYouSureYouWantToDeleteThisAttachment: 'هل أنت متأكد أنك تريد حذف هذا المرفق؟',
areYouSureYouWantToDeleteThisBoard: 'هل أنت متأكد أنك تريد حذف هذه اللوحة؟',
areYouSureYouWantToDeleteThisCard: 'هل أنت متأكد أنك تريد حذف هذه البطاقة؟',
areYouSureYouWantToDeleteThisComment: 'هل أنت متأكد أنك تريد حذف هذا التعليق؟',
areYouSureYouWantToDeleteThisLabel: 'هل أنت متأكد أنك تريد حذف هذا الملصق؟',
areYouSureYouWantToDeleteThisList: 'هل أنت متأكد أنك تريد حذف هذه القائمة؟',
areYouSureYouWantToDeleteThisProject: 'هل أنت متأكد أنك تريد حذف هذا المشروع؟',
areYouSureYouWantToDeleteThisTask: 'هل أنت متأكد أنك تريد حذف هذه المهمة؟',
areYouSureYouWantToDeleteThisUser: 'هل أنت متأكد أنك تريد حذف هذا المستخدم؟',
areYouSureYouWantToLeaveBoard: 'هل أنت متأكد أنك تريد مغادرة اللوحة؟',
areYouSureYouWantToLeaveProject: 'هل أنت متأكد أنك تريد مغادرة المشروع؟',
areYouSureYouWantToRemoveThisManagerFromProject:
'هل أنت متأكد أنك تريد إزالة هذا المدير من المشروع؟',
areYouSureYouWantToRemoveThisMemberFromBoard:
'هل أنت متأكد أنك تريد إزالة هذا العضو من اللوحة؟',
attachment: 'مرفق',
attachments: 'مرفقات',
authentication: 'المصادقة',
background: 'الخلفية',
board: 'لوحة',
boardNotFound_title: 'لم يتم العثور على اللوحة',
canComment: 'يمكن التعليق',
canEditContentOfBoard: 'يمكن تعديل محتوى اللوحة.',
canOnlyViewBoard: 'يمكن فقط عرض اللوحة.',
cardActions_title: 'إجراءات البطاقة',
cardNotFound_title: 'لم يتم العثور على البطاقة',
cardOrActionAreDeleted: 'تم حذف البطاقة أو الإجراء.',
color: 'اللون',
copy_inline: 'نسخ',
createBoard_title: 'إنشاء لوحة',
createLabel_title: 'إنشاء ملصق',
createNewOneOrSelectExistingOne: 'أنشئ واحدة جديدة أو اختر<br />واحدة موجودة.',
createProject_title: 'إنشاء مشروع',
createTextFile_title: 'إنشاء ملف نصي',
currentPassword: 'كلمة المرور الحالية',
dangerZone_title: 'منطقة الخطر',
date: 'تاريخ',
dueDate: 'تاريخ الاستحقاق',
dueDate_title: 'تاريخ الاستحقاق',
deleteAttachment_title: 'حذف المرفق',
deleteBoard_title: 'حذف اللوحة',
deleteCard_title: 'حذف البطاقة',
deleteComment_title: 'حذف التعليق',
deleteLabel_title: 'حذف الملصق',
deleteList_title: 'حذف القائمة',
deleteProject_title: 'حذف المشروع',
deleteTask_title: 'حذف المهمة',
deleteUser_title: 'حذف المستخدم',
description: 'الوصف',
detectAutomatically: 'الكشف تلقائياً',
dropFileToUpload: 'أفلت الملف لرفعه',
editor: 'محرر',
editAttachment_title: 'تعديل المرفق',
editAvatar_title: 'تحرير الصورة الرمزية',
editBoard_title: 'تعديل اللوحة',
editDueDate_title: 'تعديل تاريخ الاستحقاق',
editEmail_title: 'تعديل البريد الإلكتروني',
editInformation_title: 'تعديل المعلومات',
editLabel_title: 'تعديل الملصق',
editPassword_title: 'تعديل كلمة المرور',
editPermissions_title: 'تعديل الأذونات',
editStopwatch_title: 'تعديل المؤقت',
editUsername_title: 'تعديل اسم المستخدم',
email: 'البريد الإلكتروني',
emailAlreadyInUse: 'البريد الإلكتروني مستخدم بالفعل',
enterCardTitle: 'أدخل عنوان البطاقة... [Ctrl+Enter] لفتحها تلقائيًا.',
enterDescription: 'أدخل الوصف...',
enterFilename: 'أدخل اسم الملف',
enterListTitle: 'أدخل عنوان القائمة...',
enterProjectTitle: 'أدخل عنوان المشروع',
enterTaskDescription: 'أدخل وصف المهمة...',
filterByLabels_title: 'تصفية حسب الملصقات',
filterByMembers_title: 'تصفية حسب الأعضاء',
fromComputer_title: 'من الكمبيوتر',
fromTrello: 'من Trello',
general: 'عام',
hours: 'ساعات',
importBoard_title: 'استيراد اللوحة',
invalidCurrentPassword: 'كلمة المرور الحالية غير صالحة',
labels: 'الملصقات',
language: 'اللغة',
leaveBoard_title: 'غادر اللوحة',
leaveProject_title: 'غادر المشروع',
linkIsCopied: 'تم نسخ الرابط',
list: 'القائمة',
listActions_title: 'قائمة الإجراءات',
managers: 'المديرون',
managerActions_title: 'إجراءات المدير',
members: 'الأعضاء',
memberActions_title: 'إجراءات العضو',
minutes: 'الدقائق',
moveCard_title: 'نقل البطاقة',
name: 'الاسم',
newestFirst: 'الأحدث أولاً',
newEmail: 'بريد إلكتروني جديد',
newPassword: 'كلمة سر جديدة',
newUsername: 'مستخدم جديد',
noConnectionToServer: 'لا يوجد اتصال بالخادم',
noBoards: 'لا توجد لوحات',
noLists: 'لاتوجد قوائم',
noProjects: 'لاتوجد مشاريع',
notifications: 'الإشعارات',
noUnreadNotifications: 'لاتوجد إشعارات غير مقروءة',
oldestFirst: 'الأقدم أولاً',
openBoard_title: 'فتح اللوحة',
optional_inline: 'اختياري',
organization: 'المنظمة',
phone: 'الهاتف',
preferences: 'التفضيلات',
pressPasteShortcutToAddAttachmentFromClipboard:
'نصيحة: اضغط على Ctrl-V (Cmd-V على Mac) لإضافة مرفق من الحافظة.',
project: 'مشروع',
projectNotFound_title: 'المشروع غير موجود',
removeManager_title: 'إزالة المدير',
removeMember_title: 'إزالة العضو',
searchLabels: 'البحث عن التصنيفات...',
searchMembers: 'البحث عن الأعضاء...',
searchUsers: 'البحث عن المستخدمين...',
searchCards: 'البحث عن البطاقات...',
seconds: 'ثواني',
selectBoard: 'اختر لوحة',
selectList: 'اختر قائمة',
selectPermissions_title: 'حدد الأذونات',
selectProject: 'حدد المشروع',
settings: 'الإعدادات',
sortList_title: 'فرز القائمة',
stopwatch: 'المؤقت',
subscribeToMyOwnCardsByDefault: 'الاشتراك في بطاقاتي الخاصة إفتراضياً',
taskActions_title: 'إجراءات المهمة',
tasks: 'المهام',
thereIsNoPreviewAvailableForThisAttachment: 'لا يوجد معاينة متاحة لهذا المرفق.',
time: 'الوقت',
title: 'العنوان',
userActions_title: 'إجراءات المستخدم',
userAddedThisCardToList: '<0>{{user}}</0><1> تمت إضافة هذه البطاقة إلى {{list}}</1>',
userLeftNewCommentToCard: '{{user}} ترك تعليق جديد «{{comment}}» إلى <2>{{card}}</2>',
userMovedCardFromListToList: '{{user}} انتقل <2>{{card}}</2> من {{fromList}} إلى {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> نُقلت هذه البطاقة من {{fromList}} إلى {{toList}}</1>',
username: 'اسم المستخدم',
usernameAlreadyInUse: 'اسم المستخدم تم استخدامه بالفعل',
users: 'المستخدمين',
version: 'الإصدار',
viewer: 'مشاهد',
writeComment: 'اكتب تعليقاً...',
},
action: {
addAnotherCard: 'إضافة بطاقة أخرى',
addAnotherList: 'إضافة قائمة أخرى',
addAnotherTask: 'إضافة مهمة أخرى',
addCard: 'إضافة بطاقة',
addCard_title: 'إضافة بطاقة',
addComment: 'إضافة تعليق',
addList: 'إضافة قائمة',
addMember: 'إضافة عضو',
addMoreDetailedDescription: 'إضافة وصف أكثر تفصيلاً',
addTask: 'إضافة مهمة',
addToCard: 'إضافة إلى البطاقة',
addUser: 'إضافة مستخدم',
copyLink_title: 'نسخ الرابط',
createBoard: 'إنشاء لوحة',
createFile: 'إنشاء ملف',
createLabel: 'إنشاء ملصق',
createNewLabel: 'إنشاء ملصق جديد',
createProject: 'إنشاء مشروع',
delete: 'حذف',
deleteAttachment: 'حذف المرفق',
deleteAvatar: 'حذف الصورة الرمزية',
deleteBoard: 'حذف اللوحة',
deleteCard: 'حذف البطاقة',
deleteCard_title: 'حذف البطاقة',
deleteComment: 'حذف التعليق',
deleteImage: 'حذف الصورة',
deleteLabel: 'حذف الملصق',
deleteList: 'حذف القائمة',
deleteList_title: 'حذف القائمة',
deleteProject: 'حذف المشروع',
deleteProject_title: 'حذف المشروع',
deleteTask: 'حذف المهمة',
deleteTask_title: 'حذف المهمة',
deleteUser: 'حذف المستخدم',
duplicate: 'تكرار',
duplicateCard_title: 'تكرار البطاقة',
edit: 'تعديل',
editDueDate_title: 'تعديل تاريخ الاستحقاق',
editDescription_title: 'تعديل الوصف',
editEmail_title: 'تعديل البريد الإلكتروني',
editInformation_title: 'تعديل المعلومات',
editPassword_title: 'تعديل كلمة المرور',
editPermissions: 'تعديل الأذونات',
editStopwatch_title: 'تعديل المؤقت',
editTitle_title: 'تعديل العنوان',
editUsername_title: 'تعديل اسم المستخدم',
hideDetails: 'إخفاء التفاصيل',
import: 'استيراد',
leaveBoard: 'غادر اللوحة',
leaveProject: 'غادر المشروع',
logOut_title: 'تسجيل الخروج',
makeCover_title: 'إصنع غلافاً',
move: 'نقل',
moveCard_title: 'نقل البطاقة',
remove: 'حذف',
removeBackground: 'إزالة الخلفية',
removeCover_title: 'إزالة الغلاف',
removeFromBoard: 'إزالة اللوحة',
removeFromProject: 'إزالة المشروع',
removeManager: 'إزالة المدير',
removeMember: 'إزالة العضو',
save: 'حفظ',
showAllAttachments: 'إظهار جميع المرفقات ({{hidden}} hidden)',
showDetails: 'إظهار التفاصيل',
showFewerAttachments: 'عرض مرفقات أقل',
sortList_title: 'فرز القائمة',
start: 'ابدأ',
stop: 'توقف',
subscribe: 'اشترك',
unsubscribe: 'إلغاء الاشتراك',
uploadNewAvatar: 'رفع صورة رمزية جديدة',
uploadNewImage: 'رفع صورة جديدة',
},
},
};

View File

@@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'ar-YE',
country: 'ye',
name: 'العربية',
embeddedLocale: login,
};

View File

@@ -0,0 +1,23 @@
export default {
translation: {
common: {
emailOrUsername: 'البريد الإلكتروني أو اسم المستخدم',
invalidEmailOrUsername: 'البريد الإلكتروني أو اسم المستخدم غير صالح',
invalidCredentials: 'بيانات الاعتماد غير صالحة',
invalidPassword: 'كلمة المرور غير صالحة',
logInToPlanka: 'تسجيل الدخول إلى Planka',
noInternetConnection: 'لا يوجد اتصال بالإنترنت',
pageNotFound_title: 'الصفحة غير موجودة',
password: 'كلمة المرور',
projectManagement: 'إدارة المشروع',
serverConnectionFailed: 'فشل الاتصال بالخادم',
unknownError: 'خطأ غير معروف، يرجى المحاولة لاحقاً',
useSingleSignOn: 'استخدم تسجيل الدخول الموحد',
},
action: {
logIn: 'تسجيل الدخول',
logInWithSSO: 'تسجيل الدخول باستخدام SSO',
},
},
};

View File

@@ -0,0 +1,253 @@
import dateFns from 'date-fns/locale/en-GB';
export default {
dateFns,
format: {
date: 'P',
time: 'p',
dateTime: '$t(format:date) $t(format:time)',
longDate: 'd MMM',
longDateTime: "d MMMM 'at' p",
fullDate: 'd MMM y',
fullDateTime: "d MMMM y 'at' p",
},
translation: {
common: {
aboutPlanka: 'About Planka',
account: 'Account',
actions: 'Actions',
addAttachment_title: 'Add Attachment',
addComment: 'Add comment',
addManager_title: 'Add Manager',
addMember_title: 'Add Member',
addUser_title: 'Add User',
administrator: 'Administrator',
all: 'All',
allChangesWillBeAutomaticallySavedAfterConnectionRestored:
'All changes will be automatically saved<br />after connection restored.',
areYouSureYouWantToDeleteThisAttachment: 'Are you sure you want to delete this attachment?',
areYouSureYouWantToDeleteThisBoard: 'Are you sure you want to delete this board?',
areYouSureYouWantToDeleteThisCard: 'Are you sure you want to delete this card?',
areYouSureYouWantToDeleteThisComment: 'Are you sure you want to delete this comment?',
areYouSureYouWantToDeleteThisLabel: 'Are you sure you want to delete this label?',
areYouSureYouWantToDeleteThisList: 'Are you sure you want to delete this list?',
areYouSureYouWantToDeleteThisProject: 'Are you sure you want to delete this project?',
areYouSureYouWantToDeleteThisTask: 'Are you sure you want to delete this task?',
areYouSureYouWantToDeleteThisUser: 'Are you sure you want to delete this user?',
areYouSureYouWantToLeaveBoard: 'Are you sure you want to leave the board?',
areYouSureYouWantToLeaveProject: 'Are you sure you want to leave the project?',
areYouSureYouWantToRemoveThisManagerFromProject:
'Are you sure you want to remove this manager from the project?',
areYouSureYouWantToRemoveThisMemberFromBoard:
'Are you sure you want to remove this member from the board?',
attachment: 'Attachment',
attachments: 'Attachments',
authentication: 'Authentication',
background: 'Background',
board: 'Board',
boardNotFound_title: 'Board Not Found',
canComment: 'Can comment',
canEditContentOfBoard: 'Can edit the content of the board.',
canOnlyViewBoard: 'Can only view the board.',
cardActions_title: 'Card Actions',
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<br />an existing one.',
createProject_title: 'Create Project',
createTextFile_title: 'Create Text File',
currentPassword: 'Current password',
dangerZone_title: 'Danger Zone',
date: 'Date',
dueDate: 'Due date',
dueDate_title: 'Due Date',
deleteAttachment_title: 'Delete Attachment',
deleteBoard_title: 'Delete Board',
deleteCard_title: 'Delete Card',
deleteComment_title: 'Delete Comment',
deleteLabel_title: 'Delete Label',
deleteList_title: 'Delete List',
deleteProject_title: 'Delete Project',
deleteTask_title: 'Delete Task',
deleteUser_title: 'Delete User',
description: 'Description',
detectAutomatically: 'Detect automatically',
dropFileToUpload: 'Drop file to upload',
editor: 'Editor',
editAttachment_title: 'Edit Attachment',
editAvatar_title: 'Edit Avatar',
editBoard_title: 'Edit Board',
editDueDate_title: 'Edit Due Date',
editEmail_title: 'Edit E-mail',
editInformation_title: 'Edit Information',
editLabel_title: 'Edit Label',
editPassword_title: 'Edit Password',
editPermissions_title: 'Edit Permissions',
editStopwatch_title: 'Edit Stopwatch',
editUsername_title: 'Edit Username',
email: 'E-mail',
emailAlreadyInUse: 'E-mail already in use',
enterCardTitle: 'Enter card title... [Ctrl+Enter] to auto-open.',
enterDescription: 'Enter description...',
enterFilename: 'Enter filename',
enterListTitle: 'Enter list title...',
enterProjectTitle: 'Enter project title',
enterTaskDescription: 'Enter task description...',
filterByLabels_title: 'Filter By Labels',
filterByMembers_title: 'Filter By Members',
fromComputer_title: 'From Computer',
fromTrello: 'From Trello',
general: 'General',
hours: 'Hours',
importBoard_title: 'Import Board',
invalidCurrentPassword: 'Invalid current password',
labels: 'Labels',
language: 'Language',
leaveBoard_title: 'Leave Board',
leaveProject_title: 'Leave Project',
linkIsCopied: 'Link is copied',
list: 'List',
listActions_title: 'List Actions',
managers: 'Managers',
managerActions_title: 'Manager Actions',
members: 'Members',
memberActions_title: 'Member Actions',
minutes: 'Minutes',
moveCard_title: 'Move Card',
name: 'Name',
newestFirst: 'Newest first',
newEmail: 'New e-mail',
newPassword: 'New password',
newUsername: 'New username',
noConnectionToServer: 'No connection to server',
noBoards: 'No boards',
noLists: 'No lists',
noProjects: 'No projects',
notifications: 'Notifications',
noUnreadNotifications: 'No unread notifications.',
oldestFirst: 'Oldest first',
openBoard_title: 'Open Board',
optional_inline: 'optional',
organization: 'Organization',
phone: 'Phone',
preferences: 'Preferences',
pressPasteShortcutToAddAttachmentFromClipboard:
'Tip: press Ctrl-V (Cmd-V on Mac) to add an attachment from the clipboard.',
project: 'Project',
projectNotFound_title: 'Project Not Found',
removeManager_title: 'Remove Manager',
removeMember_title: 'Remove Member',
searchLabels: 'Search labels...',
searchMembers: 'Search members...',
searchUsers: 'Search users...',
searchCards: 'Search cards...',
seconds: 'Seconds',
selectBoard: 'Select board',
selectList: 'Select list',
selectPermissions_title: 'Select Permissions',
selectProject: 'Select project',
settings: 'Settings',
sortList_title: 'Sort List',
stopwatch: 'Stopwatch',
subscribeToMyOwnCardsByDefault: 'Subscribe to my own cards by default',
taskActions_title: 'Task Actions',
tasks: 'Tasks',
thereIsNoPreviewAvailableForThisAttachment:
'There is no preview available for this attachment.',
time: 'Time',
title: 'Title',
userActions_title: 'User Actions',
userAddedThisCardToList: '<0>{{user}}</0><1> added this card to {{list}}</1>',
userLeftNewCommentToCard: '{{user}} left a new comment «{{comment}}» to <2>{{card}}</2>',
userMovedCardFromListToList: '{{user}} moved <2>{{card}}</2> from {{fromList}} to {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> moved this card from {{fromList}} to {{toList}}</1>',
username: 'Username',
usernameAlreadyInUse: 'Username already in use',
users: 'Users',
version: 'Version',
viewer: 'Viewer',
writeComment: 'Write a comment...',
},
action: {
addAnotherCard: 'Add another card',
addAnotherList: 'Add another list',
addAnotherTask: 'Add another task',
addCard: 'Add card',
addCard_title: 'Add Card',
addComment: 'Add comment',
addList: 'Add list',
addMember: 'Add member',
addMoreDetailedDescription: 'Add more detailed description',
addTask: 'Add task',
addToCard: 'Add to card',
addUser: 'Add user',
copyLink_title: 'Copy Link',
createBoard: 'Create board',
createFile: 'Create file',
createLabel: 'Create label',
createNewLabel: 'Create new label',
createProject: 'Create project',
delete: 'Delete',
deleteAttachment: 'Delete attachment',
deleteAvatar: 'Delete avatar',
deleteBoard: 'Delete board',
deleteCard: 'Delete card',
deleteCard_title: 'Delete Card',
deleteComment: 'Delete comment',
deleteImage: 'Delete image',
deleteLabel: 'Delete label',
deleteList: 'Delete list',
deleteList_title: 'Delete List',
deleteProject: 'Delete project',
deleteProject_title: 'Delete Project',
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',
editEmail_title: 'Edit E-mail',
editInformation_title: 'Edit Information',
editPassword_title: 'Edit Password',
editPermissions: 'Edit permissions',
editStopwatch_title: 'Edit Stopwatch',
editTitle_title: 'Edit Title',
editUsername_title: 'Edit Username',
hideDetails: 'Hide details',
import: 'Import',
leaveBoard: 'Leave board',
leaveProject: 'Leave project',
logOut_title: 'Log Out',
makeCover_title: 'Make Cover',
move: 'Move',
moveCard_title: 'Move Card',
remove: 'Remove',
removeBackground: 'Remove background',
removeCover_title: 'Remove Cover',
removeFromBoard: 'Remove from board',
removeFromProject: 'Remove from project',
removeManager: 'Remove manager',
removeMember: 'Remove member',
save: 'Save',
showAllAttachments: 'Show all attachments ({{hidden}} hidden)',
showDetails: 'Show details',
showFewerAttachments: 'Show fewer attachments',
sortList_title: 'Sort List',
start: 'Start',
stop: 'Stop',
subscribe: 'Subscribe',
unsubscribe: 'Unsubscribe',
uploadNewAvatar: 'Upload new avatar',
uploadNewImage: 'Upload new image',
},
},
};

View File

@@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'en-GB',
country: 'gb',
name: 'English',
embeddedLocale: login,
};

View File

@@ -0,0 +1,23 @@
export default {
translation: {
common: {
emailOrUsername: 'E-mail or username',
invalidEmailOrUsername: 'Invalid e-mail or username',
invalidCredentials: 'Invalid credentials',
invalidPassword: 'Invalid password',
logInToPlanka: 'Log in to Planka',
noInternetConnection: 'No internet connection',
pageNotFound_title: 'Page Not Found',
password: 'Password',
projectManagement: 'Project management',
serverConnectionFailed: 'Server connection failed',
unknownError: 'Unknown error, try again later',
useSingleSignOn: 'Use single sign-on',
},
action: {
logIn: 'Log in',
logInWithSSO: 'Log in with SSO',
},
},
};

View File

@@ -1,7 +1,9 @@
import arYE from './ar-YE';
import bgBG from './bg-BG';
import csCZ from './cs-CZ';
import daDK from './da-DK';
import deDE from './de-DE';
import enGB from './en-GB';
import enUS from './en-US';
import esES from './es-ES';
import faIR from './fa-IR';
@@ -25,10 +27,12 @@ import zhCN from './zh-CN';
import zhTW from './zh-TW';
const locales = [
arYE,
bgBG,
csCZ,
daDK,
deDE,
enGB,
enUS,
esES,
faIR,

View File

@@ -115,6 +115,23 @@ export const makeSelectTasksByCardId = () =>
export const selectTasksByCardId = makeSelectTasksByCardId();
export const makeSelectAttachmentsTotalByCardId = () =>
createSelector(
orm,
(_, id) => id,
({ Card }, id) => {
const cardModel = Card.withId(id);
if (!cardModel) {
return cardModel;
}
return cardModel.attachments.count();
},
);
export const selectAttachmentsTotalByCardId = makeSelectAttachmentsTotalByCardId();
export const makeSelectLastActivityIdByCardId = () =>
createSelector(
orm,
@@ -334,6 +351,8 @@ export default {
selectTaskIdsByCardId,
makeSelectTasksByCardId,
selectTasksByCardId,
makeSelectAttachmentsTotalByCardId,
selectAttachmentsTotalByCardId,
makeSelectLastActivityIdByCardId,
selectLastActivityIdByCardId,
makeSelectNotificationsByCardId,

View File

@@ -1,5 +1,8 @@
// eslint-disable-next-line import/prefer-default-export
export const focusEnd = (element) => {
element.focus();
element.setSelectionRange(element.value.length + 1, element.value.length + 1);
};
export const isActiveTextElement = (element) =>
['input', 'textarea'].includes(element.tagName.toLowerCase()) &&
element === document.activeElement;

View File

@@ -0,0 +1,102 @@
import parseDate from 'date-fns/parse';
const TIME_REGEX =
/^((\d{1,2})((:|\.)?(\d{1,2}))?)(a|p|(am|a\.m\.|midnight|mi|pm|p\.m\.|noon|n))?$/;
const ALTERNATIVE_AM_MERIDIEMS_SET = new Set(['am', 'a.m.', 'midnight', 'mi']);
const ALTERNATIVE_PM_MERIDIEMS_SET = new Set(['pm', 'p.m.', 'noon', 'n']);
const TimeFormats = {
TWENTY_FOUR_HOUR: 'twentyFourHour',
TWELVE_HOUR: 'twelveHour',
};
const PATTERNS_GROUPS_BY_TIME_FORMAT = {
[TimeFormats.TWENTY_FOUR_HOUR]: {
byNumbersTotal: {
1: ['H'],
2: ['HH'],
3: ['Hmm'],
4: ['HHmm'],
},
withDelimiter: ['H:m', 'H:mm', 'HH:m', 'HH:mm'],
},
[TimeFormats.TWELVE_HOUR]: {
byNumbersTotal: {
1: ['haaaaa'],
2: ['hhaaaaa'],
3: ['hmmaaaaa'],
4: ['hhmmaaaaa'],
},
withDelimiter: ['h:maaaaa', 'h:mmaaaaa', 'hh:maaaaa', 'hh:mmaaaaa'],
},
};
const INVALID_DATE = new Date('invalid-date');
const normalizeDelimeter = (delimeter) => (delimeter === '.' ? ':' : delimeter);
const normalizeMeridiem = (meridiem, alternativeMeridiem) => {
if (meridiem && alternativeMeridiem) {
if (ALTERNATIVE_AM_MERIDIEMS_SET.has(alternativeMeridiem)) {
return 'a';
}
if (ALTERNATIVE_PM_MERIDIEMS_SET.has(alternativeMeridiem)) {
return 'p';
}
}
return meridiem;
};
const makeTimeString = (hours, minutes, delimeter, meridiem) => {
let timeString = hours;
if (delimeter) {
timeString += delimeter;
}
if (minutes) {
timeString += minutes;
}
if (meridiem) {
timeString += meridiem;
}
return timeString;
};
export default (string, referenceDate) => {
const match = string.replace(/\s/g, '').toLowerCase().match(TIME_REGEX);
if (!match) {
return INVALID_DATE;
}
const [, hoursAndMinutes, hours, , delimeter, minutes, meridiem, alternativeMeridiem] = match;
const normalizedDelimeter = normalizeDelimeter(delimeter);
const normalizedMeridiem = normalizeMeridiem(meridiem, alternativeMeridiem);
const timeString = makeTimeString(hours, minutes, normalizedDelimeter, normalizedMeridiem);
const timeFormat = meridiem ? TimeFormats.TWELVE_HOUR : TimeFormats.TWENTY_FOUR_HOUR;
const patternsGroups = PATTERNS_GROUPS_BY_TIME_FORMAT[timeFormat];
const patterns = delimeter
? patternsGroups.withDelimiter
: patternsGroups.byNumbersTotal[hoursAndMinutes.length];
if (!referenceDate) {
referenceDate = new Date(); // eslint-disable-line no-param-reassign
}
for (let i = 0; i < patterns.length; i += 1) {
const parsedDate = parseDate(timeString, patterns[i], referenceDate);
if (!Number.isNaN(parsedDate.getTime())) {
return parsedDate;
}
}
return INVALID_DATE;
};

View File

@@ -1 +1 @@
export default '1.22.0';
export default '1.23.3';

View File

@@ -0,0 +1,12 @@
module.exports = {
// environment
adminUser: {
email: 'demo@demo.demo',
password: 'demo',
},
baseUrl: process.env.BASE_URL ?? 'http://localhost:1337/',
// playwright
slowMo: parseInt(process.env.SLOW_MO, 10) || 1000,
timeout: parseInt(process.env.TIMEOUT, 10) || 6000,
headless: process.env.HEADLESS !== 'true',
};

View File

@@ -0,0 +1,35 @@
// cucumber.conf.js file
const { Before, BeforeAll, AfterAll, After, setDefaultTimeout } = require('@cucumber/cucumber');
const { chromium } = require('playwright');
const { deleteProject } = require('./testHelpers/apiHelpers');
const config = require('./config');
setDefaultTimeout(config.timeout);
// launch the browser
BeforeAll(async function () {
global.browser = await chromium.launch({
// makes true for CI
headless: config.headless,
slowMo: config.slowMo,
});
});
// close the browser
AfterAll(async function () {
await global.browser.close();
});
// Create a new browser context and page per scenario
Before(async function () {
global.context = await global.browser.newContext();
global.page = await global.context.newPage();
});
// Cleanup after each scenario
After(async function () {
await deleteProject();
await global.page.close();
await global.context.close();
});

View File

@@ -0,0 +1,10 @@
Feature: dashboard
As a admin
I want to create a project
So that I can manage project
Scenario: create a new project
Given user has browsed to the login page
And user has logged in with email "demo@demo.demo" and password "demo"
When the user creates a project with name "testproject" using the webUI
Then the created project "testproject" should be opened

View File

@@ -0,0 +1,27 @@
Feature: login
As a admin
I want to log in
So that I can manage project
Scenario: User logs in with valid credentials
Given user has browsed to the login page
When user logs in with username "demo@demo.demo" and password "demo" using the webUI
Then the user should be in dashboard page
Scenario Outline: login with invalid username and invalid password
Given user has browsed to the login page
When user logs in with username "<username>" and password "<password>" using the webUI
Then user should see the error message "<message>"
Examples:
| username | password | message |
| spiderman | spidy123 | Invalid credentials |
| ironman | iron123 | Invalid credentials |
| aquaman | aqua123 | Invalid credentials |
Scenario: User can log out
Given user has logged in with email "demo@demo.demo" and password "demo"
When user logs out using the webUI
Then the user should be in the login page

View File

@@ -0,0 +1,16 @@
class DashboardPage {
constructor() {
this.createProjectIconSelector = `.Projects_addTitle__tXhB4`;
this.projectTitleInputSelector = `input[name="name"]`;
this.createProjectButtonSelector = `//button[text()="Create project"]`;
this.projectTitleSelector = `//div[@class="item Header_item__OOEY7 Header_title__l+wMf"][text()="%s"]`;
}
async createProject(project) {
await page.click(this.createProjectIconSelector);
await page.fill(this.projectTitleInputSelector, project);
await page.click(this.createProjectButtonSelector);
}
}
module.exports = DashboardPage;

View File

@@ -0,0 +1,38 @@
const config = require(`../config`);
class LoginPage {
constructor() {
// url
this.homeUrl = config.baseUrl;
this.loginUrl = `${this.homeUrl}login`;
// selectors
this.loginButtonSelector = `//i[@class="right arrow icon"]`;
this.usernameSelector = `//input[@name='emailOrUsername']`;
this.passwordSelector = `//input[@name='password']`;
this.errorMessageSelector = `//div[@class='ui error visible message']`;
this.userActionSelector = `//span[@class="User_initials__9Wp90"]`;
this.logOutSelector = `//a[@class="item UserStep_menuItem__5pvtT"][contains(text(),'Log Out')]`;
}
async goToLoginUrl() {
await page.goto(this.loginUrl);
}
async logOut() {
await page.click(this.userActionSelector);
await page.click(this.logOutSelector);
}
async login(username, password) {
await page.fill(this.usernameSelector, username);
await page.fill(this.passwordSelector, password);
await page.click(this.loginButtonSelector);
}
async getErrorMessage() {
return page.innerText(this.errorMessageSelector);
}
}
module.exports = LoginPage;

View File

@@ -0,0 +1,17 @@
const { When, Then } = require('@cucumber/cucumber');
const util = require('util');
const { expect } = require('playwright/test');
const DashboardPage = require('../pageObjects/DashboardPage');
const dashboardPage = new DashboardPage();
When('the user creates a project with name {string} using the webUI', async function (project) {
await dashboardPage.createProject(project);
});
Then('the created project {string} should be opened', async function (project) {
expect(
await page.locator(util.format(dashboardPage.projectTitleSelector, project)),
).toBeVisible();
});

View File

@@ -0,0 +1,53 @@
const { Given, When, Then } = require('@cucumber/cucumber');
// import expect for assertion
const { expect } = require('@playwright/test');
// import assert
const assert = require('assert');
const LoginPage = require('../pageObjects/LoginPage');
const loginPage = new LoginPage();
Given('user has browsed to the login page', async function () {
await loginPage.goToLoginUrl();
await expect(page).toHaveURL(loginPage.loginUrl);
});
Given(
'user has logged in with email {string} and password {string}',
async function (username, password) {
await loginPage.goToLoginUrl();
await loginPage.login(username, password);
await expect(page).toHaveURL(loginPage.homeUrl);
},
);
When(
'user logs in with username {string} and password {string} using the webUI',
async function (username, password) {
await loginPage.login(username, password);
},
);
Then('the user should be in dashboard page', async function () {
await expect(page).toHaveURL(loginPage.homeUrl);
});
Then('user should see the error message {string}', async function (errorMessage) {
const actualErrorMessage = await loginPage.getErrorMessage();
assert.equal(
actualErrorMessage,
errorMessage,
`Expected message to be "${errorMessage}" but receive "${actualErrorMessage}"`,
);
});
When('user logs out using the webUI', async function () {
await loginPage.logOut();
});
Then('the user should be in the login page', async function () {
await expect(page).toHaveURL(loginPage.loginUrl);
});

View File

@@ -0,0 +1,57 @@
const axios = require('axios');
const config = require('../config');
async function getXauthToken() {
try {
const res = await axios.post(
`${config.baseUrl}api/access-tokens`,
{
emailOrUsername: config.adminUser.email,
password: config.adminUser.password,
},
{
headers: {
'Content-Type': 'application/json',
},
},
);
return res.data.item;
} catch (error) {
return `Error requesting access token: ${error.message}`;
}
}
async function getProjectIDs() {
try {
const res = await axios.get(`${config.baseUrl}api/projects`, {
headers: {
Authorization: `Bearer ${await getXauthToken()}`,
},
});
return res.data.items.map((project) => project.id);
} catch (error) {
return `Error requesting projectIDs: ${error.message}`;
}
}
async function deleteProject() {
try {
const projectIDs = await getProjectIDs();
await Promise.all(
projectIDs.map(async (project) => {
await axios.delete(`${config.baseUrl}api/projects/${project}`, {
headers: {
Authorization: `Bearer ${await getXauthToken()}`,
},
});
}),
);
return true;
} catch (error) {
return `Error deleting project: ${error.message}`;
}
}
module.exports = {
deleteProject,
};

23
client/tests/setup-symlinks.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
# This script sets up symbolic links between the client build files and the server directories,
# Navigate to the root directory of the git repository
cd "$(git rev-parse --show-toplevel)" || { echo "Failed to navigate to the git repository root"; exit 1; }
# Store paths for the client build, server public directory, and server views directory
CLIENT_PATH=$(pwd)/client/build
SERVER_PUBLIC_PATH=$(pwd)/server/public
SERVER_VIEWS_PATH=$(pwd)/server/views
# Create symbolic links for the necessary client assets in the server's public and views directories
ln -s ${CLIENT_PATH}/asset-manifest.json ${SERVER_PUBLIC_PATH}/asset-manifest.json && echo "Linked asset-manifest.json successfully"
ln -s ${CLIENT_PATH}/favicon.ico ${SERVER_PUBLIC_PATH}/favicon.ico && echo "Linked favicon.ico successfully"
ln -s ${CLIENT_PATH}/logo192.png ${SERVER_PUBLIC_PATH}/logo192.png && echo "Linked logo192.png successfully"
ln -s ${CLIENT_PATH}/logo512.png ${SERVER_PUBLIC_PATH}/logo512.png && echo "Linked logo512.png successfully"
ln -s ${CLIENT_PATH}/manifest.json ${SERVER_PUBLIC_PATH}/manifest.json && echo "Linked manifest.json successfully"
ln -s ${CLIENT_PATH}/robots.txt ${SERVER_PUBLIC_PATH}/robots.txt && echo "Linked robots.txt successfully"
ln -s ${CLIENT_PATH}/static ${SERVER_PUBLIC_PATH}/static && echo "Linked static folder successfully"
ln -s ${CLIENT_PATH}/index.html ${SERVER_VIEWS_PATH}/index.ejs && echo "Linked index.html to index.ejs successfully"
echo "Setup symbolic links completed successfully."

View File

@@ -2,7 +2,7 @@ version: '3'
services:
postgres:
image: postgres:alpine
image: postgres:16-alpine
restart: unless-stopped
volumes:
- db-data:/var/lib/postgresql/data

View File

@@ -37,6 +37,7 @@ services:
# - OIDC_RESPONSE_MODE=fragment
# - OIDC_USE_DEFAULT_RESPONSE_MODE=true
# - OIDC_ADMIN_ROLES=admin
# - OIDC_CLAIMS_SOURCE=userinfo
# - OIDC_EMAIL_ATTRIBUTE=email
# - OIDC_NAME_ATTRIBUTE=name
# - OIDC_USERNAME_ATTRIBUTE=preferred_username
@@ -53,6 +54,7 @@ services:
# - SMTP_USER=
# - SMTP_PASSWORD=
# - SMTP_FROM="Demo Demo" <demo@demo.demo>
# - SMTP_TLS_REJECT_UNAUTHORIZED=false
# Optional fields: accessToken, events, excludedEvents
# - |
@@ -108,7 +110,7 @@ services:
condition: service_healthy
postgres:
image: postgres:latest
image: postgres:16-alpine
volumes:
- db-data:/var/lib/postgresql/data
environment:

View File

@@ -44,6 +44,7 @@ services:
# - OIDC_RESPONSE_MODE=fragment
# - OIDC_USE_DEFAULT_RESPONSE_MODE=true
# - OIDC_ADMIN_ROLES=admin
# - OIDC_CLAIMS_SOURCE=userinfo
# - OIDC_EMAIL_ATTRIBUTE=email
# - OIDC_NAME_ATTRIBUTE=name
# - OIDC_USERNAME_ATTRIBUTE=preferred_username
@@ -60,6 +61,7 @@ services:
# - SMTP_USER=
# - SMTP_PASSWORD=
# - SMTP_FROM="Demo Demo" <demo@demo.demo>
# - SMTP_TLS_REJECT_UNAUTHORIZED=false
# Optional fields: accessToken, events, excludedEvents
# - |
@@ -78,7 +80,7 @@ services:
condition: service_healthy
postgres:
image: postgres:14-alpine
image: postgres:16-alpine
restart: on-failure
volumes:
- db-data:/var/lib/postgresql/data

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "planka",
"version": "1.22.0",
"version": "1.23.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "planka",
"version": "1.22.0",
"version": "1.23.3",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "planka",
"version": "1.22.0",
"version": "1.23.3",
"private": true,
"homepage": "https://plankanban.github.io/planka",
"repository": {
@@ -32,7 +32,7 @@
"test": "npm run server:test && npm run client:test"
},
"lint-staged": {
"client/**/*.{js,jsx}": [
"client/src/**/*.{js,jsx}": [
"npm run client:lint"
],
"server/**/*.js": [

View File

@@ -6,6 +6,8 @@ SECRET_KEY=notsecretkey
## Optional
# LOG_FILE=
# TRUST_PROXY=0
# TOKEN_EXPIRES_IN=365 # In days
@@ -35,6 +37,7 @@ SECRET_KEY=notsecretkey
# OIDC_RESPONSE_MODE=fragment
# OIDC_USE_DEFAULT_RESPONSE_MODE=true
# OIDC_ADMIN_ROLES=admin
# OIDC_CLAIMS_SOURCE=userinfo
# OIDC_EMAIL_ATTRIBUTE=email
# OIDC_NAME_ATTRIBUTE=name
# OIDC_USERNAME_ATTRIBUTE=preferred_username
@@ -50,6 +53,7 @@ SECRET_KEY=notsecretkey
# SMTP_USER=
# SMTP_PASSWORD=
# SMTP_FROM="Demo Demo" <demo@demo.demo>
# SMTP_TLS_REJECT_UNAUTHORIZED=false
# Optional fields: accessToken, events, excludedEvents
# WEBHOOKS='[{

View File

@@ -6,8 +6,8 @@ const Errors = {
INVALID_CODE_OR_NONCE: {
invalidCodeOrNonce: 'Invalid code or nonce',
},
INVALID_USERINFO_SIGNATURE: {
invalidUserinfoSignature: 'Invalid signature on userinfo due to client misconfiguration',
INVALID_USERINFO_CONFIGURATION: {
invalidUserinfoConfiguration: 'Invalid userinfo configuration',
},
EMAIL_ALREADY_IN_USE: {
emailAlreadyInUse: 'Email already in use',
@@ -40,7 +40,7 @@ module.exports = {
invalidCodeOrNonce: {
responseType: 'unauthorized',
},
invalidUserinfoSignature: {
invalidUserinfoConfiguration: {
responseType: 'unauthorized',
},
emailAlreadyInUse: {
@@ -63,7 +63,7 @@ module.exports = {
sails.log.warn(`Invalid code or nonce! (IP: ${remoteAddress})`);
return Errors.INVALID_CODE_OR_NONCE;
})
.intercept('invalidUserinfoSignature', () => Errors.INVALID_USERINFO_SIGNATURE)
.intercept('invalidUserinfoConfiguration', () => Errors.INVALID_USERINFO_CONFIGURATION)
.intercept('emailAlreadyInUse', () => Errors.EMAIL_ALREADY_IN_USE)
.intercept('usernameAlreadyInUse', () => Errors.USERNAME_ALREADY_IN_USE)
.intercept('missingValues', () => Errors.MISSING_VALUES);

View File

@@ -1,6 +1,3 @@
const util = require('util');
const { v4: uuid } = require('uuid');
const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
@@ -61,16 +58,9 @@ module.exports = {
throw Errors.NOT_ENOUGH_RIGHTS;
}
const upload = util.promisify((options, callback) =>
this.req.file('file').upload(options, (error, files) => callback(error, files)),
);
let files;
try {
files = await upload({
saveAs: uuid(),
maxBytes: null,
});
files = await sails.helpers.utils.receiveFile('file', this.req);
} catch (error) {
return exits.uploadError(error.message); // TODO: add error
}

View File

@@ -1,6 +1,3 @@
const util = require('util');
const { v4: uuid } = require('uuid');
const Errors = {
PROJECT_NOT_FOUND: {
projectNotFound: 'Project not found',
@@ -69,16 +66,9 @@ module.exports = {
let boardImport;
if (inputs.importType && Object.values(Board.ImportTypes).includes(inputs.importType)) {
const upload = util.promisify((options, callback) =>
this.req.file('importFile').upload(options, (error, files) => callback(error, files)),
);
let files;
try {
files = await upload({
saveAs: uuid(),
maxBytes: null,
});
files = await sails.helpers.utils.receiveFile('importFile', this.req);
} catch (error) {
return exits.uploadError(error.message); // TODO: add error
}

View File

@@ -1,6 +1,4 @@
const util = require('util');
const rimraf = require('rimraf');
const { v4: uuid } = require('uuid');
const Errors = {
PROJECT_NOT_FOUND: {
@@ -53,16 +51,9 @@ module.exports = {
throw Errors.PROJECT_NOT_FOUND; // Forbidden
}
const upload = util.promisify((options, callback) =>
this.req.file('file').upload(options, (error, files) => callback(error, files)),
);
let files;
try {
files = await upload({
saveAs: uuid(),
maxBytes: null,
});
files = await sails.helpers.utils.receiveFile('file', this.req);
} catch (error) {
return exits.uploadError(error.message); // TODO: add error
}

View File

@@ -1,6 +1,4 @@
const util = require('util');
const rimraf = require('rimraf');
const { v4: uuid } = require('uuid');
const Errors = {
USER_NOT_FOUND: {
@@ -54,16 +52,9 @@ module.exports = {
user = currentUser;
}
const upload = util.promisify((options, callback) =>
this.req.file('file').upload(options, (error, files) => callback(error, files)),
);
let files;
try {
files = await upload({
saveAs: uuid(),
maxBytes: null,
});
files = await sails.helpers.utils.receiveFile('file', this.req);
} catch (error) {
return exits.uploadError(error.message); // TODO: add error
}

View File

@@ -12,7 +12,7 @@ module.exports = {
exits: {
invalidCodeOrNonce: {},
invalidUserinfoSignature: {},
invalidUserinfoConfiguration: {},
missingValues: {},
emailAlreadyInUse: {},
usernameAlreadyInUse: {},
@@ -21,9 +21,9 @@ module.exports = {
async fn(inputs) {
const client = sails.hooks.oidc.getClient();
let userInfo;
let tokenSet;
try {
const tokenSet = await client.callback(
tokenSet = await client.callback(
sails.config.custom.oidcRedirectUri,
{
iss: sails.config.custom.oidcIssuer,
@@ -33,23 +33,36 @@ module.exports = {
nonce: inputs.nonce,
},
);
userInfo = await client.userinfo(tokenSet);
} catch (e) {
if (
e instanceof SyntaxError &&
e.message.includes('Unexpected token e in JSON at position 0')
) {
sails.log.warn('Error while exchanging OIDC code: userinfo response is signed');
throw 'invalidUserinfoSignature';
}
sails.log.warn(`Error while exchanging OIDC code: ${e}`);
} catch (error) {
sails.log.warn(`Error while exchanging OIDC code: ${error}`);
throw 'invalidCodeOrNonce';
}
let claims;
if (sails.config.custom.oidcClaimsSource === 'id_token') {
claims = tokenSet.claims();
} else {
try {
claims = await client.userinfo(tokenSet);
} catch (error) {
let errorText;
if (
error instanceof SyntaxError &&
error.message.includes('Unexpected token e in JSON at position 0')
) {
errorText = 'response is signed';
} else {
errorText = error.toString();
}
sails.log.warn(`Error while fetching OIDC userinfo: ${errorText}`);
throw 'invalidUserinfoConfiguration';
}
}
if (
!userInfo[sails.config.custom.oidcEmailAttribute] ||
!userInfo[sails.config.custom.oidcNameAttribute]
!claims[sails.config.custom.oidcEmailAttribute] ||
!claims[sails.config.custom.oidcNameAttribute]
) {
throw 'missingValues';
}
@@ -58,23 +71,23 @@ module.exports = {
if (sails.config.custom.oidcAdminRoles.includes('*')) {
isAdmin = true;
} else {
const roles = userInfo[sails.config.custom.oidcRolesAttribute];
const roles = claims[sails.config.custom.oidcRolesAttribute];
if (Array.isArray(roles)) {
// Use a Set here to avoid quadratic time complexity
const userRoles = new Set(userInfo[sails.config.custom.oidcRolesAttribute]);
const userRoles = new Set(claims[sails.config.custom.oidcRolesAttribute]);
isAdmin = sails.config.custom.oidcAdminRoles.findIndex((role) => userRoles.has(role)) > -1;
}
}
const values = {
isAdmin,
email: userInfo[sails.config.custom.oidcEmailAttribute],
email: claims[sails.config.custom.oidcEmailAttribute],
isSso: true,
name: userInfo[sails.config.custom.oidcNameAttribute],
name: claims[sails.config.custom.oidcNameAttribute],
subscribeToOwnCards: false,
};
if (!sails.config.custom.oidcIgnoreUsername) {
values.username = userInfo[sails.config.custom.oidcUsernameAttribute];
values.username = claims[sails.config.custom.oidcUsernameAttribute];
}
let user;
@@ -84,7 +97,7 @@ module.exports = {
// concurrently with logging in via OIDC.
let identityProviderUser = await IdentityProviderUser.findOne({
issuer: sails.config.custom.oidcIssuer,
sub: userInfo.sub,
sub: claims.sub,
});
if (identityProviderUser) {
@@ -108,7 +121,7 @@ module.exports = {
identityProviderUser = await IdentityProviderUser.create({
userId: user.id,
issuer: sails.config.custom.oidcIssuer,
sub: userInfo.sub,
sub: claims.sub,
});
}

View File

@@ -0,0 +1,41 @@
const util = require('util');
const { v4: uuid } = require('uuid');
async function doUpload(paramName, req, options) {
const uploadOptions = {
...options,
dirname: options.dirname || sails.config.custom.fileUploadTmpDir,
};
const upload = util.promisify((opts, callback) => {
return req.file(paramName).upload(opts, (error, files) => callback(error, files));
});
return upload(uploadOptions);
}
module.exports = {
friendlyName: 'Receive uploaded file from request',
description:
"Store a file uploaded from a MIME-multipart request part. The request part name must be 'file'; the resulting file will have a unique UUID-based name with the same extension.",
inputs: {
paramName: {
type: 'string',
required: true,
description: 'The MIME multi-part parameter containing the file to receive.',
},
req: {
type: 'ref',
required: true,
description: 'The request to receive the file from.',
},
},
fn: async function modFn(inputs, exits) {
exits.success(
await doUpload(inputs.paramName, inputs.req, {
saveAs: uuid(),
dirname: sails.config.custom.fileUploadTmpDir,
maxBytes: null,
}),
);
},
};

View File

@@ -130,8 +130,8 @@ async function sendWebhook(webhook, event, data, user) {
`Webhook ${webhook.url} failed with status ${response.status} and message: ${message}`,
);
}
} catch (e) {
sails.log.error(`Webhook ${webhook.url} failed with error message: ${e.message}`);
} catch (error) {
sails.log.error(`Webhook ${webhook.url} failed with error: ${error}`);
}
}

View File

@@ -33,6 +33,9 @@ module.exports = function defineSmtpHook(sails) {
user: sails.config.custom.smtpUser,
pass: sails.config.custom.smtpPassword,
},
tls: {
rejectUnauthorized: sails.config.custom.smtpTlsRejectUnauthorized,
},
});
},

View File

@@ -6,10 +6,12 @@
*/
const LANGUAGES = [
'ar-YE',
'bg-BG',
'cs-CZ',
'da-DK',
'de-DE',
'en-GB',
'en-US',
'es-ES',
'fa-IR',

View File

@@ -27,6 +27,9 @@ module.exports.custom = {
tokenExpiresIn: parseInt(process.env.TOKEN_EXPIRES_IN, 10) || 365,
// Location to receive uploaded files in. Default (non-string value) is a Sails-specific location.
fileUploadTmpDir: null,
userAvatarsPath: path.join(sails.config.paths.public, 'user-avatars'),
userAvatarsUrl: `${process.env.BASE_URL}/user-avatars`,
@@ -52,6 +55,7 @@ module.exports.custom = {
oidcResponseMode: process.env.OIDC_RESPONSE_MODE || 'fragment',
oidcUseDefaultResponseMode: process.env.OIDC_USE_DEFAULT_RESPONSE_MODE === 'true',
oidcAdminRoles: process.env.OIDC_ADMIN_ROLES ? process.env.OIDC_ADMIN_ROLES.split(',') : [],
oidcClaimsSource: process.env.OIDC_CLAIMS_SOURCE || 'userinfo',
oidcEmailAttribute: process.env.OIDC_EMAIL_ATTRIBUTE || 'email',
oidcNameAttribute: process.env.OIDC_NAME_ATTRIBUTE || 'name',
oidcUsernameAttribute: process.env.OIDC_USERNAME_ATTRIBUTE || 'preferred_username',
@@ -72,6 +76,7 @@ module.exports.custom = {
smtpUser: process.env.SMTP_USER,
smtpPassword: process.env.SMTP_PASSWORD,
smtpFrom: process.env.SMTP_FROM,
smtpTlsRejectUnauthorized: process.env.SMTP_TLS_REJECT_UNAUTHORIZED !== 'false',
webhooks: JSON.parse(process.env.WEBHOOKS || '[]'), // TODO: validate structure

View File

@@ -1,3 +1,56 @@
const serveStatic = require('serve-static');
const sails = require('sails');
const path = require('path');
// Remove prefix from urlPath, assuming completely matches a subpath of
// urlPath. The result preserves query params and fragment if present
//
// Examples:
// '/foo', '/foo/bar' -> '/bar'
// '/foo', '/foo' -> '/'
// '/foo', '/foo?baz=bux' -> '/?baz=bux'
// '/foo', '/foobar' -> '/foobar'
function removeRoutePrefix(prefix, urlPath) {
if (urlPath.startsWith(prefix)) {
const subpath = urlPath.substring(prefix.length);
if (subpath.startsWith('/')) {
// Prefix matched a complete set of path segments, with a valid path
// remaining.
return subpath;
}
if (subpath.length === 0 || subpath.startsWith('?') || subpath.startsWith('#')) {
// Prefix matched a complete set of path segments, but there is no path
// remaining. Add '/'.
return `/${subpath}`;
}
}
// Either the prefix didn't match at all, or it wasn't a complete path match
// (e.g. we don't want to treat '/foo' as a prefix of '/foobar'). Leave the
// path as-is.
return urlPath;
}
function staticDirServer(prefix, dirFn) {
return function handleReq(req, res, next) {
// Custom config properties are not available when the routes config is
// loaded, so resolve the target value just before serving the request.
const dir = dirFn();
const staticServer = serveStatic(dir, { index: false });
const reqPath = req.url;
if (reqPath.startsWith(prefix)) {
// serve-static treats the request url as a sub-path of
// static root; remove the leading route prefix so the static root
// doesn't have to include the prefix as a subdirectory.
req.url = removeRoutePrefix(prefix, req.url);
return staticServer(req, res, next);
}
return next();
};
}
/**
* Route Mappings
* (sails.config.routes)
@@ -81,6 +134,18 @@ module.exports.routes = {
'GET /api/notifications/:id': 'notifications/show',
'PATCH /api/notifications/:ids': 'notifications/update',
'GET /user-avatars/*': {
fn: staticDirServer('/user-avatars', () => path.resolve(sails.config.custom.userAvatarsPath)),
skipAssets: false,
},
'GET /project-background-images/*': {
fn: staticDirServer('/project-background-images', () =>
path.resolve(sails.config.custom.projectBackgroundImagesPath),
),
skipAssets: false,
},
'GET /attachments/:id/download/:filename': {
action: 'attachments/download',
skipAssets: false,

13905
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,6 @@
"db:migrate": "knex migrate:latest --cwd db",
"db:seed": "knex seed:run --cwd db",
"lint": "eslint . --max-warnings=0 --report-unused-disable-directives && echo '✔ Your .js files look good.'",
"preinstall": "npx npm-force-resolutions",
"start": "nodemon",
"start:prod": "node app.js --prod",
"test": "mocha test/lifecycle.test.js test/integration/**/*.test.js test/utils/**/*.test.js"
@@ -39,10 +38,11 @@
"nodemailer": "^6.9.15",
"openid-client": "^5.7.0",
"rimraf": "^5.0.10",
"sails": "^1.5.11",
"sails": "^1.5.12",
"sails-hook-orm": "^4.0.3",
"sails-hook-sockets": "^3.0.1",
"sails-postgresql": "^5.0.1",
"serve-static": "^1.13.1",
"sharp": "^0.33.5",
"stream-to-array": "^2.3.0",
"uuid": "^9.0.1",
@@ -58,11 +58,6 @@
"mocha": "^10.7.3",
"nodemon": "^3.1.4"
},
"resolutions": {
"body-parser": "1.20.3",
"express": "4.21.0",
"send": "0.19.0"
},
"engines": {
"node": ">=18"
}

View File

@@ -6,7 +6,8 @@ const winston = require('winston');
*/
const defaultLogTimestampFormat = 'YYYY-MM-DD HH:mm:ss';
const logfile = `${process.cwd()}/logs/planka.log`;
const logfile =
'LOG_FILE' in process.env ? process.env.LOG_FILE : `${process.cwd()}/logs/planka.log`;
/**
* Log level for both console and file log sinks.