feat: Add legal requirements (#1306)

This commit is contained in:
Maksim Eltyshev
2025-08-21 15:10:02 +02:00
committed by GitHub
parent bb40a22563
commit 2f4bcb0583
122 changed files with 1522 additions and 81 deletions

View File

@@ -13,9 +13,9 @@ jobs:
runs-on: ubuntu-latest
env:
POSTGRES_DB: planka
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_USERNAME: planka
POSTGRES_PASSWORD: planka
POSTGRES_DATABASE: planka
steps:
- name: Checkout repository
@@ -30,9 +30,9 @@ jobs:
- name: Set up PostgreSQL
uses: ikalnytskyi/action-setup-postgres@v5
with:
database: ${{ env.POSTGRES_DB }}
username: ${{ env.POSTGRES_USER }}
username: ${{ env.POSTGRES_USERNAME }}
password: ${{ env.POSTGRES_PASSWORD }}
database: ${{ env.POSTGRES_DATABASE }}
- name: Cache Node.js modules
uses: actions/cache@v3
@@ -58,7 +58,7 @@ jobs:
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
sed -i "s|^DATABASE_URL=.*|DATABASE_URL=postgresql://${POSTGRES_USERNAME}:${POSTGRES_PASSWORD}@localhost/${POSTGRES_DATABASE}|" .env
npm run db:init
npm start --prod &
@@ -67,6 +67,12 @@ jobs:
sudo apt-get install wait-for-it -y
wait-for-it -h localhost -p 1337 -t 10
- name: Seed database with terms signature
run: |
TERMS_SIGNATURE=$(sha256sum terms/en-US/extended.md | awk '{print $1}')
PGPASSWORD=$POSTGRES_PASSWORD psql -h localhost -U $POSTGRES_USERNAME -d $POSTGRES_DATABASE -c "UPDATE user_account SET terms_signature = '$TERMS_SIGNATURE';"
working-directory: ./server
- name: Run UI tests
run: |
npx playwright install chromium

View File

@@ -91,8 +91,8 @@ const logout = () => ({
payload: {},
});
logout.invalidateAccessToken = () => ({
type: ActionTypes.LOGOUT__ACCESS_TOKEN_INVALIDATE,
logout.revokeAccessToken = () => ({
type: ActionTypes.LOGOUT__ACCESS_TOKEN_REVOKE,
payload: {},
});

View File

@@ -26,10 +26,11 @@ authenticate.success = (accessToken) => ({
},
});
authenticate.failure = (error) => ({
authenticate.failure = (error, terms) => ({
type: ActionTypes.AUTHENTICATE__FAILURE,
payload: {
error,
terms,
},
});
@@ -45,10 +46,11 @@ authenticateWithOidc.success = (accessToken) => ({
},
});
authenticateWithOidc.failure = (error) => ({
authenticateWithOidc.failure = (error, terms) => ({
type: ActionTypes.WITH_OIDC_AUTHENTICATE__FAILURE,
payload: {
error,
terms,
},
});
@@ -57,9 +59,71 @@ const clearAuthenticateError = () => ({
payload: {},
});
const acceptTerms = (signature) => ({
type: ActionTypes.TERMS_ACCEPT,
payload: {
signature,
},
});
acceptTerms.success = (accessToken) => ({
type: ActionTypes.TERMS_ACCEPT__SUCCESS,
payload: {
accessToken,
},
});
acceptTerms.failure = (error) => ({
type: ActionTypes.TERMS_ACCEPT__FAILURE,
payload: {
error,
},
});
const cancelTerms = () => ({
type: ActionTypes.TERMS_CANCEL,
payload: {},
});
cancelTerms.success = () => ({
type: ActionTypes.TERMS_CANCEL__SUCCESS,
payload: {},
});
cancelTerms.failure = (error) => ({
type: ActionTypes.TERMS_CANCEL__FAILURE,
payload: {
error,
},
});
const updateTermsLanguage = (value) => ({
type: ActionTypes.TERMS_LANGUAGE_UPDATE,
payload: {
value,
},
});
updateTermsLanguage.success = (terms) => ({
type: ActionTypes.TERMS_LANGUAGE_UPDATE__SUCCESS,
payload: {
terms,
},
});
updateTermsLanguage.failure = (error) => ({
type: ActionTypes.TERMS_LANGUAGE_UPDATE__FAILURE,
payload: {
error,
},
});
export default {
initializeLogin,
authenticate,
authenticateWithOidc,
clearAuthenticateError,
acceptTerms,
cancelTerms,
updateTermsLanguage,
};

View File

@@ -13,10 +13,18 @@ const createAccessToken = (data, headers) =>
const exchangeForAccessTokenWithOidc = (data, headers) =>
http.post('/access-tokens/exchange-with-oidc?withHttpOnlyToken=true', data, headers);
// TODO: rename?
const acceptTerms = (data, headers) => http.post('/access-tokens/accept-terms', data, headers);
const revokePendingToken = (data, headers) =>
http.post('/access-tokens/revoke-pending-token', data, headers);
const deleteCurrentAccessToken = (headers) => http.delete('/access-tokens/me', undefined, headers);
export default {
createAccessToken,
exchangeForAccessTokenWithOidc,
acceptTerms,
revokePendingToken,
deleteCurrentAccessToken,
};

View File

@@ -6,6 +6,7 @@
import http from './http';
import socket from './socket';
import config from './config';
import terms from './terms';
import accessTokens from './access-tokens';
import webhooks from './webhooks';
import users from './users';
@@ -35,6 +36,7 @@ export { http, socket };
export default {
...config,
...terms,
...accessTokens,
...webhooks,
...users,

15
client/src/api/terms.js Normal file
View File

@@ -0,0 +1,15 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import http from './http';
/* Actions */
const getTerms = (type, language, headers) =>
http.get(`/terms/${type}${language ? `?language=${language}` : ''}`, undefined, headers);
export default {
getTerms,
};

View File

@@ -16,6 +16,8 @@ import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useForm, useNestedRef } from '../../../hooks';
import { isUsername } from '../../../utils/validator';
import AccessTokenSteps from '../../../constants/AccessTokenSteps';
import TermsModal from './TermsModal';
import styles from './Content.module.scss';
@@ -45,6 +47,11 @@ const createMessage = (error) => {
type: 'error',
content: 'common.useSingleSignOn',
};
case 'Admin login required to initialize instance':
return {
type: 'error',
content: 'common.adminLoginRequiredToInitializeInstance',
};
case 'Email already in use':
return {
type: 'error',
@@ -86,6 +93,7 @@ const Content = React.memo(() => {
isSubmitting,
isSubmittingWithOidc,
error,
step,
} = useSelector(selectors.selectAuthenticateForm);
const dispatch = useDispatch();
@@ -265,6 +273,7 @@ const Content = React.memo(() => {
<div className={styles.coverOverlay} />
</Grid.Column>
</Grid>
{step === AccessTokenSteps.ACCEPT_TERMS && <TermsModal />}
</div>
);
});

View File

@@ -0,0 +1,93 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Checkbox, Dropdown, Modal } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { localeByLanguage } from '../../../locales';
import TERMS_LANGUAGES from '../../../constants/TermsLanguages';
import Markdown from '../Markdown';
import styles from './TermsModal.module.scss';
const LOCALES = TERMS_LANGUAGES.map((language) => localeByLanguage[language]);
const TermsModal = React.memo(() => {
const {
termsForm: { payload: terms, isSubmitting, isCancelling, isLanguageUpdating },
} = useSelector(selectors.selectAuthenticateForm);
const dispatch = useDispatch();
const [t] = useTranslation();
const [isTermsAccepted, setIsTermsAccepted] = useState(false);
const handleContinueClick = useCallback(() => {
dispatch(entryActions.acceptTerms(terms.signature));
}, [terms.signature, dispatch]);
const handleCancelClick = useCallback(() => {
dispatch(entryActions.cancelTerms());
}, [dispatch]);
const handleLanguageChange = useCallback(
(_, { value }) => {
dispatch(entryActions.updateTermsLanguage(value));
},
[dispatch],
);
const handleToggleAcceptClick = useCallback((_, { checked }) => {
setIsTermsAccepted(checked);
}, []);
return (
<Modal open centered={false}>
<Modal.Content>
<Dropdown
fluid
selection
options={LOCALES.map((locale) => ({
value: locale.language,
flag: locale.country,
text: locale.name,
}))}
value={terms.language}
loading={isLanguageUpdating}
disabled={isLanguageUpdating}
className={styles.language}
onChange={handleLanguageChange}
/>
<Markdown>{terms.content}</Markdown>
</Modal.Content>
<Modal.Actions>
<Button
content={t('action.cancelAndClose')}
floated="left"
loading={isCancelling}
disabled={isSubmitting || isCancelling}
className={styles.cancelButton}
onClick={handleCancelClick}
/>
<Checkbox
label={t('common.iHaveReadAndAgreeToTheseTerms')}
onChange={handleToggleAcceptClick}
/>
<Button
positive
content={t('action.continue')}
loading={isSubmitting}
disabled={!isTermsAccepted || isSubmitting || isCancelling}
onClick={handleContinueClick}
/>
</Modal.Actions>
</Modal>
);
});
export default TermsModal;

View File

@@ -0,0 +1,14 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.cancelButton {
margin-left: 0;
}
.language {
margin-bottom: 20px;
}
}

View File

@@ -8,6 +8,8 @@ import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Dropdown, Header, Tab } from 'semantic-ui-react';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import { usePopupInClosableContext } from '../../../../hooks';
import locales from '../../../../locales';
import EditAvatarStep from './EditAvatarStep';
@@ -17,9 +19,6 @@ import EditUserEmailStep from '../../EditUserEmailStep';
import EditUserPasswordStep from '../../EditUserPasswordStep';
import UserAvatar from '../../UserAvatar';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import styles from './AccountPane.module.scss';
const AccountPane = React.memo(() => {

View File

@@ -0,0 +1,49 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Loader, Tab } from 'semantic-ui-react';
import selectors from '../../../selectors';
import api from '../../../api';
import Markdown from '../../common/Markdown';
import styles from './TermsPane.module.scss';
const TermsPane = React.memo(() => {
const type = useSelector((state) => selectors.selectCurrentUser(state).termsType);
const { i18n } = useTranslation();
const [content, setContent] = useState(null);
useEffect(() => {
async function fetchTerms() {
let terms;
try {
({ item: terms } = await api.getTerms(type, i18n.resolvedLanguage));
} catch {
return;
}
setContent(terms.content);
}
fetchTerms();
}, [type, i18n.resolvedLanguage]);
return (
<Tab.Pane attached={false} className={styles.wrapper}>
{content ? (
<Markdown>{content}</Markdown>
) : (
<Loader active inverted inline="centered" size="small" />
)}
</Tab.Pane>
);
});
export default TermsPane;

View File

@@ -0,0 +1,11 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapper {
border: none;
box-shadow: none;
}
}

View File

@@ -13,6 +13,7 @@ import { useClosableModal } from '../../../hooks';
import AccountPane from './AccountPane';
import PreferencesPane from './PreferencesPane';
import NotificationsPane from './NotificationsPane';
import TermsPane from './TermsPane';
import AboutPane from './AboutPane';
const UserSettingsModal = React.memo(() => {
@@ -44,6 +45,12 @@ const UserSettingsModal = React.memo(() => {
}),
render: () => <NotificationsPane />,
},
{
menuItem: t('common.terms', {
context: 'title',
}),
render: () => <TermsPane />,
},
{
menuItem: t('common.aboutPlanka', {
context: 'title',

View File

@@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
export default {
ACCEPT_TERMS: 'accept-terms',
};

View File

@@ -26,6 +26,15 @@ export default {
WITH_OIDC_AUTHENTICATE__SUCCESS: 'WITH_OIDC_AUTHENTICATE__SUCCESS',
WITH_OIDC_AUTHENTICATE__FAILURE: 'WITH_OIDC_AUTHENTICATE__FAILURE',
AUTHENTICATE_ERROR_CLEAR: 'AUTHENTICATE_ERROR_CLEAR',
TERMS_ACCEPT: 'TERMS_ACCEPT',
TERMS_ACCEPT__SUCCESS: 'TERMS_ACCEPT__SUCCESS',
TERMS_ACCEPT__FAILURE: 'TERMS_ACCEPT__FAILURE',
TERMS_CANCEL: 'TERMS_CANCEL',
TERMS_CANCEL__SUCCESS: 'TERMS_CANCEL__SUCCESS',
TERMS_CANCEL__FAILURE: 'TERMS_CANCEL__FAILURE',
TERMS_LANGUAGE_UPDATE: 'TERMS_LANGUAGE_UPDATE',
TERMS_LANGUAGE_UPDATE__SUCCESS: 'TERMS_LANGUAGE_UPDATE__SUCCESS',
TERMS_LANGUAGE_UPDATE__FAILURE: 'TERMS_LANGUAGE_UPDATE__FAILURE',
/* Core */
@@ -35,7 +44,7 @@ export default {
EDIT_MODE_TOGGLE: 'EDIT_MODE_TOGGLE',
HOME_VIEW_UPDATE: 'HOME_VIEW_UPDATE',
LOGOUT: 'LOGOUT',
LOGOUT__ACCESS_TOKEN_INVALIDATE: 'LOGOUT__ACCESS_TOKEN_INVALIDATE',
LOGOUT__ACCESS_TOKEN_REVOKE: 'LOGOUT__ACCESS_TOKEN_REVOKE',
/* Modals */

View File

@@ -18,6 +18,9 @@ export default {
AUTHENTICATE: `${PREFIX}/AUTHENTICATE`,
WITH_OIDC_AUTHENTICATE: `${PREFIX}/WITH_OIDC_AUTHENTICATE`,
AUTHENTICATE_ERROR_CLEAR: `${PREFIX}/AUTHENTICATE_ERROR_CLEAR`,
TERMS_ACCEPT: `${PREFIX}/TERMS_ACCEPT`,
TERMS_CANCEL: `${PREFIX}/TERMS_CANCEL`,
TERMS_LANGUAGE_UPDATE: `${PREFIX}/TERMS_LANGUAGE_UPDATE`,
/* Core */

View File

@@ -0,0 +1,6 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
export default ['de-DE', 'en-US'];

View File

@@ -26,10 +26,10 @@ const updateHomeView = (value) => ({
},
});
const logout = (invalidateAccessToken = true) => ({
const logout = (revokeAccessToken = true) => ({
type: EntryActionTypes.LOGOUT,
payload: {
invalidateAccessToken,
revokeAccessToken,
},
});

View File

@@ -22,8 +22,30 @@ const clearAuthenticateError = () => ({
payload: {},
});
const acceptTerms = (signature) => ({
type: EntryActionTypes.TERMS_ACCEPT,
payload: {
signature,
},
});
const cancelTerms = () => ({
type: EntryActionTypes.TERMS_CANCEL,
payload: {},
});
const updateTermsLanguage = (value) => ({
type: EntryActionTypes.TERMS_LANGUAGE_UPDATE,
payload: {
value,
},
});
export default {
authenticate,
authenticateWithOidc,
clearAuthenticateError,
acceptTerms,
cancelTerms,
updateTermsLanguage,
};

View File

@@ -270,6 +270,7 @@ export default {
taskListActions_title: null,
taskList_title: null,
team: null,
terms: null,
thereIsNoPreviewAvailableForThisAttachment: 'لا يوجد معاينة متاحة لهذا المرفق.',
time: 'الوقت',
title: 'العنوان',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'البريد الإلكتروني مستخدم بالفعل',
emailOrUsername: 'البريد الإلكتروني أو اسم المستخدم',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: 'بيانات الاعتماد غير صالحة',
invalidEmailOrUsername: 'البريد الإلكتروني أو اسم المستخدم غير صالح',
invalidPassword: 'كلمة المرور غير صالحة',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'تسجيل الدخول',
logInWithSso: 'تسجيل الدخول باستخدام SSO',
},

View File

@@ -274,6 +274,7 @@ export default {
taskListActions_title: null,
taskList_title: null,
team: null,
terms: null,
thereIsNoPreviewAvailableForThisAttachment: 'Няма наличен преглед за този прикачен файл.',
time: 'Време',
title: 'Заглавие',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'Имейлът вече се използва',
emailOrUsername: 'Имейл или потребителско име',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: null,
invalidEmailOrUsername: 'Невалиден имейл или потребителско име',
invalidPassword: 'Невалидна парола',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Вход',
logInWithSso: 'Вход чрез SSO',
},

View File

@@ -284,6 +284,7 @@ export default {
taskListActions_title: 'Akce seznamu úkolů',
taskList_title: 'Seznam úkolů',
team: 'Tým',
terms: null,
thereIsNoPreviewAvailableForThisAttachment: 'Pro tuto přílohu není k dispozici žádný náhled.',
time: 'Čas',
title: 'Titulek',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: 'Dosažený limit aktivních uživatelů',
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'E-mail se již používá',
emailOrUsername: 'E-mail nebo uživatelské jméno',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: 'Neplatné přihlašovací údaje',
invalidEmailOrUsername: 'Nesprávný e-mail nebo uživatelské jméno',
invalidPassword: 'Nesprávné heslo',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Přihlásit se',
logInWithSso: null,
},

View File

@@ -290,6 +290,7 @@ export default {
taskListActions_title: 'Opgaveliste handlinger',
taskList_title: 'Opgaveliste',
team: 'Team',
terms: null,
thereIsNoPreviewAvailableForThisAttachment:
'Der er ingen forhåndsvisning tilgængelig for denne vedhæftning.',
time: 'Tid',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: 'Grænsen for aktive brugere er nået',
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'E-mail allerede i brug',
emailOrUsername: 'E-mail eller brugernavn',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: 'Forkerte loginoplysninger',
invalidEmailOrUsername: 'Ugyldig e-mail eller brugernavn',
invalidPassword: 'Ugyldig adgangskode',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Log på',
logInWithSso: 'Log på med SSO',
},

View File

@@ -299,6 +299,7 @@ export default {
taskListActions_title: 'Aufgaben-Aktionen',
taskList_title: 'Aufgaben',
team: 'Team',
terms: null,
thereIsNoPreviewAvailableForThisAttachment: 'Für diesen Anhang ist keine Vorschau verfügbar.',
time: 'Zeit',
title: 'Titel',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: 'Maximale Anzahl aktiver Benutzer erreicht',
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'E-mail Adresse wird bereits benutzt',
emailOrUsername: 'E-Mail-Adresse oder Benutzername',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: 'Ungültige Anmeldeinformationen',
invalidEmailOrUsername: 'Ungültige E-Mail-Adresse oder Benutzername',
invalidPassword: 'Ungültiges Passwort',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Einloggen',
logInWithSso: 'Einloggen mit SSO',
},

View File

@@ -300,6 +300,7 @@ export default {
taskListActions_title: 'Ενέργειες λίστας εργασιών',
taskList_title: 'Λίστα εργασιών',
team: 'Ομάδα',
terms: null,
thereIsNoPreviewAvailableForThisAttachment:
'Δεν υπάρχει διαθέσιμη προεπισκόπηση για αυτό το συνημμένο.',
time: 'Ώρα',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: 'Έχει επιτευχθεί το όριο ενεργών χρηστών',
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'Το e-mail χρησιμοποιείται ήδη',
emailOrUsername: 'E-mail ή όνομα χρήστη',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: 'Μη έγκυρα στοιχεία σύνδεσης',
invalidEmailOrUsername: 'Μη έγκυρο e-mail ή όνομα χρήστη',
invalidPassword: 'Μη έγκυρος κωδικός',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Σύνδεση',
logInWithSso: 'Σύνδεση με SSO',
},

View File

@@ -290,6 +290,7 @@ export default {
taskListActions_title: 'Task List Actions',
taskList_title: 'Task List',
team: 'Team',
terms: 'Terms',
thereIsNoPreviewAvailableForThisAttachment:
'There is no preview available for this attachment.',
time: 'Time',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: 'Active users limit reached',
adminLoginRequiredToInitializeInstance: 'Admin login required to initialize instance',
emailAlreadyInUse: 'E-mail already in use',
emailOrUsername: 'E-mail or username',
iHaveReadAndAgreeToTheseTerms: 'I have read and agree to these Terms',
invalidCredentials: 'Invalid credentials',
invalidEmailOrUsername: 'Invalid e-mail or username',
invalidPassword: 'Invalid password',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: 'Cancel and close',
continue: 'Continue',
logIn: 'Log in',
logInWithSso: 'Log in with SSO',
},

View File

@@ -285,6 +285,7 @@ export default {
taskListActions_title: 'Task List Actions',
taskList_title: 'Task List',
team: 'Team',
terms: 'Terms',
thereIsNoPreviewAvailableForThisAttachment:
'There is no preview available for this attachment.',
time: 'Time',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: 'Active users limit reached',
adminLoginRequiredToInitializeInstance: 'Admin login required to initialize instance',
emailAlreadyInUse: 'E-mail already in use',
emailOrUsername: 'E-mail or username',
iHaveReadAndAgreeToTheseTerms: 'I have read and agree to these Terms',
invalidCredentials: 'Invalid credentials',
invalidEmailOrUsername: 'Invalid e-mail or username',
invalidPassword: 'Invalid password',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: 'Cancel and close',
continue: 'Continue',
logIn: 'Log in',
logInWithSso: 'Log in with SSO',
},

View File

@@ -290,6 +290,7 @@ export default {
taskListActions_title: 'Acciones de la lista de tareas',
taskList_title: 'Lista de tareas',
team: 'Equipo',
terms: null,
thereIsNoPreviewAvailableForThisAttachment:
'No hay vista previa disponible para este adjunto.',
time: 'Tiempo',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'El correo ya está en uso',
emailOrUsername: 'Correo o nombre de usuario',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: null,
invalidEmailOrUsername: 'Correo o nombre de usuario incorrecto',
invalidPassword: 'Contraseña incorrecta',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Iniciar sesión',
logInWithSso: null,
},

View File

@@ -289,6 +289,7 @@ export default {
taskListActions_title: 'Ülesannete nimekiri tegevused',
taskList_title: 'Ülesanne nimekiri',
team: 'Töögrupp',
terms: null,
thereIsNoPreviewAvailableForThisAttachment: 'Selle manusi eelvaadet pole saadaval.',
time: 'Aeg',
title: 'Pealkiri',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: 'Aktiivsete kasutajate limiit on täis',
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'E-post on juba kasutusel',
emailOrUsername: 'E-post või kasutajanimi',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: 'Vale kasutajanimi või parool',
invalidEmailOrUsername: 'Vale e-post või kasutajanimi',
invalidPassword: 'Vale parool',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Logi sisse',
logInWithSso: 'Logi sisse SSO-ga',
},

View File

@@ -271,6 +271,7 @@ export default {
taskListActions_title: null,
taskList_title: null,
team: null,
terms: null,
thereIsNoPreviewAvailableForThisAttachment: 'پیش نمایشی برای این پیوست موجود نیست.',
time: 'زمان',
title: 'عنوان',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'ایمیل قبلا استفاده شده است',
emailOrUsername: 'ایمیل یا نام کاربری',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: null,
invalidEmailOrUsername: 'ایمیل یا نام کاربری نامعتبر است',
invalidPassword: 'رمز عبور نامعتبر است',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'ورود',
logInWithSso: 'ورود با SSO',
},

View File

@@ -285,6 +285,7 @@ export default {
taskListActions_title: 'Tehtävälistan toiminnot',
taskList_title: 'Tehtävälista',
team: 'Tiimi',
terms: null,
thereIsNoPreviewAvailableForThisAttachment: 'Tälle liitteelle ei ole esikatselua saatavilla.',
time: 'Aika',
title: 'Otsikko',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: 'Aktiivisten käyttäjien raja saavutettu',
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'Sähköposti on jo käytössä',
emailOrUsername: 'Sähköposti tai käyttäjänimi',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: 'Virheelliset tunnistetiedot',
invalidEmailOrUsername: 'Virheellinen sähköposti tai käyttäjänimi',
invalidPassword: 'Virheellinen salasana',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Kirjaudu sisään',
logInWithSso: 'Kirjaudu SSO:lla',
},

View File

@@ -293,6 +293,7 @@ export default {
taskListActions_title: 'Actions de la liste de tâches',
taskList_title: 'Liste de tâches',
team: "Mes projets d'équipe",
terms: null,
thereIsNoPreviewAvailableForThisAttachment:
"Il n'y a pas d'aperçu disponible pour cette pièce jointe.",
time: 'Temps',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: 'La limite dutilisateurs actifs a été atteinte',
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'E-mail déjà utilisé',
emailOrUsername: "E-mail ou nom d'utilisateur",
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: 'Identifiants invalides',
invalidEmailOrUsername: "E-mail ou nom d'utilisateur invalide",
invalidPassword: 'Mot de passe invalide',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Se connecter',
logInWithSso: "Se connecter avec l'authentification unique",
},

View File

@@ -283,6 +283,7 @@ export default {
taskListActions_title: 'Feladatlista műveletek',
taskList_title: 'Feladatlista',
team: 'Csapat',
terms: null,
thereIsNoPreviewAvailableForThisAttachment: 'Nincs elérhető előnézet ehhez a melléklethez.',
time: 'Idő',
title: 'Cím',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'Az e-mail cím már használatban van',
emailOrUsername: 'E-mail vagy felhasználó',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: null,
invalidEmailOrUsername: 'Érvénytelen e-mail vagy felhasználó',
invalidPassword: 'Érvénytelen jelszó',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Belépés',
logInWithSso: 'Belépés SSO-val',
},

View File

@@ -273,6 +273,7 @@ export default {
taskListActions_title: null,
taskList_title: null,
team: null,
terms: null,
thereIsNoPreviewAvailableForThisAttachment:
'Tidak ada pratinjau yang tersedia untuk lampiran ini.',
time: 'Waktu',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'E-mail telah digunakan',
emailOrUsername: 'E-mail atau username',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: null,
invalidEmailOrUsername: 'E-mail atau username salah',
invalidPassword: 'Kata sandi salah',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Masuk',
logInWithSso: 'Masuk dengan SSO',
},

View File

@@ -3,6 +3,8 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import keyBy from 'lodash/keyBy';
import arYE from './ar-YE';
import bgBG from './bg-BG';
import csCZ from './cs-CZ';
@@ -84,3 +86,5 @@ export const embeddedLocales = locales.reduce(
}),
{},
);
export const localeByLanguage = keyBy(locales, 'language');

View File

@@ -291,6 +291,7 @@ export default {
taskListActions_title: 'Azioni lista di task',
taskList_title: 'Lista di task',
team: 'Team',
terms: null,
thereIsNoPreviewAvailableForThisAttachment:
'Non è disponibile alcuna anteprima per questo allegato.',
time: 'Tempo',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: 'Limite utenti attivi raggiunto',
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'E-mail già in uso',
emailOrUsername: 'E-mail o username',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: 'Credenziali non valide',
invalidEmailOrUsername: 'E-mail o username non valido',
invalidPassword: 'Password non valida',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Accedi',
logInWithSso: null,
},

View File

@@ -273,6 +273,7 @@ export default {
taskListActions_title: null,
taskList_title: null,
team: null,
terms: null,
thereIsNoPreviewAvailableForThisAttachment: 'この添付ファイルにはプレビューがありません。',
time: '時間',
title: 'タイトル',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'Eメールは既に使われています',
emailOrUsername: 'Eメールまたはユーザー名',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: null,
invalidEmailOrUsername: 'Eメールまたはユーザー名が無効',
invalidPassword: 'パスワードが無効',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'ログイン',
logInWithSso: 'SSOでログイン',
},

View File

@@ -271,6 +271,7 @@ export default {
taskListActions_title: null,
taskList_title: null,
team: null,
terms: null,
thereIsNoPreviewAvailableForThisAttachment:
'이 첨부 파일에 대한 미리보기를 사용할 수 없습니다.',
time: '시간',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: '이미 사용 중인 이메일',
emailOrUsername: '이메일 또는 사용자 이름',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: '잘못된 자격 증명',
invalidEmailOrUsername: '잘못된 이메일 또는 사용자 이름',
invalidPassword: '잘못된 비밀번호',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: '로그인',
logInWithSso: 'SSO로 로그인',
},

View File

@@ -273,6 +273,7 @@ export default {
taskListActions_title: null,
taskList_title: null,
team: null,
terms: null,
thereIsNoPreviewAvailableForThisAttachment:
'Er is geen voorbeeld beschikbaar voor deze bijlage.',
time: 'Tijd',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'E-mail is al in gebruik',
emailOrUsername: 'E-mail of gebruikersnaam',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: null,
invalidEmailOrUsername: 'Ongeldig e-mailadres of gebruikersnaam',
invalidPassword: 'Ongeldig wachtwoord',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Inloggen',
logInWithSso: 'Inloggen met SSO',
},

View File

@@ -281,6 +281,7 @@ export default {
taskListActions_title: 'Akcje Listy Zadań',
taskList_title: 'Lista Zadań',
team: 'Zespół',
terms: null,
thereIsNoPreviewAvailableForThisAttachment: 'Brak podglądu dostępnego dla tego załącznika.',
time: 'Czas',
title: 'Tytuł',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: 'Osiągnięto limit aktywnych użytkowników',
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'E-mail jest już używany',
emailOrUsername: 'E-mail lub nazwa użytkownika',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: 'Błędne dane logowania',
invalidEmailOrUsername: 'Błędny e-mail lub nazwa użytkownika',
invalidPassword: 'Błędne hasło',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Zaloguj',
logInWithSso: 'Zaloguj z SSO',
},

View File

@@ -292,6 +292,7 @@ export default {
taskListActions_title: 'Ações da Lista de Tarefas',
taskList_title: 'Lista de Tarefas',
team: 'Equipe',
terms: null,
thereIsNoPreviewAvailableForThisAttachment:
'Não há pré-visualização disponível para este anexo.',
time: 'Tempo',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'E-mail já está em uso',
emailOrUsername: 'E-mail ou nome de usuário',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: null,
invalidEmailOrUsername: 'E-mail ou nome de usuário inválido',
invalidPassword: 'Senha inválida',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Entrar',
logInWithSso: 'Entrar com SSO',
},

View File

@@ -274,6 +274,7 @@ export default {
taskListActions_title: null,
taskList_title: null,
team: null,
terms: null,
thereIsNoPreviewAvailableForThisAttachment:
'Não há pré-visualização disponível para este anexo.',
time: 'Tempo',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'E-mail já está em uso',
emailOrUsername: 'E-mail ou nome de utilizador',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: null,
invalidEmailOrUsername: 'E-mail ou nome de utilizador inválido',
invalidPassword: 'Palavra-passe inválida',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Iniciar sessão',
logInWithSso: 'Iniciar sessão com SSO',
},

View File

@@ -273,6 +273,7 @@ export default {
taskListActions_title: null,
taskList_title: null,
team: null,
terms: null,
thereIsNoPreviewAvailableForThisAttachment:
'Nu există nicio previzualizare disponibilă pentru acest atașament.',
time: 'Timp',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'E-mail deja utilizat',
emailOrUsername: 'E-mail sau nume de utilizator',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: null,
invalidEmailOrUsername: 'E-mail sau nume de utilizator introduse greșit',
invalidPassword: 'Parola greșita',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Autentificarea',
logInWithSso: 'Autentificarea cu SSO',
},

View File

@@ -288,6 +288,7 @@ export default {
taskListActions_title: 'Действия с списком задач',
taskList_title: 'Список задач',
team: 'Команда',
terms: null,
thereIsNoPreviewAvailableForThisAttachment: 'Предпросмотр для этого вложения недоступен.',
time: 'Время',
title: 'Название',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: 'Достигнут лимит активных пользователей',
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'E-mail уже занят',
emailOrUsername: 'E-mail или имя пользователя',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: 'Недействительные учетные данные',
invalidEmailOrUsername: 'Неверный e-mail или имя пользователя',
invalidPassword: 'Неверный пароль',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Войти',
logInWithSso: 'Войти с помощью единого входа',
},

View File

@@ -273,6 +273,7 @@ export default {
taskListActions_title: null,
taskList_title: null,
team: null,
terms: null,
thereIsNoPreviewAvailableForThisAttachment: null,
time: 'Čas',
title: 'Názov',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'E-mail je už použitý',
emailOrUsername: 'E-mail alebo používateľské meno',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: null,
invalidEmailOrUsername: 'Nesprávny e-mail alebo používateľské meno',
invalidPassword: 'Nesprávne heslo',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Prihlásiť sa',
logInWithSso: null,
},

View File

@@ -273,6 +273,7 @@ export default {
taskListActions_title: null,
taskList_title: null,
team: null,
terms: null,
thereIsNoPreviewAvailableForThisAttachment: 'Нема прегледа доступног за овај прилог.',
time: 'Време',
title: 'Наслов',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'Е-пошта је већ у употреби',
emailOrUsername: 'Е-пошта или корисничко име',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: 'Неисправни акредитиви',
invalidEmailOrUsername: 'Неисправна е-пошта или корисничко име',
invalidPassword: 'Неисправна лозинка',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Пријава',
logInWithSso: 'Пријава са УП',
},

View File

@@ -270,6 +270,7 @@ export default {
taskListActions_title: null,
taskList_title: null,
team: null,
terms: null,
thereIsNoPreviewAvailableForThisAttachment: 'Nema pregleda dostupnog za ovaj prilog.',
time: 'Vreme',
title: 'Naslov',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'E-pošta je već u upotrebi',
emailOrUsername: 'E-pošta ili korisničko ime',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: 'Neispravni akreditivi',
invalidEmailOrUsername: 'Neispravna e-pošta ili korisničko ime',
invalidPassword: 'Neispravna lozinka',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Prijava',
logInWithSso: 'Prijava sa UP',
},

View File

@@ -272,6 +272,7 @@ export default {
taskListActions_title: null,
taskList_title: null,
team: null,
terms: null,
thereIsNoPreviewAvailableForThisAttachment: null,
time: 'Tid',
title: 'Titel',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'E-mail används redan',
emailOrUsername: 'E-mail eller användarnamn',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: null,
invalidEmailOrUsername: 'Ogiltig e-mail eller användarnamn',
invalidPassword: 'Ogiltigt lösenord',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Logga in',
logInWithSso: null,
},

View File

@@ -270,6 +270,7 @@ export default {
taskListActions_title: null,
taskList_title: null,
team: null,
terms: null,
thereIsNoPreviewAvailableForThisAttachment: 'Bu ek için önizleme mevcut değil.',
time: 'zaman',
title: 'başlık',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'E-posta adresi zaten kullanımda',
emailOrUsername: 'E-posta adresi veya Kullanıcı adı',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: null,
invalidEmailOrUsername: 'Geçersiz e-posta adresi veya kullanıcı adı',
invalidPassword: 'Hatalı Şifre',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Giriş Yap',
logInWithSso: null,
},

View File

@@ -287,6 +287,7 @@ export default {
taskListActions_title: 'Дії для списку завдань',
taskList_title: 'Список завдань',
team: 'Команда',
terms: null,
thereIsNoPreviewAvailableForThisAttachment: 'Для цього вкладення немає доступного перегляду.',
time: 'Час',
title: 'Назва',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: 'Досягнуто ліміту активних користувачів',
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'Електронна пошта вже використовується',
emailOrUsername: "Електронна пошта або ім'я користувача",
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: 'Неправильні облікові дані',
invalidEmailOrUsername: "Неправильна електронна пошта або ім'я користувача",
invalidPassword: 'Неправильний пароль',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Увійти',
logInWithSso: 'Увійти за допомогою SSO',
},

View File

@@ -269,6 +269,7 @@ export default {
taskListActions_title: null,
taskList_title: null,
team: null,
terms: null,
thereIsNoPreviewAvailableForThisAttachment: null,
time: 'Vaqt',
title: 'Sarlavha',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: 'E-mail allaqachon mavjud',
emailOrUsername: 'E-mail yoki foydalanuvchi nomi',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: null,
invalidEmailOrUsername: "Noto'g'ri e-mail yoki foydalanuvchi nomi",
invalidPassword: "Noto'g'ri parol",
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: 'Kirish',
logInWithSso: null,
},

View File

@@ -271,6 +271,7 @@ export default {
taskListActions_title: '任务列表操作',
taskList_title: '任务列表',
team: '团队',
terms: null,
thereIsNoPreviewAvailableForThisAttachment: '此附件无法预览',
time: '时间',
title: '标题',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: '活跃用户数已达上限',
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: '邮箱已使用',
emailOrUsername: '邮箱或用户名',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: '无效凭证',
invalidEmailOrUsername: '无效的邮箱或用户名',
invalidPassword: '密码错误',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: '登录',
logInWithSso: '使用SSO登录',
},

View File

@@ -267,6 +267,7 @@ export default {
taskListActions_title: null,
taskList_title: null,
team: null,
terms: null,
thereIsNoPreviewAvailableForThisAttachment: '此附件無法預覽',
time: '時間',
title: '標題',

View File

@@ -2,8 +2,10 @@ export default {
translation: {
common: {
activeUsersLimitReached: null,
adminLoginRequiredToInitializeInstance: null,
emailAlreadyInUse: '郵箱已被使用',
emailOrUsername: '郵箱或使用者名稱',
iHaveReadAndAgreeToTheseTerms: null,
invalidCredentials: null,
invalidEmailOrUsername: '無效的郵箱或使用者名稱',
invalidPassword: '密碼錯誤',
@@ -20,6 +22,8 @@ export default {
},
action: {
cancelAndClose: null,
continue: null,
logIn: '登入',
logInWithSso: '使用SSO登入',
},

View File

@@ -16,6 +16,7 @@ export default (state = initialState, { type, payload }) => {
switch (type) {
case ActionTypes.AUTHENTICATE__SUCCESS:
case ActionTypes.WITH_OIDC_AUTHENTICATE__SUCCESS:
case ActionTypes.TERMS_ACCEPT__SUCCESS:
return {
...state,
accessToken: payload.accessToken,

View File

@@ -26,6 +26,7 @@ export default (state = initialState, { type, payload }) => {
};
case ActionTypes.AUTHENTICATE__SUCCESS:
case ActionTypes.WITH_OIDC_AUTHENTICATE__SUCCESS:
case ActionTypes.TERMS_ACCEPT__SUCCESS:
return {
...state,
isInitializing: true,

View File

@@ -92,7 +92,7 @@ export default (state = initialState, { type, payload }) => {
...state,
homeView: payload.value,
};
case ActionTypes.LOGOUT__ACCESS_TOKEN_INVALIDATE:
case ActionTypes.LOGOUT__ACCESS_TOKEN_REVOKE:
return {
...state,
isLogouting: true,

View File

@@ -16,6 +16,14 @@ const initialState = {
isSubmitting: false,
isSubmittingWithOidc: false,
error: null,
pendingToken: null,
step: null,
termsForm: {
payload: null,
isSubmitting: false,
isCancelling: false,
isLanguageUpdating: false,
},
};
// eslint-disable-next-line default-param-last
@@ -41,14 +49,43 @@ export default (state = initialState, { type, payload }) => {
};
case ActionTypes.AUTHENTICATE__SUCCESS:
case ActionTypes.WITH_OIDC_AUTHENTICATE__SUCCESS:
case ActionTypes.TERMS_ACCEPT__SUCCESS:
case ActionTypes.TERMS_CANCEL__SUCCESS:
case ActionTypes.TERMS_CANCEL__FAILURE:
return initialState;
case ActionTypes.AUTHENTICATE__FAILURE:
if (payload.terms) {
return {
...state,
data: initialState.data,
pendingToken: payload.error.pendingToken,
step: payload.error.step,
termsForm: {
...state.termsForm,
payload: payload.terms,
},
};
}
return {
...state,
isSubmitting: false,
error: payload.error,
};
case ActionTypes.WITH_OIDC_AUTHENTICATE__FAILURE:
if (payload.terms) {
return {
...state,
data: initialState.data,
pendingToken: payload.error.pendingToken,
step: payload.error.step,
termsForm: {
...state.termsForm,
payload: payload.terms,
},
};
}
return {
...state,
isSubmittingWithOidc: false,
@@ -59,6 +96,53 @@ export default (state = initialState, { type, payload }) => {
...state,
error: null,
};
case ActionTypes.TERMS_ACCEPT:
return {
...state,
termsForm: {
...state.termsForm,
isSubmitting: true,
},
};
case ActionTypes.TERMS_ACCEPT__FAILURE:
return {
...initialState,
error: payload.error,
};
case ActionTypes.TERMS_CANCEL:
return {
...state,
pendingToken: null,
termsForm: {
...state.termsForm,
isCancelling: true,
},
};
case ActionTypes.TERMS_LANGUAGE_UPDATE:
return {
...state,
termsForm: {
...state.termsForm,
isLanguageUpdating: true,
},
};
case ActionTypes.TERMS_LANGUAGE_UPDATE__SUCCESS:
return {
...state,
termsForm: {
...state.termsForm,
payload: payload.terms,
isLanguageUpdating: false,
},
};
case ActionTypes.TERMS_LANGUAGE_UPDATE__FAILURE:
return {
...state,
termsForm: {
...state.termsForm,
isLanguageUpdating: false,
},
};
default:
return state;
}

View File

@@ -119,11 +119,11 @@ export function* updateHomeView(value) {
}
}
export function* logout(invalidateAccessToken) {
export function* logout(revokeAccessToken) {
yield call(removeAccessToken);
if (invalidateAccessToken) {
yield put(actions.logout.invalidateAccessToken());
if (revokeAccessToken) {
yield put(actions.logout.revokeAccessToken());
try {
yield call(request, api.deleteCurrentAccessToken);

View File

@@ -19,8 +19,8 @@ export default function* coreWatchers() {
takeEvery(EntryActionTypes.HOME_VIEW_UPDATE, ({ payload: { value } }) =>
services.updateHomeView(value),
),
takeEvery(EntryActionTypes.LOGOUT, ({ payload: { invalidateAccessToken } }) =>
services.logout(invalidateAccessToken),
takeEvery(EntryActionTypes.LOGOUT, ({ payload: { revokeAccessToken } }) =>
services.logout(revokeAccessToken),
),
]);
}

View File

@@ -13,7 +13,12 @@ export default function* loginSaga() {
const watcherTasks = yield all(watchers.map((watcher) => fork(watcher)));
yield fork(services.initializeLogin);
yield take([ActionTypes.AUTHENTICATE__SUCCESS, ActionTypes.WITH_OIDC_AUTHENTICATE__SUCCESS]);
yield take([
ActionTypes.AUTHENTICATE__SUCCESS,
ActionTypes.WITH_OIDC_AUTHENTICATE__SUCCESS,
ActionTypes.TERMS_ACCEPT__SUCCESS,
]);
yield cancel(watcherTasks);
yield call(services.goToRoot);

View File

@@ -10,8 +10,10 @@ import { replace } from '../../../lib/redux-router';
import selectors from '../../../selectors';
import actions from '../../../actions';
import api from '../../../api';
import i18n from '../../../i18n';
import { setAccessToken } from '../../../utils/access-token-storage';
import Paths from '../../../constants/Paths';
import AccessTokenSteps from '../../../constants/AccessTokenSteps';
export function* initializeLogin() {
const { item: config } = yield call(api.getConfig); // TODO: handle error
@@ -26,7 +28,12 @@ export function* authenticate(data) {
try {
({ item: accessToken } = yield call(api.createAccessToken, data));
} catch (error) {
yield put(actions.authenticate.failure(error));
let terms;
if (error.step === AccessTokenSteps.ACCEPT_TERMS) {
({ item: terms } = yield call(api.getTerms, error.termsType, i18n.resolvedLanguage));
}
yield put(actions.authenticate.failure(error, terms));
return;
}
@@ -106,7 +113,12 @@ export function* authenticateWithOidcCallback() {
nonce,
}));
} catch (error) {
yield put(actions.authenticateWithOidc.failure(error));
let terms;
if (error.step === AccessTokenSteps.ACCEPT_TERMS) {
({ item: terms } = yield call(api.getTerms, error.termsType, i18n.resolvedLanguage));
}
yield put(actions.authenticateWithOidc.failure(error, terms));
return;
}
@@ -118,10 +130,70 @@ export function* clearAuthenticateError() {
yield put(actions.clearAuthenticateError());
}
export function* acceptTerms(signature) {
yield put(actions.acceptTerms(signature));
const { pendingToken } = yield select(selectors.selectAuthenticateForm);
let accessToken;
try {
({ item: accessToken } = yield call(api.acceptTerms, {
pendingToken,
signature,
}));
} catch (error) {
yield put(actions.acceptTerms.failure(error));
return;
}
yield call(setAccessToken, accessToken);
yield put(actions.acceptTerms.success(accessToken));
}
export function* cancelTerms() {
const { pendingToken } = yield select(selectors.selectAuthenticateForm);
yield put(actions.cancelTerms());
try {
yield call(api.revokePendingToken, {
pendingToken,
});
} catch (error) {
yield put(actions.cancelTerms.failure(error));
return;
}
yield put(actions.cancelTerms.success(pendingToken));
}
export function* updateTermsLanguage(value) {
yield put(actions.updateTermsLanguage(value));
const {
termsForm: {
payload: { type },
},
} = yield select(selectors.selectAuthenticateForm);
let terms;
try {
({ item: terms } = yield call(api.getTerms, type, value));
} catch (error) {
yield put(actions.updateTermsLanguage.failure(error));
return;
}
yield put(actions.updateTermsLanguage.success(terms));
}
export default {
initializeLogin,
authenticate,
authenticateWithOidc,
authenticateWithOidcCallback,
clearAuthenticateError,
acceptTerms,
cancelTerms,
updateTermsLanguage,
};

View File

@@ -15,5 +15,12 @@ export default function* loginWatchers() {
),
takeEvery(EntryActionTypes.WITH_OIDC_AUTHENTICATE, () => services.authenticateWithOidc()),
takeEvery(EntryActionTypes.AUTHENTICATE_ERROR_CLEAR, () => services.clearAuthenticateError()),
takeEvery(EntryActionTypes.TERMS_ACCEPT, ({ payload: { signature } }) =>
services.acceptTerms(signature),
),
takeEvery(EntryActionTypes.TERMS_CANCEL, () => services.cancelTerms()),
takeEvery(EntryActionTypes.TERMS_LANGUAGE_UPDATE, ({ payload: { value } }) =>
services.updateTermsLanguage(value),
),
]);
}

View File

@@ -0,0 +1,133 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const { getRemoteAddress } = require('../../../utils/remote-address');
const { AccessTokenSteps } = require('../../../constants');
const Errors = {
INVALID_PENDING_TOKEN: {
invalidPendingToken: 'Invalid pending token',
},
INVALID_SIGNATURE: {
invalidSignature: 'Invalid signature',
},
ADMIN_LOGIN_REQUIRED_TO_INITIALIZE_INSTANCE: {
adminLoginRequiredToInitializeInstance: 'Admin login required to initialize instance',
},
};
module.exports = {
inputs: {
pendingToken: {
type: 'string',
maxLength: 1024,
required: true,
},
signature: {
type: 'string',
minLength: 64,
maxLength: 64,
required: true,
},
},
exits: {
invalidPendingToken: {
responseType: 'unauthorized',
},
invalidSignature: {
responseType: 'forbidden',
},
adminLoginRequiredToInitializeInstance: {
responseType: 'forbidden',
},
},
async fn(inputs) {
const remoteAddress = getRemoteAddress(this.req);
const { httpOnlyToken } = this.req.cookies;
try {
payload = sails.helpers.utils.verifyJwtToken(inputs.pendingToken);
} catch (error) {
if (error.raw.name === 'TokenExpiredError') {
throw Errors.INVALID_PENDING_TOKEN;
}
sails.log.warn(`Invalid pending token! (IP: ${remoteAddress})`);
throw Errors.INVALID_PENDING_TOKEN;
}
if (payload.subject !== AccessTokenSteps.ACCEPT_TERMS) {
throw Errors.INVALID_PENDING_TOKEN;
}
let session = await Session.qm.getOneUndeletedByPendingToken(inputs.pendingToken);
if (!session) {
sails.log.warn(`Invalid pending token! (IP: ${remoteAddress})`);
throw Errors.INVALID_PENDING_TOKEN;
}
if (session.httpOnlyToken && httpOnlyToken !== session.httpOnlyToken) {
throw Errors.INVALID_PENDING_TOKEN;
}
let user = await User.qm.getOneById(session.userId, {
withDeactivated: false,
});
if (!user) {
throw Errors.INVALID_PENDING_TOKEN; // TODO: introduce separate error?
}
if (!user.termsSignature) {
const termsSignature = sails.hooks.terms.getSignatureByUserRole(user.role);
if (inputs.signature !== termsSignature) {
throw Errors.INVALID_SIGNATURE;
}
user = await User.qm.updateOne(user.id, {
termsSignature,
termsAcceptedAt: new Date().toISOString(),
});
}
const config = await Config.qm.getOneMain();
if (!config.isInitialized) {
if (user.role === User.Roles.ADMIN) {
await Config.qm.updateOneMain({
isInitialized: true,
});
} else {
throw Errors.ADMIN_LOGIN_REQUIRED_TO_INITIALIZE_INSTANCE;
}
}
const { token: accessToken, payload: accessTokenPayload } = sails.helpers.utils.createJwtToken(
user.id,
);
session = await Session.qm.updateOne(session.id, {
accessToken,
pendingToken: null,
});
if (session.httpOnlyToken && !this.req.isSocket) {
sails.helpers.utils.setHttpOnlyTokenCookie(
session.httpOnlyToken,
accessTokenPayload,
this.res,
);
}
return {
item: accessToken,
};
},
};

View File

@@ -4,7 +4,6 @@
*/
const bcrypt = require('bcrypt');
const { v4: uuid } = require('uuid');
const { isEmailOrUsername } = require('../../../utils/validators');
const { getRemoteAddress } = require('../../../utils/remote-address');
@@ -22,6 +21,9 @@ const Errors = {
USE_SINGLE_SIGN_ON: {
useSingleSignOn: 'Use single sign-on',
},
TERMS_ACCEPTANCE_REQUIRED: {
termsAcceptanceRequired: 'Terms acceptance required',
},
};
module.exports = {
@@ -39,7 +41,6 @@ module.exports = {
},
withHttpOnlyToken: {
type: 'boolean',
defaultsTo: false,
},
},
@@ -56,6 +57,12 @@ module.exports = {
useSingleSignOn: {
responseType: 'forbidden',
},
termsAcceptanceRequired: {
responseType: 'forbidden',
},
adminLoginRequiredToInitializeInstance: {
responseType: 'forbidden',
},
},
async fn(inputs) {
@@ -90,26 +97,19 @@ module.exports = {
: Errors.INVALID_CREDENTIALS;
}
const { token: accessToken, payload: accessTokenPayload } = sails.helpers.utils.createJwtToken(
user.id,
);
const httpOnlyToken = inputs.withHttpOnlyToken ? uuid() : null;
await Session.qm.createOne({
accessToken,
httpOnlyToken,
return sails.helpers.accessTokens.handleSteps
.with({
user,
remoteAddress,
userId: user.id,
userAgent: this.req.headers['user-agent'],
});
if (httpOnlyToken && !this.req.isSocket) {
sails.helpers.utils.setHttpOnlyTokenCookie(httpOnlyToken, accessTokenPayload, this.res);
}
return {
item: accessToken,
};
request: this.req,
response: this.res,
withHttpOnlyToken: inputs.withHttpOnlyToken,
})
.intercept('adminLoginRequiredToInitializeInstance', (error) => ({
adminLoginRequiredToInitializeInstance: error.raw,
}))
.intercept('termsAcceptanceRequired', (error) => ({
termsAcceptanceRequired: error.raw,
}));
},
};

View File

@@ -3,8 +3,6 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const { v4: uuid } = require('uuid');
const { getRemoteAddress } = require('../../../utils/remote-address');
const Errors = {
@@ -17,6 +15,9 @@ const Errors = {
INVALID_USERINFO_CONFIGURATION: {
invalidUserinfoConfiguration: 'Invalid userinfo configuration',
},
TERMS_ACCEPTANCE_REQUIRED: {
termsAcceptanceRequired: 'Terms acceptance required',
},
EMAIL_ALREADY_IN_USE: {
emailAlreadyInUse: 'Email already in use',
},
@@ -45,7 +46,6 @@ module.exports = {
},
withHttpOnlyToken: {
type: 'boolean',
defaultsTo: false,
},
},
@@ -59,6 +59,12 @@ module.exports = {
invalidUserinfoConfiguration: {
responseType: 'unauthorized',
},
termsAcceptanceRequired: {
responseType: 'forbidden',
},
adminLoginRequiredToInitializeInstance: {
responseType: 'forbidden',
},
emailAlreadyInUse: {
responseType: 'conflict',
},
@@ -89,26 +95,19 @@ module.exports = {
.intercept('activeLimitReached', () => Errors.ACTIVE_USERS_LIMIT_REACHED)
.intercept('missingValues', () => Errors.MISSING_VALUES);
const { token: accessToken, payload: accessTokenPayload } = sails.helpers.utils.createJwtToken(
user.id,
);
const httpOnlyToken = inputs.withHttpOnlyToken ? uuid() : null;
await Session.qm.createOne({
accessToken,
httpOnlyToken,
return sails.helpers.accessTokens.handleSteps
.with({
user,
remoteAddress,
userId: user.id,
userAgent: this.req.headers['user-agent'],
});
if (httpOnlyToken && !this.req.isSocket) {
sails.helpers.utils.setHttpOnlyTokenCookie(httpOnlyToken, accessTokenPayload, this.res);
}
return {
item: accessToken,
};
request: this.req,
response: this.res,
withHttpOnlyToken: inputs.withHttpOnlyToken,
})
.intercept('adminLoginRequiredToInitializeInstance', (error) => ({
adminLoginRequiredToInitializeInstance: error.raw,
}))
.intercept('termsAcceptanceRequired', (error) => ({
termsAcceptanceRequired: error.raw,
}));
},
};

View File

@@ -0,0 +1,49 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const Errors = {
PENDING_TOKEN_NOT_FOUND: {
pendingTokenNotFound: 'Pending token not found',
},
};
module.exports = {
inputs: {
pendingToken: {
type: 'string',
maxLength: 1024,
required: true,
},
},
exits: {
pendingTokenNotFound: {
responseType: 'notFound',
},
},
async fn(inputs) {
const { httpOnlyToken } = this.req.cookies;
let session = await Session.qm.getOneUndeletedByPendingToken(inputs.pendingToken);
if (!session) {
throw Errors.PENDING_TOKEN_NOT_FOUND;
}
if (session.httpOnlyToken && httpOnlyToken !== session.httpOnlyToken) {
throw Errors.PENDING_TOKEN_NOT_FOUND; // Forbidden
}
session = await Session.qm.deleteOneById(session.id);
if (session.httpOnlyToken && !this.req.isSocket) {
sails.helpers.utils.clearHttpOnlyTokenCookie(this.res);
}
return {
item: null,
};
},
};

View File

@@ -0,0 +1,26 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
type: {
type: 'string',
isIn: Object.values(sails.hooks.terms.Types),
required: true,
},
language: {
type: 'string',
isIn: User.LANGUAGES,
},
},
async fn(inputs) {
const terms = await sails.hooks.terms.getPayload(inputs.type, inputs.language);
return {
item: terms,
};
},
};

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