-
Institution Users
-
Users content will be implemented here...
+
+
+
+
{{ amountText() }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.scss b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.scss
index e69de29bb..eab134e2c 100644
--- a/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.scss
+++ b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.scss
@@ -0,0 +1,3 @@
+.title {
+ color: var(--pr-blue-1);
+}
diff --git a/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.ts b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.ts
index ba4465712..ae7802897 100644
--- a/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.ts
+++ b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.ts
@@ -1,10 +1,278 @@
-import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { createDispatchMap, select } from '@ngxs/store';
+
+import { TranslatePipe, TranslateService } from '@ngx-translate/core';
+
+import { CheckboxModule } from 'primeng/checkbox';
+import { DialogService } from 'primeng/dynamicdialog';
+import { PaginatorState } from 'primeng/paginator';
+
+import { filter } from 'rxjs';
+
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ DestroyRef,
+ effect,
+ inject,
+ OnInit,
+ signal,
+ untracked,
+} from '@angular/core';
+import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute, Params, Router } from '@angular/router';
+
+import { parseQueryFilterParams } from '@core/helpers';
+import { UserSelectors } from '@core/store/user';
+import { Primitive } from '@osf/core/helpers';
+import { AdminTableComponent } from '@osf/features/admin-institutions/components';
+import { departmentOptions, userTableColumns } from '@osf/features/admin-institutions/constants';
+import { SendEmailDialogComponent } from '@osf/features/admin-institutions/dialogs';
+import { mapUserToTableCellData } from '@osf/features/admin-institutions/mappers';
+import {
+ FetchInstitutionUsers,
+ SendUserMessage,
+} from '@osf/features/admin-institutions/store/institutions-admin.actions';
+import { InstitutionsAdminSelectors } from '@osf/features/admin-institutions/store/institutions-admin.selectors';
+import { SelectComponent } from '@osf/shared/components';
+import { TABLE_PARAMS } from '@shared/constants';
+import { SortOrder } from '@shared/enums';
+import { QueryParams } from '@shared/models';
+
+import {
+ InstitutionsUsersQueryParamsModel,
+ InstitutionUser,
+ SendEmailDialogData,
+ TableCellData,
+ TableCellLink,
+ TableIconClickEvent,
+} from '../../models';
@Component({
selector: 'osf-institutions-users',
- imports: [],
+ imports: [AdminTableComponent, FormsModule, SelectComponent, CheckboxModule, TranslatePipe],
templateUrl: './institutions-users.component.html',
styleUrl: './institutions-users.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [DialogService],
})
-export class InstitutionsUsersComponent {}
+export class InstitutionsUsersComponent implements OnInit {
+ private readonly route = inject(ActivatedRoute);
+ private readonly router = inject(Router);
+ private readonly translate = inject(TranslateService);
+ private readonly dialogService = inject(DialogService);
+ private readonly destroyRef = inject(DestroyRef);
+
+ private readonly actions = createDispatchMap({
+ fetchInstitutionUsers: FetchInstitutionUsers,
+ sendUserMessage: SendUserMessage,
+ });
+
+ institutionId = '';
+
+ queryParams = toSignal(this.route.queryParams);
+ currentPage = signal(1);
+ currentPageSize = signal(TABLE_PARAMS.rows);
+ first = signal(0);
+
+ selectedDepartment = signal
(null);
+ hasOrcidFilter = signal(false);
+
+ sortField = signal('user_name');
+ sortOrder = signal(1);
+
+ departmentOptions = departmentOptions;
+ tableColumns = userTableColumns;
+
+ users = select(InstitutionsAdminSelectors.getUsers);
+ totalCount = select(InstitutionsAdminSelectors.getUsersTotalCount);
+ isLoading = select(InstitutionsAdminSelectors.getUsersLoading);
+
+ currentUser = select(UserSelectors.getCurrentUser);
+
+ tableData = computed(() => {
+ return this.users().map((user: InstitutionUser): TableCellData => mapUserToTableCellData(user));
+ });
+
+ amountText = computed(() => {
+ const count = this.totalCount();
+ return count + ' ' + this.translate.instant('adminInstitutions.summary.totalUsers').toLowerCase();
+ });
+
+ constructor() {
+ this.setupQueryParamsEffect();
+ }
+
+ ngOnInit(): void {
+ const institutionId = this.route.parent?.snapshot.params['institution-id'];
+
+ if (institutionId) {
+ this.institutionId = institutionId;
+ }
+ }
+
+ onPageChange(event: PaginatorState): void {
+ this.currentPage.set(event.page ? event.page + 1 : 1);
+ this.first.set(event.first ?? 0);
+ this.updateQueryParams({
+ page: this.currentPage(),
+ size: event.rows || this.currentPageSize(),
+ });
+ }
+
+ onDepartmentChange(department: Primitive): void {
+ const departmentValue = department === null || department === undefined ? null : String(department);
+ this.selectedDepartment.set(departmentValue);
+ this.updateQueryParams({
+ department: departmentValue,
+ page: 1,
+ });
+ }
+
+ onOrcidFilterChange(hasOrcid: boolean): void {
+ this.hasOrcidFilter.set(hasOrcid);
+ this.updateQueryParams({
+ hasOrcid: hasOrcid,
+ page: 1,
+ });
+ }
+
+ onSortChange(sortEvent: QueryParams): void {
+ this.updateQueryParams({
+ sortColumn: sortEvent.sortColumn,
+ sortOrder: sortEvent.sortOrder,
+ page: 1,
+ });
+ }
+
+ onIconClick(event: TableIconClickEvent): void {
+ switch (event.action) {
+ case 'sendMessage': {
+ this.dialogService
+ .open(SendEmailDialogComponent, {
+ width: '448px',
+ focusOnShow: false,
+ header: this.translate.instant('adminInstitutions.institutionUsers.sendEmail'),
+ closeOnEscape: true,
+ modal: true,
+ closable: true,
+ data: this.currentUser()?.fullName,
+ })
+ .onClose.pipe(
+ filter((value) => !!value),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ .subscribe((data: SendEmailDialogData) => this.sendEmailToUser(event.rowData, data));
+ break;
+ }
+ }
+ }
+
+ private setupQueryParamsEffect(): void {
+ effect(() => {
+ const rawQueryParams = this.queryParams();
+ if (!rawQueryParams) return;
+
+ const parsedQueryParams = this.parseQueryParams(rawQueryParams);
+ this.updateComponentState(parsedQueryParams);
+
+ if (this.institutionId) {
+ const filters = untracked(() => this.buildFilters());
+
+ const sortField = untracked(() => this.sortField());
+ const sortOrder = untracked(() => this.sortOrder());
+ const sortParam = sortOrder === -1 ? `-${sortField}` : sortField;
+
+ this.actions.fetchInstitutionUsers(
+ this.institutionId,
+ parsedQueryParams.page,
+ parsedQueryParams.size,
+ sortParam,
+ filters
+ );
+ }
+ });
+ }
+
+ private updateQueryParams(updates: Partial): void {
+ const queryParams: Record = {};
+
+ if ('page' in updates) {
+ queryParams['page'] = updates.page!.toString();
+ }
+ if ('size' in updates) {
+ queryParams['size'] = updates.size!.toString();
+ }
+ if ('department' in updates) {
+ queryParams['department'] = updates.department || undefined;
+ }
+ if ('hasOrcid' in updates) {
+ queryParams['hasOrcid'] = updates.hasOrcid ? 'true' : undefined;
+ }
+ if ('sortColumn' in updates) {
+ queryParams['sortColumn'] = updates.sortColumn || undefined;
+ }
+ if ('sortOrder' in updates) {
+ queryParams['sortOrder'] = updates.sortOrder?.toString() || undefined;
+ }
+
+ this.router.navigate([], {
+ relativeTo: this.route,
+ queryParams,
+ queryParamsHandling: 'merge',
+ });
+ }
+
+ private parseQueryParams(params: Params): InstitutionsUsersQueryParamsModel {
+ const parsed = parseQueryFilterParams(params);
+ return {
+ ...parsed,
+ department: params['department'] || null,
+ hasOrcid: params['hasOrcid'] === 'true',
+ };
+ }
+
+ private updateComponentState(params: InstitutionsUsersQueryParamsModel): void {
+ untracked(() => {
+ this.currentPage.set(params.page);
+ this.currentPageSize.set(params.size);
+ this.first.set((params.page - 1) * params.size);
+ this.selectedDepartment.set(params.department || null);
+ this.hasOrcidFilter.set(params.hasOrcid || false);
+
+ if (params.sortColumn) {
+ this.sortField.set(params.sortColumn);
+ const order = params.sortOrder === SortOrder.Desc ? -1 : 1;
+ this.sortOrder.set(order);
+ }
+ });
+ }
+
+ private buildFilters(): Record {
+ const filters: Record = {};
+
+ const department = this.selectedDepartment();
+ if (department !== null) {
+ filters['filter[department]'] = department;
+ }
+
+ if (this.hasOrcidFilter()) {
+ filters['filter[orcid_id][ne]'] = '';
+ }
+
+ return filters;
+ }
+
+ private sendEmailToUser(userRowData: TableCellData, emailData: SendEmailDialogData): void {
+ const userId = (userRowData['userLink'] as TableCellLink).text as string;
+
+ this.actions.sendUserMessage(
+ userId,
+ this.institutionId,
+ emailData.emailContent,
+ emailData.ccSender,
+ emailData.allowReplyToSender
+ );
+ }
+}
diff --git a/src/app/features/admin-institutions/services/institutions-admin.service.ts b/src/app/features/admin-institutions/services/institutions-admin.service.ts
index deb219531..4310e1b2b 100644
--- a/src/app/features/admin-institutions/services/institutions-admin.service.ts
+++ b/src/app/features/admin-institutions/services/institutions-admin.service.ts
@@ -4,9 +4,9 @@ import { inject, Injectable } from '@angular/core';
import { JsonApiService } from '@core/services';
import { mapIndexCardResults } from '@osf/features/admin-institutions/mappers/institution-summary-index.mapper';
-import { departmens, summaryMetrics } from '@osf/features/admin-institutions/services/mock';
+import { sendMessageRequestMapper } from '@osf/features/admin-institutions/mappers/send-message-request.mapper';
+import { departmens, summaryMetrics, users } from '@osf/features/admin-institutions/services/mock';
-import { environment } from '../../../../environments/environment';
import {
InstitutionDepartment,
InstitutionDepartmentsJsonApi,
@@ -14,9 +14,18 @@ import {
InstitutionSearchFilter,
InstitutionSummaryMetrics,
InstitutionSummaryMetricsJsonApi,
+ InstitutionUser,
+ InstitutionUsersJsonApi,
+ SendMessageRequest,
+ SendMessageResponseJsonApi,
} from '../models';
-import { mapInstitutionDepartments, mapInstitutionSummaryMetrics } from 'src/app/features/admin-institutions/mappers';
+import {
+ mapInstitutionDepartments,
+ mapInstitutionSummaryMetrics,
+ mapInstitutionUsers,
+} from 'src/app/features/admin-institutions/mappers';
+import { environment } from 'src/environments/environment';
@Injectable({
providedIn: 'root',
@@ -50,6 +59,34 @@ export class InstitutionsAdminService {
);
}
+ fetchUsers(
+ institutionId: string,
+ page = 1,
+ pageSize = 10,
+ sort = 'user_name',
+ filters?: Record
+ ): Observable<{ users: InstitutionUser[]; totalCount: number }> {
+ const params: Record = {
+ page: page.toString(),
+ 'page[size]': pageSize.toString(),
+ sort,
+ ...filters,
+ };
+
+ return this.jsonApiService
+ .get(`${this.hardcodedUrl}/institutions/${institutionId}/metrics/users/`, params)
+ .pipe(
+ //TODO: remove mock data
+ catchError(() => {
+ return of(users);
+ }),
+ map((response) => ({
+ users: mapInstitutionUsers(response as InstitutionUsersJsonApi),
+ totalCount: response.meta.total,
+ }))
+ );
+ }
+
fetchIndexValueSearch(
institutionId: string,
valueSearchPropertyPath: string,
@@ -67,4 +104,13 @@ export class InstitutionsAdminService {
.get(`${environment.shareDomainUrl}/index-value-search`, params)
.pipe(map((response) => mapIndexCardResults(response?.included)));
}
+
+ sendMessage(request: SendMessageRequest): Observable {
+ const payload = sendMessageRequestMapper(request);
+
+ return this.jsonApiService.post(
+ `${this.hardcodedUrl}/users/${request.userId}/messages/`,
+ payload
+ );
+ }
}
diff --git a/src/app/features/admin-institutions/services/mock.ts b/src/app/features/admin-institutions/services/mock.ts
index 1538c7dc7..36742f972 100644
--- a/src/app/features/admin-institutions/services/mock.ts
+++ b/src/app/features/admin-institutions/services/mock.ts
@@ -88,3 +88,505 @@ export const summaryMetrics = {
version: '2.20',
},
};
+
+export const users = {
+ data: [
+ {
+ id: '38fa674d7bbb183efadf86070cdbe996c64ee2d25b4c74d9cd524a410b5f1f98',
+ type: 'institution-users',
+ attributes: {
+ report_yearmonth: '2025-06',
+ user_name: 'Doug Corell',
+ department: null,
+ orcid_id: '0000-0003-0945-8731',
+ month_last_login: '2023-01',
+ month_last_active: '2022-03',
+ account_creation_date: '2021-03',
+ public_projects: 0,
+ private_projects: 8,
+ public_registration_count: 3,
+ embargoed_registration_count: 0,
+ published_preprint_count: 1,
+ public_file_count: 4,
+ storage_byte_count: 4600004315,
+ contacts: [],
+ },
+ relationships: {
+ user: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/users/f5vmj/',
+ meta: {},
+ },
+ },
+ data: {
+ id: 'f5vmj',
+ type: 'users',
+ },
+ },
+ institution: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/institutions/cos/',
+ meta: {},
+ },
+ },
+ data: {
+ id: 'cos',
+ type: 'institutions',
+ },
+ },
+ },
+ links: {},
+ },
+ {
+ id: '34aad14d5eec09580661d9db38967467695f158063b24a6f21a1d533d6cf15c2',
+ type: 'institution-users',
+ attributes: {
+ report_yearmonth: '2025-06',
+ user_name: 'Blaine Butler',
+ department: null,
+ orcid_id: null,
+ month_last_login: '2025-06',
+ month_last_active: '2025-06',
+ account_creation_date: '2022-07',
+ public_projects: 27,
+ private_projects: 43,
+ public_registration_count: 65,
+ embargoed_registration_count: 4,
+ published_preprint_count: 76,
+ public_file_count: 1149,
+ storage_byte_count: 2519450324,
+ contacts: [
+ {
+ sender_name: 'Blaine Butler',
+ count: 1,
+ },
+ ],
+ },
+ relationships: {
+ user: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/users/atryg/',
+ meta: {},
+ },
+ },
+ data: {
+ id: 'atryg',
+ type: 'users',
+ },
+ },
+ institution: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/institutions/cos/',
+ meta: {},
+ },
+ },
+ data: {
+ id: 'cos',
+ type: 'institutions',
+ },
+ },
+ },
+ links: {},
+ },
+ {
+ id: '36fb6eac48f8d2329efebbea32fdeb2037781c2fdc7f810a192138619ec254fb',
+ type: 'institution-users',
+ attributes: {
+ report_yearmonth: '2025-06',
+ user_name: 'Ramyashri Virajamangala',
+ department: null,
+ orcid_id: '0009-0001-7982-6352',
+ month_last_login: '2025-07',
+ month_last_active: '2025-06',
+ account_creation_date: '2023-10',
+ public_projects: 11,
+ private_projects: 12,
+ public_registration_count: 11,
+ embargoed_registration_count: 0,
+ published_preprint_count: 25,
+ public_file_count: 137,
+ storage_byte_count: 253874264,
+ contacts: [],
+ },
+ relationships: {
+ user: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/users/kzqhy/',
+ meta: {},
+ },
+ },
+ data: {
+ id: 'kzqhy',
+ type: 'users',
+ },
+ },
+ institution: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/institutions/cos/',
+ meta: {},
+ },
+ },
+ data: {
+ id: 'cos',
+ type: 'institutions',
+ },
+ },
+ },
+ links: {},
+ },
+ {
+ id: '5cecc442620ea58a48408ef7b0e095f054bd98ff89cf64e26cbc3a2022843b96',
+ type: 'institution-users',
+ attributes: {
+ report_yearmonth: '2025-06',
+ user_name: 'Eric Olson',
+ department: null,
+ orcid_id: '0000-0002-5989-8244',
+ month_last_login: '2025-06',
+ month_last_active: '2025-06',
+ account_creation_date: '2020-02',
+ public_projects: 12,
+ private_projects: 20,
+ public_registration_count: 25,
+ embargoed_registration_count: 0,
+ published_preprint_count: 38,
+ public_file_count: 95,
+ storage_byte_count: 230015709,
+ contacts: [],
+ },
+ relationships: {
+ user: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/users/cdr63/',
+ meta: {},
+ },
+ },
+ data: {
+ id: 'cdr63',
+ type: 'users',
+ },
+ },
+ institution: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/institutions/cos/',
+ meta: {},
+ },
+ },
+ data: {
+ id: 'cos',
+ type: 'institutions',
+ },
+ },
+ },
+ links: {},
+ },
+ {
+ id: 'f81e4a6d479ea6c50b3b200be4de9f3f871ab99c5f1138714a29564372d72c72',
+ type: 'institution-users',
+ attributes: {
+ report_yearmonth: '2025-06',
+ user_name: 'Daniel Steger',
+ department: null,
+ orcid_id: null,
+ month_last_login: '2025-06',
+ month_last_active: '2025-04',
+ account_creation_date: '2021-09',
+ public_projects: 13,
+ private_projects: 17,
+ public_registration_count: 18,
+ embargoed_registration_count: 0,
+ published_preprint_count: 16,
+ public_file_count: 3562,
+ storage_byte_count: 150733824,
+ contacts: [],
+ },
+ relationships: {
+ user: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/users/fs8ux/',
+ meta: {},
+ },
+ },
+ data: {
+ id: 'fs8ux',
+ type: 'users',
+ },
+ },
+ institution: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/institutions/cos/',
+ meta: {},
+ },
+ },
+ data: {
+ id: 'cos',
+ type: 'institutions',
+ },
+ },
+ },
+ links: {},
+ },
+ {
+ id: '4144d8ae33cbdbd94f24f0e1534593c1217f356d5de1928a9dee3bee77c85921',
+ type: 'institution-users',
+ attributes: {
+ report_yearmonth: '2025-06',
+ user_name: 'Sara Bowman',
+ department: null,
+ orcid_id: null,
+ month_last_login: '2020-07',
+ month_last_active: '2022-10',
+ account_creation_date: '2017-12',
+ public_projects: 9,
+ private_projects: 4,
+ public_registration_count: 20,
+ embargoed_registration_count: 0,
+ published_preprint_count: 35,
+ public_file_count: 130,
+ storage_byte_count: 146351639,
+ contacts: [],
+ },
+ relationships: {
+ user: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/users/3pwky/',
+ meta: {},
+ },
+ },
+ data: {
+ id: '3pwky',
+ type: 'users',
+ },
+ },
+ institution: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/institutions/cos/',
+ meta: {},
+ },
+ },
+ data: {
+ id: 'cos',
+ type: 'institutions',
+ },
+ },
+ },
+ links: {},
+ },
+ {
+ id: 'f118c80b5ccd5402887ec511bb423f6dc6ccca385ee1cfd7accb27429b1a374b',
+ type: 'institution-users',
+ attributes: {
+ report_yearmonth: '2025-06',
+ user_name: 'QA Runscope',
+ department: null,
+ orcid_id: null,
+ month_last_login: '2025-06',
+ month_last_active: '2025-06',
+ account_creation_date: '2018-09',
+ public_projects: 1,
+ private_projects: 190,
+ public_registration_count: 1,
+ embargoed_registration_count: 0,
+ published_preprint_count: 973,
+ public_file_count: 1003,
+ storage_byte_count: 119760397,
+ contacts: [],
+ },
+ relationships: {
+ user: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/users/9p57h/',
+ meta: {},
+ },
+ },
+ data: {
+ id: '9p57h',
+ type: 'users',
+ },
+ },
+ institution: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/institutions/cos/',
+ meta: {},
+ },
+ },
+ data: {
+ id: 'cos',
+ type: 'institutions',
+ },
+ },
+ },
+ links: {},
+ },
+ {
+ id: '315523a12fc2283a749f2a49d219cdc82c547c15e5a327162b8328f7bc9b8b04',
+ type: 'institution-users',
+ attributes: {
+ report_yearmonth: '2025-06',
+ user_name: 'DC Test Emu',
+ department: null,
+ orcid_id: null,
+ month_last_login: '2023-08',
+ month_last_active: '2023-08',
+ account_creation_date: '2021-03',
+ public_projects: 6,
+ private_projects: 41,
+ public_registration_count: 15,
+ embargoed_registration_count: 0,
+ published_preprint_count: 2,
+ public_file_count: 38,
+ storage_byte_count: 96648435,
+ contacts: [],
+ },
+ relationships: {
+ user: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/users/e3sfk/',
+ meta: {},
+ },
+ },
+ data: {
+ id: 'e3sfk',
+ type: 'users',
+ },
+ },
+ institution: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/institutions/cos/',
+ meta: {},
+ },
+ },
+ data: {
+ id: 'cos',
+ type: 'institutions',
+ },
+ },
+ },
+ links: {},
+ },
+ {
+ id: '8236e4218a5f9031f60ac0d2838a52ad35495e9c1f22f6d083ebf43f694ff364',
+ type: 'institution-users',
+ attributes: {
+ report_yearmonth: '2025-06',
+ user_name: 'Nici Product',
+ department: null,
+ orcid_id: null,
+ month_last_login: '2025-02',
+ month_last_active: '2022-10',
+ account_creation_date: '2017-12',
+ public_projects: 9,
+ private_projects: 6,
+ public_registration_count: 19,
+ embargoed_registration_count: 0,
+ published_preprint_count: 24,
+ public_file_count: 114,
+ storage_byte_count: 68132397,
+ contacts: [],
+ },
+ relationships: {
+ user: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/users/783bw/',
+ meta: {},
+ },
+ },
+ data: {
+ id: '783bw',
+ type: 'users',
+ },
+ },
+ institution: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/institutions/cos/',
+ meta: {},
+ },
+ },
+ data: {
+ id: 'cos',
+ type: 'institutions',
+ },
+ },
+ },
+ links: {},
+ },
+ {
+ id: '2850634d59be53903057da707177a1ae34ee22a19e32308bfa2e62ad5664a2b1',
+ type: 'institution-users',
+ attributes: {
+ report_yearmonth: '2025-06',
+ user_name: 'Eric Test',
+ department: null,
+ orcid_id: null,
+ month_last_login: '2021-10',
+ month_last_active: '2021-10',
+ account_creation_date: '2021-10',
+ public_projects: 1,
+ private_projects: 4,
+ public_registration_count: 6,
+ embargoed_registration_count: 0,
+ published_preprint_count: 3,
+ public_file_count: 15,
+ storage_byte_count: 66493398,
+ contacts: [],
+ },
+ relationships: {
+ user: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/users/ahe9k/',
+ meta: {},
+ },
+ },
+ data: {
+ id: 'ahe9k',
+ type: 'users',
+ },
+ },
+ institution: {
+ links: {
+ related: {
+ href: 'https://api.test.osf.io/v2/institutions/cos/',
+ meta: {},
+ },
+ },
+ data: {
+ id: 'cos',
+ type: 'institutions',
+ },
+ },
+ },
+ links: {},
+ },
+ ],
+ meta: {
+ total: 173,
+ per_page: 10,
+ version: '2.20',
+ },
+ links: {
+ self: 'https://api.test.osf.io/v2/institutions/cos/metrics/users/?size=1',
+ first: null,
+ last: 'https://api.test.osf.io/v2/institutions/cos/metrics/users/?page=18&size=1',
+ prev: null,
+ next: 'https://api.test.osf.io/v2/institutions/cos/metrics/users/?page=2&size=1',
+ },
+};
diff --git a/src/app/features/admin-institutions/store/institutions-admin.actions.ts b/src/app/features/admin-institutions/store/institutions-admin.actions.ts
index 55cf51fb6..06cea18c0 100644
--- a/src/app/features/admin-institutions/store/institutions-admin.actions.ts
+++ b/src/app/features/admin-institutions/store/institutions-admin.actions.ts
@@ -27,11 +27,24 @@ export class FetchStorageRegionSearch {
constructor(public institutionId: string) {}
}
-export class SetSelectedInstitutionId {
- static readonly type = '[InstitutionsAdmin] Set Selected Institution Id';
- constructor(public institutionId: string) {}
+export class FetchInstitutionUsers {
+ static readonly type = '[InstitutionsAdmin] Fetch Institution Users';
+ constructor(
+ public institutionId: string,
+ public page = 1,
+ public pageSize = 10,
+ public sort = 'user_name',
+ public filters?: Record
+ ) {}
}
-export class ClearInstitutionsAdminData {
- static readonly type = '[InstitutionsAdmin] Clear Data';
+export class SendUserMessage {
+ static readonly type = '[InstitutionsAdmin] Send User Message';
+ constructor(
+ public userId: string,
+ public institutionId: string,
+ public messageText: string,
+ public bccSender: boolean,
+ public replyTo: boolean
+ ) {}
}
diff --git a/src/app/features/admin-institutions/store/institutions-admin.model.ts b/src/app/features/admin-institutions/store/institutions-admin.model.ts
index 5c30eb2dd..472df7cb3 100644
--- a/src/app/features/admin-institutions/store/institutions-admin.model.ts
+++ b/src/app/features/admin-institutions/store/institutions-admin.model.ts
@@ -1,6 +1,12 @@
-import { AsyncStateModel } from '@shared/models';
+import { AsyncStateModel, AsyncStateWithTotalCount } from '@shared/models';
-import { InstitutionDepartment, InstitutionSearchFilter, InstitutionSummaryMetrics } from '../models';
+import {
+ InstitutionDepartment,
+ InstitutionSearchFilter,
+ InstitutionSummaryMetrics,
+ InstitutionUser,
+ SendMessageResponseJsonApi,
+} from '../models';
export interface InstitutionsAdminModel {
departments: AsyncStateModel;
@@ -8,6 +14,8 @@ export interface InstitutionsAdminModel {
hasOsfAddonSearch: AsyncStateModel;
storageRegionSearch: AsyncStateModel;
searchResults: AsyncStateModel;
+ users: AsyncStateWithTotalCount;
+ sendMessage: AsyncStateModel;
selectedInstitutionId: string | null;
currentSearchPropertyPath: string | null;
}
diff --git a/src/app/features/admin-institutions/store/institutions-admin.selectors.ts b/src/app/features/admin-institutions/store/institutions-admin.selectors.ts
index 89a4558c4..07ec7e9ef 100644
--- a/src/app/features/admin-institutions/store/institutions-admin.selectors.ts
+++ b/src/app/features/admin-institutions/store/institutions-admin.selectors.ts
@@ -1,6 +1,12 @@
import { Selector } from '@ngxs/store';
-import { InstitutionDepartment, InstitutionSearchFilter, InstitutionSummaryMetrics } from '../models';
+import {
+ InstitutionDepartment,
+ InstitutionSearchFilter,
+ InstitutionSummaryMetrics,
+ InstitutionUser,
+ SendMessageResponseJsonApi,
+} from '../models';
import { InstitutionsAdminModel } from './institutions-admin.model';
import { InstitutionsAdminState } from './institutions-admin.state';
@@ -90,4 +96,39 @@ export class InstitutionsAdminSelectors {
static getCurrentSearchPropertyPath(state: InstitutionsAdminModel): string | null {
return state.currentSearchPropertyPath;
}
+
+ @Selector([InstitutionsAdminState])
+ static getUsers(state: InstitutionsAdminModel): InstitutionUser[] {
+ return state.users.data;
+ }
+
+ @Selector([InstitutionsAdminState])
+ static getUsersLoading(state: InstitutionsAdminModel): boolean {
+ return state.users.isLoading;
+ }
+
+ @Selector([InstitutionsAdminState])
+ static getUsersError(state: InstitutionsAdminModel): string | null {
+ return state.users.error;
+ }
+
+ @Selector([InstitutionsAdminState])
+ static getUsersTotalCount(state: InstitutionsAdminModel): number {
+ return state.users.totalCount;
+ }
+
+ @Selector([InstitutionsAdminState])
+ static getSendMessageResponse(state: InstitutionsAdminModel): SendMessageResponseJsonApi | null {
+ return state.sendMessage.data;
+ }
+
+ @Selector([InstitutionsAdminState])
+ static getSendMessageLoading(state: InstitutionsAdminModel): boolean {
+ return state.sendMessage.isLoading;
+ }
+
+ @Selector([InstitutionsAdminState])
+ static getSendMessageError(state: InstitutionsAdminModel): string | null {
+ return state.sendMessage.error;
+ }
}
diff --git a/src/app/features/admin-institutions/store/institutions-admin.state.ts b/src/app/features/admin-institutions/store/institutions-admin.state.ts
index 016e75b8c..478ad1072 100644
--- a/src/app/features/admin-institutions/store/institutions-admin.state.ts
+++ b/src/app/features/admin-institutions/store/institutions-admin.state.ts
@@ -1,9 +1,11 @@
import { Action, State, StateContext } from '@ngxs/store';
-import { catchError, tap, throwError } from 'rxjs';
+import { catchError, tap } from 'rxjs';
import { inject, Injectable } from '@angular/core';
+import { handleSectionError } from '@core/handlers';
+
import { InstitutionSummaryMetrics } from '../models';
import { InstitutionsAdminService } from '../services/institutions-admin.service';
@@ -12,7 +14,9 @@ import {
FetchInstitutionDepartments,
FetchInstitutionSearchResults,
FetchInstitutionSummaryMetrics,
+ FetchInstitutionUsers,
FetchStorageRegionSearch,
+ SendUserMessage,
} from './institutions-admin.actions';
import { InstitutionsAdminModel } from './institutions-admin.model';
@@ -24,6 +28,8 @@ import { InstitutionsAdminModel } from './institutions-admin.model';
hasOsfAddonSearch: { data: [], isLoading: false, error: null },
storageRegionSearch: { data: [], isLoading: false, error: null },
searchResults: { data: [], isLoading: false, error: null },
+ users: { data: [], totalCount: 0, isLoading: false, error: null },
+ sendMessage: { data: null, isLoading: false, error: null },
selectedInstitutionId: null,
currentSearchPropertyPath: null,
},
@@ -45,16 +51,8 @@ export class InstitutionsAdminState {
departments: { data: response, isLoading: false, error: null },
});
}),
- catchError((error) => {
- ctx.patchState({
- departments: {
- ...state.departments,
- isLoading: false,
- error: error.message,
- },
- });
- return throwError(() => error);
- })
+
+ catchError((error) => handleSectionError(ctx, 'departments', error))
);
}
@@ -71,16 +69,7 @@ export class InstitutionsAdminState {
summaryMetrics: { data: response, isLoading: false, error: null },
});
}),
- catchError((error) => {
- ctx.patchState({
- summaryMetrics: {
- ...state.summaryMetrics,
- isLoading: false,
- error: error.message,
- },
- });
- return throwError(() => error);
- })
+ catchError((error) => handleSectionError(ctx, 'summaryMetrics', error))
);
}
@@ -100,16 +89,7 @@ export class InstitutionsAdminState {
searchResults: { data: response, isLoading: false, error: null },
});
}),
- catchError((error) => {
- ctx.patchState({
- searchResults: {
- ...state.searchResults,
- isLoading: false,
- error: error.message,
- },
- });
- return throwError(() => error);
- })
+ catchError((error) => handleSectionError(ctx, 'searchResults', error))
);
}
@@ -126,16 +106,7 @@ export class InstitutionsAdminState {
hasOsfAddonSearch: { data: response, isLoading: false, error: null },
});
}),
- catchError((error) => {
- ctx.patchState({
- hasOsfAddonSearch: {
- ...state.hasOsfAddonSearch,
- isLoading: false,
- error: error.message,
- },
- });
- return throwError(() => error);
- })
+ catchError((error) => handleSectionError(ctx, 'hasOsfAddonSearch', error))
);
}
@@ -152,16 +123,51 @@ export class InstitutionsAdminState {
storageRegionSearch: { data: response, isLoading: false, error: null },
});
}),
- catchError((error) => {
- ctx.patchState({
- storageRegionSearch: {
- ...state.storageRegionSearch,
- isLoading: false,
- error: error.message,
- },
- });
- return throwError(() => error);
- })
+ catchError((error) => handleSectionError(ctx, 'storageRegionSearch', error))
);
}
+
+ @Action(FetchInstitutionUsers)
+ fetchUsers(ctx: StateContext, action: FetchInstitutionUsers) {
+ const state = ctx.getState();
+ ctx.patchState({
+ users: { ...state.users, isLoading: true, error: null },
+ });
+
+ return this.institutionsAdminService
+ .fetchUsers(action.institutionId, action.page, action.pageSize, action.sort, action.filters)
+ .pipe(
+ tap((response) => {
+ ctx.patchState({
+ users: { data: response.users, totalCount: response.totalCount, isLoading: false, error: null },
+ });
+ }),
+ catchError((error) => handleSectionError(ctx, 'users', error))
+ );
+ }
+
+ @Action(SendUserMessage)
+ sendUserMessage(ctx: StateContext, action: SendUserMessage) {
+ const state = ctx.getState();
+ ctx.patchState({
+ sendMessage: { ...state.sendMessage, isLoading: true, error: null },
+ });
+
+ return this.institutionsAdminService
+ .sendMessage({
+ userId: action.userId,
+ institutionId: action.institutionId,
+ messageText: action.messageText,
+ bccSender: action.bccSender,
+ replyTo: action.replyTo,
+ })
+ .pipe(
+ tap((response) => {
+ ctx.patchState({
+ sendMessage: { data: response, isLoading: false, error: null },
+ });
+ }),
+ catchError((error) => handleSectionError(ctx, 'sendMessage', error))
+ );
+ }
}
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index 7624a053a..665d04ee1 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -8,6 +8,7 @@
"addMore": "Add More",
"cancel": "Cancel",
"save": "Save",
+ "send": "Send",
"create": "Create",
"close": "Close",
"download": "Download",
@@ -1786,7 +1787,29 @@
"totalPublicFileCount": "Total Public File Count",
"totalStorageInGb": "Total Storage in GB",
"embargoedRegistrations": "Embargoed registrations",
- "privateProjects": "Private projects"
+ "privateProjects": "Private projects",
+ "publicProjects": "Public Projects",
+ "publicRegistrations": "Public Registrations"
+ },
+ "institutionUsers": {
+ "allDepartments": "All departments",
+ "lastLogin": "Last Login",
+ "lastActive": "Last Active",
+ "accountCreated": "Account Created",
+ "publishedPreprints": "Published Preprints",
+ "publicFiles": "Public Files",
+ "storageBytes": "Storage (Bytes)",
+ "contacts": "Contacts",
+ "customize": "Customize",
+ "sendEmail": "Send Email",
+ "writeEmailText": "Please write the text of your email here",
+ "sincerelyYours": "Sincerely Yours",
+ "ccSender": "CC sender",
+ "allowReplyToSenderAddress": "Allow reply to sender address",
+ "hasOrcid": "Has ORCID",
+ "sendMessage": "Send message",
+ "osfLink": "OSF Link",
+ "orcid": "ORCID"
}
}
}
diff --git a/src/assets/styles/overrides/multiselect.scss b/src/assets/styles/overrides/multiselect.scss
index e8a5d1990..7ba72a902 100644
--- a/src/assets/styles/overrides/multiselect.scss
+++ b/src/assets/styles/overrides/multiselect.scss
@@ -1,11 +1,20 @@
-.p-multiselect-header {
- .p-inputtext {
- --p-inputtext-padding-y: 0.4rem;
+.p-multiselect {
+ font-size: 1rem;
+ --p-multiselect-padding-y: 0.6rem;
+ --p-multiselect-hover-border-color: var(--pr-blue-1);
+ --p-multiselect-overlay-color: var(--dark-blue-1);
+ --p-multiselect-overlay-border-color: var(--grey-2);
+ --p-multiselect-dropdown-color: var(--grey-1);
+
+ .p-multiselect-header {
+ .p-inputtext {
+ --p-inputtext-padding-y: 0.4rem;
+ }
}
-}
-.p-multiselect-option {
- span {
- white-space: wrap;
+ .p-multiselect-option {
+ span {
+ white-space: wrap;
+ }
}
}