Skip to content

Commit

Permalink
Feature/institutional access (#2471)
Browse files Browse the repository at this point in the history
Description:
Update role permissions to enable institutional administrators to make metadata revisions. This is critical to enabling increased control for institutional admins, supporting researchers in metadata curation and meeting data sharing policies.
Enable reviews workflow that allows institutional admins to add or remove affiliations from OSF content if appropriate. This is critical to enabling increased control for institutional admins as they monitor data sharing compliance.

Impact:
OSFI members are facing new requirements on data sharing and need workflows to support compliance. Renewals could decline as other tools enable these solutions and OSF doesn’t; new revenue potential with solutions that meet data sharing compliance needs.

Commitment:
We have 50+ OSFI members that haven’t had new features on OSF since joining, we need to listen to their needs and help make it easy and possible for them to enact open scholarship practices with their researchers. 

[ENG-4980]
  • Loading branch information
Johnetordoff authored Jan 21, 2025
1 parent 8621f29 commit af5227a
Show file tree
Hide file tree
Showing 15 changed files with 643 additions and 2 deletions.
13 changes: 13 additions & 0 deletions app/adapters/node-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { inject as service } from '@ember/service';
import config from 'ember-osf-web/config/environment';
const { OSF: { apiUrl } } = config;
import OsfAdapter from './osf-adapter';

export default class NodeRequestAdapter extends OsfAdapter {
@service session;

urlForCreateRecord(modelName, snapshot) {
const nodeId = snapshot.record.target;
return `${apiUrl}/v2/nodes/${nodeId}/requests/`;
}
}
10 changes: 10 additions & 0 deletions app/adapters/user-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import config from 'ember-osf-web/config/environment';
const { OSF: { apiUrl } } = config;
import OsfAdapter from './osf-adapter';

export default class UserMessageAdapter extends OsfAdapter {
urlForCreateRecord(modelName, snapshot) {
const userId = snapshot.record.messageRecipient;
return `${apiUrl}/v2/users/${userId}/messages/`;
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { task } from 'ember-concurrency';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
Expand All @@ -10,6 +11,7 @@ import InstitutionModel from 'ember-osf-web/models/institution';
import InstitutionDepartmentsModel from 'ember-osf-web/models/institution-department';
import Analytics from 'ember-osf-web/services/analytics';
import { RelationshipWithLinks } from 'osf-api';
import { MessageTypeChoices } from 'ember-osf-web/models/user-message';

interface Column {
key: string;
Expand All @@ -27,6 +29,8 @@ interface InstitutionalUsersListArgs {
export default class InstitutionalUsersList extends Component<InstitutionalUsersListArgs> {
@service analytics!: Analytics;
@service intl!: Intl;
@service store;
@service currentUser!: CurrentUser;

institution?: InstitutionModel;

Expand All @@ -37,6 +41,12 @@ export default class InstitutionalUsersList extends Component<InstitutionalUsers
@tracked sort = 'user_name';
@tracked selectedDepartments: string[] = [];
@tracked filteredUsers = [];
@tracked messageModalShown = false;
@tracked messageText = '';
@tracked bccSender = false;
@tracked replyTo = false;
@tracked selectedUserId = null;
@service toast!: Toast;

@tracked columns: Column[] = [
{
Expand Down Expand Up @@ -262,4 +272,54 @@ export default class InstitutionalUsersList extends Component<InstitutionalUsers
clickToggleOrcidFilter(hasOrcid: boolean) {
this.hasOrcid = !hasOrcid;
}

@action
openMessageModal(userId: string) {
this.selectedUserId = userId;
this.messageModalShown = true;
}

@action
toggleMessageModal(userId: string | null = null) {
this.messageModalShown = !this.messageModalShown;
this.selectedUserId = userId;
if (!this.messageModalShown) {
this.resetModalFields();
}
}

@action
resetModalFields() {
this.messageText = '';
this.bccSender = false;
this.replyTo = false;
this.selectedUserId = null;
}

@task
@waitFor
async sendMessage() {
if (!this.messageText.trim()) {
this.toast.error(this.intl.t('error.empty_message'));
return;
}

try {
const userMessage = this.store.createRecord('user-message', {
messageText: this.messageText.trim(),
messageType: MessageTypeChoices.InstitutionalRequest,
bccSender: this.bccSender,
replyTo: this.replyTo,
institution: this.args.institution,
messageRecipient: this.selectedUserId,
});

await userMessage.save();
this.toast.success(this.intl.t('institutions.dashboard.send_message_modal.message_sent_success'));
} catch (error) {
this.toast.error(this.intl.t('institutions.dashboard.send_message_modal.message_sent_failed'));
} finally {
this.messageModalShown = false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,41 @@ input:checked + .slider::before {
justify-content: flex-end;
margin-right: 15px;
}


.icon-message {
opacity: 0;
color: $color-text-blue-dark;
/* !important used to override ember Button border scripting */
background-color: inherit !important;
border: 0 !important;
box-shadow: 0 !important;
}

.icon-message:hover {
opacity: 1;
background-color: inherit !important;
}

.message-textarea {
min-width: 450px;
min-height: 280px;
}

.message-label {
display: block;
}

.checkbox-container {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
}

.checkbox-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,15 @@
<OsfLink @href={{concat '/' institutionalUser.userGuid '/'}}>
{{institutionalUser.userName}}
</OsfLink>
{{#if @institution.institutionalRequestAccessEnabled}}
<Button
local-class='icon-message'
aria-label={{t 'institutions.dashboard.send_message_modal.open_aira_label'}}
{{on 'click' (fn this.openMessageModal institutionalUser.userGuid)}}
>
<FaIcon @icon='comment' />
</Button>
{{/if}}
{{else if (eq column.type 'osf_link')}}
<OsfLink @href={{concat '/' institutionalUser.userGuid '/'}}>
{{institutionalUser.userGuid}}
Expand Down Expand Up @@ -204,4 +213,50 @@
{{t 'institutions.dashboard.users_list.empty'}}
</list.empty>
</PaginatedList::HasMany>
<OsfDialog @isOpen={{this.messageModalShown}} @onClose={{this.toggleMessageModal}} as |dialog|>
<dialog.heading>
{{t 'institutions.dashboard.send_message_modal.title'}}
</dialog.heading>
<dialog.main>
<div>
<label for='message-text' local-class='message-label'>
{{t 'institutions.dashboard.send_message_modal.opening_message_label'}}
</label>
<Textarea
id='message-text'
local-class='message-textarea'
@value={{this.messageText}}
/>
<div>
{{t 'institutions.dashboard.send_message_modal.closing_message_label' adminName=this.currentUser.user.fullName htmlSafe=true}}
</div>
<div local-class='checkbox-container'>
<label local-class='checkbox-item'>
<Input @type='checkbox' @checked={{this.bccSender}} />
{{t 'institutions.dashboard.send_message_modal.cc_label'}}
</label>
<label local-class='checkbox-item'>
<Input @type='checkbox' @checked={{this.replyTo}} />
{{t 'institutions.dashboard.send_message_modal.reply_to_label'}}
</label>
</div>
</div>
</dialog.main>
<dialog.footer>
<Button
@type='secondary'
{{on 'click' this.toggleMessageModal}}
>
{{t 'general.cancel'}}
</Button>
<Button
@type='primary'
@disabled={{not this.messageText.trim}}
{{on 'click' (queue (perform this.sendMessage))}}

>
{{t 'institutions.dashboard.send_message_modal.send'}}
</Button>
</dialog.footer>
</OsfDialog>
{{/if}}
147 changes: 147 additions & 0 deletions app/institutions/dashboard/-components/object-list/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ import InstitutionModel from 'ember-osf-web/models/institution';
import { SuggestedFilterOperators } from 'ember-osf-web/models/related-property-path';
import SearchResultModel from 'ember-osf-web/models/search-result';
import { Filter } from 'osf-components/components/search-page/component';
import { waitFor } from '@ember/test-waiters';
import { task } from 'ember-concurrency';
import { taskFor } from 'ember-concurrency-ts';
import Toast from 'ember-toastr/services/toast';
import Intl from 'ember-intl/services/intl';
import Store from '@ember-data/store';
import CurrentUser from 'ember-osf-web/services/current-user';
import { MessageTypeChoices } from 'ember-osf-web/models/user-message';
import { RequestTypeChoices } from 'ember-osf-web/models/node-request';

import config from 'ember-osf-web/config/environment';

const shareDownloadFlag = config.featureFlagNames.shareDownload;
Expand Down Expand Up @@ -50,6 +60,20 @@ export default class InstitutionalObjectList extends Component<InstitutionalObje
@tracked sortParam?: string;
@tracked visibleColumns = this.args.columns.map(column => column.name);
@tracked dirtyVisibleColumns = [...this.visibleColumns]; // track changes to visible columns before they are saved
@tracked selectedPermissions = 'write';
@tracked projectRequestModalShown = false;
@tracked activeTab = 'request-access'; // Default tab
@tracked messageText = '';
@tracked bccSender = false;
@tracked replyTo = false;
@tracked selectedUserId = '';
@tracked selectedNodeId = '';
@tracked showSendMessagePrompt = false;
@service toast!: Toast;
@service intl!: Intl;
@service store!: Store;
@service currentUser!: CurrentUser;


get queryOptions() {
const options = {
Expand Down Expand Up @@ -162,4 +186,127 @@ export default class InstitutionalObjectList extends Component<InstitutionalObje
updatePage(newPage: string) {
this.page = newPage;
}

@action
openProjectRequestModal(contributor: any) {
this.selectedUserId = contributor.userId;
this.selectedNodeId = contributor.nodeId;
this.projectRequestModalShown = true;
}

@action
handleBackToSendMessage() {
this.activeTab = 'send-message';
this.showSendMessagePrompt = false;
setTimeout(() => {
this.projectRequestModalShown = true; // Reopen the main modal
}, 200);

}

@action
closeSendMessagePrompt() {
this.showSendMessagePrompt = false; // Hide confirmation modal without reopening
}

@action
toggleProjectRequestModal() {
this.projectRequestModalShown = !this.projectRequestModalShown;
}

@action
updateselectedPermissions(permission: string) {
this.selectedPermissions = permission;
}

@action
setActiveTab(tabName: string) {
this.activeTab = tabName;
}


@action
resetFields() {
this.selectedPermissions = 'write';
this.bccSender = false;
this.replyTo = false;
}

@task
@waitFor
async handleSend() {
try {
if (this.activeTab === 'send-message') {
await taskFor(this._sendUserMessage).perform();
} else if (this.activeTab === 'request-access') {
await taskFor(this._sendNodeRequest).perform();
}

this.toast.success(
this.intl.t('institutions.dashboard.object-list.request-project-message-modal.message_sent_success'),
);
this.resetFields();
} catch (error) {
const errorDetail = error?.errors?.[0]?.detail.user || error?.errors?.[0]?.detail || '';
const errorCode = parseInt(error?.errors?.[0]?.status, 10);

if (errorDetail.includes('does not have Access Requests enabled')) {
// This error does not include HTTP code in payload, but it returns response header of 403
// Product wanted special handling for this error that involve a second pop-up modal
// Timeout to allow the modal to exit, can't have two OSFDialogs open at same time
setTimeout(() => {
this.showSendMessagePrompt = true; // Timeout to allow the modal to exit
}, 200);
} else if ([400, 403, 409].includes(errorCode)) {
// Handle more specific errors 403s could result due if a project quickly switches it's institution
this.toast.error(errorDetail);
} else if (errorDetail.includes('Request was throttled')) { // 429 response not in JSON payload.
this.toast.error(errorDetail);
} else {
this.toast.error(
this.intl.t('institutions.dashboard.object-list.request-project-message-modal.message_sent_failed'),
);
}
} finally {
this.projectRequestModalShown = false; // Close the main modal
}
}

@task
@waitFor
async _sendUserMessage() {
const userMessage = this.store.createRecord('user-message', {
messageText: this.messageText.trim(),
messageType: MessageTypeChoices.InstitutionalRequest,
bccSender: this.bccSender,
replyTo: this.replyTo,
institution: this.args.institution,
messageRecipient: this.selectedUserOsfGuid,
});
await userMessage.save();
}

@task
@waitFor
async _sendNodeRequest() {
const userRecord = await this.store.findRecord('user', this.selectedUserOsfGuid);
const nodeRequest = this.store.createRecord('node-request', {
comment: this.messageText.trim(),
requestType: RequestTypeChoices.InstitutionalRequest,
requestedPermissions: this.selectedPermissions,
bccSender: this.bccSender,
replyTo: this.replyTo,
institution: this.args.institution,
messageRecipient: userRecord,
target: this.selectedNodeId,
});
await nodeRequest.save();
}

get selectedUserOsfGuid() {
const url = new URL(this.selectedUserId);
const pathSegments = url.pathname.split('/').filter(Boolean);
return pathSegments[pathSegments.length - 1] || ''; // Last non-empty segment
}

}
Loading

0 comments on commit af5227a

Please sign in to comment.