Skip to content

Commit

Permalink
#5223 Added support for audience targeting
Browse files Browse the repository at this point in the history
Updated to SPFx v1.19
  • Loading branch information
wobba committed Sep 25, 2024
1 parent 9aec763 commit c68bdfd
Show file tree
Hide file tree
Showing 9 changed files with 5,156 additions and 12,769 deletions.
2 changes: 1 addition & 1 deletion samples/react-script-editor/.yo-rc.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"@microsoft/generator-sharepoint": {
"libraryName": "pnp-script-editor",
"version": "1.18.2",
"version": "1.19.0",
"environment": "spo",
"isDomainIsolated": false,
"libraryId": "1425175f-3ed8-44d2-8fc4-dd1497191294",
Expand Down
1 change: 1 addition & 0 deletions samples/react-script-editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ Version|Date|Comments
1.0.22.0|April 24, 2023|Added support for script in external template
1.0.23.0|September 7, 2023|Upgrade to SPFx 1.17.4
1.0.24.0|March 8, 2024|Upgrade to SPFx 1.18.2
1.0.25.0|September 25, 2024|Upgrade to SPFx 1.19.0, added support for audience targeting using SharePoint Groups, Entra Groups and individuals.


## Minimal Path to Awesome
Expand Down
4 changes: 2 additions & 2 deletions samples/react-script-editor/config/package-solution.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"solution": {
"name": "Modern Script Editor web part by mikaelsvenson",
"id": "1425175f-3ed8-44d2-8fc4-dd1497191294",
"version": "1.0.24.0",
"version": "1.0.25.0",
"includeClientSideAssets": true,
"skipFeatureDeployment": false,
"isDomainIsolated": false,
Expand All @@ -30,7 +30,7 @@
"title": "PnP Modern Script Editor Web Part Feature",
"description": "The feature that activates ScriptEditorWebPart from the pnp-script-editor solution.",
"id": "3a328f0a-99c4-4b28-95ab-fe0847f657a3",
"version": "1.0.24.0",
"version": "1.0.25.0",
"componentIds": [
"3a328f0a-99c4-4b28-95ab-fe0847f657a3"
]
Expand Down
17,722 changes: 4,974 additions & 12,748 deletions samples/react-script-editor/package-lock.json

Large diffs are not rendered by default.

36 changes: 19 additions & 17 deletions samples/react-script-editor/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pnp-script-editor",
"version": "1.0.24",
"version": "1.0.25",
"private": true,
"main": "lib/index.js",
"resolutions": {
Expand All @@ -11,38 +11,40 @@
},
"dependencies": {
"@fluentui/react": "8.106.4",
"@microsoft/sp-core-library": "1.18.2",
"@microsoft/sp-loader": "1.18.2",
"@microsoft/sp-lodash-subset": "1.18.2",
"@microsoft/sp-property-pane": "1.18.2",
"@microsoft/sp-webpart-base": "1.18.2",
"@pnp/spfx-controls-react": "3.17.0",
"@pnp/spfx-property-controls": "3.16.0",
"@microsoft/sp-adaptive-card-extension-base": "1.19.0",
"@microsoft/sp-core-library": "1.19.0",
"@microsoft/sp-loader": "1.19.0",
"@microsoft/sp-lodash-subset": "1.19.0",
"@microsoft/sp-property-pane": "1.19.0",
"@microsoft/sp-webpart-base": "1.19.0",
"@pnp/sp": "^2.5.0",
"@pnp/spfx-controls-react": "3.19.0",
"@pnp/spfx-property-controls": "3.18.0",
"react": "17.0.1",
"react-dom": "17.0.1",
"tslib": "2.3.1"
},
"devDependencies": {
"@microsoft/eslint-config-spfx": "1.20.1",
"@microsoft/eslint-plugin-spfx": "1.20.1",
"@microsoft/rush-stack-compiler-4.5": "0.5.0",
"@microsoft/rush-stack-compiler-4.7": "0.1.0",
"@microsoft/sp-build-web": "1.20.1",
"@microsoft/sp-module-interfaces": "1.20.1",
"@rushstack/eslint-config": "2.5.1",
"@microsoft/eslint-plugin-spfx": "1.18.2",
"@microsoft/eslint-config-spfx": "1.18.2",
"@microsoft/sp-build-web": "1.18.2",
"@types/react": "17.0.45",
"@types/react-dom": "17.0.17",
"@types/webpack-env": "~1.15.2",
"ajv": "^6.12.5",
"eslint": "8.7.0",
"eslint-plugin-react-hooks": "4.3.0",
"gulp": "4.0.2",
"typescript": "4.7.4",
"@types/react": "17.0.45",
"@types/react-dom": "17.0.17",
"eslint-plugin-react-hooks": "4.3.0",
"@microsoft/sp-module-interfaces": "1.18.2",
"webpack-bundle-analyzer": "4.10.1"
"webpack-bundle-analyzer": "4.10.2"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test"
}
}
}
121 changes: 121 additions & 0 deletions samples/react-script-editor/src/webparts/scriptEditor/AccessCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { sp } from "@pnp/sp/presets/all";
import { IPropertyFieldGroupOrPerson } from "@pnp/spfx-property-controls";

export class UserGroupCheck {

_claimUserPrefix = "i:0#.f|membership";
_claimGroupPrefix = "c:0o.c|federateddirectoryclaimprovider|";
_aadGroupCacheKey = "SPFxModernScriptEditorAudienceAADCache";
_audiences: IPropertyFieldGroupOrPerson[];
_audienceCacheDuration: number; // hours
_context: WebPartContext;

constructor(audiences: IPropertyFieldGroupOrPerson[], audienceCacheDuration: number, context: WebPartContext) {
this._audiences = audiences;
this._audienceCacheDuration = audienceCacheDuration || 24;
this._context = context;
}

public async CheckAudiences(): Promise<boolean> {
const aadGroupIds = [];

// Check if an audience is a the current person first as it's no API calls
for (const audience of this._audiences) {
if (audience.id.replace(this._claimUserPrefix, "").toLocaleLowerCase() === this._context.pageContext.user.loginName.toLocaleLowerCase()) {
return true;
}
// Collect all AAD group IDs
if (audience.login === "FederatedDirectoryClaimProvider") {
aadGroupIds.push(audience.id.replace(this._claimGroupPrefix, ""));
}
}

const promises = [];
for (const audience of this._audiences) {
if (audience.id) {
// Check if the audience is a SharePoint group
const spGroupId = parseInt(audience.id, 10);
if (!isNaN(spGroupId)) {
promises.push(this.isCurrentUserMemberOfGroup(spGroupId));
}
}
}

// Check if the audience is a security group
if (aadGroupIds.length > 0) {
promises.push(this.isCurrentUserMemberOfAADGroup(aadGroupIds));
}

const results = await Promise.all(promises);
return results.some(result => result === true);
}


/**
* Function to check if the current user is a member of a specific SharePoint Group
* @param groupId The ID of the SharePoint group to check
* @returns true if the user is a member, false otherwise
*/
private async isCurrentUserMemberOfGroup(groupId: number): Promise<boolean> {
try {
const groupUsers = await sp.web.siteGroups.getById(groupId).users
.usingCaching({
storeName: "local",
key: `isCurrentUserMemberOfGroup-${groupId}-${this._context.pageContext.web.id.toString()}`,
expiration: new Date(new Date().getTime() + (this._audienceCacheDuration * 60 * 60 * 1000))
})();

// Check if the current user's ID is in the list of group users
return groupUsers.some(user => user.Id === this._context.pageContext.legacyPageContext.userId);
} catch (error) {
console?.error(`Error checking user membership: ${error}`);
return false;
}
}

/**
* Check if the current user is a member or transitive member of an AAD group
* @param groupId The ID of the Azure AD group
* @returns true if the user is a member of the group, false otherwise
*/
private async isCurrentUserMemberOfAADGroup(groupIds: string[]): Promise<boolean> {
try {
// Check if groupIds and timestamp are already cached in localStorage
const cachedData = localStorage.getItem(this._aadGroupCacheKey);
if (cachedData) {
const { ids, timestamp } = JSON.parse(cachedData);
const cachedTimestamp = new Date(parseInt(timestamp));
const currentTime = new Date();
const timeDiff = currentTime.getTime() - cachedTimestamp.getTime();
const hoursDiff = timeDiff / (1000 * 60 * 60);
if (hoursDiff < this._audienceCacheDuration) {
// Filter locally for the group IDs you're interested in
return groupIds.some(groupId => ids.includes(groupId));
}
}

const graphClient = await this._context.msGraphClientFactory.getClient('3');
// Get the list of groups (including nested groups) the current user is a member of
const transitiveGroups = await graphClient
.api('/me/transitiveMemberOf')
.version('v1.0')
.select('id') // Only select group IDs to reduce the payload
.get();

// Cache the groupIds and timestamp in localStorage
const transitiveGroupIds = transitiveGroups.value.map((group: any) => group.id);
const groupData = {
ids: transitiveGroupIds,
timestamp: new Date().getTime().toString()
};
localStorage.setItem(this._aadGroupCacheKey, JSON.stringify(groupData));

// Filter locally for the group IDs you're interested in
return groupIds.some(groupId => transitiveGroupIds.includes(groupId));
} catch (error) {
console?.error(`Error checking transitive AAD group membership: ${error}`);
return false;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { IPropertyFieldGroupOrPerson } from "@pnp/spfx-property-controls";

export interface IScriptEditorWebPartProps {
script: string;
useExternalScript: boolean;
Expand All @@ -6,4 +8,6 @@ export interface IScriptEditorWebPartProps {
removePadding: boolean;
spPageContextInfo: boolean;
teamsContext: boolean;
audiences: IPropertyFieldGroupOrPerson[];
audienceCacheDuration: number;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { IPropertyPaneConfiguration, IPropertyPaneField, PropertyPaneTextField,
import { IScriptEditorProps } from './components/IScriptEditorProps';
import { IScriptEditorWebPartProps } from './IScriptEditorWebPartProps';
import PropertyPaneLogo from './PropertyPaneLogo';
import { PrincipalType, PropertyFieldNumber, PropertyFieldPeoplePicker } from '@pnp/spfx-property-controls';
import { UserGroupCheck } from './AccessCheck';
import { sp } from '@pnp/sp/presets/all';

export default class ScriptEditorWebPart extends BaseClientSideWebPart<IScriptEditorWebPartProps> {
public _propertyPaneHelper;
Expand All @@ -33,9 +36,14 @@ export default class ScriptEditorWebPart extends BaseClientSideWebPart<IScriptEd
this._externalScriptContent = 'Failed to load external script.';
}
}

await super.onInit();
sp.setup({
spfxContext: this.context as any
});
}

public render(): void {
public async render(): Promise<void> {
this._unqiueId = this.context.instanceId;
if (this.displayMode == DisplayMode.Read) {
if (this.properties.removePadding) {
Expand All @@ -55,6 +63,12 @@ export default class ScriptEditorWebPart extends BaseClientSideWebPart<IScriptEd
}

ReactDom.unmountComponentAtNode(this.domElement);
if (this.properties.audiences) {
const checker = new UserGroupCheck(this.properties.audiences, this.properties.audienceCacheDuration, this.context);
const isInAudience = await checker.CheckAudiences();
if (!isInAudience) return;
}

this.domElement.innerHTML = this.properties.useExternalScript ? this._externalScriptContent : this.properties.script;
this.executeScript(this.domElement);
} else {
Expand Down Expand Up @@ -126,6 +140,25 @@ export default class ScriptEditorWebPart extends BaseClientSideWebPart<IScriptEd
onText: "Use external script",
offText: "Use inline script"
}),
PropertyFieldPeoplePicker('audiences', {
label: 'Target Audience',
initialData: this.properties.audiences,
allowDuplicate: false,
principalType: [PrincipalType.SharePoint, PrincipalType.Users, PrincipalType.Security],
onPropertyChange: this.onPropertyPaneFieldChanged,
context: this.context,
properties: this.properties,
onGetErrorMessage: null,
deferredValidationTime: 0,
key: 'audienceTargeting'
}),
PropertyFieldNumber('audienceCacheDuration', {
key: 'audienceCacheDuration',
label: 'Audience cache duration in hours',
description: 'Duration in hours to cache the audience information',
value: this.properties.audienceCacheDuration || 24,
minValue: 1,
})
];

if (this.properties.useExternalScript) {
Expand Down
Binary file modified samples/react-script-editor/tsconfig.json
Binary file not shown.

0 comments on commit c68bdfd

Please sign in to comment.