diff --git a/README.md b/README.md index c5eecc4..f0d4bae 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ invenio-cli services setup invenio-cli run ``` -Once the Flask server has started visit https://127.0.0.1:5000 in your browser. Once +Once the Flask server has started visit in your browser. Once finished, stop the running Flask server and use `invenio-cli services stop` to bring down the running services. @@ -57,11 +57,22 @@ application and related services (database, Elasticsearch, Redis and RabbitMQ). build and boot process will take some time to complete, especially the first time as docker images have to be downloaded during the process. -Once running, visit https://127.0.0.1 in your browser. +Once running, visit in your browser. **Note**: The server is using a self-signed SSL certificate, so your browser will issue a warning that you will have to by-pass. +### Imperial Single Sign-On + +To be able to log in using Imperial SSO the following environment variables must be set: +`ICL_OAUTH_CLIENT_ID` and `ICL_OAUTH_CLIENT_SECRET`. Appropriate values for use in +development are available from the Imperial password safe. Ask Chris C-A for access. + +Direct links: + +- [ICL_OAUTH_CLIENT_ID] +- [ICL_OAUTH_CLIENT_SECRET] + ## Development ### QA @@ -108,7 +119,7 @@ invenio-cli services start docker compose -f docker-compose.app-dev.yml up app ``` -Then access https://127.0.0.1:5000 in the browser. +Then access in the browser. ## Overview @@ -162,6 +173,8 @@ the [test_data directory]. [configuration approach]: https://inveniordm.docs.cern.ch/install/configuration/ [customisation documentation]: https://imperialcollegelondon.github.io/fair-data-repository/customisation/ [getting started]: #getting-started +[icl_oauth_client_id]: https://icsecpws.cc.ic.ac.uk:443/GetPassCard.cc?ACCOUNTID=456013&ORGN_NAME=MSP +[icl_oauth_client_secret]: https://icsecpws.cc.ic.ac.uk:443/GetPassCard.cc?ACCOUNTID=456012&ORGN_NAME=MSP [invenio-cli]: https://github.com/inveniosoftware/invenio-cli [pre-commit]: https://pre-commit.com/ [pytest-flask]: https://pytest-flask.readthedocs.io/en/latest/ diff --git a/assets/js/invenio_app_rdm/overridableRegistry/mapping.js b/assets/js/invenio_app_rdm/overridableRegistry/mapping.js index e98e6bc..38a5929 100644 --- a/assets/js/invenio_app_rdm/overridableRegistry/mapping.js +++ b/assets/js/invenio_app_rdm/overridableRegistry/mapping.js @@ -7,6 +7,7 @@ import { HiddenField } from "../../ic_data_repo/HiddenField"; import { OptionalRoleCreatibutorsField } from "../../ic_data_repo/OptionalRoleCreatibutors"; import { LimitedLicenseField } from "../../ic_data_repo/LimitedLicenseField"; +import { MandatoryPIDField } from "../../ic_data_repo/MandatoryPIDField"; import { parametrize } from "react-overridable"; import { TextAreaField } from "react-invenio-forms"; @@ -24,6 +25,7 @@ const ContributorsField = parametrize(OptionalRoleCreatibutorsField, { export const overriddenComponents = { "InvenioAppRdm.Deposit.ContributorsField.container": ContributorsField, "InvenioAppRdm.Deposit.CreatorsField.container": CreatorsField, + "InvenioAppRdm.Deposit.PIDField.container": MandatoryPIDField, "InvenioAppRdm.Deposit.ResourceTypeField.container": HiddenField, "InvenioAppRdm.Deposit.PublisherField.container": HiddenField, "InvenioAppRdm.Deposit.PublicationDateField.container": HiddenField, diff --git a/pyproject.toml b/pyproject.toml index 5e5f91f..8f4ba0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ module = [ "invenio_notifications.*", "invenio_oauthclient.*", "invenio_records_resources.*", + "invenio_rdm_records.*", "marshmallow.*", "marshmallow_utils.*", ] diff --git a/site/ic_data_repo/assets/semantic-ui/js/ic_data_repo/MandatoryPIDField.js b/site/ic_data_repo/assets/semantic-ui/js/ic_data_repo/MandatoryPIDField.js new file mode 100644 index 0000000..815e0ff --- /dev/null +++ b/site/ic_data_repo/assets/semantic-ui/js/ic_data_repo/MandatoryPIDField.js @@ -0,0 +1,351 @@ +// This file is part of Invenio-RDM-Records +// Copyright (C) 2020-2023 CERN. +// Copyright (C) 2020-2022 Northwestern University. +// +// Invenio-RDM-Records is free software; you can redistribute it and/or modify it +// under the terms of the MIT License; see LICENSE file for more details. + +import { i18next } from "@translations/invenio_rdm_records/i18next"; +import { FastField, Field, getIn } from "formik"; +import PropTypes from "prop-types"; +import React, { Component, Fragment } from "react"; +import { FieldLabel } from "react-invenio-forms"; +import { connect } from "react-redux"; +import { Form, Popup } from "semantic-ui-react"; +import { + DepositFormSubmitActions, + DepositFormSubmitContext, +} from "@js/invenio_rdm_records/src/deposit/api/DepositFormSubmitContext"; +import { DISCARD_PID_STARTED, RESERVE_PID_STARTED } from "@js/invenio_rdm_records/src/deposit/state/types"; + +const getFieldErrors = (form, fieldPath) => { + return ( + getIn(form.errors, fieldPath, null) || getIn(form.initialErrors, fieldPath, null) + ); +}; + +/** + * Button component to reserve a PID. + */ +class ReservePIDBtn extends Component { + render() { + const { disabled, handleReservePID, label, loading } = this.props; + return ( + + {({ form: formik }) => ( + handleReservePID(e, formik)} + content={label} + /> + )} + + ); + } +} + +ReservePIDBtn.propTypes = { + disabled: PropTypes.bool, + handleReservePID: PropTypes.func.isRequired, + label: PropTypes.string.isRequired, + loading: PropTypes.bool, +}; + +ReservePIDBtn.defaultProps = { + disabled: false, + loading: false, +}; + +/** + * Button component to unreserve a PID. + */ +class UnreservePIDBtn extends Component { + render() { + const { disabled, handleDiscardPID, label, loading } = this.props; + return ( + + {({ form: formik }) => ( + handleDiscardPID(e, formik)} + size="mini" + /> + )} + + } + /> + ); + } +} + +UnreservePIDBtn.propTypes = { + disabled: PropTypes.bool, + handleDiscardPID: PropTypes.func.isRequired, + label: PropTypes.string.isRequired, + loading: PropTypes.bool, +}; + +UnreservePIDBtn.defaultProps = { + disabled: false, + loading: false, +}; + +/** + * Render identifier field and reserve/unreserve + * button components for managed PID. + */ +class ManagedIdentifierComponent extends Component { + static contextType = DepositFormSubmitContext; + + handleReservePID = (event, formik) => { + const { pidType } = this.props; + const { setSubmitContext } = this.context; + setSubmitContext(DepositFormSubmitActions.RESERVE_PID, { + pidType: pidType, + }); + formik.handleSubmit(event); + }; + + handleDiscardPID = (event, formik) => { + const { pidType } = this.props; + const { setSubmitContext } = this.context; + setSubmitContext(DepositFormSubmitActions.DISCARD_PID, { + pidType: pidType, + }); + formik.handleSubmit(event); + }; + + render() { + const { + actionState, + actionStateExtra, + btnLabelDiscardPID, + btnLabelGetPID, + disabled, + helpText, + identifier, + pidType, + } = this.props; + const hasIdentifier = identifier !== ""; + + const ReserveBtn = ( + + ); + + const UnreserveBtn = ( + + ); + + return ( + <> + + {hasIdentifier && ( + + + + )} + + {identifier ? UnreserveBtn : ReserveBtn} + + {helpText && } + + ); + } +} + +ManagedIdentifierComponent.propTypes = { + btnLabelGetPID: PropTypes.string.isRequired, + disabled: PropTypes.bool, + helpText: PropTypes.string, + identifier: PropTypes.string.isRequired, + btnLabelDiscardPID: PropTypes.string.isRequired, + pidType: PropTypes.string.isRequired, + /* from Redux */ + actionState: PropTypes.string, + actionStateExtra: PropTypes.object, +}; + +ManagedIdentifierComponent.defaultProps = { + disabled: false, + helpText: null, + /* from Redux */ + actionState: "", + actionStateExtra: {}, +}; + +const mapStateToProps = (state) => ({ + actionState: state.deposit.actionState, + actionStateExtra: state.deposit.actionStateExtra, +}); + +const ManagedIdentifierCmp = connect(mapStateToProps, null)(ManagedIdentifierComponent); + +/** + * Render managed or unmanaged PID fields and update + * Formik form on input changed. + * The field value has the following format: + * { 'doi': { identifier: '', provider: '', client: '' } } + */ +class CustomPIDField extends Component { + constructor(props) { + super(props); + const { form } = props; + + // Clear the field if the DOI is external + if ('doi' in form.values.pids && form.values.pids['doi'].provider === 'external') { + form.setFieldValue("pids", {}); + } + } + + render() { + const { + btnLabelDiscardPID, + btnLabelGetPID, + form, + fieldPath, + fieldLabel, + isEditingPublishedRecord, + managedHelpText, + pidIcon, + required, + pidType, + field, + record, + } = this.props; + + const value = field.value || {}; + // If we are editing an already published record. + const currentIdentifier = value.identifier || ""; + const doi = record?.pids?.doi?.identifier || ""; + const hasDoi = doi !== ""; + const fieldError = getFieldErrors(form, fieldPath); + + return ( + <> + + + + + + + + ); + } +} + +CustomPIDField.propTypes = { + field: PropTypes.object, + form: PropTypes.object.isRequired, + btnLabelDiscardPID: PropTypes.string.isRequired, + btnLabelGetPID: PropTypes.string.isRequired, + fieldPath: PropTypes.string.isRequired, + fieldLabel: PropTypes.string.isRequired, + isEditingPublishedRecord: PropTypes.bool.isRequired, + managedHelpText: PropTypes.string, + pidIcon: PropTypes.string.isRequired, + pidType: PropTypes.string.isRequired, + required: PropTypes.bool.isRequired, + record: PropTypes.object.isRequired, +}; + +CustomPIDField.defaultProps = { + managedHelpText: null, + field: undefined, +}; + + +/** + * Render the PIDField using a custom Formik component + */ +export class PIDField extends Component { + render() { + const { fieldPath } = this.props; + return ; + } +} + +PIDField.propTypes = { + btnLabelDiscardPID: PropTypes.string, + btnLabelGetPID: PropTypes.string, + fieldPath: PropTypes.string.isRequired, + fieldLabel: PropTypes.string.isRequired, + isEditingPublishedRecord: PropTypes.bool.isRequired, + managedHelpText: PropTypes.string, + pidIcon: PropTypes.string, + pidType: PropTypes.string.isRequired, + required: PropTypes.bool, + record: PropTypes.object.isRequired, +}; + +PIDField.defaultProps = { + btnLabelDiscardPID: "Discard", + btnLabelGetPID: "Reserve", + managedHelpText: null, + pidIcon: "barcode", + required: false, +}; + + +/** + * Render the PIDField using a custom Formik component + */ +export class MandatoryPIDField extends Component { + render() { + let { config, record } = this.props; + + const pids = config.pids.map((pid) => ( + + + + )); + + return ( + + {pids} + + ); + } +} diff --git a/site/ic_data_repo/config/settings.py b/site/ic_data_repo/config/settings.py index 40d0f58..37b6d74 100644 --- a/site/ic_data_repo/config/settings.py +++ b/site/ic_data_repo/config/settings.py @@ -11,6 +11,7 @@ from invenio_notifications.backends.email import EmailNotificationBackend from invenio_oauthclient.views.client import auto_redirect_login +from invenio_rdm_records.config import RDM_PERSISTENT_IDENTIFIERS from .custom_fields import * # noqa: F401,F403 from .utils import get_user_form_default @@ -132,6 +133,9 @@ DATACITE_TEST_MODE = True DATACITE_DATACENTER_SYMBOL = "" +# Remove "external" as a DOI provider +RDM_PERSISTENT_IDENTIFIERS["doi"]["providers"].remove("external") + # Authentication - Invenio-Accounts and Invenio-OAuthclient # ========================================================= # See: https://inveniordm.docs.cern.ch/customize/authentication/ @@ -159,9 +163,10 @@ ICL_OAUTH_CLIENT_ID = os.getenv("ICL_OAUTH_CLIENT_ID") ICL_OAUTH_CLIENT_SECRET = os.getenv("ICL_OAUTH_CLIENT_SECRET") -ICL_OAUTH_WELL_KNOWN_URL = os.getenv("ICL_OAUTH_WELL_KNOWN_URL") +ICL_OAUTH_WELL_KNOWN_URL = "https://login.microsoftonline.com/2b897507-ee8c-4575-830b-4f8267c3d307/v2.0/.well-known/openid-configuration" # noqa: E501 +ICL_MICROSOFT_TENANT_ID = "2b897507-ee8c-4575-830b-4f8267c3d307" -if ICL_OAUTH_CLIENT_ID and ICL_OAUTH_CLIENT_SECRET and ICL_OAUTH_WELL_KNOWN_URL: +if ICL_OAUTH_CLIENT_ID and ICL_OAUTH_CLIENT_SECRET: OAUTHCLIENT_REMOTE_APPS["icl"] = dict( title="Imperial College Single Sign On", description="Authentication via membership of Imperial College", diff --git a/site/ic_data_repo/microsoft_graph_api_client.py b/site/ic_data_repo/microsoft_graph_api_client.py new file mode 100644 index 0000000..40504f8 --- /dev/null +++ b/site/ic_data_repo/microsoft_graph_api_client.py @@ -0,0 +1,45 @@ +"""Client interface for the Microsoft Graph API.""" + +from http import HTTPStatus +from typing import Any, Optional + +import requests +from flask import current_app + + +def _get_app_access_token() -> str: + """Get an access token for the application to use the Microsoft Graph API. + + Fetches an access token that is enabled for app-only access i.e. not on behalf of a + logged in user. + """ + tenant_id = current_app.config["ICL_MICROSOFT_TENANT_ID"] + response = requests.post( + f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token", + data={ + "grant_type": "client_credentials", + "client_id": current_app.config["ICL_OAUTH_CLIENT_ID"], + "client_secret": current_app.config["ICL_OAUTH_CLIENT_SECRET"], + "scope": "https://graph.microsoft.com/.default", + }, + ) + if not response.status_code == HTTPStatus.OK: + raise RuntimeError( + "Unable to retrieve access token for Microsoft Graph API: " + f"{response.status_code} - {response.reason}" + ) + + return response.json()["access_token"] + + +def get_user_info(username: str, access_token: Optional[str] = None) -> dict[str, Any]: + """Get user profile information from the Microsoft Graph API.""" + if access_token is None: + access_token = _get_app_access_token() + + response = requests.get( + f"https://graph.microsoft.com/v1.0/users/{username}@ic.ac.uk", + headers={"Authorization": f"Bearer {access_token}"}, + ) + response.raise_for_status() + return response.json()