diff --git a/projectforge-webapp/.eslintrc b/projectforge-webapp/.eslintrc deleted file mode 100644 index e4fe5cfd3f..0000000000 --- a/projectforge-webapp/.eslintrc +++ /dev/null @@ -1,96 +0,0 @@ -{ - "root": true, - "extends": [ - "airbnb", - "plugin:@typescript-eslint/recommended" - ], - "env": { - "browser": true, - "jest": true - }, - "plugins": [ - "react", - "@typescript-eslint" - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 2020 - }, - "settings": { - "import/resolver": { - "node": { - "extensions": [ - ".js", - ".jsx", - ".ts", - ".tsx" - ] - } - } - }, - "rules": { - "@typescript-eslint/no-explicit-any": "off", - "indent": [ - "error", - 4, - { - "SwitchCase": 1 - } - ], - "react/jsx-indent": [ - "error", - 4 - ], - "react/jsx-indent-props": [ - "error", - 4 - ], - "react/jsx-filename-extension": [ - 1, - { - "extensions": [ - ".jsx", - ".js", - ".tsx", - ".ts" - ] - } - ], - "react/require-default-props": "off", - "react/jsx-props-no-spreading": "off", - "import/extensions": [ - "error", - "ignorePackages", - { - "js": "never", - "jsx": "never", - "ts": "never", - "tsx": "never" - } - ], - "object-curly-newline": [ - "error", - { - "ObjectExpression": { - "minProperties": 4, - "multiline": true, - "consistent": true - }, - "ObjectPattern": { - "minProperties": 4, - "multiline": true, - "consistent": true - }, - "ImportDeclaration": { - "minProperties": 0, - "consistent": true - }, - "ExportDeclaration": { - "minProperties": 4, - "multiline": true, - "consistent": true - } - } - ] - } -} diff --git a/projectforge-webapp/.npmrc b/projectforge-webapp/.npmrc deleted file mode 100644 index 521a9f7c07..0000000000 --- a/projectforge-webapp/.npmrc +++ /dev/null @@ -1 +0,0 @@ -legacy-peer-deps=true diff --git a/projectforge-webapp/public/index.html b/projectforge-webapp/index.html similarity index 100% rename from projectforge-webapp/public/index.html rename to projectforge-webapp/index.html diff --git a/projectforge-webapp/src/actions/authentication.test.js b/projectforge-webapp/src/actions/authentication.test.js index 4a594f93da..095788910c 100644 --- a/projectforge-webapp/src/actions/authentication.test.js +++ b/projectforge-webapp/src/actions/authentication.test.js @@ -1,237 +1,157 @@ /* eslint-disable */ -import fetchMock from 'fetch-mock/es5/client'; -import cookies from 'react-cookies'; +import { vi } from 'vitest'; import configureMockStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; +import { thunk } from 'redux-thunk'; import { - loadSessionIfAvailable, - login, - logout, USER_LOGIN_BEGIN, USER_LOGIN_FAILURE, USER_LOGIN_SUCCESS, - USER_LOGOUT, userLoginBegin, userLoginFailure, userLoginSuccess, - userLogout, + login, + loadUserStatus, } from './authentication'; -describe('login', () => { - const username = 'demo'; - const password = 'demo123'; - - Object.freeze(username); - Object.freeze(password); - - const mockStore = configureMockStore([thunk]); +const mockStore = configureMockStore([thunk]); - afterEach(() => fetchMock.restore()); - - it('should create an action to start the login', () => { - const expectedAction = { - type: USER_LOGIN_BEGIN, - }; - - expect(userLoginBegin()) - .toEqual(expectedAction); +describe('action creators', () => { + it('userLoginBegin', () => { + expect(userLoginBegin()).toEqual({ type: USER_LOGIN_BEGIN }); }); - it('should create an action to mark the login as success', () => { - const expectedAction = { - type: USER_LOGIN_SUCCESS, - }; - - expect(userLoginSuccess()) - .toEqual(expectedAction); - }); - - it('should create an action to mark the login as success', () => { - const expectedAction = { - type: USER_LOGIN_FAILURE, - payload: { - error: 'Some uncool error message', - }, - }; - - expect(userLoginFailure('Some uncool error message')) - .toEqual(expectedAction); + it('userLoginSuccess', () => { + expect(userLoginSuccess('user', '1.0', '2024', undefined)) + .toEqual({ + type: USER_LOGIN_SUCCESS, + payload: { user: 'user', version: '1.0', buildTimestamp: '2024', alertMessage: undefined }, + }); }); - it('creates USER_LOGIN_SUCCESS when fetching login has been done without keepSignedIn', () => { - fetchMock - .mock( - (url, options) => { - if (url !== '/rsPublic/login' || options.method !== 'POST') { - return false; - } - - const body = JSON.parse(options.body); - - return body.username === username - && body.password === password - && !body.stayLoggedIn; - }, - { - status: 200, - headers: { 'Set-Cookie': 'JSESSIONID=ABCDEF0123456789' }, - }, - ) - .catch({ throws: new Error('mock failed') }); - - const expectedActions = [ - { type: USER_LOGIN_BEGIN }, - { type: USER_LOGIN_SUCCESS }, - ]; - - const store = mockStore({}); - - return store.dispatch(login(username, password, false)) - .then(() => { - expect(store.getActions()) - .toEqual(expectedActions); - - expect(cookies.loadAll()) - .toEqual({}); + it('userLoginFailure', () => { + expect(userLoginFailure('Some error')) + .toEqual({ + type: USER_LOGIN_FAILURE, + payload: { error: 'Some error' }, }); }); +}); - it('creates USER_LOGIN_SUCCESS when fetching login has been done with keepSignedIn', () => { - fetchMock - .mock( - (url, options) => { - if (url !== '/√/login' || options.method !== 'POST') { - return false; - } +describe('login', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); - const body = JSON.parse(options.body); + it('dispatches BEGIN + BEGIN + SUCCESS for valid credentials', async () => { + const userData = { username: 'demo', admin: false }; + const systemData = { version: '2.0.0', buildTimestamp: '2025-01-01 00:00' }; - return body.username === username - && body.password === password - && body.stayLoggedIn; - }, - { - status: 200, - headers: { 'Set-Cookie': 'JSESSIONID=ABCDEF0123456789' }, - }, + global.fetch = vi.fn() + .mockResolvedValueOnce( + { ok: true, status: 200, json: () => Promise.resolve({}) }, ) - .catch({ throws: new Error('mock failed') }); - - const expectedActions = [ - { type: USER_LOGIN_BEGIN }, - { type: USER_LOGIN_SUCCESS }, - ]; - - const store = mockStore({}); - - return store.dispatch(login(username, password, true)) - .then(() => { - expect(store.getActions()) - .toEqual(expectedActions); - - expect(cookies.loadAll()) - .toEqual({ - KEEP_SIGNED_IN: true, - }); + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ userData, systemData, alertMessage: undefined }), }); - }); - it('creates USER_LOGIN_FAILURE when fetching login has been failed', () => { - fetchMock - .mock('/rsPublic/login', 401) - .catch(() => { - throw new Error('mock failed'); - }); + const store = mockStore({}); + await store.dispatch(login('demo', 'demo123', false)); - const expectedActions = [ + expect(store.getActions()).toEqual([ + { type: USER_LOGIN_BEGIN }, { type: USER_LOGIN_BEGIN }, { - type: USER_LOGIN_FAILURE, - payload: { error: 'Unauthorized' }, + type: USER_LOGIN_SUCCESS, + payload: { + user: userData, + version: systemData.version, + buildTimestamp: systemData.buildTimestamp, + alertMessage: undefined, + }, }, - ]; - - const store = mockStore({}); - - return store.dispatch(login(username, password, false)) - .then(() => { - expect(store.getActions()) - .toEqual(expectedActions); - }); + ]); }); -}); -describe('logout', () => { - const mockStore = configureMockStore([thunk]); + it('dispatches BEGIN + FAILURE for invalid credentials', async () => { + global.fetch = vi.fn() + .mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({}), + }); - it('should create USER_LOGOUT action', () => { - const expectedAction = { - type: USER_LOGOUT, - }; + const store = mockStore({}); + await store.dispatch(login('demo', 'wrong', false)); - expect(userLogout()) - .toEqual(expectedAction); + expect(store.getActions()).toEqual([ + { type: USER_LOGIN_BEGIN }, + { type: USER_LOGIN_FAILURE, payload: { error: 'Fetch failed: Error 401' } }, + ]); }); - it('creates USER_LOGOUT during logout', () => { - const expectedActions = [ - { type: USER_LOGOUT }, - ]; + it('dispatches BEGIN + FAILURE for network error', async () => { + global.fetch = vi.fn() + .mockRejectedValue(new Error('Network error')); const store = mockStore({}); + await store.dispatch(login('demo', 'demo123', false)); - cookies.save('KEEP_SIGNED_IN', 'ABCDEF'); - - store.dispatch(logout()); - - expect(store.getActions()) - .toEqual(expectedActions); - - expect(cookies.loadAll()) - .toEqual({}); + expect(store.getActions()).toEqual([ + { type: USER_LOGIN_BEGIN }, + { type: USER_LOGIN_FAILURE, payload: { error: 'Network error' } }, + ]); }); }); -describe('check session', () => { - const mockStore = configureMockStore([thunk]); +describe('loadUserStatus', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('dispatches BEGIN + SUCCESS on valid session', async () => { + const userData = { username: 'existinguser', admin: true }; + const systemData = { version: '2.0.0', buildTimestamp: '2025-05-05 10:00' }; + const alertMessage = 'Some alert'; - afterEach(() => fetchMock.restore()); + global.fetch = vi.fn() + .mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ userData, systemData, alertMessage }), + }); - it('creates no action at all', () => { const store = mockStore({}); + await store.dispatch(loadUserStatus()); - expect(store.dispatch(loadSessionIfAvailable())) - .toEqual(null); - expect(store.getActions()) - .toEqual([]); + expect(store.getActions()).toEqual([ + { type: USER_LOGIN_BEGIN }, + { + type: USER_LOGIN_SUCCESS, + payload: { + user: userData, + version: systemData.version, + buildTimestamp: systemData.buildTimestamp, + alertMessage, + }, + }, + ]); }); - it('creates USER_LOGIN_SUCCESS', () => { - fetchMock - // TODO: ADD AUTHENTICATION TEST ENDPOINT - .getOnce('/rs/userStatus', 200) - .catch((url, a, b) => { - throw new Error('mock failed'); + it('dispatches BEGIN + FAILURE on session expired', async () => { + global.fetch = vi.fn() + .mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({}), }); - const expectedActions = [ - { type: USER_LOGIN_BEGIN }, - { type: USER_LOGIN_SUCCESS }, - ]; - - cookies.save('KEEP_SIGNED_IN', true); - const store = mockStore({}); + await store.dispatch(loadUserStatus()); - return store.dispatch(loadSessionIfAvailable()) - .then(() => { - expect(store.getActions()) - .toEqual(expectedActions); - - expect(cookies.loadAll()) - .toEqual({ - KEEP_SIGNED_IN: true, - }); - }); + const actions = store.getActions(); + expect(actions[0]).toEqual({ type: USER_LOGIN_BEGIN }); + expect(actions[1]).toEqual({ type: USER_LOGIN_FAILURE, payload: { error: undefined } }); }); }); diff --git a/projectforge-webapp/src/assets/style/theming.scss b/projectforge-webapp/src/assets/style/theming.scss index 5fed92af17..ad1572b4de 100644 --- a/projectforge-webapp/src/assets/style/theming.scss +++ b/projectforge-webapp/src/assets/style/theming.scss @@ -1,6 +1,6 @@ // Import Bootstrap variables and functions that we need -@import '~bootstrap/scss/functions'; -@import '~bootstrap/scss/variables'; +@import 'bootstrap/scss/functions'; +@import 'bootstrap/scss/variables'; $theme-colors: map-merge( ( diff --git a/projectforge-webapp/src/containers/RedirectToWicket.tsx b/projectforge-webapp/src/containers/RedirectToWicket.tsx index e6525a3a5a..cad3e8e1f9 100644 --- a/projectforge-webapp/src/containers/RedirectToWicket.tsx +++ b/projectforge-webapp/src/containers/RedirectToWicket.tsx @@ -6,7 +6,7 @@ function RedirectToWicket() { const location = useLocation(); useEffect(() => { - if (process.env.NODE_ENV !== 'development') { + if (!import.meta.env.DEV) { window.location.reload(); } }, []); diff --git a/projectforge-webapp/src/index.js b/projectforge-webapp/src/index.jsx similarity index 78% rename from projectforge-webapp/src/index.js rename to projectforge-webapp/src/index.jsx index c2dd2ae340..a4ce3aa9c0 100755 --- a/projectforge-webapp/src/index.js +++ b/projectforge-webapp/src/index.jsx @@ -9,7 +9,6 @@ import './assets/style/projectforge.scss'; import { createRoot } from 'react-dom/client'; import ProjectForge from './containers/ProjectForge'; import reducer from './reducers'; -import * as serviceWorker from './serviceWorker'; import './utilities/global'; import CustomRouter from './containers/CustomRouter'; @@ -28,8 +27,3 @@ createRoot(document.getElementById('root')).render( , ); - -// If you want your app to work offline and load faster, you can change -// unregister() to register() below. Note this comes with some pitfalls. -// Learn more about service workers: http://bit.ly/CRA-PWA -serviceWorker.unregister(); diff --git a/projectforge-webapp/src/reducers/authentication.test.js b/projectforge-webapp/src/reducers/authentication.test.js index ede5fbc4e8..800611b50d 100644 --- a/projectforge-webapp/src/reducers/authentication.test.js +++ b/projectforge-webapp/src/reducers/authentication.test.js @@ -4,213 +4,98 @@ const exampleError = 'Uncool error message.'; Object.freeze(exampleError); +const initialState = { + loading: true, + error: null, + user: null, +}; + describe('reducer', () => { it('initial state', () => { expect(reducer(undefined, {})) - .toEqual({ - loading: false, - error: null, - loggedIn: false, - }); + .toEqual(initialState); }); - it('unknown action', () => { + it('unknown action returns current state', () => { const state = { - loading: true, + loading: false, error: null, - loggedIn: false, + user: { name: 'test' }, }; Object.freeze(state); - expect(reducer(state, { - type: 'UNKNOWN_ACTION', - })) + expect(reducer(state, { type: 'UNKNOWN_ACTION' })) .toEqual(state); }); }); -describe('handles USER_LOGIN_BEGIN', () => { - it('fresh state', () => { - const state = { - loading: false, - error: null, - loggedIn: false, - }; - - Object.freeze(state); - - expect(reducer(state, { - type: 'USER_LOGIN_BEGIN', - })) - .toEqual({ - loading: true, - error: null, - loggedIn: false, - }); - }); - - it('error state', () => { +describe('USER_LOGIN_BEGIN', () => { + it('resets to loading with null user', () => { const state = { loading: false, - error: exampleError, - loggedIn: false, + error: 'some error', + user: { name: 'old' }, }; Object.freeze(state); - expect(reducer(state, { - type: 'USER_LOGIN_BEGIN', - })) - .toEqual({ - loading: true, - error: null, - loggedIn: false, - }); - }); - - it('loggedIn state', () => { - const state = { - loading: false, - error: null, - loggedIn: false, - }; - - Object.freeze(state); - - expect(reducer(state, { - type: 'USER_LOGIN_BEGIN', - })) + expect(reducer(state, { type: 'USER_LOGIN_BEGIN' })) .toEqual({ loading: true, error: null, - loggedIn: false, + user: null, }); }); }); -describe('handles USER_LOGIN_SUCCESS', () => { - it('loading state', () => { +describe('USER_LOGIN_SUCCESS', () => { + it('sets user and clears loading/error', () => { const state = { loading: true, error: null, - loggedIn: false, + user: null, }; Object.freeze(state); - expect(reducer(state, { - type: 'USER_LOGIN_SUCCESS', - })) - .toEqual({ - loading: false, - error: null, - loggedIn: true, - }); - }); - - it('weird state', () => { - const state = { - loading: false, - error: exampleError, - loggedIn: true, + const payload = { + user: { name: 'testuser' }, + version: '1.0', + buildTimestamp: '2024-01-01', + alertMessage: 'Welcome', }; - Object.freeze(state); - - expect(reducer(state, { - type: 'USER_LOGIN_SUCCESS', - })) + expect(reducer(state, { type: 'USER_LOGIN_SUCCESS', payload })) .toEqual({ loading: false, error: null, - loggedIn: true, + user: { name: 'testuser' }, + version: '1.0', + buildTimestamp: '2024-01-01', + alertMessage: 'Welcome', }); }); }); -describe('handles USER_LOGIN_FAILURE', () => { - it('loading state', () => { +describe('USER_LOGIN_FAILURE', () => { + it('sets error and clears user/loading', () => { const state = { loading: true, error: null, - loggedIn: false, - }; - - Object.freeze(state); - - expect(reducer(state, { - type: 'USER_LOGIN_FAILURE', - payload: { - error: exampleError, - }, - })) - .toEqual({ - loading: false, - error: exampleError, - loggedIn: false, - }); - }); - - it('weird state', () => { - const state = { - loading: false, - error: exampleError, - loggedIn: true, + user: { name: 'old' }, }; Object.freeze(state); expect(reducer(state, { type: 'USER_LOGIN_FAILURE', - payload: { - error: exampleError, - }, + payload: { error: exampleError }, })) .toEqual({ loading: false, error: exampleError, - loggedIn: false, + user: null, }); }); }); - -describe('handles USER_LOGOUT', () => { - const expectedState = { - loading: false, - error: null, - loggedIn: false, - }; - - Object.freeze(expectedState); - - it('logged in state', () => { - const state = { - loading: false, - error: null, - loggedIn: true, - }; - - Object.freeze(state); - - expect(reducer(state, { - type: 'USER_LOGOUT', - })) - .toEqual(expectedState); - }); - - it('weird state', () => { - const state = { - loading: true, - error: exampleError, - loggedIn: false, - }; - - Object.freeze(state); - - expect(reducer(state, { - type: 'USER_LOGOUT', - })) - .toEqual(expectedState); - }); -}); diff --git a/projectforge-webapp/src/serviceWorker.js b/projectforge-webapp/src/serviceWorker.js deleted file mode 100755 index 9008a865ea..0000000000 --- a/projectforge-webapp/src/serviceWorker.js +++ /dev/null @@ -1,136 +0,0 @@ -/* eslint-disable */ -// This optional code is used to register a service worker. -// register() is not called by default. - -// This lets the app load faster on subsequent visits in production, and gives -// it offline capabilities. However, it also means that developers (and users) -// will only see deployed updates on subsequent visits to a page, after all the -// existing tabs open on the page have been closed, since previously cached -// resources are updated in the background. - -// To learn more about the benefits of this model and instructions on how to -// opt-in, read http://bit.ly/CRA-PWA - -const isLocalhost = Boolean( - window.location.hostname === 'localhost' || - // [::1] is the IPv6 localhost address. - window.location.hostname === '[::1]' || - // 127.0.0.1/8 is considered localhost for IPv4. - window.location.hostname.match( - /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ - ) -); - -export function register(config) { - if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { - // The URL constructor is available in all browsers that support SW. - const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); - if (publicUrl.origin !== window.location.origin) { - // Our service worker won't work if PUBLIC_URL is on a different origin - // from what our page is served on. This might happen if a CDN is used to - // serve assets; see https://github.com/facebook/create-react-app/issues/2374 - return; - } - - window.addEventListener('load', () => { - const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; - - if (isLocalhost) { - // This is running on localhost. Let's check if a service worker still exists or not. - checkValidServiceWorker(swUrl, config); - - // Add some additional logging to localhost, pointing developers to the - // service worker/PWA documentation. - navigator.serviceWorker.ready.then(() => { - console.log( - 'This web app is being served cache-first by a service ' + - 'worker. To learn more, visit http://bit.ly/CRA-PWA' - ); - }); - } else { - // Is not localhost. Just register service worker - registerValidSW(swUrl, config); - } - }); - } -} - -function registerValidSW(swUrl, config) { - navigator.serviceWorker - .register(swUrl) - .then(registration => { - registration.onupdatefound = () => { - const installingWorker = registration.installing; - if (installingWorker == null) { - return; - } - installingWorker.onstatechange = () => { - if (installingWorker.state === 'installed') { - if (navigator.serviceWorker.controller) { - // At this point, the updated precached content has been fetched, - // but the previous service worker will still serve the older - // content until all client tabs are closed. - console.log( - 'New content is available and will be used when all ' + - 'tabs for this page are closed. See http://bit.ly/CRA-PWA.' - ); - - // Execute callback - if (config && config.onUpdate) { - config.onUpdate(registration); - } - } else { - // At this point, everything has been precached. - // It's the perfect time to display a - // "Content is cached for offline use." message. - console.log('Content is cached for offline use.'); - - // Execute callback - if (config && config.onSuccess) { - config.onSuccess(registration); - } - } - } - }; - }; - }) - .catch(error => { - console.error('Error during service worker registration:', error); - }); -} - -function checkValidServiceWorker(swUrl, config) { - // Check if the service worker can be found. If it can't reload the page. - fetch(swUrl) - .then(response => { - // Ensure service worker exists, and that we really are getting a JS file. - const contentType = response.headers.get('content-type'); - if ( - response.status === 404 || - (contentType != null && contentType.indexOf('javascript') === -1) - ) { - // No service worker found. Probably a different app. Reload the page. - navigator.serviceWorker.ready.then(registration => { - registration.unregister().then(() => { - window.location.reload(); - }); - }); - } else { - // Service worker found. Proceed as normal. - registerValidSW(swUrl, config); - } - }) - .catch(() => { - console.log( - 'No internet connection found. App is running in offline mode.' - ); - }); -} - -export function unregister() { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready.then(registration => { - registration.unregister(); - }); - } -} diff --git a/projectforge-webapp/src/utilities/rest.js b/projectforge-webapp/src/utilities/rest.js index 9c2c0a395c..9c10316e3e 100644 --- a/projectforge-webapp/src/utilities/rest.js +++ b/projectforge-webapp/src/utilities/rest.js @@ -7,7 +7,7 @@ export const debouncedWaitTime = ( navigator && navigator.connection && navigator.connection.saveData ) ? 1000 : 250; -export const baseURL = process.env.NODE_ENV === 'development' ? testServer : ''; +export const baseURL = (import.meta.env.DEV && import.meta.env.MODE !== 'test') ? testServer : ''; export const baseRestURL = `${baseURL}/rs`; export const createQueryParams = (params) => Object.keys(params) diff --git a/projectforge-webapp/webpack.config.js b/projectforge-webapp/webpack.config.js deleted file mode 100644 index c1e631146d..0000000000 --- a/projectforge-webapp/webpack.config.js +++ /dev/null @@ -1,17 +0,0 @@ -module.exports = { - module: { - rules: [ - { - test: /\.s[ac]ss$/i, - use: [ - // Creates `style` nodes from JS strings - "style-loader", - // Translates CSS into CommonJS - "css-loader", - // Compiles Sass to CSS - "sass-loader", - ], - }, - ], - }, -};