1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-18 20:59:44 +02:00

fix: OIDC finalization and refactoring

This commit is contained in:
Maksim Eltyshev 2023-10-17 19:18:19 +02:00
parent c21e9cb60a
commit b9716c6e3a
70 changed files with 753 additions and 427 deletions

View file

@ -19,6 +19,7 @@
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"node-sass": "^8.0.0", "node-sass": "^8.0.0",
"oidc-client-ts": "^2.3.0",
"photoswipe": "^5.3.3", "photoswipe": "^5.3.3",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^18.2.0", "react": "^18.2.0",
@ -30,7 +31,6 @@
"react-i18next": "^12.0.0", "react-i18next": "^12.0.0",
"react-input-mask": "^2.0.4", "react-input-mask": "^2.0.4",
"react-markdown": "^8.0.3", "react-markdown": "^8.0.3",
"react-oidc-context": "^2.2.2",
"react-photoswipe-gallery": "^2.2.2", "react-photoswipe-gallery": "^2.2.2",
"react-redux": "^8.0.5", "react-redux": "^8.0.5",
"react-router-dom": "^6.4.3", "react-router-dom": "^6.4.3",
@ -7193,8 +7193,7 @@
"node_modules/crypto-js": { "node_modules/crypto-js": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz",
"integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==", "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw=="
"peer": true
}, },
"node_modules/crypto-random-string": { "node_modules/crypto-random-string": {
"version": "2.0.0", "version": "2.0.0",
@ -17043,10 +17042,9 @@
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="
}, },
"node_modules/oidc-client-ts": { "node_modules/oidc-client-ts": {
"version": "2.2.4", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-2.2.4.tgz", "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-2.3.0.tgz",
"integrity": "sha512-nOZwIomju+AmXObl5Oq5PjrES/qTt8bLsENJCIydVgi9TEWk7SCkOU6X3RNkY7yfySRM1OJJvDKdREZdmnDT2g==", "integrity": "sha512-7RUKU+TJFQo+4X9R50IGJAIDF18uRBaFXyZn4VVCfwmwbSUhKcdDnw4zgeut3uEXkiD3NqURq+d88sDPxjf1FA==",
"peer": true,
"dependencies": { "dependencies": {
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"jwt-decode": "^3.1.2" "jwt-decode": "^3.1.2"
@ -19296,18 +19294,6 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w=="
}, },
"node_modules/react-oidc-context": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-2.2.2.tgz",
"integrity": "sha512-rke8goKuxxQhSAR11h8wVn56m7kEa1mcAfYDlIycsIgmbZOFzKA0Un0y0RodHZ/M/CG6u0JD1I8RKZHqRJjWnA==",
"engines": {
"node": ">=12.13.0"
},
"peerDependencies": {
"oidc-client-ts": "^2.2.1",
"react": ">=16.8.0"
}
},
"node_modules/react-onclickoutside": { "node_modules/react-onclickoutside": {
"version": "6.12.2", "version": "6.12.2",
"resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.12.2.tgz", "resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.12.2.tgz",
@ -28802,8 +28788,7 @@
"crypto-js": { "crypto-js": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz",
"integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==", "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw=="
"peer": true
}, },
"crypto-random-string": { "crypto-random-string": {
"version": "2.0.0", "version": "2.0.0",
@ -35972,10 +35957,9 @@
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="
}, },
"oidc-client-ts": { "oidc-client-ts": {
"version": "2.2.4", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-2.2.4.tgz", "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-2.3.0.tgz",
"integrity": "sha512-nOZwIomju+AmXObl5Oq5PjrES/qTt8bLsENJCIydVgi9TEWk7SCkOU6X3RNkY7yfySRM1OJJvDKdREZdmnDT2g==", "integrity": "sha512-7RUKU+TJFQo+4X9R50IGJAIDF18uRBaFXyZn4VVCfwmwbSUhKcdDnw4zgeut3uEXkiD3NqURq+d88sDPxjf1FA==",
"peer": true,
"requires": { "requires": {
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"jwt-decode": "^3.1.2" "jwt-decode": "^3.1.2"
@ -37419,12 +37403,6 @@
} }
} }
}, },
"react-oidc-context": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-2.2.2.tgz",
"integrity": "sha512-rke8goKuxxQhSAR11h8wVn56m7kEa1mcAfYDlIycsIgmbZOFzKA0Un0y0RodHZ/M/CG6u0JD1I8RKZHqRJjWnA==",
"requires": {}
},
"react-onclickoutside": { "react-onclickoutside": {
"version": "6.12.2", "version": "6.12.2",
"resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.12.2.tgz", "resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.12.2.tgz",

View file

@ -66,6 +66,7 @@
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"node-sass": "^8.0.0", "node-sass": "^8.0.0",
"oidc-client-ts": "^2.3.0",
"photoswipe": "^5.3.3", "photoswipe": "^5.3.3",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^18.2.0", "react": "^18.2.0",
@ -77,7 +78,6 @@
"react-i18next": "^12.0.0", "react-i18next": "^12.0.0",
"react-input-mask": "^2.0.4", "react-input-mask": "^2.0.4",
"react-markdown": "^8.0.3", "react-markdown": "^8.0.3",
"react-oidc-context": "^2.2.2",
"react-photoswipe-gallery": "^2.2.2", "react-photoswipe-gallery": "^2.2.2",
"react-redux": "^8.0.5", "react-redux": "^8.0.5",
"react-router-dom": "^6.4.3", "react-router-dom": "^6.4.3",

View file

@ -1,6 +1,7 @@
import ActionTypes from '../constants/ActionTypes'; import ActionTypes from '../constants/ActionTypes';
const initializeCore = ( const initializeCore = (
config,
user, user,
board, board,
users, users,
@ -20,6 +21,7 @@ const initializeCore = (
) => ({ ) => ({
type: ActionTypes.CORE_INITIALIZE, type: ActionTypes.CORE_INITIALIZE,
payload: { payload: {
config,
user, user,
board, board,
users, users,

View file

@ -1,5 +1,12 @@
import ActionTypes from '../constants/ActionTypes'; import ActionTypes from '../constants/ActionTypes';
const initializeLogin = (config) => ({
type: ActionTypes.LOGIN_INITIALIZE,
payload: {
config,
},
});
const authenticate = (data) => ({ const authenticate = (data) => ({
type: ActionTypes.AUTHENTICATE, type: ActionTypes.AUTHENTICATE,
payload: { payload: {
@ -21,12 +28,33 @@ authenticate.failure = (error) => ({
}, },
}); });
const authenticateWithOidc = () => ({
type: ActionTypes.WITH_OIDC_AUTHENTICATE,
payload: {},
});
authenticateWithOidc.success = (accessToken) => ({
type: ActionTypes.WITH_OIDC_AUTHENTICATE__SUCCESS,
payload: {
accessToken,
},
});
authenticateWithOidc.failure = (error) => ({
type: ActionTypes.WITH_OIDC_AUTHENTICATE__FAILURE,
payload: {
error,
},
});
const clearAuthenticateError = () => ({ const clearAuthenticateError = () => ({
type: ActionTypes.AUTHENTICATE_ERROR_CLEAR, type: ActionTypes.AUTHENTICATE_ERROR_CLEAR,
payload: {}, payload: {},
}); });
export default { export default {
initializeLogin,
authenticate, authenticate,
authenticateWithOidc,
clearAuthenticateError, clearAuthenticateError,
}; };

View file

@ -4,14 +4,15 @@ import socket from './socket';
/* Actions */ /* Actions */
const createAccessToken = (data, headers) => http.post('/access-tokens', data, headers); const createAccessToken = (data, headers) => http.post('/access-tokens', data, headers);
const exchangeOidcToken = (accessToken, headers) =>
http.post('/access-tokens/exchange', { token: accessToken }, headers); const exchangeToAccessToken = (data, headers) =>
http.post('/access-tokens/exchange', data, headers);
const deleteCurrentAccessToken = (headers) => const deleteCurrentAccessToken = (headers) =>
socket.delete('/access-tokens/me', undefined, headers); socket.delete('/access-tokens/me', undefined, headers);
export default { export default {
createAccessToken, createAccessToken,
exchangeToAccessToken,
deleteCurrentAccessToken, deleteCurrentAccessToken,
exchangeOidcToken,
}; };

View file

@ -5,9 +5,11 @@ import Config from '../constants/Config';
const http = {}; const http = {};
// TODO: add all methods // TODO: add all methods
['POST'].forEach((method) => { ['GET', 'POST'].forEach((method) => {
http[method.toLowerCase()] = (url, data, headers) => { http[method.toLowerCase()] = (url, data, headers) => {
const formData = Object.keys(data).reduce((result, key) => { const formData =
data &&
Object.keys(data).reduce((result, key) => {
result.append(key, data[key]); result.append(key, data[key]);
return result; return result;

View file

@ -1,5 +1,6 @@
import http from './http'; import http from './http';
import socket from './socket'; import socket from './socket';
import root from './root';
import accessTokens from './access-tokens'; import accessTokens from './access-tokens';
import users from './users'; import users from './users';
import projects from './projects'; import projects from './projects';
@ -20,6 +21,7 @@ import notifications from './notifications';
export { http, socket }; export { http, socket };
export default { export default {
...root,
...accessTokens, ...accessTokens,
...users, ...users,
...projects, ...projects,

9
client/src/api/root.js Normal file
View file

@ -0,0 +1,9 @@
import http from './http';
/* Actions */
const getConfig = (headers) => http.get('/config', undefined, headers);
export default {
getConfig,
};

View file

@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Button, Icon, Menu } from 'semantic-ui-react'; import { Button, Icon, Menu } from 'semantic-ui-react';
import { useAuth } from 'react-oidc-context';
import { usePopup } from '../../lib/popup'; import { usePopup } from '../../lib/popup';
import Paths from '../../constants/Paths'; import Paths from '../../constants/Paths';
@ -30,7 +29,6 @@ const Header = React.memo(
onUserSettingsClick, onUserSettingsClick,
onLogout, onLogout,
}) => { }) => {
const auth = useAuth();
const handleProjectSettingsClick = useCallback(() => { const handleProjectSettingsClick = useCallback(() => {
if (canEditProject) { if (canEditProject) {
onProjectSettingsClick(); onProjectSettingsClick();
@ -40,11 +38,6 @@ const Header = React.memo(
const NotificationsPopup = usePopup(NotificationsStep, POPUP_PROPS); const NotificationsPopup = usePopup(NotificationsStep, POPUP_PROPS);
const UserPopup = usePopup(UserStep, POPUP_PROPS); const UserPopup = usePopup(UserStep, POPUP_PROPS);
const onFullLogout = () => {
auth.signoutSilent();
onLogout();
};
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
{!project && ( {!project && (
@ -95,7 +88,7 @@ const Header = React.memo(
<UserPopup <UserPopup
isLogouting={isLogouting} isLogouting={isLogouting}
onSettingsClick={onUserSettingsClick} onSettingsClick={onUserSettingsClick}
onLogout={onFullLogout} onLogout={onLogout}
> >
<Menu.Item className={classNames(styles.item, styles.itemHoverable)}> <Menu.Item className={classNames(styles.item, styles.itemHoverable)}>
{user.name} {user.name}

View file

@ -1,10 +1,9 @@
import isEmail from 'validator/lib/isEmail'; import isEmail from 'validator/lib/isEmail';
import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useAuth } from 'react-oidc-context';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Grid, Header, Message } from 'semantic-ui-react'; import { Button, Form, Grid, Header, Message } from 'semantic-ui-react';
import { useDidUpdate, usePrevious, useToggle } from '../../lib/hooks'; import { useDidUpdate, usePrevious, useToggle } from '../../lib/hooks';
import { Input } from '../../lib/custom-ui'; import { Input } from '../../lib/custom-ui';
@ -29,6 +28,21 @@ const createMessage = (error) => {
type: 'error', type: 'error',
content: 'common.invalidPassword', content: 'common.invalidPassword',
}; };
case 'Use single sign-on':
return {
type: 'error',
content: 'common.useSingleSignOn',
};
case 'Email already in use':
return {
type: 'error',
content: 'common.emailAlreadyInUse',
};
case 'Username already in use':
return {
type: 'error',
content: 'common.usernameAlreadyInUse',
};
case 'Failed to fetch': case 'Failed to fetch':
return { return {
type: 'warning', type: 'warning',
@ -48,8 +62,16 @@ const createMessage = (error) => {
}; };
const Login = React.memo( const Login = React.memo(
({ defaultData, isSubmitting, error, onAuthenticate, onMessageDismiss }) => { ({
const auth = useAuth(); defaultData,
isSubmitting,
isSubmittingWithOidc,
error,
withOidc,
onAuthenticate,
onAuthenticateWithOidc,
onMessageDismiss,
}) => {
const [t] = useTranslation(); const [t] = useTranslation();
const wasSubmitting = usePrevious(isSubmitting); const wasSubmitting = usePrevious(isSubmitting);
@ -170,12 +192,19 @@ const Login = React.memo(
content={t('action.logIn')} content={t('action.logIn')}
floated="right" floated="right"
loading={isSubmitting} loading={isSubmitting}
disabled={isSubmitting} disabled={isSubmitting || isSubmittingWithOidc}
/> />
</Form> </Form>
<Form.Button type="button" onClick={() => auth.signinRedirect()}> {withOidc && (
Log in with SSO <Button
</Form.Button> type="button"
loading={isSubmittingWithOidc}
disabled={isSubmitting || isSubmittingWithOidc}
onClick={onAuthenticateWithOidc}
>
{t('action.logInWithSSO')}
</Button>
)}
</div> </div>
</div> </div>
</Grid.Column> </Grid.Column>
@ -206,10 +235,15 @@ const Login = React.memo(
); );
Login.propTypes = { Login.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types /* eslint-disable react/forbid-prop-types */
defaultData: PropTypes.object.isRequired,
/* eslint-enable react/forbid-prop-types */
isSubmitting: PropTypes.bool.isRequired, isSubmitting: PropTypes.bool.isRequired,
isSubmittingWithOidc: PropTypes.bool.isRequired,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
withOidc: PropTypes.bool.isRequired,
onAuthenticate: PropTypes.func.isRequired, onAuthenticate: PropTypes.func.isRequired,
onAuthenticateWithOidc: PropTypes.func.isRequired,
onMessageDismiss: PropTypes.func.isRequired, onMessageDismiss: PropTypes.func.isRequired,
}; };

View file

@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Loader } from 'semantic-ui-react';
import LoginContainer from '../containers/LoginContainer';
const LoginWrapper = React.memo(({ isInitializing }) => {
if (isInitializing) {
return <Loader active size="massive" />;
}
return <LoginContainer />;
});
LoginWrapper.propTypes = {
isInitializing: PropTypes.bool.isRequired,
};
export default LoginWrapper;

View file

@ -1,19 +0,0 @@
import { useAuth } from 'react-oidc-context';
import PropTypes from 'prop-types';
import React from 'react';
let isLoggingIn = true;
const OidcLogin = React.memo(({ onAuthenticate }) => {
const auth = useAuth();
if (isLoggingIn && auth.user) {
isLoggingIn = false;
const { user } = auth;
onAuthenticate(user);
}
});
OidcLogin.propTypes = {
onAuthenticate: PropTypes.func.isRequired,
};
export default OidcLogin;

View file

@ -1,3 +0,0 @@
import OidcLogin from './OidcLogin';
export default OidcLogin;

View file

@ -1,12 +1,11 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { AuthProvider } from 'react-oidc-context';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import { ReduxRouter } from '../lib/redux-router'; import { ReduxRouter } from '../lib/redux-router';
import Paths from '../constants/Paths'; import Paths from '../constants/Paths';
import LoginContainer from '../containers/LoginContainer'; import LoginWrapperContainer from '../containers/LoginWrapperContainer';
import CoreContainer from '../containers/CoreContainer'; import CoreContainer from '../containers/CoreContainer';
import NotFound from './NotFound'; import NotFound from './NotFound';
@ -15,24 +14,14 @@ import 'photoswipe/dist/photoswipe.css';
import 'easymde/dist/easymde.min.css'; import 'easymde/dist/easymde.min.css';
import '../lib/custom-ui/styles.css'; import '../lib/custom-ui/styles.css';
import '../styles.module.scss'; import '../styles.module.scss';
import OidcLoginContainer from '../containers/OidcLoginContainer';
function Root({ store, history, config }) { function Root({ store, history }) {
return ( return (
<AuthProvider
authority={config.authority}
client_id={config.clientId}
redirect_uri={config.redirectUri}
scope={config.scopes}
onSigninCallback={() => {
window.history.replaceState({}, document.title, window.location.pathname);
}}
>
<Provider store={store}> <Provider store={store}>
<ReduxRouter history={history}> <ReduxRouter history={history}>
<Routes> <Routes>
<Route path={Paths.LOGIN} element={<LoginContainer />} /> <Route path={Paths.LOGIN} element={<LoginWrapperContainer />} />
<Route path={Paths.OIDC_LOGIN} element={<OidcLoginContainer />} /> <Route path={Paths.OIDC_CALLBACK} element={<LoginWrapperContainer />} />
<Route path={Paths.ROOT} element={<CoreContainer />} /> <Route path={Paths.ROOT} element={<CoreContainer />} />
<Route path={Paths.PROJECTS} element={<CoreContainer />} /> <Route path={Paths.PROJECTS} element={<CoreContainer />} />
<Route path={Paths.BOARDS} element={<CoreContainer />} /> <Route path={Paths.BOARDS} element={<CoreContainer />} />
@ -41,14 +30,13 @@ function Root({ store, history, config }) {
</Routes> </Routes>
</ReduxRouter> </ReduxRouter>
</Provider> </Provider>
</AuthProvider>
); );
} }
Root.propTypes = { Root.propTypes = {
/* eslint-disable react/forbid-prop-types */ /* eslint-disable react/forbid-prop-types */
store: PropTypes.object.isRequired, store: PropTypes.object.isRequired,
history: PropTypes.object.isRequired, history: PropTypes.object.isRequired,
config: PropTypes.object.isRequired,
/* eslint-enable react/forbid-prop-types */ /* eslint-enable react/forbid-prop-types */
}; };

View file

@ -16,8 +16,8 @@ const UserSettingsModal = React.memo(
phone, phone,
organization, organization,
language, language,
isLocked,
subscribeToOwnCards, subscribeToOwnCards,
isLocked,
isAvatarUpdating, isAvatarUpdating,
usernameUpdateForm, usernameUpdateForm,
emailUpdateForm, emailUpdateForm,
@ -106,8 +106,8 @@ UserSettingsModal.propTypes = {
phone: PropTypes.string, phone: PropTypes.string,
organization: PropTypes.string, organization: PropTypes.string,
language: PropTypes.string, language: PropTypes.string,
isLocked: PropTypes.bool.isRequired,
subscribeToOwnCards: PropTypes.bool.isRequired, subscribeToOwnCards: PropTypes.bool.isRequired,
isLocked: PropTypes.bool.isRequired,
isAvatarUpdating: PropTypes.bool.isRequired, isAvatarUpdating: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */ /* eslint-disable react/forbid-prop-types */
usernameUpdateForm: PropTypes.object.isRequired, usernameUpdateForm: PropTypes.object.isRequired,

View file

@ -153,12 +153,14 @@ const ActionsStep = React.memo(
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
</>
)}
{!user.isLockedAdmin && (
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}> <Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteUser', { {t('action.deleteUser', {
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
</>
)} )}
</Menu> </Menu>
</Popup.Content> </Popup.Content>

View file

@ -18,6 +18,7 @@ const Item = React.memo(
phone, phone,
isAdmin, isAdmin,
isLocked, isLocked,
isLockedAdmin,
emailUpdateForm, emailUpdateForm,
passwordUpdateForm, passwordUpdateForm,
usernameUpdateForm, usernameUpdateForm,
@ -47,7 +48,7 @@ const Item = React.memo(
<Table.Cell>{username || '-'}</Table.Cell> <Table.Cell>{username || '-'}</Table.Cell>
<Table.Cell>{email}</Table.Cell> <Table.Cell>{email}</Table.Cell>
<Table.Cell> <Table.Cell>
<Radio toggle checked={isAdmin} disabled={isLocked} onChange={handleIsAdminChange} /> <Radio toggle checked={isAdmin} disabled={isLockedAdmin} onChange={handleIsAdminChange} />
</Table.Cell> </Table.Cell>
<Table.Cell textAlign="right"> <Table.Cell textAlign="right">
<ActionsPopup <ActionsPopup
@ -59,6 +60,7 @@ const Item = React.memo(
phone, phone,
isAdmin, isAdmin,
isLocked, isLocked,
isLockedAdmin,
emailUpdateForm, emailUpdateForm,
passwordUpdateForm, passwordUpdateForm,
usernameUpdateForm, usernameUpdateForm,
@ -91,6 +93,7 @@ Item.propTypes = {
phone: PropTypes.string, phone: PropTypes.string,
isAdmin: PropTypes.bool.isRequired, isAdmin: PropTypes.bool.isRequired,
isLocked: PropTypes.bool.isRequired, isLocked: PropTypes.bool.isRequired,
isLockedAdmin: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */ /* eslint-disable react/forbid-prop-types */
emailUpdateForm: PropTypes.object.isRequired, emailUpdateForm: PropTypes.object.isRequired,
passwordUpdateForm: PropTypes.object.isRequired, passwordUpdateForm: PropTypes.object.isRequired,

View file

@ -111,6 +111,7 @@ const UsersModal = React.memo(
phone={item.phone} phone={item.phone}
isAdmin={item.isAdmin} isAdmin={item.isAdmin}
isLocked={item.isLocked} isLocked={item.isLocked}
isLockedAdmin={item.isLockedAdmin}
emailUpdateForm={item.emailUpdateForm} emailUpdateForm={item.emailUpdateForm}
passwordUpdateForm={item.passwordUpdateForm} passwordUpdateForm={item.passwordUpdateForm}
usernameUpdateForm={item.usernameUpdateForm} usernameUpdateForm={item.usernameUpdateForm}

View file

@ -12,9 +12,13 @@ export default {
/* Login */ /* Login */
LOGIN_INITIALIZE: 'LOGIN_INITIALIZE',
AUTHENTICATE: 'AUTHENTICATE', AUTHENTICATE: 'AUTHENTICATE',
AUTHENTICATE__SUCCESS: 'AUTHENTICATE__SUCCESS', AUTHENTICATE__SUCCESS: 'AUTHENTICATE__SUCCESS',
AUTHENTICATE__FAILURE: 'AUTHENTICATE__FAILURE', AUTHENTICATE__FAILURE: 'AUTHENTICATE__FAILURE',
WITH_OIDC_AUTHENTICATE: 'WITH_OIDC_AUTHENTICATE',
WITH_OIDC_AUTHENTICATE__SUCCESS: 'WITH_OIDC_AUTHENTICATE__SUCCESS',
WITH_OIDC_AUTHENTICATE__FAILURE: 'WITH_OIDC_AUTHENTICATE__FAILURE',
AUTHENTICATE_ERROR_CLEAR: 'AUTHENTICATE_ERROR_CLEAR', AUTHENTICATE_ERROR_CLEAR: 'AUTHENTICATE_ERROR_CLEAR',
/* Core */ /* Core */

View file

@ -11,11 +11,11 @@ export default {
/* Login */ /* Login */
AUTHENTICATE: `${PREFIX}/AUTHENTICATE`, AUTHENTICATE: `${PREFIX}/AUTHENTICATE`,
WITH_OIDC_AUTHENTICATE: `${PREFIX}/WITH_OIDC_AUTHENTICATE`,
AUTHENTICATE_ERROR_CLEAR: `${PREFIX}/AUTHENTICATE_ERROR_CLEAR`, AUTHENTICATE_ERROR_CLEAR: `${PREFIX}/AUTHENTICATE_ERROR_CLEAR`,
/* Core */ /* Core */
CORE_INITIALIZE: `${PREFIX}/CORE_INITIALIZE`,
LOGOUT: `${PREFIX}/LOGOUT`, LOGOUT: `${PREFIX}/LOGOUT`,
/* Modals */ /* Modals */

View file

@ -2,7 +2,7 @@ import Config from './Config';
const ROOT = `${Config.BASE_PATH}/`; const ROOT = `${Config.BASE_PATH}/`;
const LOGIN = `${Config.BASE_PATH}/login`; const LOGIN = `${Config.BASE_PATH}/login`;
const OIDC_LOGIN = `${Config.BASE_PATH}/oidclogin`; const OIDC_CALLBACK = `${Config.BASE_PATH}/oidc-callback`;
const PROJECTS = `${Config.BASE_PATH}/projects/:id`; const PROJECTS = `${Config.BASE_PATH}/projects/:id`;
const BOARDS = `${Config.BASE_PATH}/boards/:id`; const BOARDS = `${Config.BASE_PATH}/boards/:id`;
const CARDS = `${Config.BASE_PATH}/cards/:id`; const CARDS = `${Config.BASE_PATH}/cards/:id`;
@ -10,8 +10,8 @@ const CARDS = `${Config.BASE_PATH}/cards/:id`;
export default { export default {
ROOT, ROOT,
LOGIN, LOGIN,
OIDC_CALLBACK,
PROJECTS, PROJECTS,
BOARDS, BOARDS,
CARDS, CARDS,
OIDC_LOGIN,
}; };

View file

@ -4,18 +4,18 @@ import selectors from '../selectors';
import Core from '../components/Core'; import Core from '../components/Core';
const mapStateToProps = (state) => { const mapStateToProps = (state) => {
const isCoreInitializing = selectors.selectIsCoreInitializing(state); const isInitializing = selectors.selectIsInitializing(state);
const isSocketDisconnected = selectors.selectIsSocketDisconnected(state); const isSocketDisconnected = selectors.selectIsSocketDisconnected(state);
const currentModal = selectors.selectCurrentModal(state); const currentModal = selectors.selectCurrentModal(state);
const currentProject = selectors.selectCurrentProject(state); const currentProject = selectors.selectCurrentProject(state);
const currentBoard = selectors.selectCurrentBoard(state); const currentBoard = selectors.selectCurrentBoard(state);
return { return {
isInitializing,
isSocketDisconnected, isSocketDisconnected,
currentModal, currentModal,
currentProject, currentProject,
currentBoard, currentBoard,
isInitializing: isCoreInitializing,
}; };
}; };

View file

@ -1,23 +1,33 @@
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import selectors from '../selectors';
import entryActions from '../entry-actions'; import entryActions from '../entry-actions';
import Login from '../components/Login'; import Login from '../components/Login';
const mapStateToProps = ({ const mapStateToProps = (state) => {
const oidcConfig = selectors.selectOidcConfig(state);
const {
ui: { ui: {
authenticateForm: { data: defaultData, isSubmitting, error }, authenticateForm: { data: defaultData, isSubmitting, isSubmittingWithOidc, error },
}, },
}) => ({ } = state;
return {
defaultData, defaultData,
isSubmitting, isSubmitting,
isSubmittingWithOidc,
error, error,
}); withOidc: !!oidcConfig,
};
};
const mapDispatchToProps = (dispatch) => const mapDispatchToProps = (dispatch) =>
bindActionCreators( bindActionCreators(
{ {
onAuthenticate: entryActions.authenticate, onAuthenticate: entryActions.authenticate,
onAuthenticateWithOidc: entryActions.authenticateWithOidc,
onMessageDismiss: entryActions.clearAuthenticateError, onMessageDismiss: entryActions.clearAuthenticateError,
}, },
dispatch, dispatch,

View file

@ -0,0 +1,14 @@
import { connect } from 'react-redux';
import selectors from '../selectors';
import LoginWrapper from '../components/LoginWrapper';
const mapStateToProps = (state) => {
const isInitializing = selectors.selectIsInitializing(state);
return {
isInitializing,
};
};
export default connect(mapStateToProps)(LoginWrapper);

View file

@ -1,26 +0,0 @@
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import entryActions from '../entry-actions';
import OidcLogin from '../components/OIDC';
const mapStateToProps = ({
ui: {
authenticateForm: { data: defaultData, isSubmitting, error },
},
}) => ({
defaultData,
isSubmitting,
error,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators(
{
onAuthenticate: entryActions.authenticate,
onMessageDismiss: entryActions.clearAuthenticateError,
},
dispatch,
);
export default connect(mapStateToProps, mapDispatchToProps)(OidcLogin);

View file

@ -14,8 +14,8 @@ const mapStateToProps = (state) => {
phone, phone,
organization, organization,
language, language,
isLocked,
subscribeToOwnCards, subscribeToOwnCards,
isLocked,
isAvatarUpdating, isAvatarUpdating,
emailUpdateForm, emailUpdateForm,
passwordUpdateForm, passwordUpdateForm,
@ -30,8 +30,8 @@ const mapStateToProps = (state) => {
phone, phone,
organization, organization,
language, language,
isLocked,
subscribeToOwnCards, subscribeToOwnCards,
isLocked,
isAvatarUpdating, isAvatarUpdating,
emailUpdateForm, emailUpdateForm,
passwordUpdateForm, passwordUpdateForm,

View file

@ -1,16 +1,10 @@
import EntryActionTypes from '../constants/EntryActionTypes'; import EntryActionTypes from '../constants/EntryActionTypes';
const initializeCore = () => ({
type: EntryActionTypes.CORE_INITIALIZE,
payload: {},
});
const logout = () => ({ const logout = () => ({
type: EntryActionTypes.LOGOUT, type: EntryActionTypes.LOGOUT,
payload: {}, payload: {},
}); });
export default { export default {
initializeCore,
logout, logout,
}; };

View file

@ -7,6 +7,11 @@ const authenticate = (data) => ({
}, },
}); });
const authenticateWithOidc = () => ({
type: EntryActionTypes.WITH_OIDC_AUTHENTICATE,
payload: {},
});
const clearAuthenticateError = () => ({ const clearAuthenticateError = () => ({
type: EntryActionTypes.AUTHENTICATE_ERROR_CLEAR, type: EntryActionTypes.AUTHENTICATE_ERROR_CLEAR,
payload: {}, payload: {},
@ -14,5 +19,6 @@ const clearAuthenticateError = () => ({
export default { export default {
authenticate, authenticate,
authenticateWithOidc,
clearAuthenticateError, clearAuthenticateError,
}; };

View file

@ -1,15 +1,11 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import Config from './constants/Config';
import store from './store'; import store from './store';
import history from './history'; import history from './history';
import Root from './components/Root'; import Root from './components/Root';
import './i18n'; import './i18n';
fetch(`${Config.SERVER_BASE_URL}/api/appconfig`).then((response) => { const root = ReactDOM.createRoot(document.getElementById('root'));
response.json().then((config) => { root.render(React.createElement(Root, { store, history }));
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(React.createElement(Root, { store, history, config }));
});
});

View file

@ -11,10 +11,12 @@ export default {
projectManagement: 'Project management', projectManagement: 'Project management',
serverConnectionFailed: 'Server connection failed', serverConnectionFailed: 'Server connection failed',
unknownError: 'Unknown error, try again later', unknownError: 'Unknown error, try again later',
useSingleSignOn: 'Use single sign-on',
}, },
action: { action: {
logIn: 'Log in', logIn: 'Log in',
logInWithSSO: 'Log in with SSO',
}, },
}, },
}; };

View file

@ -43,16 +43,13 @@ export default class extends BaseModel {
organization: attr(), organization: attr(),
language: attr(), language: attr(),
subscribeToOwnCards: attr(), subscribeToOwnCards: attr(),
isAdmin: attr(),
isLocked: attr(),
isLockedAdmin: attr(),
deletedAt: attr(),
createdAt: attr({ createdAt: attr({
getDefault: () => new Date(), getDefault: () => new Date(),
}), }),
deletedAt: attr(),
isAdmin: attr({
getDefault: () => false,
}),
isLocked: attr({
getDefault: () => false,
}),
isAvatarUpdating: attr({ isAvatarUpdating: attr({
getDefault: () => false, getDefault: () => false,
}), }),

View file

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

View file

@ -4,7 +4,6 @@ import ActionTypes from '../constants/ActionTypes';
import ModalTypes from '../constants/ModalTypes'; import ModalTypes from '../constants/ModalTypes';
const initialState = { const initialState = {
isInitializing: true,
isLogouting: false, isLogouting: false,
currentModal: null, currentModal: null,
}; };
@ -18,11 +17,6 @@ export default (state = initialState, { type, payload }) => {
...state, ...state,
currentModal: null, currentModal: null,
}; };
case ActionTypes.CORE_INITIALIZE:
return {
...state,
isInitializing: false,
};
case ActionTypes.LOGOUT__ACCESS_TOKEN_INVALIDATE: case ActionTypes.LOGOUT__ACCESS_TOKEN_INVALIDATE:
return { return {
...state, ...state,

View file

@ -3,6 +3,7 @@ import { combineReducers } from 'redux';
import router from './router'; import router from './router';
import socket from './socket'; import socket from './socket';
import orm from './orm'; import orm from './orm';
import root from './root';
import auth from './auth'; import auth from './auth';
import core from './core'; import core from './core';
import ui from './ui'; import ui from './ui';
@ -11,6 +12,7 @@ export default combineReducers({
router, router,
socket, socket,
orm, orm,
root,
auth, auth,
core, core,
ui, ui,

View file

@ -0,0 +1,34 @@
import ActionTypes from '../constants/ActionTypes';
const initialState = {
isInitializing: true,
config: null,
};
// eslint-disable-next-line default-param-last
export default (state = initialState, { type, payload }) => {
switch (type) {
case ActionTypes.LOGIN_INITIALIZE:
return {
...state,
isInitializing: false,
config: payload.config,
};
case ActionTypes.AUTHENTICATE__SUCCESS:
case ActionTypes.WITH_OIDC_AUTHENTICATE__SUCCESS:
return {
...state,
isInitializing: true,
};
case ActionTypes.CORE_INITIALIZE:
return {
...state,
isInitializing: false,
...(payload.config && {
config: payload.config,
}),
};
default:
return state;
}
};

View file

@ -1,4 +1,7 @@
import { LOCATION_CHANGE_HANDLE } from '../../lib/redux-router';
import ActionTypes from '../../constants/ActionTypes'; import ActionTypes from '../../constants/ActionTypes';
import Paths from '../../constants/Paths';
const initialState = { const initialState = {
data: { data: {
@ -6,12 +9,22 @@ const initialState = {
password: '', password: '',
}, },
isSubmitting: false, isSubmitting: false,
isSubmittingWithOidc: false,
error: null, error: null,
}; };
// eslint-disable-next-line default-param-last // eslint-disable-next-line default-param-last
export default (state = initialState, { type, payload }) => { export default (state = initialState, { type, payload }) => {
switch (type) { switch (type) {
case LOCATION_CHANGE_HANDLE:
if (payload.location.pathname === Paths.OIDC_CALLBACK) {
return {
...state,
isSubmittingWithOidc: true,
};
}
return state;
case ActionTypes.AUTHENTICATE: case ActionTypes.AUTHENTICATE:
return { return {
...state, ...state,
@ -22,6 +35,7 @@ export default (state = initialState, { type, payload }) => {
isSubmitting: true, isSubmitting: true,
}; };
case ActionTypes.AUTHENTICATE__SUCCESS: case ActionTypes.AUTHENTICATE__SUCCESS:
case ActionTypes.WITH_OIDC_AUTHENTICATE__SUCCESS:
return initialState; return initialState;
case ActionTypes.AUTHENTICATE__FAILURE: case ActionTypes.AUTHENTICATE__FAILURE:
return { return {
@ -29,6 +43,12 @@ export default (state = initialState, { type, payload }) => {
isSubmitting: false, isSubmitting: false,
error: payload.error, error: payload.error,
}; };
case ActionTypes.WITH_OIDC_AUTHENTICATE__FAILURE:
return {
...state,
isSubmittingWithOidc: false,
error: payload.error,
};
case ActionTypes.AUTHENTICATE_ERROR_CLEAR: case ActionTypes.AUTHENTICATE_ERROR_CLEAR:
return { return {
...state, ...state,

View file

@ -1,13 +1,22 @@
import { call, put, take } from 'redux-saga/effects'; import { apply, call, put, select, take } from 'redux-saga/effects';
import request from '../request'; import request from '../request';
import requests from '../requests'; import requests from '../requests';
import selectors from '../../../selectors';
import actions from '../../../actions'; import actions from '../../../actions';
import api from '../../../api'; import api from '../../../api';
import i18n from '../../../i18n'; import i18n from '../../../i18n';
import { createOidcManager } from '../../../utils/oidc-manager';
import { removeAccessToken } from '../../../utils/access-token-storage'; import { removeAccessToken } from '../../../utils/access-token-storage';
export function* initializeCore() { export function* initializeCore() {
const currentConfig = yield select(selectors.selectConfig); // TODO: add boolean selector?
let config;
if (!currentConfig) {
({ item: config } = yield call(api.getConfig)); // TODO: handle error
}
const { const {
user, user,
board, board,
@ -32,6 +41,7 @@ export function* initializeCore() {
yield put( yield put(
actions.initializeCore( actions.initializeCore(
config,
user, user,
board, board,
users, users,
@ -74,6 +84,16 @@ export function* logout(invalidateAccessToken = true) {
} catch (error) {} // eslint-disable-line no-empty } catch (error) {} // eslint-disable-line no-empty
} }
const oidcConfig = yield select(selectors.selectOidcConfig);
if (oidcConfig) {
const oidcManager = createOidcManager(oidcConfig);
try {
yield apply(oidcManager, oidcManager.logout);
} catch (error) {} // eslint-disable-line no-empty
}
yield put(actions.logout()); yield put(actions.logout());
yield take(); yield take();
} }

View file

@ -33,15 +33,16 @@ export function* handleLocationChange() {
switch (pathsMatch.pattern.path) { switch (pathsMatch.pattern.path) {
case Paths.LOGIN: case Paths.LOGIN:
case Paths.OIDC_CALLBACK:
yield call(goToRoot); yield call(goToRoot);
break; return;
default: default:
} }
const isCoreInitializing = yield select(selectors.selectIsCoreInitializing); const isInitializing = yield select(selectors.selectIsInitializing);
if (isCoreInitializing) { if (isInitializing) {
yield take(ActionTypes.CORE_INITIALIZE); yield take(ActionTypes.CORE_INITIALIZE);
} }

View file

@ -4,8 +4,5 @@ import services from '../services';
import EntryActionTypes from '../../../constants/EntryActionTypes'; import EntryActionTypes from '../../../constants/EntryActionTypes';
export default function* coreWatchers() { export default function* coreWatchers() {
yield all([ yield all([takeEvery(EntryActionTypes.LOGOUT, () => services.logout())]);
takeEvery(EntryActionTypes.CORE_INITIALIZE, () => services.initializeCore()),
takeEvery(EntryActionTypes.LOGOUT, () => services.logout()),
]);
} }

View file

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

View file

@ -1,19 +1,25 @@
import { call, put } from 'redux-saga/effects'; import { apply, call, put, select } from 'redux-saga/effects';
import { replace } from '../../../lib/redux-router';
import selectors from '../../../selectors';
import actions from '../../../actions'; import actions from '../../../actions';
import api from '../../../api'; import api from '../../../api';
import { createOidcManager } from '../../../utils/oidc-manager';
import { setAccessToken } from '../../../utils/access-token-storage'; import { setAccessToken } from '../../../utils/access-token-storage';
import Paths from '../../../constants/Paths';
export function* initializeLogin() {
const { item: config } = yield call(api.getConfig); // TODO: handle error
yield put(actions.initializeLogin(config));
}
export function* authenticate(data) { export function* authenticate(data) {
yield put(actions.authenticate(data)); yield put(actions.authenticate(data));
let accessToken = data.access_token; let accessToken;
try { try {
if (accessToken) {
({ item: accessToken } = yield call(api.exchangeOidcToken, accessToken));
} else {
({ item: accessToken } = yield call(api.createAccessToken, data)); ({ item: accessToken } = yield call(api.createAccessToken, data));
}
} catch (error) { } catch (error) {
yield put(actions.authenticate.failure(error)); yield put(actions.authenticate.failure(error));
return; return;
@ -23,11 +29,50 @@ export function* authenticate(data) {
yield put(actions.authenticate.success(accessToken)); yield put(actions.authenticate.success(accessToken));
} }
export function* authenticateWithOidc() {
const oidcConfig = yield select(selectors.selectOidcConfig);
const oidcManager = createOidcManager(oidcConfig);
yield apply(oidcManager, oidcManager.login);
}
export function* authenticateWithOidcCallback() {
const oidcConfig = yield select(selectors.selectOidcConfig);
const oidcManager = createOidcManager(oidcConfig);
let oidcToken;
try {
({ access_token: oidcToken } = yield apply(oidcManager, oidcManager.loginCallback));
} catch (error) {
yield put(actions.authenticateWithOidc.failure(error));
}
yield put(replace(Paths.LOGIN));
if (oidcToken) {
let accessToken;
try {
({ item: accessToken } = yield call(api.exchangeToAccessToken, {
token: oidcToken,
}));
} catch (error) {
yield put(actions.authenticateWithOidc.failure(error));
return;
}
yield call(setAccessToken, accessToken);
yield put(actions.authenticateWithOidc.success(accessToken));
}
}
export function* clearAuthenticateError() { export function* clearAuthenticateError() {
yield put(actions.clearAuthenticateError()); yield put(actions.clearAuthenticateError());
} }
export default { export default {
initializeLogin,
authenticate, authenticate,
authenticateWithOidc,
authenticateWithOidcCallback,
clearAuthenticateError, clearAuthenticateError,
}; };

View file

@ -1,7 +1,9 @@
import { call, put, select } from 'redux-saga/effects'; import { call, put, select, take } from 'redux-saga/effects';
import { push } from '../../../lib/redux-router'; import { push } from '../../../lib/redux-router';
import { authenticateWithOidcCallback } from './login';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
import ActionTypes from '../../../constants/ActionTypes';
import Paths from '../../../constants/Paths'; import Paths from '../../../constants/Paths';
export function* goToLogin() { export function* goToLogin() {
@ -27,6 +29,19 @@ export function* handleLocationChange() {
yield call(goToLogin); yield call(goToLogin);
break; break;
case Paths.OIDC_CALLBACK: {
const isInitializing = yield select(selectors.selectIsInitializing);
if (isInitializing) {
yield take(ActionTypes.LOGIN_INITIALIZE);
}
// TODO: check if OIDC is enabled
yield call(authenticateWithOidcCallback);
break;
}
default: default:
} }
} }

View file

@ -8,6 +8,7 @@ export default function* loginWatchers() {
takeEvery(EntryActionTypes.AUTHENTICATE, ({ payload: { data } }) => takeEvery(EntryActionTypes.AUTHENTICATE, ({ payload: { data } }) =>
services.authenticate(data), services.authenticate(data),
), ),
takeEvery(EntryActionTypes.WITH_OIDC_AUTHENTICATE, () => services.authenticateWithOidc()),
takeEvery(EntryActionTypes.AUTHENTICATE_ERROR_CLEAR, () => services.clearAuthenticateError()), takeEvery(EntryActionTypes.AUTHENTICATE_ERROR_CLEAR, () => services.clearAuthenticateError()),
]); ]);
} }

View file

@ -6,8 +6,6 @@ import Config from '../constants/Config';
export const selectAccessToken = ({ auth: { accessToken } }) => accessToken; export const selectAccessToken = ({ auth: { accessToken } }) => accessToken;
export const selectIsCoreInitializing = ({ core: { isInitializing } }) => isInitializing;
export const selectIsLogouting = ({ core: { isLogouting } }) => isLogouting; export const selectIsLogouting = ({ core: { isLogouting } }) => isLogouting;
const nextPosition = (items, index, excludedId) => { const nextPosition = (items, index, excludedId) => {
@ -115,7 +113,6 @@ export const selectNextTaskPosition = createSelector(
export default { export default {
selectAccessToken, selectAccessToken,
selectIsCoreInitializing,
selectIsLogouting, selectIsLogouting,
selectNextBoardPosition, selectNextBoardPosition,
selectNextLabelPosition, selectNextLabelPosition,

View file

@ -1,5 +1,6 @@
import router from './router'; import router from './router';
import socket from './socket'; import socket from './socket';
import root from './root';
import core from './core'; import core from './core';
import modals from './modals'; import modals from './modals';
import users from './users'; import users from './users';
@ -16,6 +17,7 @@ import attachments from './attachments';
export default { export default {
...router, ...router,
...socket, ...socket,
...root,
...core, ...core,
...modals, ...modals,
...users, ...users,

View file

@ -0,0 +1,11 @@
export const selectIsInitializing = ({ root: { isInitializing } }) => isInitializing;
export const selectConfig = ({ root: { config } }) => config;
export const selectOidcConfig = (state) => selectConfig(state).oidc;
export default {
selectIsInitializing,
selectConfig,
selectOidcConfig,
};

View file

@ -0,0 +1,27 @@
import { UserManager } from 'oidc-client-ts';
export default class OidcManager {
constructor(config) {
this.userManager = new UserManager({
...config,
authority: config.issuer,
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: config.scopes,
});
}
login() {
return this.userManager.signinRedirect();
}
loginCallback() {
return this.userManager.signinRedirectCallback();
}
logout() {
return this.userManager.signoutSilent();
}
}
export const createOidcManager = (config) => new OidcManager(config);

View file

@ -37,6 +37,16 @@ services:
# Configure knex to accept SSL certificates # Configure knex to accept SSL certificates
# - KNEX_REJECT_UNAUTHORIZED_SSL_CERTIFICATE=false # - KNEX_REJECT_UNAUTHORIZED_SSL_CERTIFICATE=false
# - OIDC_ISSUER=
# - OIDC_CLIENT_ID=
# - OIDC_REDIRECT_URI=http://localhost:3000/oidc-callback
# - OIDC_SCOPES=openid email profile
# - OIDC_JWKS_URI=
# - OIDC_AUDIENCE=
# - OIDC_ADMIN_ROLES=admin
# - OIDC_ROLES_ATTRIBUTE=groups
# - OIDC_SKIP_USER_INFO=true
depends_on: depends_on:
- postgres - postgres

View file

@ -24,6 +24,16 @@ DEFAULT_ADMIN_USERNAME=demo
# Configure knex to accept SSL certificates # Configure knex to accept SSL certificates
# KNEX_REJECT_UNAUTHORIZED_SSL_CERTIFICATE=false # KNEX_REJECT_UNAUTHORIZED_SSL_CERTIFICATE=false
# OIDC_ISSUER=
# OIDC_CLIENT_ID=
# OIDC_REDIRECT_URI=http://localhost:1337/oidc-callback
# OIDC_SCOPES=openid email profile
# OIDC_JWKS_URI=
# OIDC_AUDIENCE=
# OIDC_ADMIN_ROLES=admin
# OIDC_ROLES_ATTRIBUTE=groups
# OIDC_SKIP_USER_INFO=true
## Do not edit this ## Do not edit this
TZ=UTC TZ=UTC

View file

@ -10,6 +10,9 @@ const Errors = {
INVALID_PASSWORD: { INVALID_PASSWORD: {
invalidPassword: 'Invalid password', invalidPassword: 'Invalid password',
}, },
USE_SINGLE_SIGN_ON: {
useSingleSignOn: 'Use single sign-on',
},
}; };
const emailOrUsernameValidator = (value) => const emailOrUsernameValidator = (value) =>
@ -37,6 +40,9 @@ module.exports = {
invalidPassword: { invalidPassword: {
responseType: 'unauthorized', responseType: 'unauthorized',
}, },
useSingleSignOn: {
responseType: 'forbidden',
},
}, },
async fn(inputs) { async fn(inputs) {
@ -44,6 +50,10 @@ module.exports = {
const user = await sails.helpers.users.getOneByEmailOrUsername(inputs.emailOrUsername); const user = await sails.helpers.users.getOneByEmailOrUsername(inputs.emailOrUsername);
if (user.isSso) {
throw Errors.USE_SINGLE_SIGN_ON;
}
if (!user) { if (!user) {
sails.log.warn( sails.log.warn(
`Invalid email or username: "${inputs.emailOrUsername}"! (IP: ${remoteAddress})`, `Invalid email or username: "${inputs.emailOrUsername}"! (IP: ${remoteAddress})`,

View file

@ -1,88 +1,20 @@
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const openidClient = require('openid-client');
const { getRemoteAddress } = require('../../../utils/remoteAddress'); const { getRemoteAddress } = require('../../../utils/remoteAddress');
const Errors = { const Errors = {
INVALID_TOKEN: { INVALID_TOKEN: {
invalidToken: 'Access Token is invalid', invalidToken: 'Invalid token',
},
EMAIL_ALREADY_IN_USE: {
emailAlreadyInUse: 'Email already in use',
},
USERNAME_ALREADY_IN_USE: {
usernameAlreadyInUse: 'Username already in use',
}, },
MISSING_VALUES: { MISSING_VALUES: {
missingValues: missingValues: 'Unable to retrieve required values (email, name)',
'Unable to retrieve required values. Verify the access token or UserInfo endpoint has email, username and name claims',
}, },
}; };
const jwks = jwksClient({
jwksUri: sails.config.custom.oidcJwksUri,
requestHeaders: {}, // Optional
timeout: 30000, // Defaults to 30s
});
const getJwtVerificationOptions = () => {
const options = {};
if (sails.config.custom.oidcIssuer) {
options.issuer = sails.config.custom.oidcIssuer;
}
if (sails.config.custom.oidcAudience) {
options.audience = sails.config.custom.oidcAudience;
}
return options;
};
const validateAndDecodeToken = async (accessToken, options) => {
const keys = await jwks.getSigningKeys();
let validToken = {};
const isTokenValid = keys.some((signingKey) => {
try {
const key = signingKey.getPublicKey();
validToken = jwt.verify(accessToken, key, options);
return 'true';
} catch (error) {
sails.log.error(error);
}
return false;
});
if (!isTokenValid) {
const tokenForLogging = jwt.decode(accessToken);
const remoteAddress = getRemoteAddress(this.req);
sails.log.warn(
`invalid token: sub: "${tokenForLogging.sub}" issuer: "${tokenForLogging.iss}" audience: "${tokenForLogging.aud}" exp: ${tokenForLogging.exp} (IP: ${remoteAddress})`,
);
throw Errors.INVALID_TOKEN;
}
return validToken;
};
const getUserInfo = async (accessToken, options) => {
if (sails.config.custom.oidcSkipUserInfo) {
return {};
}
const issuer = await openidClient.Issuer.discover(options.issuer);
const oidcClient = new issuer.Client({
client_id: 'irrelevant',
});
const userInfo = await oidcClient.userinfo(accessToken);
return userInfo;
};
const mergeUserData = (validToken, userInfo) => {
const oidcUser = { ...validToken, ...userInfo };
return oidcUser;
};
const getOrCreateUser = async (newUser) => {
const user = await User.findOne({
where: {
username: newUser.username,
},
});
if (user) {
return user;
}
return User.create(newUser).fetch();
};
module.exports = { module.exports = {
inputs: { inputs: {
token: { token: {
@ -95,83 +27,41 @@ module.exports = {
invalidToken: { invalidToken: {
responseType: 'unauthorized', responseType: 'unauthorized',
}, },
emailAlreadyInUse: {
responseType: 'conflict',
},
usernameAlreadyInUse: {
responseType: 'conflict',
},
missingValues: { missingValues: {
responseType: 'unauthorized', responseType: 'unprocessableEntity',
}, },
}, },
async fn(inputs) { async fn(inputs) {
const options = getJwtVerificationOptions();
const validToken = await validateAndDecodeToken(inputs.token, options);
const userInfo = await getUserInfo(inputs.token, options);
const oidcUser = mergeUserData(validToken, userInfo);
const now = new Date();
let isAdmin = false;
if (sails.config.custom.oidcAdminRoles.includes('*')) isAdmin = true;
else if (Array.isArray(oidcUser[sails.config.custom.oidcRolesAttribute])) {
const userRoles = new Set(oidcUser[sails.config.custom.oidcRolesAttribute]);
isAdmin = sails.config.custom.oidcAdminRoles.findIndex((role) => userRoles.has(role)) > -1;
}
const newUser = {
email: oidcUser.email,
isAdmin,
name: oidcUser.name,
username: oidcUser.preferred_username,
subscribeToOwnCards: false,
createdAt: now,
updatedAt: now,
locked: true,
};
if (!newUser.email || !newUser.username || !newUser.name) {
sails.log.error(Errors.MISSING_VALUES.missingValues);
throw Errors.MISSING_VALUES;
}
const identityProviderUser = await IdentityProviderUser.findOne({
where: {
issuer: oidcUser.iss,
sub: oidcUser.sub,
},
}).populate('userId');
let user = identityProviderUser ? identityProviderUser.userId : {};
if (!identityProviderUser) {
user = await getOrCreateUser(newUser);
await IdentityProviderUser.create({
issuer: oidcUser.iss,
sub: oidcUser.sub,
userId: user.id,
});
}
const controlledFields = ['email', 'password', 'isAdmin', 'name', 'username'];
const updateFields = {};
controlledFields.forEach((field) => {
if (user[field] !== newUser[field]) {
updateFields[field] = newUser[field];
}
});
if (Object.keys(updateFields).length > 0) {
updateFields.updatedAt = now;
await User.updateOne({ id: user.id }).set(updateFields);
}
const plankaToken = sails.helpers.utils.createToken(user.id);
const remoteAddress = getRemoteAddress(this.req); const remoteAddress = getRemoteAddress(this.req);
const user = await sails.helpers.users
.getOrCreateOneByOidcToken(inputs.token)
.intercept('invalidToken', () => {
sails.log.warn(`Invalid token! (IP: ${remoteAddress})`);
return Errors.INVALID_TOKEN;
})
.intercept('emailAlreadyInUse', () => Errors.EMAIL_ALREADY_IN_USE)
.intercept('usernameAlreadyInUse', () => Errors.USERNAME_ALREADY_IN_USE)
.intercept('missingValues', () => Errors.MISSING_VALUES);
const accessToken = sails.helpers.utils.createToken(user.id);
await Session.create({ await Session.create({
accessToken: plankaToken, accessToken,
remoteAddress, remoteAddress,
userId: user.id, userId: user.id,
userAgent: this.req.headers['user-agent'], userAgent: this.req.headers['user-agent'],
}); });
return { return {
item: plankaToken, item: accessToken,
}; };
}, },
}; };

View file

@ -1,11 +0,0 @@
module.exports = {
async fn() {
const config = {
authority: sails.config.custom.oidcIssuer,
clientId: sails.config.custom.oidcClientId,
redirectUri: sails.config.custom.oidcredirectUri,
scopes: sails.config.custom.oidcScopes,
};
return config;
},
};

View file

@ -0,0 +1,16 @@
module.exports = {
fn() {
return {
item: {
oidc: sails.config.custom.oidcIssuer
? {
issuer: sails.config.custom.oidcIssuer,
clientId: sails.config.custom.oidcClientId,
redirectUri: sails.config.custom.oidcRedirectUri,
scopes: sails.config.custom.oidcScopes,
}
: null,
},
};
},
};

View file

@ -1,4 +1,7 @@
const Errors = { const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
},
USER_NOT_FOUND: { USER_NOT_FOUND: {
userNotFound: 'User not found', userNotFound: 'User not found',
}, },
@ -14,6 +17,9 @@ module.exports = {
}, },
exits: { exits: {
notEnoughRights: {
responseType: 'forbidden',
},
userNotFound: { userNotFound: {
responseType: 'notFound', responseType: 'notFound',
}, },
@ -27,7 +33,7 @@ module.exports = {
} }
if (user.email === sails.config.custom.defaultAdminEmail) { if (user.email === sails.config.custom.defaultAdminEmail) {
throw Errors.USER_NOT_FOUND; // Forbidden throw Errors.NOT_ENOUGH_RIGHTS;
} }
user = await sails.helpers.users.deleteOne.with({ user = await sails.helpers.users.deleteOne.with({

View file

@ -1,6 +1,9 @@
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const Errors = { const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
},
USER_NOT_FOUND: { USER_NOT_FOUND: {
userNotFound: 'User not found', userNotFound: 'User not found',
}, },
@ -31,6 +34,9 @@ module.exports = {
}, },
exits: { exits: {
notEnoughRights: {
responseType: 'forbidden',
},
userNotFound: { userNotFound: {
responseType: 'notFound', responseType: 'notFound',
}, },
@ -59,8 +65,8 @@ module.exports = {
throw Errors.USER_NOT_FOUND; throw Errors.USER_NOT_FOUND;
} }
if (user.email === sails.config.custom.defaultAdminEmail) { if (user.email === sails.config.custom.defaultAdminEmail || user.isSso) {
throw Errors.USER_NOT_FOUND; // Forbidden throw Errors.NOT_ENOUGH_RIGHTS;
} }
if ( if (

View file

@ -4,6 +4,9 @@ const zxcvbn = require('zxcvbn');
const { getRemoteAddress } = require('../../../utils/remoteAddress'); const { getRemoteAddress } = require('../../../utils/remoteAddress');
const Errors = { const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
},
USER_NOT_FOUND: { USER_NOT_FOUND: {
userNotFound: 'User not found', userNotFound: 'User not found',
}, },
@ -33,6 +36,9 @@ module.exports = {
}, },
exits: { exits: {
notEnoughRights: {
responseType: 'forbidden',
},
userNotFound: { userNotFound: {
responseType: 'notFound', responseType: 'notFound',
}, },
@ -58,8 +64,8 @@ module.exports = {
throw Errors.USER_NOT_FOUND; throw Errors.USER_NOT_FOUND;
} }
if (user.email === sails.config.custom.defaultAdminEmail) { if (user.email === sails.config.custom.defaultAdminEmail || user.isSso) {
throw Errors.USER_NOT_FOUND; // Forbidden throw Errors.NOT_ENOUGH_RIGHTS;
} }
if ( if (

View file

@ -1,6 +1,9 @@
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const Errors = { const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
},
USER_NOT_FOUND: { USER_NOT_FOUND: {
userNotFound: 'User not found', userNotFound: 'User not found',
}, },
@ -33,6 +36,9 @@ module.exports = {
}, },
exits: { exits: {
notEnoughRights: {
responseType: 'forbidden',
},
userNotFound: { userNotFound: {
responseType: 'notFound', responseType: 'notFound',
}, },
@ -61,8 +67,8 @@ module.exports = {
throw Errors.USER_NOT_FOUND; throw Errors.USER_NOT_FOUND;
} }
if (user.email === sails.config.custom.defaultAdminEmail) { if (user.email === sails.config.custom.defaultAdminEmail || user.isSso) {
throw Errors.USER_NOT_FOUND; // Forbidden throw Errors.NOT_ENOUGH_RIGHTS;
} }
if ( if (

View file

@ -72,6 +72,8 @@ module.exports = {
delete inputs.isAdmin; delete inputs.isAdmin;
delete inputs.name; delete inputs.name;
/* eslint-enable no-param-reassign */ /* eslint-enable no-param-reassign */
} else if (user.isSso) {
delete inputs.name; // eslint-disable-line no-param-reassign
} }
const values = { const values = {

View file

@ -9,7 +9,7 @@ const valuesValidator = (value) => {
return false; return false;
} }
if (!_.isString(value.password)) { if (!_.isNil(value.password) && !_.isString(value.password)) {
return false; return false;
} }
@ -40,6 +40,10 @@ module.exports = {
async fn(inputs) { async fn(inputs) {
const { values } = inputs; const { values } = inputs;
if (values.password) {
values.password = bcrypt.hashSync(values.password, 10);
}
if (values.username) { if (values.username) {
values.username = values.username.toLowerCase(); values.username = values.username.toLowerCase();
} }
@ -47,7 +51,6 @@ module.exports = {
const user = await User.create({ const user = await User.create({
...values, ...values,
email: values.email.toLowerCase(), email: values.email.toLowerCase(),
password: bcrypt.hashSync(values.password, 10),
}) })
.intercept( .intercept(
{ {

View file

@ -10,6 +10,10 @@ module.exports = {
}, },
async fn(inputs) { async fn(inputs) {
await IdentityProviderUser.destroy({
userId: inputs.record.id,
});
await ProjectManager.destroy({ await ProjectManager.destroy({
userId: inputs.record.id, userId: inputs.record.id,
}); });

View file

@ -0,0 +1,159 @@
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const openidClient = require('openid-client');
const jwks = jwksClient({
jwksUri: sails.config.custom.oidcJwksUri,
});
const verifyOidcToken = async (oidcToken) => {
const signingKeys = await jwks.getSigningKeys();
const options = {
issuer: sails.config.custom.oidcIssuer,
};
if (sails.config.custom.oidcAudience) {
options.audience = sails.config.custom.oidcAudience;
}
let payload = null;
signingKeys.some((signingKey) => {
try {
const publicKey = signingKey.getPublicKey();
payload = jwt.verify(oidcToken, publicKey, options);
} catch (error) {
sails.log.error(error);
}
return !!payload;
});
return payload;
};
const getUserInfo = async (oidcToken) => {
const issuer = await openidClient.Issuer.discover(sails.config.custom.oidcIssuer);
const client = new issuer.Client({
client_id: 'irrelevant',
});
return client.userinfo(oidcToken);
};
module.exports = {
inputs: {
token: {
type: 'string',
required: true,
},
},
exits: {
invalidToken: {},
missingValues: {},
emailAlreadyInUse: {},
usernameAlreadyInUse: {},
},
async fn(inputs) {
const oidcUser = await verifyOidcToken(inputs.token);
if (!oidcUser) {
throw 'invalidToken';
}
if (!sails.config.custom.oidcSkipUserInfo) {
const userInfo = await getUserInfo(inputs.token);
Object.assign(oidcUser, userInfo);
}
if (!oidcUser.email || !oidcUser.name) {
throw 'missingValues';
}
let isAdmin = false;
if (sails.config.custom.oidcAdminRoles.includes('*')) {
isAdmin = true;
} else {
const roles = oidcUser[sails.config.custom.oidcRolesAttribute];
if (Array.isArray(roles)) {
isAdmin = sails.config.custom.oidcAdminRoles.some((role) => roles.includes(role));
}
}
const values = {
isAdmin,
email: oidcUser.email,
isSso: true,
name: oidcUser.name,
username: oidcUser.preferred_username,
subscribeToOwnCards: false,
};
let user;
/* eslint-disable no-constant-condition, no-await-in-loop */
while (true) {
let identityProviderUser = await IdentityProviderUser.findOne({
issuer: oidcUser.iss,
sub: oidcUser.sub,
});
if (identityProviderUser) {
user = await sails.helpers.users.getOne(identityProviderUser.userId);
} else {
while (true) {
user = await sails.helpers.users.getOne({
email: values.email,
});
if (!user) {
user = await sails.helpers.users
.createOne(values)
.tolerate('emailAlreadyInUse')
.intercept('usernameAlreadyInUse', 'usernameAlreadyInUse');
}
if (user) {
break;
}
}
identityProviderUser = await IdentityProviderUser.create({
userId: user.id,
issuer: oidcUser.iss,
sub: oidcUser.sub,
}).tolerate('E_UNIQUE');
}
if (identityProviderUser) {
break;
}
}
/* eslint-enable no-constant-condition, no-await-in-loop */
const updateFieldKeys = ['email', 'isAdmin', 'isSso', 'name', 'username'];
const updateValues = updateFieldKeys.reduce((result, fieldKey) => {
if (values[fieldKey] === user[fieldKey]) {
return result;
}
return {
...result,
[fieldKey]: values[fieldKey],
};
}, {});
if (Object.keys(updateValues).length > 0) {
user = await sails.helpers.users
.updateOne(user, updateValues, {}) // FIXME: hack for last parameter
.intercept('emailAlreadyInUse', 'emailAlreadyInUse')
.intercept('usernameAlreadyInUse', 'usernameAlreadyInUse');
}
return user;
},
};

View file

@ -24,6 +24,11 @@ module.exports = {
defaultsTo: false, defaultsTo: false,
columnName: 'is_admin', columnName: 'is_admin',
}, },
isSso: {
type: 'boolean',
defaultsTo: false,
columnName: 'is_sso',
},
name: { name: {
type: 'string', type: 'string',
required: true, required: true,
@ -67,10 +72,6 @@ module.exports = {
type: 'ref', type: 'ref',
columnName: 'password_changed_at', columnName: 'password_changed_at',
}, },
locked: {
type: 'boolean',
columnName: 'locked',
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗
@ -109,12 +110,15 @@ module.exports = {
tableName: 'user_account', tableName: 'user_account',
customToJSON() { customToJSON() {
const isLockedAdmin = this.email === sails.config.custom.defaultAdminEmail;
return { return {
..._.omit(this, ['password', 'avatar', 'passwordChangedAt']), ..._.omit(this, ['password', 'isSso', 'avatar', 'passwordChangedAt']),
isLockedAdmin,
isLocked: this.isSso || isLockedAdmin,
avatarUrl: avatarUrl:
this.avatar && this.avatar &&
`${sails.config.custom.userAvatarsUrl}/${this.avatar.dirname}/square-100.${this.avatar.extension}`, `${sails.config.custom.userAvatarsUrl}/${this.avatar.dirname}/square-100.${this.avatar.extension}`,
isLocked: this.email === sails.config.custom.defaultAdminEmail,
}; };
}, },
}; };

View file

@ -31,15 +31,15 @@ module.exports.custom = {
attachmentsPath: path.join(sails.config.appPath, 'private', 'attachments'), attachmentsPath: path.join(sails.config.appPath, 'private', 'attachments'),
attachmentsUrl: `${process.env.BASE_URL}/attachments`, attachmentsUrl: `${process.env.BASE_URL}/attachments`,
oidcIssuer: process.env.OIDC_ISSUER,
oidcAudience: process.env.OIDC_AUDIENCE,
oidcClientId: process.env.OIDC_CLIENT_ID,
oidcRolesAttribute: process.env.OIDC_ROLES_ATTRIBUTE || 'groups',
oidcAdminRoles: process.env.OIDC_ADMIN_ROLES ? process.env.OIDC_ADMIN_ROLES.split(',') : [],
oidcredirectUri: process.env.OIDC_REDIRECT_URI,
oidcJwksUri: process.env.OIDC_JWKS_URI,
oidcScopes: process.env.OIDC_SCOPES || 'openid profile email',
oidcSkipUserInfo: process.env.OIDC_SKIP_USER_INFO === 'true',
defaultAdminEmail: process.env.DEFAULT_ADMIN_EMAIL, defaultAdminEmail: process.env.DEFAULT_ADMIN_EMAIL,
oidcIssuer: process.env.OIDC_ISSUER,
oidcClientId: process.env.OIDC_CLIENT_ID,
oidcRedirectUri: process.env.OIDC_REDIRECT_URI,
oidcScopes: process.env.OIDC_SCOPES || 'openid email profile',
oidcJwksUri: process.env.OIDC_JWKS_URI,
oidcAudience: process.env.OIDC_AUDIENCE,
oidcAdminRoles: process.env.OIDC_ADMIN_ROLES ? process.env.OIDC_ADMIN_ROLES.split(',') : [],
oidcRolesAttribute: process.env.OIDC_ROLES_ATTRIBUTE || 'groups',
oidcSkipUserInfo: process.env.OIDC_SKIP_USER_INFO === 'true',
}; };

View file

@ -23,7 +23,7 @@ module.exports.policies = {
'projects/create': ['is-authenticated', 'is-admin'], 'projects/create': ['is-authenticated', 'is-admin'],
'show-config': true,
'access-tokens/create': true, 'access-tokens/create': true,
'access-tokens/exchange': true, 'access-tokens/exchange': true,
'appconfig/index': true,
}; };

View file

@ -9,7 +9,7 @@
*/ */
module.exports.routes = { module.exports.routes = {
'GET /api/appconfig': 'appconfig/index', 'GET /api/config': 'show-config',
'POST /api/access-tokens': 'access-tokens/create', 'POST /api/access-tokens': 'access-tokens/create',
'POST /api/access-tokens/exchange': 'access-tokens/exchange', 'POST /api/access-tokens/exchange': 'access-tokens/exchange',

View file

@ -1,24 +0,0 @@
module.exports.up = (knex) =>
knex.schema.createTable('identity_provider_user', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
table
.bigInteger('user_id')
.notNullable()
.references('id')
.inTable('user_account')
.onDelete('CASCADE');
table.text('issuer').notNullable();
table.text('sub').notNullable();
/* Indexes */
table.index('user_id');
});
module.exports.down = (knex) => knex.schema.dropTable('identity_provider_user');

View file

@ -0,0 +1,44 @@
module.exports.up = async (knex) => {
await knex.schema.createTable('identity_provider_user', (table) => {
/* Columns */
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
table.bigInteger('user_id').notNullable();
table.text('issuer').notNullable();
table.text('sub').notNullable();
table.timestamp('created_at', true);
table.timestamp('updated_at', true);
/* Indexes */
table.unique(['issuer', 'sub']);
table.index('user_id');
});
await knex.schema.table('user_account', (table) => {
/* Columns */
table.boolean('is_sso').notNullable().default(false);
/* Modifications */
table.setNullable('password');
});
return knex.schema.alterTable('user_account', (table) => {
table.boolean('is_sso').notNullable().alter();
});
};
module.exports.down = async (knex) => {
await knex.schema.dropTable('identity_provider_user');
return knex.schema.table('user_account', (table) => {
table.dropColumn('is_sso');
table.dropNullable('password');
});
};

View file

@ -1,11 +0,0 @@
module.exports.up = async (knex) => {
return knex.schema.table('user_account', (table) => {
table.setNullable('password');
});
};
module.exports.down = async (knex) => {
return knex.schema.table('user_account', (table) => {
table.dropNullable('password');
});
};

View file

@ -1,11 +0,0 @@
module.exports.up = async (knex) => {
return knex.schema.table('user_account', (table) => {
table.boolean('locked').default(false);
});
};
module.exports.down = async (knex) => {
return knex.schema.table('user_account', (table) => {
table.dropColumn('locked');
});
};

View file

@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
const buildData = () => { const buildData = () => {
const data = { const data = {
isAdmin: true, isAdmin: true,
isSso: false,
}; };
if (process.env.DEFAULT_ADMIN_PASSWORD) { if (process.env.DEFAULT_ADMIN_PASSWORD) {