Added Delete User Dialog and graph calls

This commit is contained in:
Nick Brown 2022-08-26 13:50:11 +01:00
parent 15f04cf57e
commit 4d06a84754
9 changed files with 309 additions and 3513 deletions

View File

@ -13,7 +13,6 @@
},
"externals": {},
"localizedResources": {
"GroupMembershipManagerWebPartStrings": "lib/webparts/groupMembershipManager/loc/{locale}.js",
"ControlStrings": "node_modules/@pnp/spfx-controls-react/lib/loc/{locale}.js"
"GroupMembershipManagerWebPartStrings": "lib/webparts/groupMembershipManager/loc/{locale}.js"
}
}

View File

@ -42,6 +42,10 @@
{
"resource": "Microsoft Graph",
"scope": "GroupMember.ReadWrite.All"
},
{
"resource": "Microsoft Graph",
"scope": "Group.ReadWrite.All"
}
]
},

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,6 @@
"@microsoft/sp-office-ui-fabric-core": "1.15.2",
"@microsoft/sp-property-pane": "1.15.2",
"@microsoft/sp-webpart-base": "1.15.2",
"@pnp/spfx-controls-react": "^3.10.0",
"react": "16.13.1",
"react-dom": "16.13.1",
"tslib": "2.3.1"

View File

@ -1,5 +1,6 @@
import * as React from 'react';
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
import styles from './GroupMembershipManager.module.scss';
import * as strings from 'GroupMembershipManagerWebPartStrings'
import { Dialog, DialogTrigger, DialogSurface, DialogTitle, DialogBody, DialogActions, Alert } from "@fluentui/react-components/unstable";
import { Button, Checkbox, Divider, Input, Label, Spinner, useId } from "@fluentui/react-components";
@ -8,6 +9,8 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
import { MSGraphClientV3 } from '@microsoft/sp-http';
import { PersonaSize } from 'office-ui-fabric-react/lib/Persona';
import SPFxPeopleCard from './SPFxPeopleCard';
import { GraphError } from '@microsoft/microsoft-graph-client';
export enum AddUserMode { Member, Owner }
enum rState { Idle, Running, Error, Completed }
@ -26,14 +29,29 @@ export default function AddUser({ Group, Mode, context, onCompleted }: Props): R
const [users, setUsers] = React.useState<MicrosoftGraph.Person[]>([]);
const [_error, setError] = React.useState<string>(null);
const [running, setRunning] = React.useState<rState>(rState.Idle);
const add = (): void => {
setRunning(rState.Running);
setTimeout(() => setRunning(rState.Completed), 10000);
};
const handleError = (error: unknown): void => {
console.error(error);
setError(error.toString());
setRunning(rState.Error);
};
const add = async (): Promise<void> => {
setRunning(rState.Running);
try {
const client: MSGraphClientV3 = await context.msGraphClientFactory.getClient("3");
client.api(`/groups/${Group.id}`).update(Mode === AddUserMode.Member ? {
"members@odata.bind": users.map(m => `https://graph.microsoft.com/v1.0/directoryObjects/${m.id}`)
} : {
"owners@odata.bind": users.map(m => `https://graph.microsoft.com/v1.0/directoryObjects/${m.id}`)
}, (e: GraphError, response ) => {
if (e) throw e.message;
else setRunning(rState.Completed)
}).catch(handleError);
} catch (error) {
handleError(error);
}
};
React.useEffect(() => {
@ -47,9 +65,9 @@ export default function AddUser({ Group, Mode, context, onCompleted }: Props): R
React.useEffect(() => {
if (searchTerm && searchTerm !== "") {
context.msGraphClientFactory.getClient("3").then((client: MSGraphClientV3) => {
client.api('/me/people').search(searchTerm).filter("personType/subclass eq 'OrganizationUser'").get((error, response: { value: MicrosoftGraph.Person[] }) => {
if (error) throw error;
setSearchResults(response.value);
client.api('/me/people').search(searchTerm).filter("personType/subclass eq 'OrganizationUser'").get((error: GraphError, response: { value: MicrosoftGraph.Person[] }) => {
if (error) throw error.message;
else setSearchResults(response.value);
}).catch(handleError);
}).catch(handleError);
} else setSearchResults(null)
@ -66,12 +84,12 @@ export default function AddUser({ Group, Mode, context, onCompleted }: Props): R
<DialogSurface aria-label="label">
<DialogTitle>{Mode === AddUserMode.Owner ? strings.AddDialogTitleOwner : strings.AddDialogTitle}{Group.displayName}</DialogTitle>
<DialogBody>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div className={styles.stack}>
{running !== rState.Error && _error && <Alert intent="error">{_error}</Alert>}
{running === rState.Error && <Alert intent="error" action={{ children: 'Retry', onClick: () => setRunning(rState.Running) }}>{_error}</Alert>}
{running === rState.Completed && <Alert intent="success">{strings.Owners} {strings.Added} {Group.displayName}</Alert>}
{users.length > 0 && <div style={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', gap: '10px' }}>
{users.map(u => <div key={u.id} style={{ display: 'flex', flexDirection: 'row', gap: '10px', maxWidth: 200, whiteSpace: 'nowrap' }}>
{running === rState.Completed && <Alert intent="success">{Mode === AddUserMode.Owner ? strings.Owners : strings.Members} {strings.Added} {Group.displayName}</Alert>}
{users.length > 0 && <div className={styles.stackHoz} style={{ flexWrap: 'wrap' }}>
{users.map(u => <div key={u.id} className={styles.stackHoz} style={{ maxWidth: 200, whiteSpace: 'nowrap' }}>
{running !== rState.Completed && <Checkbox disabled={running === rState.Running} defaultChecked onChange={(e, d?) => setUsers(d?.checked ? users.concat([u]) : users.filter(_u => _u.id !== u.id)) } />}
<SPFxPeopleCard primaryText={u.displayName} serviceScope={context.serviceScope} email={u.userPrincipalName} size={PersonaSize.size24} secondaryText={u.scoredEmailAddresses[0].address} />
</div>)}
@ -80,7 +98,7 @@ export default function AddUser({ Group, Mode, context, onCompleted }: Props): R
<Divider />
<Label htmlFor={inputId}>{strings.Search}</Label>
<Input placeholder={strings.SearchPlaceholder} onChange={(e, d) => setSearchTerm(d.value)} id={inputId} />
{searchResults && searchResults.map(u => <div key={u.id} style={{ display: 'flex', flexDirection: 'row', gap: '10px' }}>
{searchResults && searchResults.map(u => <div key={u.id} className={styles.stackHoz}>
<Checkbox onChange={(e, d?) => setUsers(d?.checked ? users.concat([u]) : users.filter(_u => _u.id !== u.id)) } />
<SPFxPeopleCard primaryText={u.displayName} serviceScope={context.serviceScope} email={u.userPrincipalName} size={PersonaSize.size24} secondaryText={u.scoredEmailAddresses[0].address} />
</div>)}

View File

@ -0,0 +1,106 @@
import * as React from 'react';
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
import styles from './GroupMembershipManager.module.scss';
import * as strings from 'GroupMembershipManagerWebPartStrings'
import { Dialog, DialogTrigger, DialogSurface, DialogTitle, DialogBody, DialogActions, Alert } from "@fluentui/react-components/unstable";
import { Button, Spinner } from "@fluentui/react-components";
import { AddUserMode } from './AddUser';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { PersonDeleteRegular } from '@fluentui/react-icons';
import { MSGraphClientV3 } from '@microsoft/sp-http';
import { PersonaSize } from 'office-ui-fabric-react/lib/Persona';
import SPFxPeopleCard from './SPFxPeopleCard';
import { BatchRequestStep, BatchRequestContent, BatchRequestBody, BatchResponseContent } from '@microsoft/microsoft-graph-client';
enum rState { Idle, Running, Error, Completed }
type Props = {
Group: MicrosoftGraph.Group,
Mode: AddUserMode,
Users: MicrosoftGraph.User[]
context: WebPartContext,
onCompleted?: () => void
}
export default function RemoveUser({ Group, Users, Mode, context, onCompleted }: Props): React.ReactElement<Props> {
const [open, setOpen] = React.useState(false);
const [_error, setError] = React.useState<string>(null);
const [running, setRunning] = React.useState<rState>(rState.Idle);
const handleError = (error: unknown): void => {
console.error(error);
setError(error.toString());
setRunning(rState.Error);
};
const remove = async (): Promise<void> => {
setRunning(rState.Running);
try {
const client: MSGraphClientV3 = await context.msGraphClientFactory.getClient("3");
const userRequestSteps: BatchRequestStep[] = Users.map((v, i) => ({
id: i.toString(),
request: new Request(`/groups/${Group.id}/${Mode === AddUserMode.Member ? 'members' : 'owners'}/${v.id}/$ref`, {
method: "DELETE"
})
}));
const _content: BatchRequestBody = await (new BatchRequestContent(userRequestSteps).getContent());
// POST the batch request content to the /$batch endpoint
const batchResponse = await client.api('/$batch').post(_content);
const errors: string[] = [];
new BatchResponseContent(batchResponse).getResponses().forEach((v, k) => {
if (!v.ok) {
errors.push(v.statusText);
}
});
if (errors.length > 0) throw errors.join(', ');
else setRunning(rState.Completed);
} catch (error) {
handleError(error);
}
};
React.useEffect(() => {
if (running === rState.Completed) setTimeout(() => {
setOpen(false);
setRunning(rState.Idle);
if (onCompleted) onCompleted();
}, 5000);
}, [running]);
return (
<Dialog open={open} onOpenChange={(event, data) => setOpen(data.open)}>
<DialogTrigger>
<Button appearance='primary' icon={<PersonDeleteRegular />}>{strings.Remove}</Button>
</DialogTrigger>
<DialogSurface aria-label="label">
<DialogTitle>{Mode === AddUserMode.Owner ? strings.RemoveDialogTitleOwner : strings.RemoveDialogTitle}{Group.displayName}</DialogTitle>
<DialogBody>
<div className={styles.stack}>
{running !== rState.Error && _error && <Alert intent="error">{_error}</Alert>}
{running === rState.Error && <Alert intent="error" action={{ children: 'Retry', onClick: () => setRunning(rState.Running) }}>{_error}</Alert>}
{running === rState.Completed && <Alert intent="success">{Mode === AddUserMode.Owner ? strings.Owners : strings.Members} {strings.Removed} {Group.displayName}</Alert>}
{Users.length > 0 && <div className={styles.stackHoz} style={{ flexWrap: 'wrap' }}>
{Users.map(u => <div key={u.id} className={styles.stackHoz} style={{ maxWidth: 200, whiteSpace: 'nowrap' }}>
<SPFxPeopleCard primaryText={u.displayName} serviceScope={context.serviceScope} email={u.userPrincipalName} size={PersonaSize.size24} secondaryText={u.mail} />
</div>)}
</div>}
{running === rState.Running && <Spinner labelPosition='below' label={strings.Removing} />}
</div>
</DialogBody>
<DialogActions>
<DialogTrigger>
<Button appearance="secondary" disabled={running === rState.Running}>{strings.Close}</Button>
</DialogTrigger>
<Button appearance="primary" disabled={running === rState.Completed || running === rState.Running} onClick={remove}>{strings.Remove}</Button>
</DialogActions>
</DialogSurface>
</Dialog>
)
}

View File

@ -4,11 +4,12 @@ import { IGroupMembershipManagerProps } from './IGroupMembershipManagerProps';
import * as strings from 'GroupMembershipManagerWebPartStrings';
import { MSGraphClientV3 } from '@microsoft/sp-http';
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
import { Button, Checkbox, CheckboxOnChangeData, Spinner, Subtitle1 } from '@fluentui/react-components';
import { Checkbox, CheckboxOnChangeData, Spinner, Subtitle1, Textarea } from '@fluentui/react-components';
import { Dropdown, Option, Toolbar } from "@fluentui/react-components/unstable";
import { TableBody, TableCell, TableRow, Table } from '@fluentui/react-table';
import { PersonDeleteRegular } from '@fluentui/react-icons';
import AddUser, { AddUserMode } from './AddUser';
import RemoveUser from './DeleteUser';
import { GraphError } from '@microsoft/microsoft-graph-client';
type OnSelectData = {
optionValue: string;
@ -26,7 +27,7 @@ export default function GroupMembershipManager(props: IGroupMembershipManagerPro
React.useEffect(() => {
context.msGraphClientFactory.getClient("3").then((client: MSGraphClientV3) => {
client.api('/me/ownedObjects').select("id,displayName,groupTypes,visibility,securityEnabled").get((error, response: { value: MicrosoftGraph.Group[] }) => {
client.api('/me/ownedObjects').select("id,displayName,groupTypes,visibility,securityEnabled,description").get((error, response: { value: MicrosoftGraph.Group[] }) => {
if (error) throw error;
setGroups(response.value.sort((a, b) => a.displayName.localeCompare(b.displayName)));
}).catch(console.error);
@ -39,11 +40,11 @@ export default function GroupMembershipManager(props: IGroupMembershipManagerPro
setToRemove([]);
setRemoveOwner([]);
const client: MSGraphClientV3 = await context.msGraphClientFactory.getClient("3");
client.api(`/groups/${groups.filter(g => g.displayName === group)[0].id}/members`).select("id,displayName").get((error, response: { value: MicrosoftGraph.User[] }) => {
client.api(`/groups/${groups.filter(g => g.displayName === group)[0].id}/members`).select("id,displayName").get((error: GraphError, response: { value: MicrosoftGraph.User[] }) => {
if (error) throw error;
setMembers(response.value.sort((a, b) => a.displayName.localeCompare(b.displayName)));
}).catch(console.error);
client.api(`/groups/${groups.filter(g => g.displayName === group)[0].id}/owners`).select("id,displayName").get((error, response: { value: MicrosoftGraph.User[] }) => {
client.api(`/groups/${groups.filter(g => g.displayName === group)[0].id}/owners`).select("id,displayName").get((error: GraphError, response: { value: MicrosoftGraph.User[] }) => {
if (error) throw error;
setOwners(response.value);
}).catch(console.error);
@ -71,49 +72,52 @@ export default function GroupMembershipManager(props: IGroupMembershipManagerPro
</Dropdown>
</section>}
{(!members || !owners) && groups && group && <Spinner labelPosition='below' label={strings.LoadingMembers} />}
{groups && group && <div className={`${styles.stackHoz} ${styles.spaceBetween}`} style={{marginTop: 10 }}>
<div style={{width: '49%'}}>
<Subtitle1>{strings.Members}</Subtitle1>
{!members && <Spinner labelPosition='below' label={strings.LoadingMembers} />}
{members && <>
{//don't display the toolbar if the group is dynamic
!(groups.filter(g => g.displayName === group)[0].groupTypes?.filter(g => g === "DynamicMembership").length > 0) && <Toolbar>
<AddUser context={context} Group={groups.filter(g => g.displayName === group)[0]} Mode={AddUserMode.Member} onCompleted={loadGroup} />
<Button icon={<PersonDeleteRegular />} disabled={toRemove.length === 0}>{strings.Remove} {toRemove.length === 0 ? null : toRemove.length}</Button>
</Toolbar>}
<Table>
<TableBody>
{members.map(user => (
<TableRow key={user.id}>
<TableCell>
<Checkbox label={user.displayName} onChange={(ev, data) => checkUser(data, user)} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>}
</div>
<div style={{width: '49%'}}>
<Subtitle1>{strings.Owners}</Subtitle1>
{!owners && <Spinner labelPosition='below' label={strings.LoadingOwners} />}
{owners && <>
<Toolbar>
<AddUser context={context} Group={groups.filter(g => g.displayName === group)[0]} Mode={AddUserMode.Owner} onCompleted={loadGroup} />
<Button icon={<PersonDeleteRegular />} disabled={removeOwner.length === 0}>{strings.Remove} {removeOwner.length === 0 ? null : removeOwner.length }</Button>
</Toolbar>
<Table>
<TableBody>
{owners.map(user => (
<TableRow key={user.id}>
<TableCell>
<Checkbox label={user.displayName} onChange={(ev, data) => checkOwner(data, user)} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>}
{groups && group && <div className={styles.stack} style={{marginTop: 10 }}>
<Textarea defaultValue={groups.filter(g => g.displayName === group)[0].description} resize='vertical' readOnly />
<div className={`${styles.stackHoz} ${styles.spaceBetween}`} style={{marginTop: 10 }}>
<div style={{width: '49%'}}>
<Subtitle1>{strings.Members}</Subtitle1>
{!members && <Spinner labelPosition='below' label={strings.LoadingMembers} />}
{members && <>
{//don't display the toolbar if the group is dynamic
!(groups.filter(g => g.displayName === group)[0].groupTypes?.filter(g => g === "DynamicMembership").length > 0) && <Toolbar>
<AddUser context={context} Group={groups.filter(g => g.displayName === group)[0]} Mode={AddUserMode.Member} onCompleted={loadGroup} />
<RemoveUser Users={toRemove} context={context} Group={groups.filter(g => g.displayName === group)[0]} Mode={AddUserMode.Member} onCompleted={loadGroup} />
</Toolbar>}
<Table>
<TableBody>
{members.map(user => (
<TableRow key={user.id}>
<TableCell>
<Checkbox label={user.displayName} onChange={(ev, data) => checkUser(data, user)} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>}
</div>
<div style={{width: '49%'}}>
<Subtitle1>{strings.Owners}</Subtitle1>
{!owners && <Spinner labelPosition='below' label={strings.LoadingOwners} />}
{owners && <>
<Toolbar>
<AddUser context={context} Group={groups.filter(g => g.displayName === group)[0]} Mode={AddUserMode.Owner} onCompleted={loadGroup} />
<RemoveUser Users={removeOwner} context={context} Group={groups.filter(g => g.displayName === group)[0]} Mode={AddUserMode.Owner} onCompleted={loadGroup} />
</Toolbar>
<Table>
<TableBody>
{owners.map(user => (
<TableRow key={user.id}>
<TableCell>
<Checkbox label={user.displayName} onChange={(ev, data) => checkOwner(data, user)} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>}
</div>
</div>
</div>}
</section>

View File

@ -23,6 +23,10 @@ define([], function() {
"SearchPlaceholder": "Start typing to search",
"Retry": "Retry",
"Adding": "Adding Users",
"Added": "Added to"
"Added": "Added to",
"Removing": "Removing Users",
"Removed": "Removed from",
"RemoveDialogTitle": "Remove users from ",
"RemoveDialogTitleOwner": "Remove owners from "
}
});

View File

@ -23,6 +23,10 @@ declare interface IGroupMembershipManagerWebPartStrings {
Retry: string;
Adding: string;
Added: string;
Removing: string;
Removed: string;
RemoveDialogTitle: string;
RemoveDialogTitleOwner: string;
}
declare module 'GroupMembershipManagerWebPartStrings' {