New sample: react-sp-site-user-groups.

Webpart to display a user's associated SP and AAD groups, along with associated SP site user/group ids.
This commit is contained in:
Daniel Watford 2020-09-08 15:15:22 +01:00
parent 7e8964db17
commit 83c5db4585
36 changed files with 18506 additions and 0 deletions

View File

@ -0,0 +1,25 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# change these settings to your own preference
indent_style = space
indent_size = 2
# we recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[{package,bower}.json]
indent_style = space
indent_size = 2

32
samples/react-sp-site-user-groups/.gitignore vendored Executable file
View File

@ -0,0 +1,32 @@
# Logs
logs
*.log
npm-debug.log*
# Dependency directories
node_modules
# Build generated files
dist
lib
solution
temp
*.sppkg
# Coverage directory used by tools like istanbul
coverage
# OSX
.DS_Store
# Visual Studio files
.ntvs_analysis.dat
.vs
bin
obj
# Resx Generated Code
*.resx.ts
# Styles Generated Code
*.scss.ts

View File

@ -0,0 +1,3 @@
{
"printWidth": 120
}

View File

@ -0,0 +1,12 @@
{
"@microsoft/generator-sharepoint": {
"isCreatingSolution": true,
"environment": "spo",
"version": "1.11.0",
"libraryName": "user-group-info",
"libraryId": "a6bff1c7-f1a8-4132-b14b-6b39799ceaee",
"packageManager": "npm",
"isDomainIsolated": false,
"componentType": "webpart"
}
}

View File

@ -0,0 +1,87 @@
# Site User and Group Information
## Summary
Looks up the SharePoint site user/group ids related to a user. Azure AD groups that the user belongs to, and which are known to the SharePoint site, are also displayed.
Note: Azure AD Groups are represented as Site Users in SharePoint.
## Used SharePoint Framework Version
![version](https://img.shields.io/badge/version-1.11-green.svg)
## Applies to
- [SharePoint Framework](https://aka.ms/spfx)
- [Microsoft 365 tenant](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/set-up-your-developer-tenant)
> Get your own free development tenant by subscribing to [Microsoft 365 developer program](http://aka.ms/o365devprogram)
## Prerequisites
Access to a SharePoint online site with various tenant users granted access to various site resources directly, via AAD groups and via SharePoint groups.
## Solution
| Solution | Author(s) |
| ------------------------- | ------------------------------------------------------------------------------------------------------- |
| react-sp-site-user-groups | Daniel Watford (https://twitter.com/DanWatford), Watford Consulting Ltd (https://watfordconsulting.com) |
## Version history
| Version | Date | Comments |
| ------- | ----------------- | --------------- |
| 0.0.1 | September 8, 2020 | Initial release |
## Disclaimer
**THIS CODE IS PROVIDED _AS IS_ WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
---
## Minimal Path to Awesome
- Clone this repository
- Ensure that you are at the solution folder
- in the command-line run:
- **npm install**
- **gulp trust-dev-cert**
- **gulp serve --nobrowser**
- Open the hosted workbench on a SharePoint site - i.e. https://_tenant_.sharepoint.com/site/_sitename_/_layouts/workbench.aspx
- Add the User and Group Info webpart to the page.
- View the current user's information and the 'user' information for any Azure AD groups the user belongs to which are also known to the SharePoint site.
## Features
This webpart was created to better understand the relationship between AAD users, AAD groups, SP User and SP Groups. In particular I was interested in how SP site user/group ids are mapped to the 4 principal types so that I might match a user against values entered in a SP People column which also accepted group values.
Experiment by granting permissions directly to some users for a site, directly to an AAD group for a site, and to a SP group where the membership consists of the AAD user or nested AAD groups.
Pick a user to retrieve information for by either searching for the user in the tenant-wide People Picker, or by choosing from the site's current set of users.
For the principals found that relate to a user links are provided to the relevant site user/group view.
## Display user's membership of AAD and SP groups
When the webpart is first loaded the current user is selected.
Details of the user's membership of SP and AAD groups are shown, along with corresponding SP site user/group ids (highlighted).
![MyTasks](./assets/screen1.png)
## Select User
The user can be selected from the tenant-wide People Picker control.
Click the X to clear the currently selected user and then start typing the name of the next user. The People Picker will search across all tenant users as you type.
![MyTasks](./assets/screen2.png)
You can also select a user from the current set of site users.
Click on the /Select site user/ button and choose a user from the list displayed on the panel.
![MyTasks](./assets/screen3.png)
## References
- [User profile synchronization](https://docs.microsoft.com/en-us/sharepoint/user-profile-sync)
---
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-sp-site-user-groups" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@ -0,0 +1,19 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"user-and-group-info-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/userAndGroupInfo/UserAndGroupInfoWebPart.js",
"manifest": "./src/webparts/userAndGroupInfo/UserAndGroupInfoWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"UserAndGroupInfoWebPartStrings": "lib/webparts/userAndGroupInfo/loc/{locale}.js",
"ControlStrings": "node_modules/@pnp/spfx-controls-react/lib/loc/{locale}.js"
}
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json",
"deployCdnPath": "temp/deploy"
}

View File

@ -0,0 +1,7 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
"workingDir": "./temp/deploy/",
"account": "<!-- STORAGE ACCOUNT NAME -->",
"container": "user-group-info",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1,20 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "user-group-info-client-side-solution",
"id": "a6bff1c7-f1a8-4132-b14b-6b39799ceaee",
"version": "1.0.0.0",
"includeClientSideAssets": true,
"isDomainIsolated": false,
"developer": {
"name": "",
"websiteUrl": "",
"privacyUrl": "",
"termsOfUseUrl": "",
"mpnId": ""
}
},
"paths": {
"zippedPackage": "solution/user-group-info.sppkg"
}
}

View File

@ -0,0 +1,9 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/core-build/serve.schema.json",
"port": 4321,
"https": true,
"api": {
"port": 5432,
"entryPath": "node_modules/@microsoft/sp-webpart-workbench/lib/api/"
}
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
"cdnBasePath": "<!-- PATH TO CDN -->"
}

View File

@ -0,0 +1,7 @@
'use strict';
const build = require('@microsoft/sp-build-web');
build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`);
build.initialize(require('gulp'));

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
{
"name": "user-group-info",
"version": "0.0.1",
"private": true,
"main": "lib/index.js",
"engines": {
"node": ">=0.10.0"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test"
},
"dependencies": {
"@microsoft/sp-core-library": "1.11.0",
"@microsoft/sp-lodash-subset": "1.11.0",
"@microsoft/sp-office-ui-fabric-core": "1.11.0",
"@microsoft/sp-property-pane": "1.11.0",
"@microsoft/sp-webpart-base": "1.11.0",
"@pnp/graph": "^2.0.8",
"@pnp/sp": "^2.0.8",
"@pnp/spfx-controls-react": "^1.19.0",
"office-ui-fabric-react": "6.214.0",
"react": "16.8.5",
"react-dom": "16.8.5"
},
"devDependencies": {
"@types/react": "16.8.8",
"@types/react-dom": "16.8.3",
"@microsoft/sp-build-web": "1.11.0",
"@microsoft/sp-tslint-rules": "1.11.0",
"@microsoft/sp-module-interfaces": "1.11.0",
"@microsoft/sp-webpart-workbench": "1.11.0",
"@microsoft/rush-stack-compiler-3.3": "0.3.5",
"gulp": "~3.9.1",
"@types/chai": "3.4.34",
"@types/mocha": "2.2.38",
"ajv": "~5.2.2",
"@types/webpack-env": "1.13.1",
"@types/es6-promise": "0.0.33"
}
}

View File

@ -0,0 +1 @@
// A file is required to be in the root of the /src directory by the TypeScript compiler

View File

@ -0,0 +1,55 @@
import { PrincipalType } from "@pnp/sp/presets/all";
import { graph } from "@pnp/graph";
import "@pnp/graph/users";
import "@pnp/graph/groups";
import { IUser } from "@pnp/graph/users";
export interface ISpGroupMembership {
spGroup: string | undefined;
spGroupId: number | undefined;
membershipViaPrincipalName: string;
membershipViaPrincipalType: PrincipalType;
membershipViaPrincipalSpId: number;
}
class AadUserGroupLookup {
private aadUserPromises: Map<string, Promise<any>> = new Map();
private aadUserGroupIdsPromises: Map<string, Promise<string[]>> = new Map();
public async getAadUser(email: string | undefined): Promise<IUser> {
if (this.aadUserPromises.has(email)) {
return this.aadUserPromises.get(email);
} else {
let aadUserPromise: Promise<IUser>;
if (email) {
console.debug("Getting AAD user by email:", email);
aadUserPromise = graph.users.getById(email).get();
} else {
console.debug("Getting AAD user for current user");
aadUserPromise = graph.me() as Promise<IUser>;
}
this.aadUserPromises.set(email, aadUserPromise);
return aadUserPromise;
}
}
public getAadUserGroupIds(email: string): Promise<string[]> {
if (this.aadUserGroupIdsPromises.has(email)) {
return this.aadUserGroupIdsPromises.get(email);
} else {
let aadUserGroupIds: Promise<string[]>;
if (email) {
console.debug("Getting AAD group ids for user by email:", email);
aadUserGroupIds = graph.users.getById(email).getMemberGroups();
} else {
console.debug("Getting AAD group ids for current user");
aadUserGroupIds = graph.me.getMemberGroups();
}
this.aadUserGroupIdsPromises.set(email, aadUserGroupIds);
return aadUserGroupIds;
}
}
}
export default AadUserGroupLookup;

View File

@ -0,0 +1,201 @@
import { sp, PrincipalType, ISiteGroupInfo } from "@pnp/sp/presets/all";
import { HttpRequestError } from "@pnp/odata";
import "@pnp/graph/users";
import "@pnp/graph/groups";
import { ISiteUserInfo } from "@pnp/sp/site-users/types";
import AadUserGroupLookup from "./AadUserGroupLookup";
export interface ISpGroupMembership {
spGroup: string | undefined;
spGroupId: number | undefined;
membershipViaPrincipalName: string;
membershipViaPrincipalType: PrincipalType;
membershipViaPrincipalSpId: number;
}
class SpUserGroupLookup {
private aadUserGroupLookup: AadUserGroupLookup;
private spUserAndMemberGroupsPromises: Map<number, Promise<ISiteUserInfo>> = new Map();
private aadGroupSpMembershipsPromises: Map<string, Promise<ISpGroupMembership[]>> = new Map();
constructor(aadUserGroupLookup: AadUserGroupLookup) {
this.aadUserGroupLookup = aadUserGroupLookup;
}
/**
* Returns the SharePoint Site User and Group Ids related to the given user.
* @param siteUserId The SharePoint site user Id of the user to retrieve information for.
* If siteUserId is 0 then the current SP site user's information is retrieved.
* @param email The email address of AAD user to retrieve information for. If email is
* undefined then the current AAD user's information is retrieved.
*/
public async getRelatedSiteUserAndGroupIds(siteUserId: number, email: string): Promise<number[]> {
const ids = new Set<number>();
const siteUserInfo = await this.getSpUserAndMemberGroupsPromise(siteUserId);
ids.add(siteUserInfo.Id);
const memberships = await this.getUserMemberships(siteUserId, email);
memberships.forEach((membership) => {
ids.add(membership.membershipViaPrincipalSpId);
if (membership.spGroupId) {
ids.add(membership.spGroupId);
}
});
return Array.from(ids);
}
/**
* Returns the specified user's membership of SP site groups where:
* - The user is a directly assigned member of the SP site group.
* - The user is a member of an AAD group which is itself a member of the SP site group.
*
* Included in the results are cases where the user is a member of an AAD group which
* is known to the SP site, and the AAD group is therefore represented as an SP site user,
* but where the AAD group is not a member of any SP site group.
*
* @param siteUserId The SharePoint site user Id of the user to retrieve information for.
* If siteUserId is 0 then the current SP site user's information is retrieved.
* @param email The email address of AAD user to retrieve information for. If email is
* undefined then the current AAD user's information is retrieved.
*/
public async getUserMemberships(siteUserId: number, email: string): Promise<ISpGroupMembership[]> {
console.debug("Get user memberships", siteUserId, email);
const userDirectMemberships = await this.getSpUserMemberships(siteUserId);
const aadGroupMemberships = await this.getAadGroupSpMemberships(email);
return [...userDirectMemberships, ...aadGroupMemberships];
}
/**
* Returns the ISiteUserInfo of the site user with the given siteUserId. The resultant ISiteUserInfo
* has the Groups property expanded.
* @param siteUserId The SharePoint site user to retrieve the ISiteUserInfo object for.
* If siteUserId is 0 then the current SP site user's information is retrieved. If siteUserId is undefined then a
* promise resolved with underfined is returned.
*/
public getSpUserAndMemberGroupsPromise(siteUserId: number): Promise<ISiteUserInfo | undefined> {
if (this.spUserAndMemberGroupsPromises.has(siteUserId)) {
return this.spUserAndMemberGroupsPromises.get(siteUserId);
} else {
let spUserAndMemberGroupsPromise: Promise<ISiteUserInfo>;
if (siteUserId) {
spUserAndMemberGroupsPromise = sp.web.getUserById(siteUserId).expand("Groups").get();
} else if (siteUserId === 0) {
spUserAndMemberGroupsPromise = sp.web.currentUser.expand("Groups").get();
} else {
spUserAndMemberGroupsPromise = Promise.resolve(undefined);
}
this.spUserAndMemberGroupsPromises.set(siteUserId, spUserAndMemberGroupsPromise);
return spUserAndMemberGroupsPromise;
}
}
public async getSpSiteUserByLoginName(loginName: string): Promise<ISiteUserInfo | undefined> {
try {
const user = await sp.web.siteUsers.getByLoginName(loginName).get();
console.debug("Get SP site user by login name", loginName, user);
return user;
} catch (err) {
console.debug("Exception when getting site user by loginName", loginName, err);
if (await this.isUserNotFoundException(err)) {
return undefined;
} else {
throw err;
}
}
}
public async getSpSiteUsers(): Promise<ISiteUserInfo[]> {
return sp.web.siteUsers.filter("PrincipalType eq " + PrincipalType.User).get();
}
private async getSpUserMemberships(siteUserId: number): Promise<ISpGroupMembership[]> {
const siteUserInfo = await this.getSpUserAndMemberGroupsPromise(siteUserId);
// There MUST be a better way to do this rather than casting.
// We know that the ISiteUserInfo was expanded to include Groups, but the ISiteUserInfo type
// doesn't have the Groups property. There is probably something I should be doing with union
// types here!
if (siteUserInfo) {
const siteGroups = (siteUserInfo as any).Groups as ISiteGroupInfo[];
if (siteGroups.length) {
return siteGroups.map((siteGroup) => {
return {
spGroup: siteGroup.Title,
spGroupId: siteGroup.Id,
membershipViaPrincipalName: siteUserInfo.Title,
membershipViaPrincipalType: siteUserInfo.PrincipalType,
membershipViaPrincipalSpId: siteUserInfo.Id,
};
});
} else {
return [
{
spGroup: undefined,
spGroupId: undefined,
membershipViaPrincipalName: siteUserInfo.Title,
membershipViaPrincipalType: siteUserInfo.PrincipalType,
membershipViaPrincipalSpId: siteUserInfo.Id,
},
];
}
} else {
return [];
}
}
private getAadGroupSpMemberships(email: string): Promise<ISpGroupMembership[]> {
if (this.aadGroupSpMembershipsPromises.has(email)) {
return this.aadGroupSpMembershipsPromises.get(email);
} else {
const aadGroupSpMemberships = this.populateAadGroupsAsSpUsers(email);
this.aadGroupSpMembershipsPromises.set(email, aadGroupSpMemberships);
return aadGroupSpMemberships;
}
}
private async populateAadGroupsAsSpUsers(email: string): Promise<ISpGroupMembership[]> {
const aadGroupIds = await this.aadUserGroupLookup.getAadUserGroupIds(email);
console.debug("Retrieved AAD group ids for user", email, aadGroupIds);
if (aadGroupIds.length === 0) {
return Promise.resolve([]);
}
const filter = aadGroupIds.map((id) => `substringof('|${id}',LoginName)`).join(" or ");
const groupSiteUserInfos = await sp.web.siteUsers.filter(filter).get();
console.debug("Found SP site users corresponding to AAD groups", groupSiteUserInfos);
const groupSiteUserMembershipsPromises = groupSiteUserInfos.map((groupSiteUserInfo) =>
this.getSpUserMemberships(groupSiteUserInfo.Id)
);
const groupSiteUserMemberships = await Promise.all(groupSiteUserMembershipsPromises);
return ([] as ISpGroupMembership[]).concat(...groupSiteUserMemberships);
}
private isHttpRequestError(e): e is HttpRequestError {
return (e as HttpRequestError).isHttpRequestError;
}
/**
* Checks whether the given exception indicates that a user could not be found.
* @param e The exception to test.
*/
private async isUserNotFoundException(e): Promise<boolean> {
if (this.isHttpRequestError(e)) {
const json = await e.response.json();
// Error code documented at https://docs.microsoft.com/en-us/previous-versions/office/sharepoint-csom/ee540879(v=office.15)
return (
e.status === 500 &&
typeof json["odata.error"] === "object" &&
json["odata.error"].code.startsWith("-2146232832,")
);
} else {
return false;
}
}
}
export default SpUserGroupLookup;

View File

@ -0,0 +1,29 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "e1b9eac6-4d68-4ee7-ab2a-9a90496158d4",
"alias": "UserAndGroupInfoWebPart",
"componentType": "WebPart",
// The "*" signifies that the version should be taken from the package.json
"version": "*",
"manifestVersion": 2,
// If true, the component can only be installed on sites where Custom Script is allowed.
// Components that allow authors to embed arbitrary script code should set this to true.
// https://support.office.com/en-us/article/Turn-scripting-capabilities-on-or-off-1f2c515f-5d7e-448a-9fd7-835da935584f
"requiresCustomScript": false,
"supportedHosts": ["SharePointWebPart"],
"preconfiguredEntries": [
{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Other
"group": { "default": "Other" },
"title": { "default": "User and Group Info" },
"description": { "default": "User and Group Info description" },
"officeFabricIconFontName": "Page",
"properties": {
"description": "User and Group Info"
}
}
]
}

View File

@ -0,0 +1,62 @@
import { override } from "@microsoft/decorators";
import { setup as pnpSetup } from "@pnp/common";
import * as React from "react";
import * as ReactDom from "react-dom";
import { Version } from "@microsoft/sp-core-library";
import { IPropertyPaneConfiguration, PropertyPaneTextField } from "@microsoft/sp-property-pane";
import { BaseClientSideWebPart } from "@microsoft/sp-webpart-base";
import * as strings from "UserAndGroupInfoWebPartStrings";
import UserAndGroupInfo from "./components/UserAndGroupInfo";
import { IUserAndGroupInfoProps } from "./components/IUserAndGroupInfoProps";
export interface IUserAndGroupInfoWebPartProps {
description: string;
}
export default class UserAndGroupInfoWebPart extends BaseClientSideWebPart<IUserAndGroupInfoWebPartProps> {
@override
public async onInit(): Promise<void> {
await super.onInit();
console.debug("Initialising SPFX context", this.context);
pnpSetup({ spfxContext: this.context });
}
public render(): void {
const element: React.ReactElement<IUserAndGroupInfoProps> = React.createElement(UserAndGroupInfo, {
context: this.context,
});
ReactDom.render(element, this.domElement);
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version {
return Version.parse("1.0");
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
header: {
description: strings.PropertyPaneDescription,
},
groups: [
{
groupName: strings.BasicGroupName,
groupFields: [
PropertyPaneTextField("description", {
label: strings.DescriptionFieldLabel,
}),
],
},
],
},
],
};
}
}

View File

@ -0,0 +1,6 @@
import { ISiteUserInfo } from "@pnp/sp/site-users/types";
import { WebPartContext } from "@microsoft/sp-webpart-base";
export interface IUserAndGroupInfoProps {
context: WebPartContext;
}

View File

@ -0,0 +1,87 @@
import * as React from "react";
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { useConstCallback } from "@uifabric/react-hooks";
import { DefaultButton } from "office-ui-fabric-react";
import { Panel } from "office-ui-fabric-react/lib/Panel";
import { TextField } from "office-ui-fabric-react/lib/TextField";
import { List } from "office-ui-fabric-react/lib/List";
import { Persona } from "office-ui-fabric-react/lib/Persona";
import SpUserGroupLookup from "../../../services/SpUserGroupLookup";
import { ISiteUserInfo } from "@pnp/sp/site-users/types";
import styles from "./UserAndGroupInfo.module.scss";
export interface ISiteUserPickerProps {
context: WebPartContext;
spUserGroupLookup: SpUserGroupLookup;
onSelectedUserChanged: (siteUserId: number, email: string) => void;
}
const SiteUserPicker: React.FunctionComponent<ISiteUserPickerProps> = (props) => {
const [isOpen, setIsOpen] = React.useState(false);
const [filter, setFilter] = React.useState("");
const [siteUserInfos, setSiteUserInfos] = React.useState([] as ISiteUserInfo[]);
const [filteredSiteUserInfos, setFilteredSiteUserInfos] = React.useState([]);
const [selectedUser, setSelectedUser] = React.useState(undefined);
React.useEffect(() => {
props.spUserGroupLookup.getSpSiteUsers().then((_) => setSiteUserInfos(_));
}, [props.spUserGroupLookup]);
React.useEffect(() => {
if (filter) {
setFilteredSiteUserInfos(siteUserInfos.filter((i) => i.Title.toLowerCase().includes(filter.toLowerCase())));
} else {
setFilteredSiteUserInfos(siteUserInfos);
}
}, [filter, siteUserInfos]);
React.useEffect(() => {
setFilteredSiteUserInfos([...filteredSiteUserInfos]);
}, [selectedUser]);
const openPanel = useConstCallback(() => setIsOpen(true));
const dismissPanel = useConstCallback(() => setIsOpen(false));
const onFilter = (ev: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, text: string): void => {
setFilter(text);
};
const personaClickedHandler = (siteUserInfo: ISiteUserInfo) => {
setSelectedUser(siteUserInfo);
setIsOpen(false);
props.onSelectedUserChanged(siteUserInfo.Id, siteUserInfo.Email);
};
const onRenderCell = (item: ISiteUserInfo) => {
const classes = [styles.SiteUser];
if (item === selectedUser) {
classes.push(styles.Active);
}
return (
<div className={classes.join(" ")} onClick={() => personaClickedHandler(item)}>
<Persona text={item.Title} />
</div>
);
};
return (
<>
<DefaultButton text="Select site user" onClick={openPanel} />
<Panel
isOpen={isOpen}
isLightDismiss
onDismiss={dismissPanel}
closeButtonAriaLabel="Close"
headerText="Select site user"
>
<TextField label="Filter by Name:" value={filter} onChange={onFilter} />
<List className={styles.SiteUserList} items={filteredSiteUserInfos} onRenderCell={onRenderCell} />
</Panel>
</>
);
};
export default SiteUserPicker;

View File

@ -0,0 +1,35 @@
import * as React from "react";
import { PeoplePicker, PrincipalType } from "@pnp/spfx-controls-react/lib/PeoplePicker";
import { WebPartContext } from "@microsoft/sp-webpart-base";
export interface ITenantUserPickerProps {
context: WebPartContext;
pickedUserEmail: string;
onPickedTenantUserChanged: (loginName: string) => void;
}
const TenantUserPicker: React.FunctionComponent<ITenantUserPickerProps> = (props) => {
const selectedItemsHandler = (items: any[]) => {
console.debug("TenantUserPicked selection handler", items);
if (items.length) {
const item = items[0];
const loginName: string = item.loginName;
props.onPickedTenantUserChanged(loginName);
} else {
props.onPickedTenantUserChanged(undefined);
}
};
return (
<PeoplePicker
context={props.context}
placeholder="Enter tenant user name"
principalTypes={[PrincipalType.User]}
selectedItems={selectedItemsHandler}
ensureUser={false}
defaultSelectedUsers={[props.pickedUserEmail]}
/>
);
};
export default TenantUserPicker;

View File

@ -0,0 +1,23 @@
@import "~office-ui-fabric-react/dist/sass/References.scss";
.userAndGroupInfo {
.container {
max-width: 800px;
padding: 10px;
margin: 0px auto;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1);
.UserSelection {
.TenantUserPicker {
width: 200px;
}
.SiteUserPicker {
.SiteUserList {
.SiteUser .Active {
background-color: gray;
}
}
}
}
}
}

View File

@ -0,0 +1,61 @@
import * as React from "react";
import styles from "./UserAndGroupInfo.module.scss";
import { IUserAndGroupInfoProps } from "./IUserAndGroupInfoProps";
import SpUserGroupLookup from "../../../services/SpUserGroupLookup";
import AadUserGroupLookup from "../../../services/AadUserGroupLookup";
import UserInfo from "./UserInfo";
import UserGroupMemberships from "./UserGroupMemberships";
import UserSelection from "./UserSelection";
const UserAndGroupInfo: React.FunctionComponent<IUserAndGroupInfoProps> = (props) => {
const [aadUserGroupLookup] = React.useState(new AadUserGroupLookup());
const [userGroupLookup] = React.useState(new SpUserGroupLookup(aadUserGroupLookup));
// We use a SiteUserId of 0 to refer to the current user.
const [siteUserId, setSiteUserId] = React.useState(0);
const [email, setEmail] = React.useState(undefined);
// Since we are defaulting to the current user we can consider that a user is selected.
const [userIsSelected, setUserIsSelected] = React.useState(true);
const onSelectedUserChanged = (selectedSiteUserId: number, selectedEmail: string) => {
console.debug("Selected user changed.", selectedSiteUserId, selectedEmail);
setSiteUserId(selectedSiteUserId);
setEmail(selectedEmail);
setUserIsSelected(selectedSiteUserId !== undefined || selectedEmail !== undefined);
};
let selectedUserContent;
if (userIsSelected) {
selectedUserContent = (
<>
<UserInfo
context={props.context}
siteUserInfoPromise={userGroupLookup.getSpUserAndMemberGroupsPromise(siteUserId)}
aadUserPromise={aadUserGroupLookup.getAadUser(email)}
/>
<UserGroupMemberships
context={props.context}
membershipsPromise={userGroupLookup.getUserMemberships(siteUserId, email)}
/>
</>
);
} else {
selectedUserContent = <p>Please select a user.</p>;
}
return (
<div className={styles.userAndGroupInfo}>
<div className={styles.container}>
<UserSelection
context={props.context}
userGroupLookup={userGroupLookup}
onSelectedUserChanged={onSelectedUserChanged}
/>
{selectedUserContent}
</div>
</div>
);
};
export default UserAndGroupInfo;

View File

@ -0,0 +1,145 @@
import * as React from "react";
import { useState } from "react";
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { DetailsList, IColumn } from "office-ui-fabric-react/lib/DetailsList";
import { Link } from "office-ui-fabric-react";
import { PrincipalType } from "@pnp/sp";
import { ISpGroupMembership } from "../../../services/SpUserGroupLookup";
export interface IUserGroupMembershipsProps {
context: WebPartContext;
membershipsPromise: Promise<ISpGroupMembership[]>;
}
const UserGroupMemberships: React.FunctionComponent<IUserGroupMembershipsProps> = (props) => {
const [loading, setLoading] = useState(true);
const [memberships, setMemberships] = useState(undefined as ISpGroupMembership[]);
React.useEffect(() => {
if (props.membershipsPromise) {
props.membershipsPromise.then((spGroupMemberships) => {
setMemberships(spGroupMemberships);
setLoading(false);
});
}
}, [props.membershipsPromise]);
const columns: IColumn[] = [
{
key: "spGroup",
name: "SP Group (SP Id)",
minWidth: 200,
onRender: (item) => {
const membership = item as ISpGroupMembership;
return membership.spGroupId ? (
<span>
<Link
target="_blank"
data-interception="off"
href={props.context.pageContext.web.absoluteUrl + "/_layouts/userdisp.aspx?ID=" + membership.spGroupId}
>
{membership.spGroup} ({membership.spGroupId})
</Link>
</span>
) : (
<span>none</span>
);
},
isResizable: true,
},
{
key: "membershipViaPrincipal",
name: "Membership Via Principal",
minWidth: 200,
onRender: (item) => {
const membership = item as ISpGroupMembership;
return (
<span>
<Link
target="_blank"
data-interception="off"
href={
props.context.pageContext.web.absoluteUrl +
"/_layouts/userdisp.aspx?ID=" +
membership.membershipViaPrincipalSpId
}
>
{membership.membershipViaPrincipalName}
</Link>
</span>
);
},
isResizable: true,
},
{
key: "membershipViaPrincipalType",
name: "Principal Type",
minWidth: 100,
onRender: (item) => {
const membership = item as ISpGroupMembership;
const principalTypeNames: string[] = [];
const principalType = membership.membershipViaPrincipalType;
if (principalType & PrincipalType.User) {
principalTypeNames.push("User");
}
if (principalType & PrincipalType.DistributionList) {
principalTypeNames.push("DistributionList");
}
if (principalType & PrincipalType.SecurityGroup) {
principalTypeNames.push("SecurityGroup");
}
if (principalType & PrincipalType.SharePointGroup) {
principalTypeNames.push("SharePointGroup");
}
return (
<span>
{principalTypeNames.join(" ")} ({principalType})
</span>
);
},
isResizable: true,
},
{
key: "membershipViaPrincipalSpId",
name: "Principal's SP Id",
minWidth: 100,
onRender: (item) => {
const membership = item as ISpGroupMembership;
return (
<span>
<Link
target="_blank"
data-interception="off"
href={
props.context.pageContext.web.absoluteUrl +
"/_layouts/userdisp.aspx?ID=" +
membership.membershipViaPrincipalSpId
}
>
{membership.membershipViaPrincipalSpId}
</Link>
</span>
);
},
isResizable: true,
},
];
return loading ? (
<h4>User's Group Information - Loading...</h4>
) : (
<div>
<h4>User's Group Information</h4>
{memberships.length ? (
<DetailsList items={memberships} columns={columns} />
) : (
<p style={{ textAlign: "center" }}>User is not a member of any AAD groups known to this SharePoint site</p>
)}
</div>
);
};
export default UserGroupMemberships;

View File

@ -0,0 +1,133 @@
import * as React from "react";
import { useState } from "react";
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { ISiteUserInfo } from "@pnp/sp/site-users/types";
import { IUser } from "@pnp/graph/users";
import { DetailsList, IColumn } from "office-ui-fabric-react/lib/DetailsList";
import { Link } from "office-ui-fabric-react";
export interface IUserInfoProps {
context: WebPartContext;
siteUserInfoPromise: Promise<ISiteUserInfo | undefined>;
aadUserPromise: Promise<IUser>;
}
const UserInfo: React.FunctionComponent<IUserInfoProps> = (props) => {
const [spLoading, setSpLoading] = useState(true);
const [isSpUser, setIsSpUser] = useState(false);
const [spUserInfo, setSpUserInfo] = useState([]);
const [aadLoading, setAadLoading] = useState(true);
const [aadUserInfo, setAadUserInfo] = useState([]);
const renderValueCell = (item?: any) => {
switch (item.propertyName) {
case "SP Site UserId":
return (
<span>
<Link
target="_blank"
data-interception="off"
href={props.context.pageContext.web.absoluteUrl + "/_layouts/userdisp.aspx?ID=" + item.value}
>
{item.value}
</Link>
</span>
);
default:
return <span>{item.value}</span>;
}
};
const userInfoColumns: IColumn[] = [
{
key: "propertyName",
name: "Property",
fieldName: "propertyName",
minWidth: 100,
maxWidth: 150,
isResizable: true,
},
{
key: "propertyValue",
name: "Value",
fieldName: "value",
minWidth: 200,
maxWidth: 1600,
isResizable: true,
onRender: renderValueCell,
},
];
React.useEffect(() => {
setSpLoading(true);
if (props.siteUserInfoPromise) {
props.siteUserInfoPromise.then((siteUserInfo) => {
if (siteUserInfo) {
const newSpUserInfo = [
{ propertyName: "Title", value: siteUserInfo.Title },
{ propertyName: "SP Site UserId", value: siteUserInfo.Id },
{ propertyName: "Login Name", value: siteUserInfo.LoginName },
{ propertyName: "Is Site Admin", value: siteUserInfo.IsSiteAdmin ? "Yes" : "No" },
];
setSpUserInfo(newSpUserInfo);
setIsSpUser(true);
} else {
setIsSpUser(false);
}
setSpLoading(false);
});
}
}, [props.siteUserInfoPromise]);
React.useEffect(() => {
setAadLoading(true);
if (props.aadUserPromise) {
props.aadUserPromise.then((user: IUser) => {
const userObj: { id: string; displayName: string; mail: string } = user as any;
const newAadUserInfo = [
{ propertyName: "Display Name", value: userObj.displayName },
{ propertyName: "Mail", value: userObj.mail },
{ propertyName: "Id", value: userObj.id },
];
setAadUserInfo(newAadUserInfo);
setAadLoading(false);
});
}
}, [props.aadUserPromise]);
const renderAadDetails = () => {
return (
<>
<h4>User's AAD (graph) Information</h4>
<DetailsList items={aadUserInfo} columns={userInfoColumns} isHeaderVisible={false} />
</>
);
};
const renderSpSiteDetails = () => {
return (
<>
<h4>SharePoint Site User Information</h4>
{isSpUser ? (
<DetailsList items={spUserInfo} columns={userInfoColumns} isHeaderVisible={false} />
) : (
<p style={{ textAlign: "center" }}>User is not a member of this SharePoint site</p>
)}
</>
);
};
const aadInformation = aadLoading ? <h4>User's AAD (graph) Information - Loading...</h4> : renderAadDetails();
const spInformation = spLoading ? <h4>SharePoint Site User Information - Loading...</h4> : renderSpSiteDetails();
return (
<>
{aadInformation}
{spInformation}
</>
);
};
export default UserInfo;

View File

@ -0,0 +1,62 @@
import * as React from "react";
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { Stack } from "office-ui-fabric-react";
import TenantUserPicker from "./TenantUserPicker";
import SpUserGroupLookup from "../../../services/SpUserGroupLookup";
import SiteUserPicker from "./SiteUserPicker";
import styles from "./UserAndGroupInfo.module.scss";
export interface IUserSelectionProps {
context: WebPartContext;
userGroupLookup: SpUserGroupLookup;
onSelectedUserChanged: (siteUserId: number, email: string) => void;
}
const UserSelection: React.FunctionComponent<IUserSelectionProps> = (props) => {
const [pickedUserEmail, setPickedUserEmail] = React.useState(props.context.pageContext.user.email);
const pickedTenantUserHandler = (loginName: string) => {
if (loginName) {
const email = loginName.substring(loginName.lastIndexOf("|") + 1);
props.userGroupLookup.getSpSiteUserByLoginName(loginName).then((userInfo) => {
setPickedUserEmail(email);
if (userInfo) {
props.onSelectedUserChanged(userInfo.Id, email);
} else {
props.onSelectedUserChanged(undefined, email);
}
});
} else {
setPickedUserEmail(undefined);
props.onSelectedUserChanged(undefined, undefined);
}
};
const pickedSiteUserHandler = (siteUserId: number, email: string) => {
setPickedUserEmail(email);
props.onSelectedUserChanged(siteUserId, email);
};
return (
<Stack horizontal disableShrink>
<div className={styles.TenantUserPicker}>
<TenantUserPicker
context={props.context}
pickedUserEmail={pickedUserEmail}
onPickedTenantUserChanged={pickedTenantUserHandler}
/>
</div>
<div className={styles.SiteUserPicker}>
<SiteUserPicker
context={props.context}
spUserGroupLookup={props.userGroupLookup}
onSelectedUserChanged={pickedSiteUserHandler}
/>
</div>
</Stack>
);
};
export default UserSelection;

View File

@ -0,0 +1,7 @@
define([], function() {
return {
"PropertyPaneDescription": "Description",
"BasicGroupName": "Group Name",
"DescriptionFieldLabel": "Description Field"
}
});

View File

@ -0,0 +1,10 @@
declare interface IUserAndGroupInfoWebPartStrings {
PropertyPaneDescription: string;
BasicGroupName: string;
DescriptionFieldLabel: string;
}
declare module 'UserAndGroupInfoWebPartStrings' {
const strings: IUserAndGroupInfoWebPartStrings;
export = strings;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 B

View File

@ -0,0 +1,23 @@
{
"extends": "./node_modules/@microsoft/rush-stack-compiler-3.3/includes/tsconfig-web.json",
"compilerOptions": {
"target": "es5",
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"jsx": "react",
"declaration": true,
"sourceMap": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"outDir": "lib",
"inlineSources": false,
"strictNullChecks": false,
"noUnusedLocals": false,
"typeRoots": ["./node_modules/@types", "./node_modules/@microsoft"],
"types": ["es6-promise", "webpack-env"],
"lib": ["es6", "dom", "es2015.collection"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "lib"]
}

View File

@ -0,0 +1,30 @@
{
"extends": "@microsoft/sp-tslint-rules/base-tslint.json",
"rules": {
"class-name": false,
"export-name": false,
"forin": false,
"label-position": false,
"member-access": true,
"no-arg": false,
"no-console": false,
"no-construct": false,
"no-duplicate-variable": true,
"no-eval": false,
"no-function-expression": true,
"no-internal-module": true,
"no-shadowed-variable": true,
"no-switch-case-fall-through": true,
"no-unnecessary-semicolons": true,
"no-unused-expression": true,
"no-use-before-declare": true,
"no-with-statement": true,
"semicolon": true,
"trailing-comma": false,
"typedef": false,
"typedef-whitespace": false,
"use-named-parameter": true,
"variable-name": false,
"whitespace": false
}
}