diff --git a/Common/Models/DatabaseModels/IncidentRole.ts b/Common/Models/DatabaseModels/IncidentRole.ts
index fac37da9f3..b419b0f25c 100644
--- a/Common/Models/DatabaseModels/IncidentRole.ts
+++ b/Common/Models/DatabaseModels/IncidentRole.ts
@@ -493,4 +493,38 @@ export default class IncidentRole extends BaseModel {
default: true,
})
public isDeleteable?: boolean = undefined;
+
+ @ColumnAccessControl({
+ create: [
+ Permission.ProjectOwner,
+ Permission.ProjectAdmin,
+ Permission.ProjectMember,
+ Permission.CreateIncidentRole,
+ ],
+ read: [
+ Permission.ProjectOwner,
+ Permission.ProjectAdmin,
+ Permission.ProjectMember,
+ Permission.ReadIncidentRole,
+ Permission.ReadAllProjectResources,
+ ],
+ update: [
+ Permission.ProjectOwner,
+ Permission.ProjectAdmin,
+ Permission.ProjectMember,
+ Permission.EditIncidentRole,
+ ],
+ })
+ @TableColumn({
+ isDefaultValueColumn: true,
+ type: TableColumnType.Boolean,
+ title: "Can Assign Multiple Users",
+ description:
+ "Can multiple users be assigned to this role? If false, only one user can be assigned.",
+ })
+ @Column({
+ type: ColumnType.Boolean,
+ default: false,
+ })
+ public canAssignMultipleUsers?: boolean = undefined;
}
diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/1769802715014-MigrationName.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1769802715014-MigrationName.ts
new file mode 100644
index 0000000000..a6987a41ce
--- /dev/null
+++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1769802715014-MigrationName.ts
@@ -0,0 +1,17 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class MigrationName1769802715014 implements MigrationInterface {
+ public name = "MigrationName1769802715014";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "IncidentRole" ADD "canAssignMultipleUsers" boolean NOT NULL DEFAULT false`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "IncidentRole" DROP COLUMN "canAssignMultipleUsers"`,
+ );
+ }
+}
diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts
index b0f95ed848..df36f39e1c 100644
--- a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts
+++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts
@@ -247,6 +247,7 @@ import { MigrationName1769723982900 } from "./1769723982900-MigrationName";
import { MigrationName1769772215532 } from "./1769772215532-MigrationName";
import { MigrationName1769774527481 } from "./1769774527481-MigrationName";
import { MigrationName1769780297584 } from "./1769780297584-MigrationName";
+import { MigrationName1769802715014 } from "./1769802715014-MigrationName";
export default [
InitialMigration,
@@ -498,4 +499,5 @@ export default [
MigrationName1769772215532,
MigrationName1769774527481,
MigrationName1769780297584,
+ MigrationName1769802715014,
];
diff --git a/Common/Server/Services/ProjectService.ts b/Common/Server/Services/ProjectService.ts
index 6092f9c867..25a26c7e0d 100755
--- a/Common/Server/Services/ProjectService.ts
+++ b/Common/Server/Services/ProjectService.ts
@@ -1006,6 +1006,7 @@ export class ProjectService extends DatabaseService {
observer.color = Gray500;
observer.roleIcon = IconProp.Activity;
observer.projectId = createdItem.id!;
+ observer.canAssignMultipleUsers = true;
observer = await IncidentRoleService.create({
data: observer,
diff --git a/Common/UI/Components/MemberRoleAssignment/MemberRoleAssignment.tsx b/Common/UI/Components/MemberRoleAssignment/MemberRoleAssignment.tsx
index d744fec504..b0aa163906 100644
--- a/Common/UI/Components/MemberRoleAssignment/MemberRoleAssignment.tsx
+++ b/Common/UI/Components/MemberRoleAssignment/MemberRoleAssignment.tsx
@@ -24,6 +24,7 @@ export interface MemberRole {
color: Color;
isPrimaryRole?: boolean;
icon?: IconProp;
+ canAssignMultipleUsers?: boolean;
}
export interface AssignedMember {
@@ -108,28 +109,39 @@ const MemberRoleAssignment: FunctionComponent = (
[props.onUnassignMember],
);
- // Get member for a specific role (only one per role)
- const getMemberForRole: (roleId: ObjectID) => AssignedMember | null =
+ // Get members for a specific role
+ const getMembersForRole: (roleId: ObjectID) => Array =
useCallback(
- (roleId: ObjectID): AssignedMember | null => {
- return (
- props.assignedMembers.find((member: AssignedMember) => {
- return member.roleId.toString() === roleId.toString();
- }) || null
- );
+ (roleId: ObjectID): Array => {
+ return props.assignedMembers.filter((member: AssignedMember) => {
+ return member.roleId.toString() === roleId.toString();
+ });
},
[props.assignedMembers],
);
- const getUserDropdownOptions: () => Array =
- useCallback((): Array => {
- return props.availableUsers.map((user: AvailableUser) => {
- return {
- value: user.id.toString(),
- label: user.name || user.email,
- };
- });
- }, [props.availableUsers]);
+ // Get available users for a role (excluding already assigned users for that role)
+ const getAvailableUsersForRole: (roleId: ObjectID) => Array =
+ useCallback(
+ (roleId: ObjectID): Array => {
+ const assignedUserIds: Set = new Set(
+ getMembersForRole(roleId).map((m: AssignedMember) => {
+ return m.userId.toString();
+ }),
+ );
+ return props.availableUsers
+ .filter((user: AvailableUser) => {
+ return !assignedUserIds.has(user.id.toString());
+ })
+ .map((user: AvailableUser) => {
+ return {
+ value: user.id.toString(),
+ label: user.name || user.email,
+ };
+ });
+ },
+ [props.availableUsers, getMembersForRole],
+ );
const cardTitle: string = props.title || "Team Members";
const cardDescription: string =
@@ -159,6 +171,65 @@ const MemberRoleAssignment: FunctionComponent = (
);
}
+ // Render a single member row
+ const renderMemberRow: (
+ member: AssignedMember,
+ isLastInGroup?: boolean,
+ ) => ReactElement = (
+ member: AssignedMember,
+ isLastInGroup?: boolean,
+ ): ReactElement => {
+ return (
+
+
+ {member.userProfilePictureUrl ? (
+
+ ) : (
+
+
+ {member.userName?.charAt(0)?.toUpperCase() ||
+ member.userEmail?.charAt(0)?.toUpperCase() ||
+ "?"}
+
+
+ )}
+
+
+ {member.userName || member.userEmail}
+
+ {member.userName && member.userEmail && (
+
+ {member.userEmail}
+
+ )}
+
+
+
+
+ );
+ };
+
return (
<>
= (
) : (
-
-
-
-
- |
- Role
- |
-
- Assigned Member
- |
-
- Action
- |
-
-
-
- {props.roles.map((role: MemberRole) => {
- const member: AssignedMember | null = getMemberForRole(
- role.id,
- );
- const isDropdownActive: boolean =
- activeRoleDropdown?.toString() === role.id.toString();
- const availableUsers: Array =
- getUserDropdownOptions();
+
+ {props.roles.map((role: MemberRole) => {
+ const members: Array
= getMembersForRole(
+ role.id,
+ );
+ const isDropdownActive: boolean =
+ activeRoleDropdown?.toString() === role.id.toString();
+ const availableUsers: Array =
+ getAvailableUsersForRole(role.id);
+ const canAddMore: boolean =
+ role.canAssignMultipleUsers || members.length === 0;
- return (
-
- {/* Role Column */}
-
-
-
-
-
-
-
- {role.name}
-
- {role.isPrimaryRole && (
-
- Primary
-
- )}
-
-
- |
-
- {/* Member Column */}
-
- {isDropdownActive ? (
-
-
-
- | null,
- ) => {
- if (value && !Array.isArray(value)) {
- const option:
- | DropdownOption
- | undefined = availableUsers.find(
- (opt: DropdownOption) => {
- return (
- opt.value.toString() ===
- value.toString()
- );
- },
- );
- setSelectedUserOption(option || null);
- } else {
- setSelectedUserOption(null);
- }
- }}
- value={selectedUserOption || undefined}
- />
-
-
-
-
- ) : member ? (
-
- {/* Avatar */}
- {member.userProfilePictureUrl ? (
-
- ) : (
-
-
- {member.userName?.charAt(0)?.toUpperCase() ||
- member.userEmail
- ?.charAt(0)
- ?.toUpperCase() ||
- "?"}
-
-
- )}
- {/* User Info */}
-
-
- {member.userName || member.userEmail}
-
- {member.userName && member.userEmail && (
-
- {member.userEmail}
-
- )}
-
-
- ) : (
-
- Not assigned
+ return (
+ |
+ {role.isPrimaryRole && (
+
+ Primary
+
+ )}
+ {role.canAssignMultipleUsers && (
+
+ Multiple
+
+ )}
+
+
+ {members.length}{" "}
+ {members.length === 1 ? "member" : "members"}{" "}
+ assigned
+
+
+
- {/* Action Column */}
-
- {!isDropdownActive && (
- <>
- {member ? (
-
- ) : availableUsers.length > 0 ? (
-
- ) : (
-
- No users
-
- )}
- >
- )}
- |
-
- );
- })}
-
-
+ {/* Add Button */}
+ {!isDropdownActive &&
+ canAddMore &&
+ availableUsers.length > 0 && (
+
+ )}
+
+
+ {/* Dropdown for adding member */}
+ {isDropdownActive && (
+
+
+
+
+ | null,
+ ) => {
+ if (value && !Array.isArray(value)) {
+ const option: DropdownOption | undefined =
+ availableUsers.find(
+ (opt: DropdownOption) => {
+ return (
+ opt.value.toString() ===
+ value.toString()
+ );
+ },
+ );
+ setSelectedUserOption(option || null);
+ } else {
+ setSelectedUserOption(null);
+ }
+ }}
+ value={selectedUserOption || undefined}
+ />
+
+
+
+
+
+ )}
+
+ {/* Members List */}
+
+ {members.length === 0 ? (
+
+ Not assigned
+
+ ) : (
+ members.map(
+ (member: AssignedMember, index: number) => {
+ return renderMemberRow(
+ member,
+ index === members.length - 1,
+ );
+ },
+ )
+ )}
+
+
+ );
+ })}
)}
diff --git a/Dashboard/src/Components/Incident/IncidentMemberRoleAssignment.tsx b/Dashboard/src/Components/Incident/IncidentMemberRoleAssignment.tsx
index ed20e08ade..d3ddc6de15 100644
--- a/Dashboard/src/Components/Incident/IncidentMemberRoleAssignment.tsx
+++ b/Dashboard/src/Components/Incident/IncidentMemberRoleAssignment.tsx
@@ -70,6 +70,7 @@ const IncidentMemberRoleAssignment: FunctionComponent = (
color: true,
isPrimaryRole: true,
roleIcon: true,
+ canAssignMultipleUsers: true,
},
sort: {
isPrimaryRole: SortOrder.Descending,
@@ -118,6 +119,7 @@ const IncidentMemberRoleAssignment: FunctionComponent = (
color: role.color || Color.fromString("#6b7280"),
isPrimaryRole: role.isPrimaryRole || false,
icon: role.roleIcon || undefined,
+ canAssignMultipleUsers: role.canAssignMultipleUsers || false,
};
});
diff --git a/Worker/DataMigrations/Index.ts b/Worker/DataMigrations/Index.ts
index 7714b33dd4..4efa279999 100644
--- a/Worker/DataMigrations/Index.ts
+++ b/Worker/DataMigrations/Index.ts
@@ -55,6 +55,7 @@ import LowercaseDomains from "./LowercaseDomains";
import AddAttributeKeysColumnToTelemetryTables from "./AddAttributeKeysColumnToTelemetryTables";
import AddDefaultIncidentRolesToExistingProjects from "./AddDefaultIncidentRolesToExistingProjects";
import AddDefaultIconsToIncidentRoles from "./AddDefaultIconsToIncidentRoles";
+import UpdateObserverRoleToAllowMultipleUsers from "./UpdateObserverRoleToAllowMultipleUsers";
// This is the order in which the migrations will be run. Add new migrations to the end of the array.
@@ -114,6 +115,7 @@ const DataMigrations: Array = [
new AddAttributeKeysColumnToTelemetryTables(),
new AddDefaultIncidentRolesToExistingProjects(),
new AddDefaultIconsToIncidentRoles(),
+ new UpdateObserverRoleToAllowMultipleUsers(),
];
export default DataMigrations;
diff --git a/Worker/DataMigrations/UpdateObserverRoleToAllowMultipleUsers.ts b/Worker/DataMigrations/UpdateObserverRoleToAllowMultipleUsers.ts
new file mode 100644
index 0000000000..096b4c612b
--- /dev/null
+++ b/Worker/DataMigrations/UpdateObserverRoleToAllowMultipleUsers.ts
@@ -0,0 +1,49 @@
+import DataMigrationBase from "./DataMigrationBase";
+import { LIMIT_INFINITY } from "Common/Types/Database/LimitMax";
+import IncidentRoleService from "Common/Server/Services/IncidentRoleService";
+import IncidentRole from "Common/Models/DatabaseModels/IncidentRole";
+
+export default class UpdateObserverRoleToAllowMultipleUsers extends DataMigrationBase {
+ public constructor() {
+ super("UpdateObserverRoleToAllowMultipleUsers");
+ }
+
+ public override async migrate(): Promise {
+ // Get all Observer roles across all projects
+ const observerRoles: Array = await IncidentRoleService.findBy(
+ {
+ query: {
+ name: "Observer",
+ },
+ select: {
+ _id: true,
+ canAssignMultipleUsers: true,
+ },
+ skip: 0,
+ limit: LIMIT_INFINITY,
+ props: {
+ isRoot: true,
+ },
+ },
+ );
+
+ for (const role of observerRoles) {
+ // Update the role to allow multiple users
+ if (!role.canAssignMultipleUsers) {
+ await IncidentRoleService.updateOneById({
+ id: role.id!,
+ data: {
+ canAssignMultipleUsers: true,
+ },
+ props: {
+ isRoot: true,
+ },
+ });
+ }
+ }
+ }
+
+ public override async rollback(): Promise {
+ return;
+ }
+}