瀏覽代碼

Merge pull request #15326 from jeclrsg/hpcc-26435-user-details

HPCC-26435 Security Page user details

Reviewed-By: Gordon Smith <gordon.smith@lexisnexis.com>
Reviewed-By: Richard Chapman <rchapman@hpccsystems.com>
Richard Chapman 3 年之前
父節點
當前提交
582da8d434

+ 3 - 1
esp/src/src-react/components/DojoGrid.tsx

@@ -5,12 +5,14 @@ import * as declare from "dojo/_base/declare";
 import * as selector from "dgrid/selector";
 // @ts-ignore
 import * as tree from "dgrid/tree";
+// @ts-ignore
+import * as editor from "dgrid/editor";
 import * as ESPUtil from "src/ESPUtil";
 import { DojoComponent } from "../layouts/DojoAdapter";
 
 import "src-react-css/components/DojoGrid.css";
 
-export { selector, tree };
+export { editor, selector, tree };
 
 const SimpleGrid = declare([ESPUtil.Grid(false, false, undefined, false, "SimpleGrid")]);
 const PageSelGrid = declare([ESPUtil.Grid(true, true, undefined, false, "PageSelGrid")]);

+ 161 - 0
esp/src/src-react/components/UserDetails.tsx

@@ -0,0 +1,161 @@
+import * as React from "react";
+import { CommandBar, ICommandBarItemProps, MessageBar, MessageBarType, Pivot, PivotItem, Sticky, StickyPositionType } from "@fluentui/react";
+import { SizeMe } from "react-sizeme";
+import { scopedLogger } from "@hpcc-js/util";
+import * as WsAccess from "src/ws_access";
+import nlsHPCC from "src/nlsHPCC";
+import { pivotItemStyle } from "../layouts/pivot";
+import { DojoAdapter } from "../layouts/DojoAdapter";
+import { TableGroup } from "./forms/Groups";
+import { UserGroups } from "./UserGroups";
+import { pushUrl } from "../util/history";
+
+const logger = scopedLogger("src-react/components/UserDetails.tsx");
+
+interface UserDetailsProps {
+    username?: string;
+    tab?: string;
+}
+
+export const UserDetails: React.FunctionComponent<UserDetailsProps> = ({
+    username,
+    tab = "summary"
+}) => {
+
+    const [user, setUser] = React.useState<any>();
+    const [employeeID, setEmployeeID] = React.useState("");
+    const [employeeNumber, setEmployeeNumber] = React.useState("");
+    const [firstName, setFirstName] = React.useState("");
+    const [lastName, setLastName] = React.useState("");
+    const [password1, setPassword1] = React.useState("");
+    const [password2, setPassword2] = React.useState("");
+    const [showError, setShowError] = React.useState(false);
+    const [errorMessage, setErrorMessage] = React.useState("");
+
+    const canSave = user && (
+        employeeID !== user?.employeeID ||
+        employeeNumber !== user?.employeeNumber ||
+        firstName !== user.firstname ||
+        lastName !== user.lastname ||
+        (password1 === password2 && password1 !== "")
+    );
+
+    const buttons = React.useMemo((): ICommandBarItemProps[] => [
+        {
+            key: "save", text: nlsHPCC.Save, iconProps: { iconName: "Save" }, disabled: !canSave,
+            onClick: () => {
+                WsAccess.UserInfoEdit({
+                    request: {
+                        username: username,
+                        firstname: firstName,
+                        lastname: lastName,
+                        employeeID: employeeID,
+                        employeeNumber: employeeNumber
+                    }
+                });
+
+                if (password1 !== "") {
+                    WsAccess.UserResetPass({
+                        request: {
+                            username: username,
+                            newPassword: password1,
+                            newPasswordRetype: password2
+                        }
+                    })
+                        .then(({ Exceptions }) => {
+                            const err = Exceptions?.Exception[0];
+                            if (err.Code < 0) {
+                                setShowError(true);
+                                setErrorMessage(err.Message);
+                            } else {
+                                setShowError(false);
+                                setErrorMessage("");
+                            }
+                        })
+                        .catch(logger.error)
+                        ;
+                }
+            }
+        }
+    ], [canSave, employeeID, employeeNumber, firstName, lastName, password1, password2, username]);
+
+    React.useEffect(() => {
+        WsAccess.UserInfoEditInput({
+            request: {
+                username: username
+            }
+        })
+            .then(({ UserInfoEditInputResponse }) => {
+                setUser(UserInfoEditInputResponse);
+                setEmployeeID(UserInfoEditInputResponse.employeeID);
+                setEmployeeNumber(UserInfoEditInputResponse.employeeNumber);
+                setFirstName(UserInfoEditInputResponse.firstname);
+                setLastName(UserInfoEditInputResponse.lastname);
+            })
+            .catch(logger.error)
+            ;
+    }, [username, setUser]);
+
+    return <SizeMe monitorHeight>{({ size }) =>
+        <Pivot
+            overflowBehavior="menu" style={{ height: "100%" }} selectedKey={tab}
+            onLinkClick={evt => {
+                pushUrl(`/security/users/${user?.username}/${evt.props.itemKey}`);
+            }}
+        >
+            <PivotItem headerText={user?.username} itemKey="summary" style={pivotItemStyle(size)} >
+                <Sticky stickyPosition={StickyPositionType.Header}>
+                    <CommandBar items={buttons} />
+                </Sticky>
+                {showError &&
+                    <MessageBar messageBarType={MessageBarType.error} isMultiline={true} onDismiss={() => setShowError(false)} dismissButtonAriaLabel="Close">
+                        {errorMessage}
+                    </MessageBar>
+                }
+                <TableGroup fields={{
+                    "username": { label: nlsHPCC.Name, type: "string", value: username, readonly: true },
+                    "employeeID": { label: nlsHPCC.EmployeeID, type: "string", value: employeeID },
+                    "employeeNumber": { label: nlsHPCC.EmployeeNumber, type: "string", value: employeeNumber },
+                    "firstname": { label: nlsHPCC.FirstName, type: "string", value: firstName },
+                    "lastname": { label: nlsHPCC.LastName, type: "string", value: lastName },
+                    "password1": { label: nlsHPCC.NewPassword, type: "password", value: password1 },
+                    "password2": { label: nlsHPCC.ConfirmPassword, type: "password", value: password2 },
+                    "PasswordExpiration": { label: nlsHPCC.PasswordExpiration, type: "string", value: user?.PasswordExpiration, readonly: true },
+                }} onChange={(id, value) => {
+                    switch (id) {
+                        case "employeeID":
+                            setEmployeeID(value);
+                            break;
+                        case "employeeNumber":
+                            setEmployeeNumber(value);
+                            break;
+                        case "firstname":
+                            setFirstName(value);
+                            break;
+                        case "lastname":
+                            setLastName(value);
+                            break;
+                        case "password1":
+                            setPassword1(value);
+                            break;
+                        case "password2":
+                            setPassword2(value);
+                            break;
+                        default:
+                            console.log(id, value);
+                    }
+                }} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.MemberOf} itemKey="groups" style={pivotItemStyle(size, 0)}>
+                <UserGroups username={username} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.title_ActivePermissions} itemKey="activePermissions" style={pivotItemStyle(size, 0)}>
+                <DojoAdapter widgetClassID="ShowAccountPermissionsWidget" params={{ IsGroup: false, IncludeGroup: true, AccountName: username }} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.title_AvailablePermissions} itemKey="availablePermissions" style={pivotItemStyle(size, 0)}>
+                <DojoAdapter widgetClassID="PermissionsWidget" params={{ username: username }} />
+            </PivotItem>
+        </Pivot>
+    }</SizeMe>;
+
+};

+ 147 - 0
esp/src/src-react/components/UserGroups.tsx

@@ -0,0 +1,147 @@
+import * as React from "react";
+import { CommandBar, ContextualMenuItemType, ICommandBarItemProps } from "@fluentui/react";
+import { useConst } from "@fluentui/react-hooks";
+import { scopedLogger } from "@hpcc-js/util";
+import * as Observable from "dojo/store/Observable";
+import { Memory } from "src/Memory";
+import * as WsAccess from "src/ws_access";
+import nlsHPCC from "src/nlsHPCC";
+import { ShortVerticalDivider } from "./Common";
+import { pushUrl } from "../util/history";
+import { HolyGrail } from "../layouts/HolyGrail";
+import { DojoGrid, selector } from "./DojoGrid";
+import { UserAddGroupForm } from "./forms/UserAddGroup";
+
+const logger = scopedLogger("src-react/components/UserGroups.tsx");
+
+const defaultUIState = {
+    hasSelection: false,
+};
+
+interface UserGroupsProps {
+    username?: string;
+}
+
+export const UserGroups: React.FunctionComponent<UserGroupsProps> = ({
+    username,
+}) => {
+
+    const [grid, setGrid] = React.useState<any>(undefined);
+    const [selection, setSelection] = React.useState([]);
+    const [showAdd, setShowAdd] = React.useState(false);
+    const [uiState, setUIState] = React.useState({ ...defaultUIState });
+
+    //  Grid ---
+    const gridStore = useConst(new Observable(new Memory("name")));
+    const gridSort = useConst([{ attribute: "name", "descending": false }]);
+    const gridQuery = useConst({});
+    const gridColumns = useConst({
+        check: selector({ width: 27, label: " " }, "checkbox"),
+        name: {
+            label: nlsHPCC.GroupName,
+            formatter: function (_name, idx) {
+                _name = _name.replace(/[^-_a-zA-Z0-9\s]+/g, "");
+                return `<a href="#/security/groups/${_name}">${_name}</a>`;
+            }
+        }
+    });
+
+    //  Selection  ---
+    React.useEffect(() => {
+        const state = { ...defaultUIState };
+
+        if (selection.length > 0) {
+            state.hasSelection = true;
+        }
+
+        setUIState(state);
+    }, [selection]);
+
+    const refreshTable = React.useCallback((clearSelection = false) => {
+        WsAccess.UserEdit({
+            request: { username: username }
+        })
+            .then(({ UserEditResponse }) => {
+                if (UserEditResponse?.Groups) {
+                    const groups = UserEditResponse?.Groups?.Group;
+                    gridStore.setData(groups.map(group => {
+                        return {
+                            name: group.name
+                        };
+                    }));
+                } else {
+                    gridStore.setData([]);
+                }
+
+                grid?.set("query", gridQuery);
+                if (clearSelection) {
+                    grid?.clearSelection();
+                }
+            })
+            .catch(logger.error)
+            ;
+    }, [grid, gridQuery, gridStore, username]);
+
+    const buttons = React.useMemo((): ICommandBarItemProps[] => [
+        {
+            key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
+            onClick: () => refreshTable()
+        },
+        { key: "divider_1", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
+        {
+            key: "open", text: nlsHPCC.Open, disabled: !uiState.hasSelection,
+            onClick: () => {
+                if (selection.length === 1) {
+                    pushUrl(`/security/groups/${selection[0].name}`);
+                } else {
+                    selection.forEach(group => {
+                        window.open(`#/security/groups/${group?.name}`, "_blank");
+                    });
+                }
+            }
+        },
+        { key: "divider_2", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
+        {
+            key: "add", text: nlsHPCC.Add,
+            onClick: () => setShowAdd(true)
+        },
+        {
+            key: "delete", text: nlsHPCC.Delete, disabled: !uiState.hasSelection,
+            onClick: () => {
+                if (confirm(nlsHPCC.YouAreAboutToRemoveUserFrom)) {
+                    const requests = [];
+                    selection.forEach((group, idx) => {
+                        const request = {
+                            username: username,
+                            action: "Delete"
+                        };
+                        request["groupnames_i" + idx] = group.name;
+                        requests.push(WsAccess.UserGroupEdit({ request: request }));
+                    });
+                    Promise.all(requests)
+                        .then(responses => refreshTable())
+                        .catch(logger.error);
+                }
+            }
+        },
+    ], [refreshTable, selection, uiState.hasSelection, username]);
+
+    React.useEffect(() => {
+        if (!grid || !gridStore) return;
+        refreshTable();
+    }, [grid, gridStore, refreshTable]);
+
+    return <>
+        <HolyGrail
+            header={<CommandBar items={buttons} />}
+            main={
+                <DojoGrid
+                    store={gridStore} query={gridQuery} sort={gridSort}
+                    columns={gridColumns} setGrid={setGrid} setSelection={setSelection}
+                />
+            }
+        />
+        <UserAddGroupForm showForm={showAdd} setShowForm={setShowAdd} refreshGrid={refreshTable} username={username} />
+    </>;
+
+};

+ 3 - 3
esp/src/src-react/components/Users.tsx

@@ -12,7 +12,7 @@ import { Fields } from "./forms/Fields";
 import { HolyGrail } from "../layouts/HolyGrail";
 import { pushParams, pushUrl } from "../util/history";
 
-const logger = scopedLogger("../components/Users.tsx");
+const logger = scopedLogger("src-react/components/Users.tsx");
 
 const FilterFields: Fields = {
     "username": { type: "string", label: nlsHPCC.User }
@@ -115,7 +115,7 @@ export const Users: React.FunctionComponent<UsersProps> = ({
                     pushUrl(`/security/users/${selection[0].username}`);
                 } else {
                     selection.forEach(user => {
-                        window.open(`#/security/users/${user.username}`, "_blank");
+                        window.open(`#/security/users/${user?.username}`, "_blank");
                     });
                 }
             }
@@ -174,4 +174,4 @@ export const Users: React.FunctionComponent<UsersProps> = ({
         <AddUserForm showForm={showAddUser} setShowForm={setShowAddUser} refreshGrid={refreshTable} />
     </>;
 
-};
+};

+ 2 - 2
esp/src/src-react/components/forms/AddUser.tsx

@@ -6,7 +6,7 @@ import nlsHPCC from "src/nlsHPCC";
 import * as WsAccess from "src/ws_access";
 import { MessageBox } from "../../layouts/MessageBox";
 
-const logger = scopedLogger("../components/forms/AddUser.tsx");
+const logger = scopedLogger("src-react/components/forms/AddUser.tsx");
 
 interface AddUserFormValues {
     username: string;
@@ -204,4 +204,4 @@ export const AddUserForm: React.FunctionComponent<AddUserFormProps> = ({
             </div>
         }
     </MessageBox>;
-};
+};

+ 77 - 19
esp/src/src-react/components/forms/Fields.tsx

@@ -4,6 +4,7 @@ import { TextField as MaterialUITextField } from "@material-ui/core";
 import { Topology, TpLogicalClusterQuery } from "@hpcc-js/comms";
 import { scopedLogger } from "@hpcc-js/util";
 import { TpDropZoneQuery, TpGroupQuery, TpServiceQuery } from "src/WsTopology";
+import * as WsAccess from "src/ws_access";
 import { States } from "src/WsWorkunits";
 import { FileList, States as DFUStates } from "src/FileSpray";
 import nlsHPCC from "src/nlsHPCC";
@@ -38,38 +39,31 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
     className
 }) => {
     React.useEffect(() => {
-        if (required === true && optional == false) {
-            logger.error(`${label} (${key}):  required == true and optional == false is illogical`);
+        if (required === true && optional === true) {
+            logger.error(`${label} (${key}):  required == true and optional == true is illogical`);
         }
     }, [key, label, optional, required]);
 
     const [selOptions, setSelOptions] = React.useState<IDropdownOption[]>([]);
     const [selectedKey, setSelectedKey] = React.useState<string | number | undefined>(defaultSelectedKey);
 
-    const handleOnChange = React.useCallback((evt, row) => {
-        if (onChange) {
-            onChange(evt, row);
-        }
-        setSelectedKey(row.key);
-    }, [onChange]);
-
     React.useEffect(() => {
         const selOptions = (optional ? [{ key: "", text: "" }, ...options] : [...options]);
         if (!optional && !defaultSelectedKey && selOptions.length) {
-            handleOnChange(undefined, selOptions[0]);
+            setSelectedKey(selOptions[0].key);
         }
         setSelOptions(selOptions);
-    }, [optional, options, defaultSelectedKey, handleOnChange]);
+    }, [optional, options, defaultSelectedKey, setSelectedKey]);
 
-    return <DropdownBase key={key} label={label} errorMessage={errorMessage} required={required} className={className} defaultSelectedKey={defaultSelectedKey} selectedKey={selectedKey} onChange={handleOnChange} placeholder={placeholder} options={selOptions} disabled={disabled} />;
+    return <DropdownBase key={key} label={label} errorMessage={errorMessage} required={required} className={className} defaultSelectedKey={selectedKey} onChange={onChange} placeholder={placeholder} options={selOptions} disabled={disabled} />;
 };
 
-export type FieldType = "string" | "number" | "checkbox" | "datetime" | "dropdown" | "link" | "links" | "progress" |
+export type FieldType = "string" | "password" | "number" | "checkbox" | "datetime" | "dropdown" | "link" | "links" | "progress" |
     "workunit-state" |
     "file-type" | "file-sortby" |
     "queries-priority" | "queries-suspend-state" | "queries-active-state" |
     "target-cluster" | "target-dropzone" | "target-server" | "target-group" |
-    "target-dfuqueue" |
+    "target-dfuqueue" | "user-groups" |
     "logicalfile-type" | "dfuworkunit-state";
 
 export type Values = { [name: string]: string | number | boolean | (string | number | boolean)[] };
@@ -84,7 +78,7 @@ interface BaseField {
 }
 
 interface StringField extends BaseField {
-    type: "string";
+    type: "string" | "password";
     value?: string;
     readonly?: boolean;
     multiline?: boolean;
@@ -176,6 +170,12 @@ interface DFUWorkunitStateField extends BaseField {
     value?: string;
 }
 
+interface UserGroupsField extends BaseField {
+    type: "user-groups";
+    username: string;
+    value?: string;
+}
+
 interface LinkField extends BaseField {
     type: "link";
     href: string;
@@ -199,7 +199,7 @@ type Field = StringField | NumericField | CheckboxField | DateTimeField | Dropdo
     FileTypeField | FileSortByField |
     QueriesPriorityField | QueriesSuspendStateField | QueriesActiveStateField |
     TargetClusterField | TargetDropzoneField | TargetServerField | TargetGroupField |
-    TargetDfuSprayQueueField |
+    TargetDfuSprayQueueField | UserGroupsField |
     LogicalFileType | DFUWorkunitStateField;
 
 export type Fields = { [id: string]: Field };
@@ -370,7 +370,7 @@ export const TargetGroupTextField: React.FunctionComponent<TargetGroupTextFieldP
 export interface TargetDfuSprayQueueTextFieldProps {
     key: string;
     label?: string;
-    selectedKey?: string;
+    defaultSelectedKey?: string;
     className?: string;
     required?: boolean;
     optional?: boolean;
@@ -478,6 +478,46 @@ export const TargetFolderTextField: React.FunctionComponent<TargetFolderTextFiel
     return <Dropdown {...props} options={folders} />;
 };
 
+export interface UserGroupsProps {
+    key: string;
+    label?: string;
+    selectedKey?: string;
+    className?: string;
+    required?: boolean;
+    optional?: boolean;
+    username: string;
+    errorMessage?: string;
+    onChange?: (event: React.FormEvent<HTMLDivElement>, option?: IDropdownOption, index?: number) => void;
+    placeholder?: string;
+}
+
+export const UserGroupsField: React.FunctionComponent<UserGroupsProps> = (props) => {
+
+    const [groups, setGroups] = React.useState<IDropdownOption[]>([]);
+
+    React.useEffect(() => {
+        const request = { username: props.username };
+        WsAccess.UserGroupEditInput({ request: request })
+            .then(({ UserGroupEditInputResponse }) => {
+                const groups = UserGroupEditInputResponse.Groups.Group
+                    .filter(group => group.name !== "Administrators")
+                    .map(group => {
+                        return {
+                            key: group.name,
+                            text: group.name
+                        };
+                    });
+                groups.unshift({ key: "", text: "" });
+                setGroups(groups);
+            })
+            .catch(logger.error)
+            ;
+    }, [props.username]);
+
+    return <Dropdown {...props} options={groups} />;
+
+};
+
 const states = Object.keys(States).map(s => States[s]);
 const dfustates = Object.keys(DFUStates).map(s => DFUStates[s]);
 
@@ -491,13 +531,14 @@ export function createInputs(fields: Fields, onChange?: (id: string, newValue: a
         }
         switch (field.type) {
             case "string":
+            case "password":
                 field.value = field.value !== undefined ? field.value : "";
                 retVal.push({
                     id: fieldID,
                     label: field.label,
                     field: <TextField
                         key={fieldID}
-                        type="string"
+                        type={field.type}
                         name={fieldID}
                         value={field.value}
                         placeholder={field.placeholder}
@@ -506,6 +547,8 @@ export function createInputs(fields: Fields, onChange?: (id: string, newValue: a
                         readOnly={field.readonly}
                         required={field.required}
                         multiline={field.multiline}
+                        canRevealPassword={field.type === "password" ? true : false}
+                        revealPasswordAriaLabel={nlsHPCC.ShowPassword}
                     />
                 });
                 break;
@@ -763,6 +806,21 @@ export function createInputs(fields: Fields, onChange?: (id: string, newValue: a
                     />
                 });
                 break;
+            case "user-groups":
+                field.value = field.value !== undefined ? field.value : "";
+                retVal.push({
+                    id: fieldID,
+                    label: field.label,
+                    field: <UserGroupsField
+                        key={fieldID}
+                        username={field.username}
+                        required={field.required}
+                        selectedKey={field.value}
+                        onChange={(ev, row) => onChange(fieldID, row.key)}
+                        placeholder={field.placeholder}
+                    />
+                });
+                break;
             case "target-dfuqueue":
                 field.value = field.value !== undefined ? field.value : "";
                 retVal.push({
@@ -770,7 +828,7 @@ export function createInputs(fields: Fields, onChange?: (id: string, newValue: a
                     label: field.label,
                     field: <TargetDfuSprayQueueTextField
                         key={fieldID}
-                        selectedKey={field.value}
+                        defaultSelectedKey={field.value}
                         onChange={(ev, row) => onChange(fieldID, row.key)}
                         placeholder={field.placeholder}
                     />

+ 105 - 0
esp/src/src-react/components/forms/UserAddGroup.tsx

@@ -0,0 +1,105 @@
+import * as React from "react";
+import { DefaultButton, MessageBar, MessageBarType, PrimaryButton, } from "@fluentui/react";
+import { scopedLogger } from "@hpcc-js/util";
+import { useForm, Controller } from "react-hook-form";
+import nlsHPCC from "src/nlsHPCC";
+import * as WsAccess from "src/ws_access";
+import { UserGroupsField } from "./Fields";
+import { MessageBox } from "../../layouts/MessageBox";
+
+const logger = scopedLogger("src-react/components/forms/AddUser.tsx");
+
+interface UserAddGroupValues {
+    groupnames: string;
+}
+
+const defaultValues: UserAddGroupValues = {
+    groupnames: ""
+};
+
+interface UserAddGroupProps {
+    refreshGrid?: () => void;
+    showForm: boolean;
+    setShowForm: (_: boolean) => void;
+    username: string;
+}
+
+export const UserAddGroupForm: React.FunctionComponent<UserAddGroupProps> = ({
+    refreshGrid,
+    showForm,
+    setShowForm,
+    username
+}) => {
+
+    const { handleSubmit, control, reset } = useForm<UserAddGroupValues>({ defaultValues });
+
+    const [showError, setShowError] = React.useState(false);
+    const [errorMessage, setErrorMessage] = React.useState("");
+
+    const closeForm = React.useCallback(() => {
+        setShowForm(false);
+    }, [setShowForm]);
+
+    const onSubmit = React.useCallback(() => {
+        handleSubmit(
+            (data, evt) => {
+                const request: any = data;
+                request.username = username;
+                request.action = "add";
+
+                WsAccess.UserGroupEdit({ request: request })
+                    .then(({ UserGroupEditResponse }) => {
+                        if (UserGroupEditResponse?.retcode < 0) {
+                            //log exception from API
+                            setShowError(true);
+                            setErrorMessage(UserGroupEditResponse?.retmsg);
+                            logger.error(UserGroupEditResponse?.retmsg);
+                        } else {
+                            closeForm();
+                            reset(defaultValues);
+                            if (refreshGrid) refreshGrid();
+                        }
+                    })
+                    .catch(logger.error)
+                    ;
+            },
+            logger.info
+        )();
+    }, [closeForm, handleSubmit, refreshGrid, reset, username]);
+
+    return <MessageBox show={showForm} setShow={closeForm} title={nlsHPCC.PleaseSelectAGroupToAddUser} width={400}
+        footer={<>
+            <PrimaryButton text={nlsHPCC.Add} onClick={handleSubmit(onSubmit)} />
+            <DefaultButton text={nlsHPCC.Cancel} onClick={() => { reset(defaultValues); closeForm(); }} />
+        </>}>
+        <Controller
+            control={control} name="groupnames"
+            render={({
+                field: { onChange, name: fieldName, value },
+                fieldState: { error }
+            }) => <UserGroupsField
+                    key={fieldName}
+                    username={username}
+                    required={true}
+                    label={nlsHPCC.GroupName}
+                    selectedKey={value}
+                    onChange={(evt, option) => {
+                        onChange(option.key);
+                    }}
+                    errorMessage={error && error?.message}
+                />}
+            rules={{
+                required: nlsHPCC.ValidationErrorRequired
+            }}
+        />
+        {showError &&
+            <div style={{ marginTop: 16 }}>
+                <MessageBar
+                    messageBarType={MessageBarType.error} isMultiline={true}
+                    onDismiss={() => setShowError(false)} dismissButtonAriaLabel="Close">
+                    {errorMessage}
+                </MessageBar>
+            </div>
+        }
+    </MessageBox>;
+};

+ 1 - 1
esp/src/src-react/components/forms/landing-zone/BlobImportForm.tsx

@@ -150,7 +150,7 @@ export const BlobImportForm: React.FunctionComponent<BlobImportFormProps> = ({
                         key="DFUServerQueue"
                         label={nlsHPCC.Queue}
                         required={true}
-                        selectedKey={value}
+                        defaultSelectedKey={value}
                         placeholder={nlsHPCC.SelectValue}
                         onChange={(evt, option) => {
                             onChange(option.key);

+ 1 - 1
esp/src/src-react/components/forms/landing-zone/DelimitedImportForm.tsx

@@ -170,7 +170,7 @@ export const DelimitedImportForm: React.FunctionComponent<DelimitedImportFormPro
                         key="DFUServerQueue"
                         label={nlsHPCC.Queue}
                         required={true}
-                        selectedKey={value}
+                        defaultSelectedKey={value}
                         placeholder={nlsHPCC.SelectValue}
                         onChange={(evt, option) => {
                             onChange(option.key);

+ 1 - 1
esp/src/src-react/components/forms/landing-zone/FixedImportForm.tsx

@@ -155,7 +155,7 @@ export const FixedImportForm: React.FunctionComponent<FixedImportFormProps> = ({
                         key="DFUServerQueue"
                         label={nlsHPCC.Queue}
                         required={true}
-                        selectedKey={value}
+                        defaultSelectedKey={value}
                         placeholder={nlsHPCC.SelectValue}
                         onChange={(evt, option) => {
                             onChange(option.key);

+ 1 - 1
esp/src/src-react/components/forms/landing-zone/JsonImportForm.tsx

@@ -159,7 +159,7 @@ export const JsonImportForm: React.FunctionComponent<JsonImportFormProps> = ({
                         key="DFUServerQueue"
                         label={nlsHPCC.Queue}
                         required={true}
-                        selectedKey={value}
+                        defaultSelectedKey={value}
                         placeholder={nlsHPCC.SelectValue}
                         onChange={(evt, option) => {
                             onChange(option.key);

+ 1 - 1
esp/src/src-react/components/forms/landing-zone/VariableImportForm.tsx

@@ -153,7 +153,7 @@ export const VariableImportForm: React.FunctionComponent<VariableImportFormProps
                         key="DFUServerQueue"
                         label={nlsHPCC.Queue}
                         required={true}
-                        selectedKey={value}
+                        defaultSelectedKey={value}
                         placeholder={nlsHPCC.SelectValue}
                         onChange={(evt, option) => {
                             onChange(option.key);

+ 1 - 1
esp/src/src-react/components/forms/landing-zone/XmlImportForm.tsx

@@ -158,7 +158,7 @@ export const XmlImportForm: React.FunctionComponent<XmlImportFormProps> = ({
                         key="DFUServerQueue"
                         label={nlsHPCC.Queue}
                         required={true}
-                        selectedKey={value}
+                        defaultSelectedKey={value}
                         placeholder={nlsHPCC.SelectValue}
                         onChange={(evt, option) => {
                             onChange(option.key);

+ 2 - 1
esp/src/src-react/routes.tsx

@@ -181,7 +181,8 @@ export const routes: RoutesEx = [
         children: [
             { path: "", action: (ctx, params) => import("./components/Security").then(_ => <_.Security filter={parseSearch(ctx.search) as any} />) },
             { path: "/:Tab", action: (ctx, params) => import("./components/Security").then(_ => <_.Security filter={parseSearch(ctx.search) as any} tab={params.Tab as string} />) },
-            { path: "/groups/:id", action: (ctx, params) => import("./components/GroupDetails").then(_ => <_.GroupDetails id={params.id as string} />) },
+            { path: "/users/:username", action: (ctx, params) => import("./components/UserDetails").then(_ => <_.UserDetails username={params.username as string} />) },
+            { path: "/users/:username/:Tab", action: (ctx, params) => import("./components/UserDetails").then(_ => <_.UserDetails username={params.username as string} tab={params.Tab as string} />) },
         ]
     },
     {