Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ Sample content of this file is following:
"PERSISTENT_TOKENS_KEY_PREFIX": "recodex",
"ENVIRONMENTS_INFO_URL": "https://github.com/ReCodEx/wiki/wiki/Runtime-Environments",
"ALLOW_LOCAL_REGISTRATION": false,
"SHORT_SESSION": 15,
"EXTERNAL_AUTH_URL": "https://some.other.domain/cas/",
"EXTERNAL_AUTH_SERVICE_ID": "id-from-core-api",
"EXTERNAL_AUTH_NAME": {
Expand All @@ -105,6 +106,7 @@ Meaning of individual values:
* `PERSISTENT_TOKENS_KEY_PREFIX` - Prefix used for security token identifiers (in cookies or in local storage). If you run multiple ReCodEx instances on the same domain, it might be necessary to give each instance different prefix.
* `ENVIRONMENTS_INFO_URL` - Link to a web page where individual runtime environments are explained (default refers to our wiki).
* `ALLOW_LOCAL_REGISTRATION` - Allows or disables different forms for registration. Note that this configuration should match which registration types are supported by the API.
* `SHORT_SESSION` - Default expiration time (in minutes) of short sessions (if supported by the API). If set to `0` (or omitted), short sessions are disabled.
* `EXTERNAL_AUTH_URL` - URL of external authentication service (that implements [ReCodEx protocol](https://github.com/ReCodEx/wiki/wiki/External-Authenticators)).
* `EXTERNAL_AUTH_SERVICE_ID` - Identifier (name) of the external authenticator as specified in core-api configuration (and in database).
* `EXTERNAL_AUTH_NAME` - Caption (string) or object with localized captions (keys are locales) of the service (will be displayed in UI).
Expand Down
1 change: 1 addition & 0 deletions etc/env.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"PERSISTENT_TOKENS_KEY_PREFIX": "recodex",
"ENVIRONMENTS_INFO_URL": "https://github.com/ReCodEx/wiki/wiki/Runtime-Environments",
"ALLOW_LOCAL_REGISTRATION": false,
"SHORT_SESSION": 15,
"EXTERNAL_AUTH_URL": "https://some.other.domain/cas/",
"EXTERNAL_AUTH_SERVICE_ID": "id-from-core-api",
"EXTERNAL_AUTH_NAME": {
Expand Down
35 changes: 33 additions & 2 deletions src/components/forms/LoginForm/LoginForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,18 @@ import { SignInIcon, SuccessIcon, LoadingIcon } from '../../icons';
import FormBox from '../../widgets/FormBox';
import Button from '../../widgets/TheButton';
import Callout from '../../widgets/Callout';
import { EmailField, PasswordField } from '../Fields';
import Explanation from '../../widgets/Explanation';
import { CheckboxField, EmailField, PasswordField } from '../Fields';

const LoginForm = ({ invalid, handleSubmit, submitFailed: hasFailed, submitting, hasSucceeded, error }) => (
const LoginForm = ({
shortSession = null,
invalid,
handleSubmit,
submitFailed: hasFailed,
submitting,
hasSucceeded,
error,
}) => (
<FormBox
title={<FormattedMessage id="app.loginForm.title" defaultMessage="Sign-in by Local Account" />}
type={hasSucceeded ? 'success' : undefined}
Expand Down Expand Up @@ -61,10 +70,32 @@ const LoginForm = ({ invalid, handleSubmit, submitFailed: hasFailed, submitting,
tabIndex={2}
label={<FormattedMessage id="app.loginForm.password" defaultMessage="Password:" />}
/>

{Boolean(shortSession) && shortSession !== 0 && (
<Field
name="short"
required
component={CheckboxField}
ignoreDirty
tabIndex={3}
label={
<>
<FormattedMessage id="app.loginForm.short" defaultMessage="Short session" /> ({shortSession} min)
<Explanation id="loginForm.shortSession">
<FormattedMessage
id="app.loginForm.short.explanation"
defaultMessage="Use short session on public computers to reduce the risk of unauthorized access to your account (if you forget to log out)."
/>
</Explanation>
</>
}
/>
)}
</FormBox>
);

LoginForm.propTypes = {
shortSession: PropTypes.number,
onSubmit: PropTypes.func.isRequired,
handleSubmit: PropTypes.func.isRequired,
invalid: PropTypes.bool,
Expand Down
1 change: 1 addition & 0 deletions src/components/icons/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export const LoadingIcon = ({ className = '', ...props }) => (
);
export const LocalIcon = props => <Icon {...props} icon="thumbtack" />;
export { LockIcon };
export const LogoutIcon = props => <Icon {...props} icon="sign-out-alt" />;
export const MailIcon = props => <Icon {...props} icon={defaultMessageIcon} />;
export { NeedFixingIcon };
export const NoteIcon = props => <Icon {...props} icon={['far', 'sticky-note']} />;
Expand Down
6 changes: 3 additions & 3 deletions src/components/widgets/Sidebar/UserPanel/UserPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Tooltip, OverlayTrigger, Modal } from 'react-bootstrap';
import UserName from '../../../Users/UsersName';
import EffectiveRoleSwitching from '../../../Users/EffectiveRoleSwitching';
import withLinks from '../../../../helpers/withLinks.js';
import Icon from '../../../icons';
import { EditIcon, LogoutIcon } from '../../../icons';
import { isSuperadminRole, UserRoleIcon, roleLabels } from '../../../helpers/usersRoles.js';

import './userPanel.css';
Expand Down Expand Up @@ -50,7 +50,7 @@ class UserPanel extends Component {
<div className="small text-center mt-1">
<span className="sidebar-up-collapsed-block">
<Link to={EDIT_USER_URI_FACTORY(user.id)}>
<Icon icon="edit" className="text-warning sidebar-up-collapse-gaps" gapRight={1} />
<EditIcon className="text-warning sidebar-up-collapse-gaps" gapRight={1} />
<span className="sidebar-up-hide-collapsed">
<FormattedMessage id="generic.settings" defaultMessage="Settings" />
</span>
Expand All @@ -76,7 +76,7 @@ class UserPanel extends Component {
e.preventDefault();
logout();
}}>
<Icon icon="sign-out-alt" className="text-danger sidebar-up-collapse-gaps" gapLeft={2} gapRight={1} />
<LogoutIcon className="text-danger sidebar-up-collapse-gaps" gapLeft={2} gapRight={1} />
<span className="sidebar-up-hide-collapsed">
<FormattedMessage id="app.logout" defaultMessage="Logout" />
</span>
Expand Down
30 changes: 26 additions & 4 deletions src/containers/ExternalLogin/ExternalLoginBox.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import { FormattedMessage, injectIntl } from 'react-intl';
import Button from '../../components/widgets/TheButton';
import Box from '../../components/widgets/Box';
import Callout from '../../components/widgets/Callout';
import Explanation from '../../components/widgets/Explanation';
import { LinkIcon, LoadingIcon, SuccessIcon } from '../../components/icons';
import NiceCheckbox from '../../components/forms/NiceCheckbox';

import { externalLogin, externalLoginFailed, statusTypes } from '../../redux/modules/auth.js';
import { statusSelector, loginErrorSelector } from '../../redux/selectors/auth.js';
import { hasErrorMessage, getErrorMessage } from '../../locales/apiErrorMessages.js';
Expand All @@ -17,22 +20,27 @@ export const openPopupWindow = url =>
: null;

class ExternalLoginBox extends Component {
state = { pending: false, lastError: null };
state = { pending: false, lastError: null, short: false };

constructor(props) {
super(props);
this.popupWindow = null;
this.pollPopupClosed = null;
}

setShort = () => {
this.setState({ short: !this.state.short });
};

// Handle the messages from our popup window...
messageHandler = e => {
const { shortSessionConfig, login } = this.props;
const token = e.data; // the message should be the external JWT token

if (token !== null && e.source === this.popupWindow && this.popupWindow !== null) {
// cancel the window and the interval
this.popupWindow.postMessage('received', e.origin);
this.props.login(token, this.popupWindow, error => {
login(token, this.state.short && shortSessionConfig ? shortSessionConfig * 60 : null, this.popupWindow, error => {
if (hasErrorMessage(error)) {
this.setState({ lastError: error });
}
Expand Down Expand Up @@ -93,6 +101,7 @@ class ExternalLoginBox extends Component {
const {
name,
helpUrl,
shortSessionConfig = null,
loginStatus,
loginError,
intl: { formatMessage },
Expand Down Expand Up @@ -150,6 +159,18 @@ class ExternalLoginBox extends Component {
</p>
)}

{shortSessionConfig && shortSessionConfig > 0 && (
<NiceCheckbox name="external.short" checked={this.state.short} onChange={this.setShort}>
<FormattedMessage id="app.loginForm.short" defaultMessage="Short session" /> ({shortSessionConfig} min)
<Explanation id="loginForm.shortSession">
<FormattedMessage
id="app.loginForm.short.explanation"
defaultMessage="Use short session on public computers to reduce the risk of unauthorized access to your account (if you forget to log out)."
/>
</Explanation>
</NiceCheckbox>
)}

{!pending && (loginStatus === statusTypes.LOGIN_FAILED || this.state.lastError) && (
<Callout variant="danger" className="mt-3">
{loginError || this.state.lastError ? (
Expand All @@ -170,6 +191,7 @@ ExternalLoginBox.propTypes = {
url: PropTypes.string.isRequired,
service: PropTypes.string.isRequired,
helpUrl: PropTypes.string,
shortSessionConfig: PropTypes.number,
loginStatus: PropTypes.string,
loginError: PropTypes.object,
login: PropTypes.func.isRequired,
Expand All @@ -184,8 +206,8 @@ export default connect(
loginError: loginErrorSelector(state, service),
}),
(dispatch, { service, afterLogin = null }) => ({
login: (token, popupWindow, errorHandler = null) => {
const promise = dispatch(externalLogin(service, token, popupWindow));
login: (token, expiration, popupWindow, errorHandler = null) => {
const promise = dispatch(externalLogin(service, token, expiration, popupWindow));
return (afterLogin ? promise.then(afterLogin) : promise).catch(e => errorHandler && errorHandler(e));
},
fail: () => dispatch(externalLoginFailed(service)),
Expand Down
6 changes: 5 additions & 1 deletion src/locales/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1271,6 +1271,8 @@
"app.loginForm.login": "Přihlásit se",
"app.loginForm.password": "Heslo:",
"app.loginForm.processing": "Ověřují se přihlašovací údaje...",
"app.loginForm.short": "Krátká doba expirace relace",
"app.loginForm.short.explanation": "Použijte krátkou expiraci relace na veřejných počítačích, abyste snížili riziko neoprávněného přístupu k vašemu účtu (pokud se zapomenete odhlásit).",
"app.loginForm.success": "Přihlášení bylo úspěšné",
"app.loginForm.title": "Přihlásit se lokálním účtem",
"app.loginForm.validation.emailIsNotAnEmail": "E-mailová adresa není zadána správně.",
Expand Down Expand Up @@ -2074,6 +2076,8 @@
"app.users.createUser": "Vytvořit uživatele",
"app.users.createUser.userCreated": "Uživatelský účet byl vytvořen.",
"app.users.listTitle": "Uživatelé",
"app.users.logoutEverywhere": "Odhlásit ze všech zařízení",
"app.users.logoutEverywhereConfirm": "Tato operace zneplatní všechny autentizační tokeny uživatele vytvořené před tímto okamžikem. Opravdu chcete pokračovat?",
"app.users.takeOver": "Přihlásit jako",
"app.users.title": "Seznam všech uživatelů",
"app.users.userCreatedAt": "Uživatel vytvořen",
Expand Down Expand Up @@ -2174,4 +2178,4 @@
"recodex-judge-shuffle-all": "Sudí neuspořádaných tokenů a řádků",
"recodex-judge-shuffle-newline": "Sudí neuspořádaných tokenů (ignorující konce řádků)",
"recodex-judge-shuffle-rows": "Sudí neuspořádaných řádků"
}
}
6 changes: 5 additions & 1 deletion src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1271,6 +1271,8 @@
"app.loginForm.login": "Sign in",
"app.loginForm.password": "Password:",
"app.loginForm.processing": "Signing in...",
"app.loginForm.short": "Short session",
"app.loginForm.short.explanation": "Use short session on public computers to reduce the risk of unauthorized access to your account (if you forget to log out).",
"app.loginForm.success": "You are successfully signed in",
"app.loginForm.title": "Sign-in by Local Account",
"app.loginForm.validation.emailIsNotAnEmail": "E-mail address is not valid.",
Expand Down Expand Up @@ -2074,6 +2076,8 @@
"app.users.createUser": "Create User",
"app.users.createUser.userCreated": "The user account was created.",
"app.users.listTitle": "Users",
"app.users.logoutEverywhere": "Logout From All Sessions",
"app.users.logoutEverywhereConfirm": "This operation will invalidate all access tokens of the user generated before this moment. Are you sure you want to proceed?",
"app.users.takeOver": "Login as",
"app.users.title": "List of All Users",
"app.users.userCreatedAt": "User created",
Expand Down Expand Up @@ -2174,4 +2178,4 @@
"recodex-judge-shuffle-all": "Unordered-tokens-and-rows judge",
"recodex-judge-shuffle-newline": "Unordered-tokens judge (ignoring ends of lines)",
"recodex-judge-shuffle-rows": "Unordered-rows judge"
}
}
66 changes: 62 additions & 4 deletions src/pages/EditUser/EditUser.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Page from '../../components/layout/Page';
import { UserNavigation } from '../../components/layout/Navigation';
import NotVerifiedEmailCallout from '../../components/Users/NotVerifiedEmailCallout';
import Button, { TheButtonGroup } from '../../components/widgets/TheButton';
import { LocalIcon, TransferIcon, EditUserIcon } from '../../components/icons';
import { EditUserIcon, LoadingIcon, LocalIcon, LogoutIcon, TransferIcon } from '../../components/icons';
import { isStudentRole } from '../../components/helpers/usersRoles.js';
import AllowUserButtonContainer from '../../containers/AllowUserButtonContainer';
import EditUserProfileForm, {
Expand Down Expand Up @@ -38,9 +38,11 @@ import {
updateUIData,
makeLocalLogin,
setRole,
invalidateTokens,
} from '../../redux/modules/users.js';
import { getUser, isLoggedAsSuperAdmin } from '../../redux/selectors/users.js';
import { generateToken, takeOver } from '../../redux/modules/auth.js';
import { generateToken, takeOver, logout } from '../../redux/modules/auth.js';
import { addNotification } from '../../redux/modules/notifications.js';
import { lastGeneratedToken, loggedInUserIdSelector } from '../../redux/selectors/auth.js';
import {
fetchUserCalendarsIfNeeded,
Expand Down Expand Up @@ -88,6 +90,8 @@ class EditUser extends Component {
this.user = null;
}

state = { invalidateTokensInProgress: false };

static loadAsync = ({ userId }, dispatch) =>
Promise.all([dispatch(fetchUserIfNeeded(userId)), dispatch(fetchUserCalendarsIfNeeded(userId))]);

Expand All @@ -114,6 +118,31 @@ class EditUser extends Component {
return setRole(role);
};

invalidateTokens = async () => {
const {
params: { userId },
loggedUserId,
invalidateTokens,
logout,
addNotification,
} = this.props;

this.setState({ invalidateTokensInProgress: true });

try {
await invalidateTokens();
} catch (e) {
addNotification(e.message, false);
this.setState({ invalidateTokensInProgress: false });
return;
}

if (userId === loggedUserId) {
await logout();
}
this.setState({ invalidateTokensInProgress: false });
};

render() {
const {
user,
Expand Down Expand Up @@ -146,8 +175,8 @@ class EditUser extends Component {
/>
)}

{data && (!data.privateData.isLocal || (isSuperAdmin && data.id !== loggedUserId)) && (
<div className="mb-3">
{data && (isSuperAdmin || data.id === loggedUserId) && (
<div className="mb-3 d-flex justify-content-between">
<TheButtonGroup>
{!data.privateData.isLocal && (
<Button variant="warning" onClick={makeLocalLogin}>
Expand All @@ -165,6 +194,29 @@ class EditUser extends Component {

{isSuperAdmin && data.id !== loggedUserId && <AllowUserButtonContainer id={data.id} />}
</TheButtonGroup>

<TheButtonGroup>
{data.privateData.isAllowed && (
<Button
variant="danger"
disabled={this.state.invalidateTokensInProgress}
confirmId="invalidateTokens"
confirm={
<FormattedMessage
id="app.users.logoutEverywhereConfirm"
defaultMessage="This operation will invalidate all access tokens of the user generated before this moment. Are you sure you want to proceed?"
/>
}
onClick={this.invalidateTokens}>
{this.state.invalidateTokensInProgress ? (
<LoadingIcon gapRight={2} />
) : (
<LogoutIcon gapRight={2} />
)}
<FormattedMessage id="app.users.logoutEverywhere" defaultMessage="Logout From All Sessions" />
</Button>
)}
</TheButtonGroup>
</div>
)}

Expand Down Expand Up @@ -269,6 +321,9 @@ EditUser.propTypes = {
takeOver: PropTypes.func.isRequired,
createCalendar: PropTypes.func.isRequired,
setCalendarExpired: PropTypes.func.isRequired,
invalidateTokens: PropTypes.func.isRequired,
logout: PropTypes.func.isRequired,
addNotification: PropTypes.func.isRequired,
};

export default connect(
Expand Down Expand Up @@ -307,5 +362,8 @@ export default connect(
takeOver: userId => dispatch(takeOver(userId)),
createCalendar: () => dispatch(createUserCalendar(userId)),
setCalendarExpired: calendarId => dispatch(setUserCalendarExpired(userId, calendarId)),
invalidateTokens: () => dispatch(invalidateTokens(userId)),
logout: () => dispatch(logout()),
addNotification: (msg, successful) => dispatch(addNotification(msg, successful)),
})
)(EditUser);
Loading