diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 7d921336..24bf0963 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -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 diff --git a/client/src/actions/core.js b/client/src/actions/core.js index aebc9174..a2f05f4a 100644 --- a/client/src/actions/core.js +++ b/client/src/actions/core.js @@ -91,8 +91,8 @@ const logout = () => ({ payload: {}, }); -logout.invalidateAccessToken = () => ({ - type: ActionTypes.LOGOUT__ACCESS_TOKEN_INVALIDATE, +logout.revokeAccessToken = () => ({ + type: ActionTypes.LOGOUT__ACCESS_TOKEN_REVOKE, payload: {}, }); diff --git a/client/src/actions/login.js b/client/src/actions/login.js index 8f9b72a0..67e31e11 100644 --- a/client/src/actions/login.js +++ b/client/src/actions/login.js @@ -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, }; diff --git a/client/src/api/access-tokens.js b/client/src/api/access-tokens.js index 53c769cb..5b423558 100755 --- a/client/src/api/access-tokens.js +++ b/client/src/api/access-tokens.js @@ -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, }; diff --git a/client/src/api/index.js b/client/src/api/index.js index 70a1bb4c..98d045f1 100755 --- a/client/src/api/index.js +++ b/client/src/api/index.js @@ -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, diff --git a/client/src/api/terms.js b/client/src/api/terms.js new file mode 100644 index 00000000..f0908624 --- /dev/null +++ b/client/src/api/terms.js @@ -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, +}; diff --git a/client/src/components/common/Login/Content.jsx b/client/src/components/common/Login/Content.jsx index 977cba35..650f9093 100644 --- a/client/src/components/common/Login/Content.jsx +++ b/client/src/components/common/Login/Content.jsx @@ -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(() => {
+ {step === AccessTokenSteps.ACCEPT_TERMS && }
); }); diff --git a/client/src/components/common/Login/TermsModal.jsx b/client/src/components/common/Login/TermsModal.jsx new file mode 100644 index 00000000..3fe09b36 --- /dev/null +++ b/client/src/components/common/Login/TermsModal.jsx @@ -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 ( + + + ({ + value: locale.language, + flag: locale.country, + text: locale.name, + }))} + value={terms.language} + loading={isLanguageUpdating} + disabled={isLanguageUpdating} + className={styles.language} + onChange={handleLanguageChange} + /> + {terms.content} + + +