From 01f0540cc4c9582835c09b48916ac44ae30938f6 Mon Sep 17 00:00:00 2001 From: Tara Epp <102187683+taraepp@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:01:17 +0000 Subject: [PATCH 1/6] parse token data in user-profile resource from MS users and update the data. Add more fields to MS user table, update model. Add versioning, fix issues with circular dependencies so it works. Update a field name. TS-ify the user list page --- ...0.30.05.04__minespace_user_new_columns.sql | 34 ++ ...d_minespace_user_version_history_table.sql | 26 + ...ce_user_version_history_table_backfill.sql | 6 + .../src/components/mine/MineUserAccess.tsx | 9 +- .../common/src/interfaces/mine.interface.ts | 8 + .../src/interfaces/minespaceUser.interface.ts | 10 +- .../src/redux/slices/minespaceSlice.spec.ts | 17 +- .../common/src/redux/slices/minespaceSlice.ts | 2 +- services/common/src/tests/mocks/dataMocks.tsx | 30 +- .../activity/models/activity_notification.py | 2 +- .../parties/party/resources/party_resource.py | 2 +- .../users/minespace/models/minespace_user.py | 72 ++- .../minespace/resources/minespace_user.py | 18 +- .../core-api/app/api/users/models/user.py | 4 +- .../app/api/users/resources/user_resource.py | 61 ++- .../core-api/app/api/users/response_models.py | 16 +- .../core-api/app/api/utils/models_mixins.py | 7 +- services/core-api/app/auth.py | 7 +- services/core-api/app/commands.py | 6 +- .../app/sqlalchemy_extensions/__init__.py | 4 +- .../core-api/tests/auth/test_expected_auth.py | 2 +- .../core-api/tests/auth/test_user_auth.py | 2 +- services/core-api/tests/factories.py | 3 +- services/core-api/tests/helpers.py | 2 +- .../party_appt/test_party_appointment_jobs.py | 2 +- .../test_ams_final_application_resource.py | 2 +- .../tests/users/models/test_minespace_user.py | 122 ++++- .../resources/test_minespace_user_resource.py | 36 +- .../resources/test_user_profile_resource.py | 53 ++ .../src/components/Forms/AddMinespaceUser.js | 10 +- .../components/Forms/EditMinespaceUser.tsx | 8 +- .../src/components/admin/MinespaceUserList.js | 106 ---- .../components/admin/MinespaceUserList.tsx | 88 ++++ .../admin/MinespaceUserManagement.js | 2 +- .../MineUserAccessPage.spec.tsx.snap | 251 +++++++++- .../core-web/src/customPropTypes/minespace.js | 2 +- .../admin/MinespaceUserList.spec.tsx | 14 +- .../MinespaceUserList.spec.tsx.snap | 467 +++++++++++++++++- .../MinespaceUserManagement.spec.tsx.snap | 43 +- .../NewMinespaceUser.spec.tsx.snap | 16 +- .../UpdateMinespaceUserModal.spec.tsx.snap | 16 +- services/minespace-web/src/App.tsx | 62 +-- .../MineUserAccessPage.spec.tsx.snap | 251 +++++++++- 43 files changed, 1569 insertions(+), 332 deletions(-) create mode 100644 migrations/sql/V2025.10.30.05.04__minespace_user_new_columns.sql create mode 100644 migrations/sql/V2025.10.30.05.05__add_minespace_user_version_history_table.sql create mode 100644 migrations/sql/V2025.10.30.05.06__add_minespace_user_version_history_table_backfill.sql delete mode 100644 services/core-web/src/components/admin/MinespaceUserList.js create mode 100644 services/core-web/src/components/admin/MinespaceUserList.tsx diff --git a/migrations/sql/V2025.10.30.05.04__minespace_user_new_columns.sql b/migrations/sql/V2025.10.30.05.04__minespace_user_new_columns.sql new file mode 100644 index 0000000000..70e62169ee --- /dev/null +++ b/migrations/sql/V2025.10.30.05.04__minespace_user_new_columns.sql @@ -0,0 +1,34 @@ +-- Add new columns to minespace_user table to align with user table structure +ALTER TABLE minespace_user +ADD COLUMN sub VARCHAR, +ADD COLUMN email VARCHAR, +ADD COLUMN given_name VARCHAR, +ADD COLUMN family_name VARCHAR, +ADD COLUMN display_name VARCHAR, +ADD COLUMN identity_provider VARCHAR, +ADD COLUMN bceid_user_guid VARCHAR, +ADD COLUMN last_logged_in TIMESTAMPTZ, +ADD COLUMN create_user VARCHAR(255), +ADD COLUMN create_timestamp TIMESTAMP WITH TIME ZONE DEFAULT now(), +ADD COLUMN update_user VARCHAR(255), +ADD COLUMN update_timestamp TIMESTAMP WITH TIME ZONE DEFAULT now(); + +ALTER TABLE minespace_user +RENAME COLUMN email_or_username TO bceid_username; + +ALTER TABLE minespace_user +DROP COLUMN IF EXISTS keycloak_guid; + +COMMENT ON COLUMN minespace_user.sub IS 'User subject identifier from identity provider'; +COMMENT ON COLUMN minespace_user.email IS 'User email address'; +COMMENT ON COLUMN minespace_user.given_name IS 'User given/first name'; +COMMENT ON COLUMN minespace_user.family_name IS 'User family/last name'; +COMMENT ON COLUMN minespace_user.display_name IS 'User display name'; +COMMENT ON COLUMN minespace_user.bceid_username IS 'BCeID username (renamed from email_or_username)'; +COMMENT ON COLUMN minespace_user.identity_provider IS 'Identity provider used for authentication'; +COMMENT ON COLUMN minespace_user.bceid_user_guid IS 'BCeID user GUID'; +COMMENT ON COLUMN minespace_user.last_logged_in IS 'Timestamp of last login'; +COMMENT ON COLUMN minespace_user.create_user IS 'User who created the record'; +COMMENT ON COLUMN minespace_user.create_timestamp IS 'Timestamp when record was created'; +COMMENT ON COLUMN minespace_user.update_user IS 'User who last updated the record'; +COMMENT ON COLUMN minespace_user.update_timestamp IS 'Timestamp when record was last updated'; \ No newline at end of file diff --git a/migrations/sql/V2025.10.30.05.05__add_minespace_user_version_history_table.sql b/migrations/sql/V2025.10.30.05.05__add_minespace_user_version_history_table.sql new file mode 100644 index 0000000000..bf86ab745c --- /dev/null +++ b/migrations/sql/V2025.10.30.05.05__add_minespace_user_version_history_table.sql @@ -0,0 +1,26 @@ +-- This file was generated by the generate_history_table_ddl command +-- The file contains the corresponding history table definition for the minespace_user table +CREATE TABLE minespace_user_version ( + create_user VARCHAR(60), + create_timestamp TIMESTAMP WITHOUT TIME ZONE, + update_user VARCHAR(60), + update_timestamp TIMESTAMP WITHOUT TIME ZONE, + deleted_ind BOOLEAN default FALSE, + user_id INTEGER NOT NULL, + bceid_username VARCHAR(100), + sub VARCHAR, + email VARCHAR, + given_name VARCHAR, + family_name VARCHAR, + display_name VARCHAR, + identity_provider VARCHAR, + bceid_user_guid VARCHAR, + last_logged_in TIMESTAMP WITHOUT TIME ZONE, + transaction_id BIGINT NOT NULL, + end_transaction_id BIGINT, + operation_type SMALLINT NOT NULL, + PRIMARY KEY (user_id, transaction_id) +); +CREATE INDEX ix_minespace_user_version_operation_type ON minespace_user_version (operation_type); +CREATE INDEX ix_minespace_user_version_end_transaction_id ON minespace_user_version (end_transaction_id); +CREATE INDEX ix_minespace_user_version_transaction_id ON minespace_user_version (transaction_id); \ No newline at end of file diff --git a/migrations/sql/V2025.10.30.05.06__add_minespace_user_version_history_table_backfill.sql b/migrations/sql/V2025.10.30.05.06__add_minespace_user_version_history_table_backfill.sql new file mode 100644 index 0000000000..b907a71b78 --- /dev/null +++ b/migrations/sql/V2025.10.30.05.06__add_minespace_user_version_history_table_backfill.sql @@ -0,0 +1,6 @@ +-- This file was generated by the generate_history_table_ddl command +-- The file contains the data migration to backfill history records for the minespace_user table +with transaction AS (insert into transaction(id) values(DEFAULT) RETURNING id) +insert into minespace_user_version (transaction_id, operation_type, end_transaction_id, "create_user", "create_timestamp", "update_user", "update_timestamp", "deleted_ind", "user_id", "bceid_username", "sub", "email", "given_name", "family_name", "display_name", "identity_provider", "bceid_user_guid", "last_logged_in") +select t.id, '0', null, "create_user", "create_timestamp", "update_user", "update_timestamp", "deleted_ind", "user_id", "bceid_username", "sub", "email", "given_name", "family_name", "display_name", "identity_provider", "bceid_user_guid", "last_logged_in" +from "minespace_user",transaction t; \ No newline at end of file diff --git a/services/common/src/components/mine/MineUserAccess.tsx b/services/common/src/components/mine/MineUserAccess.tsx index 874ef44eb4..02b15aad5a 100644 --- a/services/common/src/components/mine/MineUserAccess.tsx +++ b/services/common/src/components/mine/MineUserAccess.tsx @@ -2,7 +2,7 @@ import React, { FC, useEffect } from "react"; import { useAppDispatch, useAppSelector } from "@mds/common/redux/rootState"; import { fetchMinespaceUsersByMine, getMinespaceUsersByMineGuid } from "@mds/common/redux/slices/minespaceSlice"; import CoreTable from "../common/CoreTable"; -import { renderTextColumn } from "../common/CoreTableCommonColumns"; +import { renderDateColumn, renderTextColumn } from "../common/CoreTableCommonColumns"; import { Col, Row, Typography } from "antd"; import { getIsCore } from "@mds/common/redux/reducers/authenticationReducer"; import { MDS_EMAIL } from "@mds/common/constants/strings"; @@ -24,9 +24,12 @@ const MineUserAccess: FC = ({ mineGuid }) => { }, []); const columns = [ - renderTextColumn("email_or_username", "BCeID/Email", true) + renderTextColumn("display_name", "Name", true), + renderTextColumn("bceid_username", "BCeID", true), + renderTextColumn("email", "Email", true), + renderDateColumn("last_logged_in", "Last Login", true), ]; - + console.log({ mineUsers }) return ( User Access to Mine Records diff --git a/services/common/src/interfaces/mine.interface.ts b/services/common/src/interfaces/mine.interface.ts index f64b3ae8ce..5c1ec68a68 100644 --- a/services/common/src/interfaces/mine.interface.ts +++ b/services/common/src/interfaces/mine.interface.ts @@ -29,3 +29,11 @@ export interface IMine { latest_mine_status: IMineStatus; mine_status: IMineStatus[]; } + +export interface IMineName { + mine_guid: string; + mine_name: string; + mine_no: string; + latitude?: string; + longitude?: string; +} \ No newline at end of file diff --git a/services/common/src/interfaces/minespaceUser.interface.ts b/services/common/src/interfaces/minespaceUser.interface.ts index cd873012a8..d9cd07468e 100644 --- a/services/common/src/interfaces/minespaceUser.interface.ts +++ b/services/common/src/interfaces/minespaceUser.interface.ts @@ -1,7 +1,13 @@ export interface IMinespaceUser { user_id: number; - email_or_username: string; - keycloak_guid?: string; + sub: string; + email: string; + given_name: string; + family_name: string; + display_name: string; + bceid_username: string; + identity_provider: string; + last_logged_in: string; mines: string[]; } diff --git a/services/common/src/redux/slices/minespaceSlice.spec.ts b/services/common/src/redux/slices/minespaceSlice.spec.ts index 840d4cffa8..9a38eaf9ae 100644 --- a/services/common/src/redux/slices/minespaceSlice.spec.ts +++ b/services/common/src/redux/slices/minespaceSlice.spec.ts @@ -1,3 +1,4 @@ +import { IMinespaceUser } from "@mds/common/interfaces"; import { minespaceReducer, createMinespaceUser, @@ -40,11 +41,17 @@ jest.mock("antd", () => ({ describe("minespaceSlice", () => { let store; - const mockMinespaceUser = { + const mockMinespaceUser: IMinespaceUser = { user_id: 1, - email_or_username: "test@example.com", - keycloak_guid: "123-456-789", + bceid_username: "test@example.com", mines: ["mine-guid-1", "mine-guid-2"], + sub: "sub-guid-string@bceidboth", + email: "email@email.com", + given_name: "Given", + family_name: "Family", + display_name: "Given Family", + identity_provider: "bceidboth", + last_logged_in: "2025-10-31 22:39:29.200932+00" }; const mockMinespaceUserMine = { @@ -113,7 +120,7 @@ describe("minespaceSlice", () => { })); const payload = { - email_or_username: "test@example.com", + bceid_username: "test@example.com", mine_guids: ["mine-guid-1"], }; @@ -144,7 +151,7 @@ describe("minespaceSlice", () => { })); const payload = { - email_or_username: "test@example.com", + bceid_username: "test@example.com", mine_guids: ["mine-guid-1"], }; diff --git a/services/common/src/redux/slices/minespaceSlice.ts b/services/common/src/redux/slices/minespaceSlice.ts index b997aa194d..dece9a7979 100644 --- a/services/common/src/redux/slices/minespaceSlice.ts +++ b/services/common/src/redux/slices/minespaceSlice.ts @@ -364,7 +364,7 @@ export const getMinespaceUserEmailHash = createSelector( (users) => users.reduce( (map, fields) => ({ - [fields.email_or_username]: fields, + [fields.bceid_username]: fields, ...map, }), {} diff --git a/services/common/src/tests/mocks/dataMocks.tsx b/services/common/src/tests/mocks/dataMocks.tsx index 1979ed6051..45c271d9a9 100644 --- a/services/common/src/tests/mocks/dataMocks.tsx +++ b/services/common/src/tests/mocks/dataMocks.tsx @@ -929,21 +929,39 @@ export const MINE_NO = "BLAH6666"; export const MINESPACE_USERS: IMinespaceUser[] = [ { user_id: 1, - email_or_username: "user1@bceid", - keycloak_guid: "keycloak-guid-1", + bceid_username: "user1@bceid", mines: [MINE_NAME_LIST.mines[0].mine_guid, MINE_NAME_LIST.mines[1].mine_guid,], + sub: "sub-guid-string@bceidboth", + email: "user1@email.com", + given_name: "Ursula", + family_name: "Underwood", + display_name: "Ursula Underwood", + identity_provider: "bceidboth", + last_logged_in: "2025-10-31 22:39:29.200932+00" }, { user_id: 2, - email_or_username: "user2@example.com", - keycloak_guid: "", + bceid_username: "user2@bceid", mines: [MINE_NAME_LIST.mines[0].mine_guid,], + sub: "", + email: "", + given_name: "", + family_name: "", + display_name: "", + identity_provider: "", + last_logged_in: "" }, { user_id: 3, - email_or_username: "user3@bceid", - keycloak_guid: "keycloak-guid-3", + bceid_username: "user3@bceid", mines: [MINE_NAME_LIST.mines[2].mine_guid,], + sub: "", + email: "", + given_name: "", + family_name: "", + display_name: "", + identity_provider: "", + last_logged_in: "" }, ]; diff --git a/services/core-api/app/api/activity/models/activity_notification.py b/services/core-api/app/api/activity/models/activity_notification.py index 97434f4501..754f21df6e 100644 --- a/services/core-api/app/api/activity/models/activity_notification.py +++ b/services/core-api/app/api/activity/models/activity_notification.py @@ -148,7 +148,7 @@ def create(cls, notification_recipient, notification_document, commit=False): @classmethod def create_many(cls, mine_guid, activity_type, document, idempotency_key=None, commit=True, recipients=ActivityRecipients.all_users, renotify_period_minutes=-1): MinespaceUserMineTable = table(MinespaceUserMine.__tablename__, column('mine_guid'), column('user_id')) - MinespaceUserTable = table(MinespaceUser.__tablename__, column('email_or_username'), column('user_id')) + MinespaceUserTable = table(MinespaceUser.__tablename__, column('bceid_username'), column('user_id')) SubscriptionTable = table(Subscription.__tablename__, column('mine_guid'), column('user_name')) users = [] diff --git a/services/core-api/app/api/parties/party/resources/party_resource.py b/services/core-api/app/api/parties/party/resources/party_resource.py index 45f0f1ac6e..c02f2fad07 100644 --- a/services/core-api/app/api/parties/party/resources/party_resource.py +++ b/services/core-api/app/api/parties/party/resources/party_resource.py @@ -212,7 +212,7 @@ def _save_new_party_business_appointment(self, def put(self, party_guid): if is_minespace_user(): user = bceid_username() - minespace_user = MinespaceUser.find_by_email(user + "@bceid") + minespace_user = MinespaceUser.find_by_username(user + "@bceid") if not minespace_user: raise BadRequest('User not found.') diff --git a/services/core-api/app/api/users/minespace/models/minespace_user.py b/services/core-api/app/api/users/minespace/models/minespace_user.py index cd89c56d22..dd4574a72f 100644 --- a/services/core-api/app/api/users/minespace/models/minespace_user.py +++ b/services/core-api/app/api/users/minespace/models/minespace_user.py @@ -1,21 +1,30 @@ -import uuid, re - -from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import validates from sqlalchemy.schema import FetchedValue from sqlalchemy.ext.hybrid import hybrid_property from app.extensions import db -from app.api.utils.models_mixins import SoftDeleteMixin, Base +from app.api.utils.models_mixins import HistoryMixin, SoftDeleteMixin, Base, AuditMixin from app.api.users.minespace.models.minespace_user_mine import MinespaceUserMine -class MinespaceUser(SoftDeleteMixin, Base): +class MinespaceUser(HistoryMixin, SoftDeleteMixin, AuditMixin, Base): __tablename__ = 'minespace_user' + __versioned__ = { + 'exclude': ['last_logged_in'] + } user_id = db.Column(db.Integer, primary_key=True, server_default=FetchedValue()) - keycloak_guid = db.Column(UUID(as_uuid=True)) - email_or_username = db.Column(db.String(100), nullable=False) + bceid_username = db.Column(db.String(), nullable=False) + + # new data fields must all be nullable for legacy data + sub = db.Column(db.String()) + email = db.Column(db.String()) + given_name = db.Column(db.String()) + family_name = db.Column(db.String()) + display_name = db.Column(db.String()) + identity_provider = db.Column(db.String()) + bceid_user_guid = db.Column(db.String()) + last_logged_in = db.Column(db.DateTime()) minespace_user_mines = db.relationship('MinespaceUserMine', backref='user', lazy='joined') @@ -33,7 +42,7 @@ def find_by_id(cls, id): @classmethod def find_by_guid(cls, user_guid): - return cls.query.filter_by(keycloak_guid=user_guid).filter_by(deleted_ind=False).first() + return cls.query.filter_by(bceid_user_guid=user_guid).filter_by(deleted_ind=False).first() @classmethod def find_by_mine_guid(cls, mine_guid): @@ -42,20 +51,51 @@ def find_by_mine_guid(cls, mine_guid): ).all() @classmethod - def find_by_email(cls, email_or_username): - return cls.query.filter_by(email_or_username=email_or_username).filter_by( + def find_by_username(cls, bceid_username): + return cls.query.filter_by(bceid_username=bceid_username).filter_by( deleted_ind=False).first() @classmethod - def create_minespace_user(cls, email_or_username, add_to_session=True): - minespace_user = cls(email_or_username=email_or_username) + def create_minespace_user(cls, bceid_username, add_to_session=True): + minespace_user = cls(bceid_username=bceid_username) if add_to_session: minespace_user.save(commit=False) return minespace_user - @validates('email_or_username') - def validate_email(self, key, email_or_username): - if not email_or_username: + @classmethod + def find_by_token_data(cls, **kwargs): + sub = kwargs.get("sub") + bceid_username = kwargs.get("bceid_username") + + # if there is a user that has logged in with token data- return that user + sub_user = cls.query.filter_by(sub=sub).filter_by(deleted_ind=False).first() + if sub_user: + return sub_user + + # otherwise look for an older record + bceid_user = cls.find_by_username(bceid_username) + + return bceid_user + + @classmethod + def update_from_token_data(cls, **kwargs): + user = cls.find_by_token_data(**kwargs) + + if user is None: + return + + for key, value in kwargs.items(): + setattr(user, key, value) + user.save() + + return user + + + @validates('bceid_username') + def validate_email(self, key, bceid_username): + if not bceid_username: raise AssertionError('Identifier is not provided.') - return email_or_username + if not bceid_username.endswith('@bceid'): + raise AssertionError('BCeID username must end with "@bceid".') + return bceid_username \ No newline at end of file diff --git a/services/core-api/app/api/users/minespace/resources/minespace_user.py b/services/core-api/app/api/users/minespace/resources/minespace_user.py index d33cdb7a12..814bb7ca10 100644 --- a/services/core-api/app/api/users/minespace/resources/minespace_user.py +++ b/services/core-api/app/api/users/minespace/resources/minespace_user.py @@ -17,11 +17,11 @@ class MinespaceUserListResource(Resource, UserMixin): parser = reqparse.RequestParser(trim=True) - parser.add_argument('email_or_username', type=str, location='json', required=True) + parser.add_argument('bceid_username', type=str, location='json', required=True) parser.add_argument('mine_guids', type=list, location='json', required=True) @api.doc(params={ - 'email_or_username': 'find by email, this will return a list with at most one element', + 'bceid_username': 'find by bceid, this will return a list with at most one element', 'mine_guid': 'find by mine guid, this will return all users with access to the specified mine' }) @api.marshal_with(MINESPACE_USER_MODEL, envelope='records') @@ -33,8 +33,8 @@ def get(self): if not is_admin and mine_guid is None: raise BadRequest("mine_guid is a required argument") - if request.args.get('email_or_username'): - ms_users = [MinespaceUser.find_by_email(request.args.get('email_or_username'))] + if request.args.get('bceid_username'): + ms_users = [MinespaceUser.find_by_username(request.args.get('bceid_username'))] elif mine_guid: mine = Mine.find_by_mine_guid(mine_guid) if not mine: @@ -48,7 +48,7 @@ def get(self): @requires_role_mine_admin def post(self): data = self.parser.parse_args() - new_user = MinespaceUser.create_minespace_user(data.get('email_or_username')) + new_user = MinespaceUser.create_minespace_user(data.get('bceid_username')) new_user.save() for guid in data.get('mine_guids'): guid = uuid.UUID(guid) #ensure good formatting @@ -59,7 +59,7 @@ def post(self): class MinespaceUserResource(Resource, UserMixin): parser = reqparse.RequestParser(trim=True) - parser.add_argument('email_or_username', type=str, location='json', required=True) + parser.add_argument('bceid_username', type=str, location='json', required=True) parser.add_argument('mine_guids', type=list, location='json', required=True) @api.marshal_with(MINESPACE_USER_MODEL) @@ -91,9 +91,9 @@ def put(self, user_id): raise NotFound('Contact not found.') data = self.parser.parse_args() - if data.get('email_or_username'): - if contact.email_or_username != data.get('email_or_username'): - contact.email_or_username = data.get('email_or_username') + if data.get('bceid_username'): + if contact.bceid_username != data.get('bceid_username'): + contact.bceid_username = data.get('bceid_username') if not data.get('mine_guids'): raise BadRequest('Empty list mine_guids is not permitted. Please provide a list of mine GUIDS.') diff --git a/services/core-api/app/api/users/models/user.py b/services/core-api/app/api/users/models/user.py index 21346e8912..ed995e60e8 100644 --- a/services/core-api/app/api/users/models/user.py +++ b/services/core-api/app/api/users/models/user.py @@ -1,10 +1,10 @@ from datetime import datetime from pytz import utc -from app.api.utils.models_mixins import SoftDeleteMixin, Base, AuditMixin +from app.api.utils.models_mixins import SoftDeleteMixin, Base, AuditMixin, HistoryMixin from app.extensions import db -class User(SoftDeleteMixin, AuditMixin, Base): +class User(HistoryMixin, SoftDeleteMixin, AuditMixin, Base): __tablename__ = "user" __versioned__ = { 'exclude': ['last_logged_in'] diff --git a/services/core-api/app/api/users/resources/user_resource.py b/services/core-api/app/api/users/resources/user_resource.py index d6df5cd4bf..1fd7b78ba9 100644 --- a/services/core-api/app/api/users/resources/user_resource.py +++ b/services/core-api/app/api/users/resources/user_resource.py @@ -5,8 +5,9 @@ from pytz import utc from app.api.users.models.user import User +from app.api.users.minespace.models.minespace_user import MinespaceUser from app.api.users.response_models import USER_MODEL -from app.api.utils.access_decorators import requires_role_view_all +from app.api.utils.access_decorators import requires_any_of, VIEW_ALL, MINESPACE_PROPONENT, is_minespace_user from app.api.utils.include.user_info import User as UserUtils from app.api.utils.resources_mixins import UserMixin from app.extensions import api @@ -14,7 +15,7 @@ class UserResource(Resource, UserMixin): @api.doc(description='Update and retrieve the user from the token') - @requires_role_view_all + @requires_any_of([VIEW_ALL, MINESPACE_PROPONENT]) @api.marshal_with(USER_MODEL, code=200) def get(self): user_util = UserUtils() @@ -23,19 +24,49 @@ def get(self): try: # Extract token information - user_data = { - "sub": user_info.get("sub"), - "email": user_info.get("email", ""), - "given_name": user_info.get("given_name", ""), - "family_name": user_info.get("family_name", ""), - "display_name": user_info.get("display_name", ""), - "idir_username": user_info.get("idir_username", ""), - "identity_provider": user_info.get("identity_provider", ""), - "idir_user_guid": user_info.get("idir_user_guid", ""), - "last_logged_in": datetime.now(tz=utc), - } - - user = User.create_or_update_user(**user_data) + if is_minespace_user(): + # bceid given/family name may be combined + given_name = user_info.get("given_name", "") + family_name = user_info.get("family_name", "") + display_name = user_info.get("display_name", "") + + bceid_username_data = user_info.get("bceid_username", None) + bceid_username = bceid_username_data + "@bceid" if bceid_username_data is not None else "" + + if given_name == display_name and family_name == "": + name = display_name.split() + given_name = name[0] if len(name) > 0 else "" + family_name = name[1] if len(name) > 1 else "" + + user_data = { + "sub": user_info.get("sub"), + "email": user_info.get("email", ""), + "given_name": given_name, + "family_name": family_name, + "display_name": user_info.get("display_name", ""), + "bceid_username": bceid_username, + "identity_provider": user_info.get("identity_provider", ""), + "bceid_user_guid": user_info.get("bceid_user_guid", ""), + "last_logged_in": datetime.now(tz=utc), + } + + user = MinespaceUser.update_from_token_data(**user_data) + print(user) + + else: + user_data = { + "sub": user_info.get("sub"), + "email": user_info.get("email", ""), + "given_name": user_info.get("given_name", ""), + "family_name": user_info.get("family_name", ""), + "display_name": user_info.get("display_name", ""), + "idir_username": user_info.get("idir_username", ""), + "identity_provider": user_info.get("identity_provider", ""), + "idir_user_guid": user_info.get("idir_user_guid", ""), + "last_logged_in": datetime.now(tz=utc), + } + + user = User.create_or_update_user(**user_data) return user diff --git a/services/core-api/app/api/users/response_models.py b/services/core-api/app/api/users/response_models.py index c89fc15356..390717fbb0 100644 --- a/services/core-api/app/api/users/response_models.py +++ b/services/core-api/app/api/users/response_models.py @@ -2,10 +2,16 @@ from flask_restx import fields MINESPACE_USER_MODEL = api.model( - 'MineDocument', { - 'user_id': fields.String, - 'keycloak_guid': fields.String, - 'email_or_username': fields.String, + 'MinespaceUser', { + 'user_id': fields.Integer, + 'sub': fields.String, + 'email': fields.String, + 'given_name': fields.String, + 'family_name': fields.String, + 'display_name': fields.String, + 'bceid_username': fields.String, + 'identity_provider': fields.String, + 'last_logged_in': fields.DateTime, 'mines': fields.List(fields.String), }) @@ -18,4 +24,4 @@ 'display_name': fields.String, 'last_logged_in': fields.DateTime, } -) +) \ No newline at end of file diff --git a/services/core-api/app/api/utils/models_mixins.py b/services/core-api/app/api/utils/models_mixins.py index ae4cd0dede..78486682cf 100644 --- a/services/core-api/app/api/utils/models_mixins.py +++ b/services/core-api/app/api/utils/models_mixins.py @@ -491,7 +491,12 @@ class AuditMixin(object): db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) class HistoryMixin(object): - __versioned__ = {} + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + # Only set default __versioned__ if the model hasn't already defined it + if not hasattr(cls, '__versioned__'): + cls.__versioned__ = {} @hybrid_property def history(self): diff --git a/services/core-api/app/auth.py b/services/core-api/app/auth.py index 28001df010..728f1d3922 100644 --- a/services/core-api/app/auth.py +++ b/services/core-api/app/auth.py @@ -5,7 +5,6 @@ from typing import Optional, Set from app.extensions import db from .api.utils.include.user_info import User -from .api.users.minespace.models.minespace_user import MinespaceUser from app.api.utils.access_decorators import MINESPACE_PROPONENT, MINE_ADMIN # This is for use when the database models are being used outside of the context of a flask application. @@ -36,18 +35,18 @@ def get_permission(self, mine_id: UUID): def get_mine_access(): + from .api.users.minespace.models.minespace_user import MinespaceUser user = get_current_user() return list(x.mine_guid for x in user.minespace_user_mines) def get_current_user(): + from .api.users.minespace.models.minespace_user import MinespaceUser rv = getattr(g, 'current_user', None) if rv == None: - email = get_user_email() username = get_user_username() rv = MinespaceUser.query.unbound_unsafe().filter( - MinespaceUser.email_or_username.in_([email, - username])).filter_by(deleted_ind=False).first() + MinespaceUser.bceid_username.in_([username])).filter_by(deleted_ind=False).first() g.current_user = rv return rv diff --git a/services/core-api/app/commands.py b/services/core-api/app/commands.py index b3827c9443..c1be537bf9 100644 --- a/services/core-api/app/commands.py +++ b/services/core-api/app/commands.py @@ -82,7 +82,7 @@ def _create_cypress_data(): ## Create a minespace user with data corresponding to ## the Cypress test user (cypress/keycloak-users.json) minespace_user = MinespaceUserFactory( - email_or_username='cypress@bceid', + bceid_username='cypress@bceid', keycloak_guid='a28dfc3a-5e5c-4501-ab2f-399d8e64f2c8') ## Subscribe the minespace user to a mine so we have a mine to test with @@ -274,11 +274,11 @@ def _seed_user_data(user_name, is_idir): subscription = Subscription(mine_guid=mine.mine_guid, user_name=full_user_name) db.session.add(subscription) else: - minespace_user = MinespaceUser.find_by_email(full_user_name) + minespace_user = MinespaceUser.find_by_username(full_user_name) subscribed_mine_guids = [] if not minespace_user: minespace_user = MinespaceUserFactory( - email_or_username=full_user_name, + bceid_username=full_user_name, keycloak_guid='b28dfc3a-5e5c-4501-ab2f-399d8e64f2c8') else: from app.api.users.minespace.models.minespace_user_mine import MinespaceUserMine diff --git a/services/core-api/app/sqlalchemy_extensions/__init__.py b/services/core-api/app/sqlalchemy_extensions/__init__.py index 4d90828eda..586408e068 100644 --- a/services/core-api/app/sqlalchemy_extensions/__init__.py +++ b/services/core-api/app/sqlalchemy_extensions/__init__.py @@ -1,9 +1,11 @@ -from app.auth import get_user_username from sqlalchemy_continuum import make_versioned from .sqlalchemy_continuum_userprovider import MDSSqlAlchemyContinuumPlugin def register_sqlalchemy_continuum(): + # Import after registration to avoid circular import timing issues + from app.auth import get_user_username + # Register the sqlalchemy-continuum extension. # Any models with the __versioned__ attribute will be versioned automatically on # inserts, updates, and deletes. diff --git a/services/core-api/tests/auth/test_expected_auth.py b/services/core-api/tests/auth/test_expected_auth.py index 2c432d0bdc..5a7fe780e6 100644 --- a/services/core-api/tests/auth/test_expected_auth.py +++ b/services/core-api/tests/auth/test_expected_auth.py @@ -269,7 +269,7 @@ (MinespaceUserResource, 'get', [MINE_ADMIN]), (MinespaceUserResource, 'delete', [MINE_ADMIN]), (MinespaceUserMineListResource, 'post', [MINE_ADMIN]), (MinespaceUserMineResource, 'delete', [MINE_ADMIN]), - (UserResource, 'get', [VIEW_ALL]), + (UserResource, 'get', [VIEW_ALL, MINESPACE_PROPONENT]), (UserListResource, 'get', [VIEW_ALL]), (NOWActivityTypeResource, 'get', [VIEW_ALL]), (NOWApplicationImportResource, 'post', [EDIT_PERMIT]), diff --git a/services/core-api/tests/auth/test_user_auth.py b/services/core-api/tests/auth/test_user_auth.py index 1a7cbb0a29..53490061b6 100644 --- a/services/core-api/tests/auth/test_user_auth.py +++ b/services/core-api/tests/auth/test_user_auth.py @@ -42,7 +42,7 @@ def put(self): def setup_info(db_session): User._test_mode = False auth.clear_cache() - MinespaceUserFactory(email_or_username='test-proponent-email@minespace.ca') + MinespaceUserFactory(bceid_username='test-proponent@bceid') yield diff --git a/services/core-api/tests/factories.py b/services/core-api/tests/factories.py index 8012c1172f..779a0561cd 100644 --- a/services/core-api/tests/factories.py +++ b/services/core-api/tests/factories.py @@ -897,8 +897,7 @@ class MinespaceUserFactory(BaseFactory): class Meta: model = MinespaceUser - keycloak_guid = GUID - email_or_username = factory.Faker('email') + bceid_username = factory.Sequence(lambda n: f"testuser{n}@bceid") # Core subscriptions diff --git a/services/core-api/tests/helpers.py b/services/core-api/tests/helpers.py index 5b891063a6..f9dc1a1486 100644 --- a/services/core-api/tests/helpers.py +++ b/services/core-api/tests/helpers.py @@ -10,7 +10,7 @@ def get_datetime_tz_naive_string(date: datetime): def subscribe_minespace_user(db_session, mine, email='test-proponent@bceid'): """Create a MineSpace user and subscribe them to the given mine.""" - ms_user = MinespaceUserFactory(email_or_username=email) + ms_user = MinespaceUserFactory(bceid_username=email) MinespaceSubscriptionFactory(mine=mine, minespace_user=ms_user) db_session.commit() auth.clear_cache() diff --git a/services/core-api/tests/parties/party_appt/test_party_appointment_jobs.py b/services/core-api/tests/parties/party_appt/test_party_appointment_jobs.py index dd583181d8..2223796c9e 100644 --- a/services/core-api/tests/parties/party_appt/test_party_appointment_jobs.py +++ b/services/core-api/tests/parties/party_appt/test_party_appointment_jobs.py @@ -167,7 +167,7 @@ def test_notify_expired_qp_triggers_correct_notification_data(self, expired_qp_i idempotency_key = f'tsf_qp_expired_{qp.mine_party_appt_guid}_{qp.end_date.strftime("%Y-%m-%d")}' - user_name = MinespaceUser.find_by_id(sub.user_id).email_or_username + user_name = MinespaceUser.find_by_id(sub.user_id).bceid_username assert notification.idempotency_key == idempotency_key assert notification.activity_type == ActivityType.tsf_qp_expired diff --git a/services/core-api/tests/projects/ams_final_application/resources/test_ams_final_application_resource.py b/services/core-api/tests/projects/ams_final_application/resources/test_ams_final_application_resource.py index 2767ffe942..41a2df033a 100644 --- a/services/core-api/tests/projects/ams_final_application/resources/test_ams_final_application_resource.py +++ b/services/core-api/tests/projects/ams_final_application/resources/test_ams_final_application_resource.py @@ -29,7 +29,7 @@ def _proponent_header(auth_headers): def subscribe_minespace_user(db_session, project_summary, email='test-proponent@bceid'): """Create a MineSpace user and subscribe them to the mine of the given project summary.""" - ms_user = MinespaceUserFactory(email_or_username=email) # type: ignore[arg-type] + ms_user = MinespaceUserFactory(bceid_username=email) # type: ignore[arg-type] MinespaceSubscriptionFactory(mine=project_summary.project.mine, minespace_user=ms_user) # type: ignore[arg-type] db_session.commit() auth.clear_cache() diff --git a/services/core-api/tests/users/models/test_minespace_user.py b/services/core-api/tests/users/models/test_minespace_user.py index 8bc43adccc..06444e1c9f 100644 --- a/services/core-api/tests/users/models/test_minespace_user.py +++ b/services/core-api/tests/users/models/test_minespace_user.py @@ -2,18 +2,34 @@ from tests.factories import MinespaceUserFactory, MineFactory, MinespaceSubscriptionFactory +def test_minespace_user_has_versioning_attributes(db_session): + # Test that MinespaceUser has SQLAlchemy-Continuum versioning attributes. + # This serves as a canary test to catch import timing issues that break versioning. + # Circular imports with MinespaceUser can easily break versioning (generally, use lazy imports inside a function to fix) + + user = MinespaceUserFactory() + + # Assert that versioning attributes exist + assert hasattr(user, 'versions'), "MinespaceUser should have 'versions' attribute for SQLAlchemy-Continuum versioning" + assert hasattr(user, 'history'), "MinespaceUser should have 'history' attribute for SQLAlchemy-Continuum versioning" + + # Verify that history is accessible and returns a list + history = user.history + assert isinstance(history, list), "MinespaceUser.history should return a list" + + def test_minespace_user_model_find_by_id(db_session): user = MinespaceUserFactory() mu = MinespaceUser.find_by_id(user.user_id) - assert mu.email_or_username == user.email_or_username + assert mu.bceid_username == user.bceid_username -def test_minespace_user_model_find_by_email(db_session): - email_or_username = MinespaceUserFactory().email_or_username +def test_minespace_user_model_find_by_username(db_session): + bceid_username = MinespaceUserFactory().bceid_username - mu = MinespaceUser.find_by_email(email_or_username) - assert mu.email_or_username == email_or_username + mu = MinespaceUser.find_by_username(bceid_username) + assert mu.bceid_username == bceid_username def test_minespace_user_model_find_all(db_session): @@ -22,7 +38,7 @@ def test_minespace_user_model_find_all(db_session): all_mu = MinespaceUser.get_all() assert len(all_mu) == 2 - assert any(mu.email_or_username == user1.email_or_username for mu in all_mu) + assert any(mu.bceid_username == user1.bceid_username for mu in all_mu) def test_minespace_user_model_find_by_mine_guid(db_session): @@ -42,4 +58,96 @@ def test_minespace_user_model_find_by_mine_guid(db_session): # all users assigned to mine1 should be returned, but not other subscriptions user_ids = list(x.user_id for x in users_by_mine1) - assert user_ids == [user1.user_id, user2.user_id] \ No newline at end of file + assert user_ids == [user1.user_id, user2.user_id] + + +def test_minespace_user_find_by_token_data_with_sub(db_session): + # Test finding user by sub field when it exists + user = MinespaceUserFactory() + user.sub = f"{user.bceid_username}@bceid" + user.deleted_ind = False + user.save() + + token_data = { + "sub": user.sub, + "bceid_username": user.bceid_username, + "email": "test@example.com" + } + + found_user = MinespaceUser.find_by_token_data(**token_data) + assert found_user is not None, "Should find user by sub field" + assert found_user.user_id == user.user_id, "Should return the correct user" + + +def test_minespace_user_find_by_token_data_by_bceid_username(db_session): + # Test finding user by bceid_username when sub doesn't exist + user = MinespaceUserFactory() + user.sub = None + user.deleted_ind = False + user.save() + + token_data = { + "sub": f"{user.bceid_username}@bceid", + "bceid_username": user.bceid_username, + "email": "test@example.com" + } + + found_user = MinespaceUser.find_by_token_data(**token_data) + assert found_user is not None, "Should find user by bceid_username" + assert found_user.user_id == user.user_id, "Should return the correct user" + + +def test_minespace_user_find_by_token_data_no_match(db_session): + # Test when no user matches the token data + user = MinespaceUserFactory() + + token_data = { + "sub": "nonexistent@bceid", + "bceid_username": "nonexistent_username", + "email": "test@example.com" + } + + found_user = MinespaceUser.find_by_token_data(**token_data) + assert found_user is None, "Should return None when no user matches" + + +def test_minespace_user_update_from_token_data_existing_user(db_session): + # Test updating an existing user with token data + user = MinespaceUserFactory() + user.sub = None + user.deleted_ind = False + user.given_name = "OriginalName" + user.family_name = "OriginalFamily" + user.email = "original@example.com" + user.save() + + token_data = { + "sub": f"{user.bceid_username}@bceid", + "bceid_username": user.bceid_username, + "given_name": "UpdatedName", + "family_name": "UpdatedFamily", + "email": "updated@example.com", + "display_name": "First Last" + } + + updated_user = MinespaceUser.update_from_token_data(**token_data) + + assert updated_user is not None + assert updated_user.user_id == user.user_id + assert updated_user.sub == token_data["sub"] + assert updated_user.given_name == token_data["given_name"] + assert updated_user.family_name == token_data["family_name"] + assert updated_user.email == token_data["email"] + assert updated_user.display_name == token_data["display_name"] + + +def test_minespace_user_update_from_token_data_no_user(db_session): + # Test updating when no user is found + token_data = { + "sub": "nonexistent@bceid", + "bceid_username": "nonexistent_username", + "given_name": "UpdatedName" + } + + result = MinespaceUser.update_from_token_data(**token_data) + assert result is None, "Should return None when no user is found to update" diff --git a/services/core-api/tests/users/resources/test_minespace_user_resource.py b/services/core-api/tests/users/resources/test_minespace_user_resource.py index 89ec4952c6..847bca8447 100644 --- a/services/core-api/tests/users/resources/test_minespace_user_resource.py +++ b/services/core-api/tests/users/resources/test_minespace_user_resource.py @@ -3,13 +3,13 @@ from tests.helpers import subscribe_minespace_user def test_get_minespace_users_all(test_client, db_session, auth_headers): - user_email = MinespaceUserFactory().email_or_username + user_email = MinespaceUserFactory().bceid_username get_resp = test_client.get('/users/minespace', headers=auth_headers['full_auth_header']) get_data = json.loads(get_resp.data.decode()) assert get_resp.status_code == 200, get_resp.response assert len(get_data['records']) == 1 - assert get_data['records'][0]['email_or_username'] == user_email + assert get_data['records'][0]['bceid_username'] == user_email def test_get_minespace_user_by_id(test_client, db_session, auth_headers): @@ -19,24 +19,24 @@ def test_get_minespace_user_by_id(test_client, db_session, auth_headers): f'/users/minespace/{user.user_id}', headers=auth_headers['full_auth_header']) get_data = json.loads(get_resp.data.decode()) assert get_resp.status_code == 200, get_resp.response - assert get_data['email_or_username'] == user.email_or_username + assert get_data['bceid_username'] == user.bceid_username def test_get_minespace_user_by_email(test_client, db_session, auth_headers): user = MinespaceUserFactory() get_resp = test_client.get( - f'/users/minespace?email={user.email_or_username}', + f'/users/minespace?email={user.bceid_username}', headers=auth_headers['full_auth_header']) get_data = json.loads(get_resp.data.decode()) assert get_resp.status_code == 200, get_resp.response - assert get_data['records'][0]['email_or_username'] == user.email_or_username + assert get_data['records'][0]['bceid_username'] == user.bceid_username def test_post_minespace_user_duplicate_email(test_client, db_session, auth_headers): user = MinespaceUserFactory() - data = {'email_or_username': user.email_or_username, "mine_guids": [str(uuid.uuid4())]} + data = {'bceid_username': user.bceid_username, "mine_guids": [str(uuid.uuid4())]} post_resp = test_client.post( '/users/minespace', json=data, headers=auth_headers['full_auth_header']) assert post_resp.status_code == 400, post_resp.response @@ -44,7 +44,7 @@ def test_post_minespace_user_duplicate_email(test_client, db_session, auth_heade def test_post_minespace_user_email_too_long(test_client, db_session, auth_headers): - data = {'email_or_username': 'a' * 255 + "@server.com", "mine_guids": [str(uuid.uuid4())]} + data = {'bceid_username': 'a' * 255 + "@bceid", "mine_guids": [str(uuid.uuid4())]} post_resp = test_client.post( '/users/minespace', json=data, headers=auth_headers['full_auth_header']) @@ -53,12 +53,12 @@ def test_post_minespace_user_email_too_long(test_client, db_session, auth_header def test_post_minespace_user_new_email(test_client, db_session, auth_headers): - data = {'email_or_username': "new_email@server.com", "mine_guids": [str(uuid.uuid4())]} + data = {'bceid_username': "new_email@bceid", "mine_guids": [str(uuid.uuid4())]} post_resp = test_client.post( '/users/minespace', json=data, headers=auth_headers['full_auth_header']) assert post_resp.status_code == 200, post_resp.response - assert json.loads(post_resp.data.decode())['email_or_username'] == data['email_or_username'] + assert json.loads(post_resp.data.decode())['bceid_username'] == data['bceid_username'] def test_delete_minespace_success(test_client, db_session, auth_headers): @@ -79,11 +79,11 @@ def test_update_minespace_user_mines_success(test_client, db_session, auth_heade user = MinespaceUserFactory() mine = MineFactory() - email = user.email_or_username + email = user.bceid_username mine_guids = [str(mine.mine_guid)] data = { - "email_or_username": f"{email}", + "bceid_username": f"{email}", "mine_guids": mine_guids } @@ -101,11 +101,11 @@ def test_update_minespace_user_mines_success(test_client, db_session, auth_heade def test_update_minespace_user_empty_mine_list(test_client, db_session, auth_headers): user = MinespaceUserFactory() - email = user.email_or_username + email = user.bceid_username mine_guids = [] data = { - "email_or_username": f"{email}", + "bceid_username": f"{email}", "mine_guids": mine_guids } @@ -117,11 +117,11 @@ def test_update_minespace_user_empty_mine_list(test_client, db_session, auth_hea def test_update_minespace_user_mine_does_not_exist(test_client, db_session, auth_headers): user = MinespaceUserFactory() - email = user.email_or_username + email = user.bceid_username mine_guids = [str(uuid.uuid4())] data = { - "email_or_username": f"{email}", + "bceid_username": f"{email}", "mine_guids": mine_guids } @@ -136,7 +136,7 @@ def test_update_minespace_user_does_not_exist(test_client, db_session, auth_head mine_guids = [str(uuid.uuid4())] data = { - "email_or_username": f"{email}", + "bceid_username": f"{email}", "mine_guids": mine_guids } @@ -164,8 +164,8 @@ def test_get_minespace_users_by_mine_guid(test_client, db_session, auth_headers) get_data = json.loads(get_resp_proponent.data.decode()) assert get_resp_proponent.status_code == 200, get_resp_proponent.response - user_names = sorted([x['email_or_username'] for x in get_data['records']]) - expected_names = sorted([user1.email_or_username, user2.email_or_username, test_user.email_or_username]) + user_names = sorted([x['bceid_username'] for x in get_data['records']]) + expected_names = sorted([user1.bceid_username, user2.bceid_username, test_user.bceid_username]) assert user_names == expected_names # test that view only can access diff --git a/services/core-api/tests/users/resources/test_user_profile_resource.py b/services/core-api/tests/users/resources/test_user_profile_resource.py index d475046c5b..37127bc58b 100644 --- a/services/core-api/tests/users/resources/test_user_profile_resource.py +++ b/services/core-api/tests/users/resources/test_user_profile_resource.py @@ -4,7 +4,9 @@ from unittest.mock import patch from app.api.users.models.user import User +from app.api.users.minespace.models.minespace_user import MinespaceUser from app.api.utils.include.user_info import User as UserUtils +from tests.factories import MinespaceUserFactory def test_user_resource_get(test_client, auth_headers): @@ -35,3 +37,54 @@ def test_user_resource_get(test_client, auth_headers): assert get_data["given_name"] == test_user_info["given_name"] assert get_data["family_name"] == test_user_info["family_name"] assert get_data["display_name"] == test_user_info["display_name"] + + +def test_minespace_user_profile_update(test_client, auth_headers, db_session): + # create a realistic "legacy" minespace user with minimal attributes + minespace_user = MinespaceUserFactory(bceid_username="test-proponent@bceid") + minespace_user.deleted_ind = False + minespace_user.save() + + original_given_name = minespace_user.given_name + original_family_name = minespace_user.family_name + original_display_name = minespace_user.display_name + original_email = minespace_user.email + + updated_user_info = { + "sub": "43e6a245-0bf7-4ccf-9bd0-e7fb85fd18cc@bceidboth", + "email": "updated-email@example.com", + "given_name": "UpdatedFirstName", + "family_name": "UpdatedLastName", + "display_name": "UpdatedLastName, UpdatedFirstName", + "bceid_username": "test-proponent", + "bceid_user_guid": minespace_user.bceid_user_guid, + "identity_provider": "bceid", + } + + with patch.object(UserUtils, 'get_user_raw_info', return_value=updated_user_info): + + get_resp = test_client.get('/users/profile', headers=auth_headers['proponent_only_auth_header']) + assert get_resp.status_code == 200 + + get_data = json.loads(get_resp.data.decode()) + + # Verify that the profile data in response matches updated info + assert get_data["email"] == updated_user_info["email"] + assert get_data["given_name"] == updated_user_info["given_name"] + assert get_data["family_name"] == updated_user_info["family_name"] + assert get_data["display_name"] == updated_user_info["display_name"] + + db_session.refresh(minespace_user) + + assert minespace_user.email == updated_user_info["email"] + assert minespace_user.given_name == updated_user_info["given_name"] + assert minespace_user.family_name == updated_user_info["family_name"] + assert minespace_user.display_name == updated_user_info["display_name"] + assert minespace_user.sub == updated_user_info["sub"] + assert minespace_user.bceid_username == "test-proponent@bceid" + assert minespace_user.last_logged_in is not None + + assert minespace_user.given_name != original_given_name + assert minespace_user.family_name != original_family_name + assert minespace_user.display_name != original_display_name + assert minespace_user.email != original_email \ No newline at end of file diff --git a/services/core-web/src/components/Forms/AddMinespaceUser.js b/services/core-web/src/components/Forms/AddMinespaceUser.js index a2051b40d7..843ce9d3f7 100644 --- a/services/core-web/src/components/Forms/AddMinespaceUser.js +++ b/services/core-web/src/components/Forms/AddMinespaceUser.js @@ -8,6 +8,7 @@ import * as FORM from "@/constants/forms"; import { renderConfig } from "@/components/common/config"; import CustomPropTypes from "@/customPropTypes"; import FormWrapper from "@mds/common/components/forms/FormWrapper"; +import { resetForm } from "@mds/common/redux/utils/helpers"; const propTypes = { mines: CustomPropTypes.options.isRequired, @@ -30,16 +31,17 @@ export const AddMinespaceUser = (props) => { initialValues={{ proponent_mine_access: [] }} reduxFormConfig={{ touchOnBlur: false, + onSubmitSuccess: resetForm(FORM.ADD_MINESPACE_USER) }} > = ({ diff --git a/services/core-web/src/components/admin/MinespaceUserList.js b/services/core-web/src/components/admin/MinespaceUserList.js deleted file mode 100644 index 5b005ab805..0000000000 --- a/services/core-web/src/components/admin/MinespaceUserList.js +++ /dev/null @@ -1,106 +0,0 @@ -import React from "react"; -import { Button, Popconfirm } from "antd"; -import PropTypes from "prop-types"; -import * as Strings from "@mds/common/constants/strings"; -import { TRASHCAN, EDIT_OUTLINE_VIOLET } from "@/constants/assets"; -import CustomPropTypes from "@/customPropTypes"; -import CoreTable from "@mds/common/components/common/CoreTable"; -import { renderTextColumn } from "@mds/common/components/common/CoreTableCommonColumns"; - -const propTypes = { - minespaceUsers: PropTypes.arrayOf(CustomPropTypes.minespaceUser), - minespaceUserMines: PropTypes.arrayOf(CustomPropTypes.mineName), - handleDelete: PropTypes.func, - isLoaded: PropTypes.bool.isRequired, - handleOpenModal: PropTypes.func.isRequired, -}; - -const defaultProps = { - minespaceUsers: [], - minespaceUserMines: [], - handleDelete: () => { }, -}; - -const columns = [ - renderTextColumn("email_or_username", "Email/BCeID", true), - { - title: "Mines", - dataIndex: "mineNames", - render: (text) => ( -
- {text && - text.map(({ mine_guid, mine_name }) => ( - - {mine_name} -
-
- ))} -
- ), - }, - { - title: "", - dataIndex: "delete", - width: 175, - render: (text, record) => ( -
- - text(record.user_id)} - okText="Delete" - cancelText="Cancel" - > - - -
- ), - }, -]; - -const lookupMineName = (mine_guids, mines) => - mine_guids.map((mine_guid) => { - const mine_record = mines.find((mine) => mine.mine_guid === mine_guid); - return { - mine_guid, - mine_name: mine_record ? `${mine_record.mine_name}-${mine_record.mine_no}` : "", - }; - }).sort((a, b) => a.mine_name.localeCompare(b.mine_name)); - -const transformRowData = (minespaceUsers, mines, deleteFunc, handleOpenModal) => - minespaceUsers.map((user) => ({ - key: user.user_id, - emptyField: Strings.EMPTY_FIELD, - email_or_username: user.email_or_username, - mineNames: lookupMineName(user.mines, mines), - user_id: user.user_id, - delete: deleteFunc, - update: handleOpenModal, - })); - -export const MinespaceUserList = (props) => ( - -); -MinespaceUserList.propTypes = propTypes; -MinespaceUserList.defaultProps = defaultProps; - -export default MinespaceUserList; diff --git a/services/core-web/src/components/admin/MinespaceUserList.tsx b/services/core-web/src/components/admin/MinespaceUserList.tsx new file mode 100644 index 0000000000..c7c84bcca0 --- /dev/null +++ b/services/core-web/src/components/admin/MinespaceUserList.tsx @@ -0,0 +1,88 @@ +import React, { FC } from "react"; +import CoreTable from "@mds/common/components/common/CoreTable"; +import { renderTextColumn, renderDateColumn, renderActionsColumn } from "@mds/common/components/common/CoreTableCommonColumns"; +import { IMineName, IMinespaceUser } from "@mds/common/interfaces"; +import { deleteConfirmWrapper } from "@mds/common/components/common/ActionMenu"; + +interface MinespaceUserListProps { + minespaceUsers: IMinespaceUser[]; + minespaceUserMines: IMineName[]; + handleDelete: (userId) => void | Promise; + isLoaded: boolean; + handleOpenModal: (event, record) => void | Promise; +} + +export const MinespaceUserList: FC = ({ + minespaceUsers = [], + minespaceUserMines = [], + handleDelete = () => { }, + isLoaded, + handleOpenModal +}) => { + + const lookupMineName = (mine_guids, mines) => + mine_guids.map((mine_guid) => { + const mine_record = mines.find((mine) => mine.mine_guid === mine_guid); + return { + mine_guid, + mine_name: mine_record ? `${mine_record.mine_name}-${mine_record.mine_no}` : "", + }; + }).sort((a, b) => a.mine_name.localeCompare(b.mine_name)); + + const getRowData = () => + minespaceUsers.map((user) => ({ + key: user.user_id, + ...user, + mineNames: lookupMineName(user.mines, minespaceUserMines), + })); + + const columns = [ + renderTextColumn("display_name", "Name", true), + renderTextColumn("bceid_username", "BCeID", true), + renderTextColumn("email", "Email", true), + renderDateColumn("last_logged_in", "Last Login", true), + { + title: "Mines", + dataIndex: "mineNames", + render: (text) => ( +
+ {text && + text.map(({ mine_guid, mine_name }) => ( + + {mine_name} +
+
+ ))} +
+ ), + }, + renderActionsColumn({ + actions: [ + { + key: "edit", + label: "Edit User", + clickFunction: (e, record) => { handleOpenModal(e, record) } + }, + { + key: "delete", + label: "Delete", + clickFunction: (_, record) => { + deleteConfirmWrapper(`MineSpace user: ${record.bceid_username}`, + () => handleDelete(record.user_id) + ) + } + } + ], + }) + ]; + + return ( + + ) +}; + +export default MinespaceUserList; \ No newline at end of file diff --git a/services/core-web/src/components/admin/MinespaceUserManagement.js b/services/core-web/src/components/admin/MinespaceUserManagement.js index a6688eae36..7407b3a7f8 100644 --- a/services/core-web/src/components/admin/MinespaceUserManagement.js +++ b/services/core-web/src/components/admin/MinespaceUserManagement.js @@ -85,7 +85,7 @@ export const MinespaceUserManagement = (props) => { const handleOpenModal = (e, record) => { props.openModal({ props: { - title: `Update User: ${record.email_or_username}`, + title: `Update User: ${record.bceid_username}`, initialValues: record, handleSubmit: handleUpdate, }, diff --git a/services/core-web/src/components/mine/Users/__snapshots__/MineUserAccessPage.spec.tsx.snap b/services/core-web/src/components/mine/Users/__snapshots__/MineUserAccessPage.spec.tsx.snap index 644524a95c..8b02a07fdc 100644 --- a/services/core-web/src/components/mine/Users/__snapshots__/MineUserAccessPage.spec.tsx.snap +++ b/services/core-web/src/components/mine/Users/__snapshots__/MineUserAccessPage.spec.tsx.snap @@ -74,7 +74,7 @@ exports[`MineUserAccessPage - core renders properly 1`] = ` > @@ -84,7 +84,190 @@ exports[`MineUserAccessPage - core renders properly 1`] = ` - BCeID/Email + Name + + + + + + + + + + + + + + +
+ + BCeID + + + + + + + + + + + +
+ + +
+ + Email + + + + + + + + + + + +
+ + +
+ + Last Login + + +
user1@bceid
+ + + + +
+ Oct 31 2025 +
+