Browse Source

Merge pull request #433 from pwm-project/feature/advanced-search

Implementation for the advanced search feature
Jason Rivard 6 năm trước cách đây
mục cha
commit
3c801c134b
49 tập tin đã thay đổi với 2008 bổ sung604 xóa
  1. 4 0
      .gitignore
  2. 223 223
      client/package-lock.json
  3. 13 13
      client/package.json
  4. 5 0
      client/src/i18n/translations_en.json
  5. 144 23
      client/src/modules/helpdesk/helpdesk-search-base.component.ts
  6. 55 18
      client/src/modules/helpdesk/helpdesk-search-cards.component.html
  7. 9 5
      client/src/modules/helpdesk/helpdesk-search-cards.component.ts
  8. 65 29
      client/src/modules/helpdesk/helpdesk-search-table.component.html
  9. 9 5
      client/src/modules/helpdesk/helpdesk-search-table.component.ts
  10. 30 4
      client/src/modules/helpdesk/helpdesk-search.component.scss
  11. 3 1
      client/src/modules/helpdesk/helpdesk.module.ts
  12. 14 1
      client/src/modules/helpdesk/routes.ts
  13. 147 30
      client/src/modules/peoplesearch/peoplesearch-base.component.ts
  14. 57 19
      client/src/modules/peoplesearch/peoplesearch-cards.component.html
  15. 1 1
      client/src/modules/peoplesearch/peoplesearch-cards.component.scss
  16. 10 5
      client/src/modules/peoplesearch/peoplesearch-cards.component.ts
  17. 69 31
      client/src/modules/peoplesearch/peoplesearch-table.component.html
  18. 1 1
      client/src/modules/peoplesearch/peoplesearch-table.component.scss
  19. 10 5
      client/src/modules/peoplesearch/peoplesearch-table.component.ts
  20. 3 1
      client/src/modules/peoplesearch/peoplesearch.module.ts
  21. 34 0
      client/src/modules/peoplesearch/peoplesearch.scss
  22. 22 0
      client/src/services/base-config.service.ts
  23. 128 0
      client/src/services/common-search.service.test.ts
  24. 132 0
      client/src/services/common-search.service.ts
  25. 22 1
      client/src/services/helpdesk-config.service.ts
  26. 25 0
      client/src/services/helpdesk.service.ts
  27. 54 1
      client/src/services/people.service.dev.ts
  28. 44 0
      client/src/services/people.service.ts
  29. 22 1
      client/src/services/peoplesearch-config.service.dev.ts
  30. 23 1
      client/src/services/peoplesearch-config.service.ts
  31. 6 5
      client/test/karma-test-suite.ts
  32. 7 9
      client/test/karma.conf.js
  33. 13 9
      client/webpack.config.js
  34. 14 2
      server/src/main/java/password/pwm/config/PwmSetting.java
  35. 27 0
      server/src/main/java/password/pwm/config/stored/StoredConfigurationImpl.java
  36. 16 1
      server/src/main/java/password/pwm/http/servlet/helpdesk/HelpdeskClientDataBean.java
  37. 47 0
      server/src/main/java/password/pwm/http/servlet/helpdesk/HelpdeskSearchRequestBean.java
  38. 12 19
      server/src/main/java/password/pwm/http/servlet/helpdesk/HelpdeskSearchResultsBean.java
  39. 95 29
      server/src/main/java/password/pwm/http/servlet/helpdesk/HelpdeskServlet.java
  40. 72 3
      server/src/main/java/password/pwm/http/servlet/helpdesk/HelpdeskServletUtil.java
  41. 3 51
      server/src/main/java/password/pwm/http/servlet/helpdesk/HelpdeskVerificationStateBean.java
  42. 56 2
      server/src/main/java/password/pwm/http/servlet/peoplesearch/PeopleSearchClientConfigBean.java
  43. 30 0
      server/src/main/java/password/pwm/http/servlet/peoplesearch/PeopleSearchConfiguration.java
  44. 77 37
      server/src/main/java/password/pwm/http/servlet/peoplesearch/PeopleSearchDataReader.java
  45. 5 11
      server/src/main/java/password/pwm/http/servlet/peoplesearch/PeopleSearchServlet.java
  46. 86 0
      server/src/main/java/password/pwm/http/servlet/peoplesearch/SearchRequestBean.java
  47. 47 1
      server/src/main/resources/password/pwm/config/PwmSetting.xml
  48. 10 2
      server/src/main/resources/password/pwm/i18n/PwmSetting.properties
  49. 7 4
      webapp/src/main/webapp/public/resources/js/configeditor-settings-form.js

+ 4 - 0
.gitignore

@@ -21,3 +21,7 @@
 
 
 
 
 /target
 /target
+/webapp/src/main/webapp/public/resources/themes/netiq
+/webapp/src/main/webapp/public/resources/themes/netiq32
+/webapp/src/main/webapp/public/resources/themes/idm
+/webapp/src/main/webapp/public/resources/themes/mdefault

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 223 - 223
client/package-lock.json


+ 13 - 13
client/package.json

@@ -10,17 +10,17 @@
     "scripts": {
     "scripts": {
         "build": "webpack --mode=production",
         "build": "webpack --mode=production",
         "clean": "rimraf dist/",
         "clean": "rimraf dist/",
-        "test": "karma start test/karma.conf.js --mode=development",
-        "test-single-run": "karma start test/karma.conf.js --mode=development --singleRun --no-auto-watch",
+        "test": "karma start test/karma.conf.js",
+        "test-single-run": "karma start test/karma.conf.js --singleRun --no-auto-watch",
         "start": "webpack-dev-server --mode=development --port 4000 --history-api-fallback --colors",
         "start": "webpack-dev-server --mode=development --port 4000 --history-api-fallback --colors",
-        "sync": "webpack --mode=production --output-path=../webapp/target/pwm-1.8.0-SNAPSHOT/public/resources/webjars/pwm-client --watch --progress --colors"
+        "sync": "webpack --env.disableMinimize=true --mode=production --output-path=../webapp/target/pwm-1.8.0-SNAPSHOT/public/resources/webjars/pwm-client --watch --progress --colors"
     },
     },
     "author": "",
     "author": "",
     "license": "ISC",
     "license": "ISC",
     "dependencies": {
     "dependencies": {
-        "@microfocus/ias-icons": "1.0.0-alpha",
+        "@microfocus/ias-icons": "1.0.0",
         "@microfocus/ng-ias": "1.0.0-alpha.2",
         "@microfocus/ng-ias": "1.0.0-alpha.2",
-        "@microfocus/ux-ias": "1.0.0-alpha.1",
+        "@microfocus/ux-ias": "1.0.0-rc",
         "@uirouter/angularjs": "1.0.15",
         "@uirouter/angularjs": "1.0.15",
         "angular": "1.6.9",
         "angular": "1.6.9",
         "angular-aria": "1.6.9",
         "angular-aria": "1.6.9",
@@ -32,7 +32,7 @@
         "@types/angular-mocks": "1.5.11",
         "@types/angular-mocks": "1.5.11",
         "@types/angular-translate": "2.15.2",
         "@types/angular-translate": "2.15.2",
         "@types/angular-ui-router": "1.1.40",
         "@types/angular-ui-router": "1.1.40",
-        "@types/jasmine": "2.8.6",
+        "@types/jasmine": "2.8.9",
         "@types/node": "9.4.7",
         "@types/node": "9.4.7",
         "angular-mocks": "1.6.9",
         "angular-mocks": "1.6.9",
         "autoprefixer": "8.1.0",
         "autoprefixer": "8.1.0",
@@ -43,19 +43,19 @@
         "html-webpack-plugin": "3.0.6",
         "html-webpack-plugin": "3.0.6",
         "ignore-loader": "0.1.2",
         "ignore-loader": "0.1.2",
         "imports-loader": "0.8.0",
         "imports-loader": "0.8.0",
-        "jasmine": "3.1.0",
-        "jasmine-core": "3.1.0",
+        "jasmine": "3.2.0",
+        "jasmine-core": "3.2.1",
         "jshint": "2.9.5",
         "jshint": "2.9.5",
         "jshint-loader": "0.8.4",
         "jshint-loader": "0.8.4",
         "json-loader": "0.5.7",
         "json-loader": "0.5.7",
-        "karma": "2.0.0",
+        "karma": "3.1.1",
         "karma-chrome-launcher": "2.2.0",
         "karma-chrome-launcher": "2.2.0",
-        "karma-jasmine": "1.1.1",
-        "karma-jasmine-html-reporter": "1.0.0",
+        "karma-jasmine": "1.1.2",
+        "karma-jasmine-html-reporter": "1.3.1",
         "karma-phantomjs-launcher": "1.0.4",
         "karma-phantomjs-launcher": "1.0.4",
         "karma-sourcemap-loader": "0.3.7",
         "karma-sourcemap-loader": "0.3.7",
         "karma-spec-reporter": "0.0.32",
         "karma-spec-reporter": "0.0.32",
-        "karma-webpack": "2.0.13",
+        "karma-webpack": "3.0.5",
         "moment": "2.21.0",
         "moment": "2.21.0",
         "ngtemplate-loader": "2.0.1",
         "ngtemplate-loader": "2.0.1",
         "node-sass": "4.7.2",
         "node-sass": "4.7.2",
@@ -68,7 +68,7 @@
         "string-replace-loader": "2.1.1",
         "string-replace-loader": "2.1.1",
         "style-loader": "0.20.3",
         "style-loader": "0.20.3",
         "ts-loader": "4.0.1",
         "ts-loader": "4.0.1",
-        "ts-mockito": "2.3.0",
+        "ts-mockito": "2.3.1",
         "tslint": "5.9.1",
         "tslint": "5.9.1",
         "tslint-loader": "3.6.0",
         "tslint-loader": "3.6.0",
         "typescript": "2.7.2",
         "typescript": "2.7.2",

+ 5 - 0
client/src/i18n/translations_en.json

@@ -1,8 +1,10 @@
 {
 {
+  "Button_AddSearchAttribute": "Add Search Attribute",
   "Button_Attributes": "User Data",
   "Button_Attributes": "User Data",
   "Button_Cancel": "Cancel",
   "Button_Cancel": "Cancel",
   "Button_ChangePassword": "Change Password",
   "Button_ChangePassword": "Change Password",
   "Button_ClearResponses": "Clear Answers",
   "Button_ClearResponses": "Clear Answers",
+  "Button_Close": "Close",
   "Button_CloseWindow": "Close Window",
   "Button_CloseWindow": "Close Window",
   "Button_Confirm": "Confirm",
   "Button_Confirm": "Confirm",
   "Button_Delete": "Delete",
   "Button_Delete": "Delete",
@@ -12,6 +14,7 @@
   "Button_More": "More",
   "Button_More": "More",
   "Button_OK": "OK",
   "Button_OK": "OK",
   "Button_OTP": "OTP",
   "Button_OTP": "OTP",
+  "Button_Remove": "Remove",
   "Button_Show": "Show",
   "Button_Show": "Show",
   "Button_SMS": "SMS",
   "Button_SMS": "SMS",
   "Button_Unlock": "Unlock",
   "Button_Unlock": "Unlock",
@@ -31,6 +34,7 @@
   "Display_Random": "Random",
   "Display_Random": "Random",
   "Display_SearchResultsExceeded": "Search results exceeded maximum search size",
   "Display_SearchResultsExceeded": "Search results exceeded maximum search size",
   "Display_SearchResultsNone": "No results",
   "Display_SearchResultsNone": "No results",
+  "Display_SelectAttribute": "Select attribute...",
   "Display_SetRandomPasswordPrompt": "Set a new random password for this user?",
   "Display_SetRandomPasswordPrompt": "Set a new random password for this user?",
   "Display_StrengthMeter": "Password Strength",
   "Display_StrengthMeter": "Password Strength",
   "Display_TokenDestination": "Token Destination",
   "Display_TokenDestination": "Token Destination",
@@ -45,6 +49,7 @@
   "Field_Username": "User Name",
   "Field_Username": "User Name",
   "Long_Title_VerificationSend": "Before this user can be selected, the user's identity must be verified.  Please select a verification method.",
   "Long_Title_VerificationSend": "Before this user can be selected, the user's identity must be verified.  Please select a verification method.",
   "Placeholder_Search": "Search",
   "Placeholder_Search": "Search",
+  "Title_AdvancedSearch": "Advanced Search",
   "Title_ChangePassword": "Change Password",
   "Title_ChangePassword": "Change Password",
   "Title_DirectReports": "Direct Report(s)",
   "Title_DirectReports": "Direct Report(s)",
   "Title_Helpdesk": "Help Desk",
   "Title_Helpdesk": "Help Desk",

+ 144 - 23
client/src/modules/helpdesk/helpdesk-search-base.component.ts

@@ -29,17 +29,25 @@ import LocalStorageService from '../../services/local-storage.service';
 import PromiseService from '../../services/promise.service';
 import PromiseService from '../../services/promise.service';
 import {IHelpDeskService} from '../../services/helpdesk.service';
 import {IHelpDeskService} from '../../services/helpdesk.service';
 import IPwmService from '../../services/pwm.service';
 import IPwmService from '../../services/pwm.service';
+import {IAdvancedSearchConfig, IAdvancedSearchQuery, IAttributeMetadata} from '../../services/base-config.service';
+import CommonSearchService from '../../services/common-search.service';
+
 
 
 let verificationsDialogTemplateUrl = require('./verifications-dialog.template.html');
 let verificationsDialogTemplateUrl = require('./verifications-dialog.template.html');
 let recentVerificationsDialogTemplateUrl = require('./recent-verifications-dialog.template.html');
 let recentVerificationsDialogTemplateUrl = require('./recent-verifications-dialog.template.html');
 
 
 export default abstract class HelpDeskSearchBaseComponent {
 export default abstract class HelpDeskSearchBaseComponent {
+    advancedSearch = false;
+    advancedSearchTags = {};
+    advancedSearchEnabled: boolean;
+    advancedSearchMaxRows: number;
     columnConfiguration: any;
     columnConfiguration: any;
     errorMessage: string;
     errorMessage: string;
     inputDebounce: number;
     inputDebounce: number;
     protected pendingRequests: IPromise<any>[] = [];
     protected pendingRequests: IPromise<any>[] = [];
     photosEnabled: boolean;
     photosEnabled: boolean;
     query: string;
     query: string;
+    queries: IAdvancedSearchQuery[];
     searchMessage: string;
     searchMessage: string;
     searchResult: SearchResult;
     searchResult: SearchResult;
     searchTextLocalStorageKey: string;
     searchTextLocalStorageKey: string;
@@ -57,27 +65,55 @@ export default abstract class HelpDeskSearchBaseComponent {
                 protected IasDialogService: any,
                 protected IasDialogService: any,
                 protected localStorageService: LocalStorageService,
                 protected localStorageService: LocalStorageService,
                 protected promiseService: PromiseService,
                 protected promiseService: PromiseService,
-                protected pwmService: IPwmService) {
+                protected pwmService: IPwmService,
+                protected commonSearchService: CommonSearchService) {
         this.searchTextLocalStorageKey = this.localStorageService.keys.HELPDESK_SEARCH_TEXT;
         this.searchTextLocalStorageKey = this.localStorageService.keys.HELPDESK_SEARCH_TEXT;
         this.searchViewLocalStorageKey = this.localStorageService.keys.HELPDESK_SEARCH_VIEW;
         this.searchViewLocalStorageKey = this.localStorageService.keys.HELPDESK_SEARCH_VIEW;
 
 
         this.inputDebounce = this.pwmService.ajaxTypingWait;
         this.inputDebounce = this.pwmService.ajaxTypingWait;
-
-        $scope.$watch('$ctrl.query', (newValue: string, oldValue: string) => {
-            this.onSearchTextChange(newValue, oldValue);
-        });
     }
     }
 
 
-    protected initialize(): void {
-        this.query = this.getSearchText();
+    protected initialize(): IPromise<void> {
+        return this.$q.all(
+            [
+                this.configService.verificationsEnabled().then((verificationsEnabled: boolean) => {
+                    this.verificationsEnabled = verificationsEnabled;
+                }),
+                this.configService.advancedSearchConfig().then((advancedSearchConfig: IAdvancedSearchConfig) => {
+                    this.advancedSearchEnabled = advancedSearchConfig.enabled;
+                    this.advancedSearchMaxRows = advancedSearchConfig.maxRows;
+
+                    for (let advancedSearchTag of advancedSearchConfig.attributes) {
+                        this.advancedSearchTags[advancedSearchTag.attribute] = advancedSearchTag;
+                    }
+                })
+            ]
+        ).then(result => {
+            const searchQuery = this.getSearchQuery();
+            if (searchQuery) {
+                // A search query has been passed in, disregard the current search state
+                this.query = searchQuery;
+                this.advancedSearch = false;
+                this.storeSearchText();
+                this.commonSearchService.setHdAdvancedSearchActive(this.advancedSearch);
+                this.commonSearchService.setHdAdvSearchQueries([]);
+            } else {
+                this.query = this.getSearchText();
+                this.advancedSearch = this.commonSearchService.isHdAdvancedSearchActive();
+                this.queries = this.commonSearchService.getHdAdvSearchQueries();
+                if (this.queries.length === 0) {
+                    this.addSearchTag();
+                }
+            }
 
 
-        this.configService.verificationsEnabled().then((verificationsEnabled: boolean) => {
-            this.verificationsEnabled = verificationsEnabled;
-        });
+            // Once <ias-search-box> from ng-ias allows the autofocus attribute, we can remove this code
+            this.$timeout(() => {
+                document.getElementsByTagName('input')[0].focus();
+            });
 
 
-        // Once <ias-search-box> from ng-ias allows the autofocus attribute, we can remove this code
-        this.$timeout(() => {
-            document.getElementsByTagName('input')[0].focus();
+            this.$scope.$watch('$ctrl.query', (newValue: string, oldValue: string) => {
+                this.onSearchTextChange(newValue, oldValue);
+            });
         });
         });
     }
     }
 
 
@@ -85,7 +121,7 @@ export default abstract class HelpDeskSearchBaseComponent {
         return this.errorMessage || this.searchMessage;
         return this.errorMessage || this.searchMessage;
     }
     }
 
 
-    private getSearchText(): string {
+    private getSearchQuery(): string {
         let param: string = this.$stateParams['query'];
         let param: string = this.$stateParams['query'];
         // If multiple query parameters are defined, use the first one
         // If multiple query parameters are defined, use the first one
         if (isArray(param)) {
         if (isArray(param)) {
@@ -95,13 +131,18 @@ export default abstract class HelpDeskSearchBaseComponent {
             param = param.trim();
             param = param.trim();
         }
         }
 
 
-        return param || this.localStorageService.getItem(this.searchTextLocalStorageKey);
+        return param;
+    }
+
+    private getSearchText(): string {
+        return this.localStorageService.getItem(this.searchTextLocalStorageKey);
     }
     }
 
 
     abstract fetchData(): void;
     abstract fetchData(): void;
 
 
     protected clearSearch(): void {
     protected clearSearch(): void {
         this.query = null;
         this.query = null;
+        this.queries = [];
         this.searchResult = null;
         this.searchResult = null;
         this.clearErrorMessage();
         this.clearErrorMessage();
         this.clearSearchMessage();
         this.clearSearchMessage();
@@ -111,13 +152,35 @@ export default abstract class HelpDeskSearchBaseComponent {
     protected fetchSearchData(): IPromise<void | SearchResult> {
     protected fetchSearchData(): IPromise<void | SearchResult> {
         this.abortPendingRequests();
         this.abortPendingRequests();
         this.searchResult = null;
         this.searchResult = null;
+        let promise;
 
 
-        if (!this.query) {
-            this.clearSearch();
-            return null;
+        if (this.advancedSearch) {
+            if (!this.queries || (this.queries.length === 1 && !this.queries[0].key)) {
+                this.clearSearch();
+                return null;
+            }
+
+            const keys = new Set();
+            for (let searchQuery of this.queries) {
+                keys.add(searchQuery.key);
+            }
+
+            if (keys.size < this.queries.length) {
+                this.searchMessage = 'Search attributes must be unique';
+                return null;
+            }
+
+            promise = this.helpDeskService.advancedSearch(this.queries);
+        }
+        else {
+            if (!this.query) {
+                this.clearSearch();
+                return null;
+            }
+
+            promise = this.helpDeskService.search(this.query);
         }
         }
 
 
-        let promise = this.helpDeskService.search(this.query);
         this.pendingRequests.push(promise);
         this.pendingRequests.push(promise);
 
 
         return promise
         return promise
@@ -153,7 +216,13 @@ export default abstract class HelpDeskSearchBaseComponent {
     }
     }
 
 
     private gotoState(state: string): void {
     private gotoState(state: string): void {
-        this.$state.go(state, { query: this.query });
+        this.$state.go(state);
+    }
+
+    private initiateSearch() {
+        this.clearSearchMessage();
+        this.clearErrorMessage();
+        this.fetchData();
     }
     }
 
 
     private onSearchTextChange(newValue: string, oldValue: string): void {
     private onSearchTextChange(newValue: string, oldValue: string): void {
@@ -162,9 +231,7 @@ export default abstract class HelpDeskSearchBaseComponent {
         }
         }
 
 
         this.storeSearchText();
         this.storeSearchText();
-        this.clearSearchMessage();
-        this.clearErrorMessage();
-        this.fetchData();
+        this.initiateSearch();
     }
     }
 
 
     protected abortPendingRequests() {
     protected abortPendingRequests() {
@@ -211,6 +278,53 @@ export default abstract class HelpDeskSearchBaseComponent {
         }
         }
     }
     }
 
 
+    private onAdvancedSearchAttributeChanged(query: IAdvancedSearchQuery) {
+        // Make sure we set the default value if the type is select
+        const attributeMetadata: IAttributeMetadata = this.advancedSearchTags[query.key];
+        if (attributeMetadata.type == 'select') {
+            query.value = this.commonSearchService.getDefaultValue(attributeMetadata);
+        }
+
+        this.commonSearchService.setHdAdvSearchQueries(this.queries);
+        this.initiateSearch();
+    }
+
+    private onAdvancedSearchAttributeValueChanged() {
+        this.commonSearchService.setHdAdvSearchQueries(this.queries);
+        this.initiateSearch();
+    }
+
+    private onAdvancedSearchValueChanged() {
+        this.commonSearchService.setHdAdvSearchQueries(this.queries);
+        this.initiateSearch();
+    }
+
+    removeSearchTag(tagIndex: number): void {
+        this.queries.splice(tagIndex, 1);
+        this.commonSearchService.setHdAdvSearchQueries(this.queries);
+
+        if (this.queries.length > 0) {
+            this.initiateSearch();
+        }
+        else {
+            this.clearSearch();
+            this.advancedSearch = false;
+            this.commonSearchService.setHdAdvancedSearchActive(this.advancedSearch);
+        }
+    }
+
+    addSearchTag(): void {
+        const firstTagName = Object.keys(this.advancedSearchTags)[0];
+        const attributeMetaData: IAttributeMetadata = this.advancedSearchTags[firstTagName];
+
+        const query: IAdvancedSearchQuery = {
+            key: attributeMetaData.attribute,
+            value: this.commonSearchService.getDefaultValue(attributeMetaData),
+        };
+
+        this.queries.push(query);
+    }
+
     protected selectPerson(person: IPerson): void {
     protected selectPerson(person: IPerson): void {
         this.IasDialogService
         this.IasDialogService
             .open({
             .open({
@@ -235,6 +349,13 @@ export default abstract class HelpDeskSearchBaseComponent {
         this.localStorageService.setItem(this.searchTextLocalStorageKey, this.query || '');
         this.localStorageService.setItem(this.searchTextLocalStorageKey, this.query || '');
     }
     }
 
 
+    enableAdvancedSearch(): void {
+        this.clearSearch();
+        this.addSearchTag();
+        this.advancedSearch = true;
+        this.commonSearchService.setHdAdvancedSearchActive(this.advancedSearch);
+    }
+
     protected toggleView(state: string): void {
     protected toggleView(state: string): void {
         this.storeSearchView(state);
         this.storeSearchView(state);
         this.storeSearchText();
         this.storeSearchText();

+ 55 - 18
client/src/modules/helpdesk/helpdesk-search-cards.component.html

@@ -20,26 +20,63 @@
   ~ Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
   ~ Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
   -->
   -->
 
 
-<div class="ias-header">
-    <h2 id="page-content-title" translate="Title_Helpdesk">Help Desk</h2>
-    <ias-search-box ng-model="$ctrl.query" ng-model-options="{debounce: $ctrl.inputDebounce}"
-                    placeholder="{{'Placeholder_Search' | translate}}">
-    </ias-search-box>
-    <ias-button class="verifications-button ias-cta" ng-if="$ctrl.verificationsEnabled"
-                ng-click="$ctrl.showVerifications()">{{ 'Button_Verifications' | translate }}</ias-button>
 
 
-    <span class="ias-fill"></span>
+<div class="peoplesearch-header">
+    <div class="basic-search-container" ng-if="!$ctrl.advancedSearch">
+        <h2 id="page-content-title" ng-if="!$ctrl.advancedSearch" translate="Title_Helpdesk">Help Desk</h2>
+        <ias-search-box id="input" ng-model="$ctrl.query"
+                        ng-model-options="{debounce: $ctrl.inputDebounce}"
+                        placeholder="{{'Placeholder_Search' | translate}}" auto-focus>
+        </ias-search-box>
+
+        <ias-button id="advanced-search-icon" class="ias-icon-button" ng-click="$ctrl.enableAdvancedSearch()"
+                    ng-if="$ctrl.advancedSearchEnabled"
+                    ng-attr-title="{{ 'Title_AdvancedSearch' | translate }}">
+            <ias-icon class="ias-selected" icon="search_advanced"></ias-icon>
+        </ias-button>
+    </div>
+    <div class="advanced-search-container" ng-if="$ctrl.advancedSearch">
+        <div class="attribute-row" ng-repeat="query in $ctrl.queries">
+            <select ng-model="query.key" ng-change="$ctrl.onAdvancedSearchAttributeChanged(query)">
+                <option ng-repeat="tag in $ctrl.advancedSearchTags" ng-attr-value="{{tag.attribute}}">{{tag.label}}</option>
+            </select>
+
+            <!--Show a drop-down if the attribute type is 'select'-->
+            <select ng-model="query.value" ng-if="$ctrl.advancedSearchTags[query.key].type === 'select'"
+                    ng-change="$ctrl.onAdvancedSearchValueChanged($event)" class="attribute-value">
+                <option ng-attr-value="{{name}}"
+                        ng-repeat="(name, label) in $ctrl.advancedSearchTags[query.key].options">{{label}}</option>
+            </select>
 
 
-    <ias-button id="view-tile-icon" class="ias-icon-button ias-selected"
-                ng-disabled="true"
-                ng-attr-title="{{ 'Title_HelpDeskCard' | translate }}">
-        <ias-icon icon="view_tile_thin"></ias-icon>
-    </ias-button>
-    <ias-button id="view-list-icon" class="ias-icon-button"
-                ng-click="$ctrl.gotoTableView()"
-                ng-attr-title="{{ 'Title_HelpDeskTable' | translate }}">
-        <ias-icon icon="view_list_thin"></ias-icon>
-    </ias-button>
+            <!--Otherwise, just show a regular input field-->
+            <input ng-model="query.value" ng-if="$ctrl.advancedSearchTags[query.key].type !== 'select'"
+                   ng-change="$ctrl.onAdvancedSearchValueChanged($event)" class="attribute-value"
+                   autocomplete="off" ng-model-options="{debounce: $ctrl.inputDebounce}">
+
+            <ias-button class="ias-icon-button" ng-click="$ctrl.removeSearchTag($index)"
+                        ng-attr-title="{{ 'Button_Remove' | translate }}">
+                <ias-icon icon="close_thin"></ias-icon>
+            </ias-button>
+        </div>
+        <ias-button id="add-attribute-row" class="ias-icon-button" ng-click="$ctrl.addSearchTag()"
+                    ng-if="$ctrl.queries.length < $ctrl.advancedSearchMaxRows"
+                    ng-attr-title="{{ 'Button_AddSearchAttribute' | translate }}">
+            <ias-icon icon="new_thin"></ias-icon>
+        </ias-button>
+    </div>
+    <span class="ias-fill"></span>
+    <div class="ias-header">
+        <ias-button id="view-tile-icon" class="ias-icon-button ias-selected"
+                    ng-disabled="true"
+                    ng-attr-title="{{ 'Title_HelpDeskCard' | translate }}">
+            <ias-icon icon="view_tile_thin"></ias-icon>
+        </ias-button>
+        <ias-button id="view-list-icon" class="ias-icon-button"
+                    ng-click="$ctrl.gotoTableView()"
+                    ng-attr-title="{{ 'Title_HelpDeskTable' | translate }}">
+            <ias-icon icon="view_list_thin"></ias-icon>
+        </ias-button>
+    </div>
 </div>
 </div>
 
 
 <div class="search-info-container">
 <div class="search-info-container">

+ 9 - 5
client/src/modules/helpdesk/helpdesk-search-cards.component.ts

@@ -31,6 +31,7 @@ import {IPerson} from '../../models/person.model';
 import PromiseService from '../../services/promise.service';
 import PromiseService from '../../services/promise.service';
 import {IHelpDeskService} from '../../services/helpdesk.service';
 import {IHelpDeskService} from '../../services/helpdesk.service';
 import IPwmService from '../../services/pwm.service';
 import IPwmService from '../../services/pwm.service';
+import CommonSearchService from '../../services/common-search.service';
 
 
 @Component({
 @Component({
     stylesheetUrl: require('./helpdesk-search.component.scss'),
     stylesheetUrl: require('./helpdesk-search.component.scss'),
@@ -49,7 +50,8 @@ export default class HelpDeskSearchCardsComponent extends HelpDeskSearchBaseComp
         'IasDialogService',
         'IasDialogService',
         'LocalStorageService',
         'LocalStorageService',
         'PromiseService',
         'PromiseService',
-        'PwmService'
+        'PwmService',
+        'CommonSearchService'
     ];
     ];
     constructor($q: IQService,
     constructor($q: IQService,
                 $scope: IScope,
                 $scope: IScope,
@@ -62,14 +64,16 @@ export default class HelpDeskSearchCardsComponent extends HelpDeskSearchBaseComp
                 IasDialogService: any,
                 IasDialogService: any,
                 localStorageService: LocalStorageService,
                 localStorageService: LocalStorageService,
                 promiseService: PromiseService,
                 promiseService: PromiseService,
-                pwmService: IPwmService) {
+                pwmService: IPwmService,
+                commonSearchService: CommonSearchService) {
         super($q, $scope, $state, $stateParams, $timeout, $translate, configService, helpDeskService, IasDialogService,
         super($q, $scope, $state, $stateParams, $timeout, $translate, configService, helpDeskService, IasDialogService,
-            localStorageService, promiseService, pwmService);
+            localStorageService, promiseService, pwmService, commonSearchService);
     }
     }
 
 
     $onInit() {
     $onInit() {
-        this.initialize();
-        this.fetchData();
+        this.initialize().then(() => {
+            this.fetchData();
+        });
 
 
         this.configService.photosEnabled().then((photosEnabled: boolean) => {
         this.configService.photosEnabled().then((photosEnabled: boolean) => {
             this.photosEnabled = photosEnabled;
             this.photosEnabled = photosEnabled;

+ 65 - 29
client/src/modules/helpdesk/helpdesk-search-table.component.html

@@ -20,38 +20,74 @@
   ~ Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
   ~ Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
   -->
   -->
 
 
-<div class="ias-header">
-    <h2 id="page-content-title" translate="Title_Helpdesk">Help Desk</h2>
-    <ias-search-box ng-model="$ctrl.query" ng-model-options="{debounce: $ctrl.inputDebounce}"
-                    placeholder="{{'Placeholder_Search' | translate}}">
-    </ias-search-box>
-    <ias-button class="verifications-button ias-cta" ng-if="$ctrl.verificationsEnabled"
-                ng-click="$ctrl.showVerifications()">{{ 'Button_Verifications' | translate }}</ias-button>
+<div class="peoplesearch-header">
+    <div class="basic-search-container" ng-if="!$ctrl.advancedSearch">
+        <h2 id="page-content-title" ng-if="!$ctrl.advancedSearch" translate="Title_Helpdesk">Help Desk</h2>
+        <ias-search-box id="input" ng-model="$ctrl.query"
+                        ng-model-options="{debounce: $ctrl.inputDebounce}"
+                        placeholder="{{'Placeholder_Search' | translate}}" auto-focus>
+        </ias-search-box>
 
 
-    <span class="ias-fill"></span>
+        <ias-button id="advanced-search-icon" class="ias-icon-button" ng-click="$ctrl.enableAdvancedSearch()"
+                    ng-if="$ctrl.advancedSearchEnabled"
+                    ng-attr-title="{{ 'Title_AdvancedSearch' | translate }}">
+            <ias-icon class="ias-selected" icon="search_advanced"></ias-icon>
+        </ias-button>
+    </div>
+    <div class="advanced-search-container" ng-if="$ctrl.advancedSearch">
+        <div class="attribute-row" ng-repeat="query in $ctrl.queries">
+            <select ng-model="query.key" ng-change="$ctrl.onAdvancedSearchAttributeChanged(query)">
+                <option ng-repeat="tag in $ctrl.advancedSearchTags" ng-attr-value="{{tag.attribute}}">{{tag.label}}</option>
+            </select>
 
 
-    <ias-button id="view-tile-icon" class="ias-icon-button"
-                ng-click="$ctrl.gotoCardsView()"
-                ng-attr-title="{{ 'Title_HelpDeskCard' | translate }}">
-        <ias-icon icon="view_tile_thin"></ias-icon>
-    </ias-button>
-    <ias-button id="view-list-icon" class="ias-icon-button ias-selected"
-                ng-disabled="true"
-                ng-attr-title="{{ 'Title_HelpDeskTable' | translate }}">
-        <ias-icon icon="view_list_thin"></ias-icon>
-    </ias-button>
-    <div class="icon-divider vertical"></div>
-    <ias-button class="ias-icon-button table-configuration-menu-toggle" ias-toggle="menu1">
-        <ias-icon icon="configure_thick"></ias-icon>
-    </ias-button>
-    <ias-menu name="menu1" ias-align="end end">
-        <div class="ias-input-container">
-            <div class="checkbox-button" ng-repeat="(key, value) in $ctrl.columnConfiguration">
-                <input type="checkbox" ng-checked="value.visible" aria-label="Toggle column visibility" disabled/>
-                <ias-button ng-click="$ctrl.toggleColumnVisible($event, key)">{{value.label}}</ias-button>
-            </div>
+            <!--Show a drop-down if the attribute type is 'select'-->
+            <select ng-model="query.value" ng-if="$ctrl.advancedSearchTags[query.key].type === 'select'"
+                    ng-change="$ctrl.onAdvancedSearchValueChanged($event)" class="attribute-value">
+                <option ng-attr-value="{{name}}"
+                        ng-repeat="(name, label) in $ctrl.advancedSearchTags[query.key].options">{{label}}</option>
+            </select>
+
+            <!--Otherwise, just show a regular input field-->
+            <input ng-model="query.value" ng-if="$ctrl.advancedSearchTags[query.key].type !== 'select'"
+                   ng-change="$ctrl.onAdvancedSearchValueChanged($event)" class="attribute-value"
+                   autocomplete="off" ng-model-options="{debounce: $ctrl.inputDebounce}">
+
+            <ias-button class="ias-icon-button" ng-click="$ctrl.removeSearchTag($index)"
+                        ng-attr-title="{{ 'Button_Remove' | translate }}">
+                <ias-icon icon="close_thin"></ias-icon>
+            </ias-button>
         </div>
         </div>
-    </ias-menu>
+        <ias-button id="add-attribute-row" class="ias-icon-button" ng-click="$ctrl.addSearchTag()"
+                    ng-if="$ctrl.queries.length < $ctrl.advancedSearchMaxRows"
+                    ng-attr-title="{{ 'Button_AddSearchAttribute' | translate }}">
+            <ias-icon icon="new_thin"></ias-icon>
+        </ias-button>
+    </div>
+    <span class="ias-fill"></span>
+    <div class="ias-header">
+        <ias-button id="view-tile-icon" class="ias-icon-button"
+                    ng-click="$ctrl.gotoCardsView()"
+                    ng-attr-title="{{ 'Title_HelpDeskCard' | translate }}">
+            <ias-icon icon="view_tile_thin"></ias-icon>
+        </ias-button>
+        <ias-button id="view-list-icon" class="ias-icon-button ias-selected"
+                    ng-disabled="true"
+                    ng-attr-title="{{ 'Title_HelpDeskTable' | translate }}">
+            <ias-icon icon="view_list_thin"></ias-icon>
+        </ias-button>
+        <div class="icon-divider vertical"></div>
+        <ias-button class="ias-icon-button table-configuration-menu-toggle" ias-toggle="menu1">
+            <ias-icon icon="configure_thick"></ias-icon>
+        </ias-button>
+        <ias-menu name="menu1" ias-align="end end">
+            <div class="ias-input-container">
+                <div class="checkbox-button" ng-repeat="(key, value) in $ctrl.columnConfiguration">
+                    <input type="checkbox" ng-checked="value.visible" aria-label="Toggle column visibility" disabled/>
+                    <ias-button ng-click="$ctrl.toggleColumnVisible($event, key)">{{value.label}}</ias-button>
+                </div>
+            </div>
+        </ias-menu>
+    </div>
 </div>
 </div>
 
 
 <div class="search-info-container">
 <div class="search-info-container">

+ 9 - 5
client/src/modules/helpdesk/helpdesk-search-table.component.ts

@@ -30,6 +30,7 @@ import LocalStorageService from '../../services/local-storage.service';
 import PromiseService from '../../services/promise.service';
 import PromiseService from '../../services/promise.service';
 import {IHelpDeskService} from '../../services/helpdesk.service';
 import {IHelpDeskService} from '../../services/helpdesk.service';
 import IPwmService from '../../services/pwm.service';
 import IPwmService from '../../services/pwm.service';
+import CommonSearchService from '../../services/common-search.service';
 
 
 @Component({
 @Component({
     stylesheetUrl: require('./helpdesk-search.component.scss'),
     stylesheetUrl: require('./helpdesk-search.component.scss'),
@@ -50,7 +51,8 @@ export default class HelpDeskSearchTableComponent extends HelpDeskSearchBaseComp
         'IasDialogService',
         'IasDialogService',
         'LocalStorageService',
         'LocalStorageService',
         'PromiseService',
         'PromiseService',
-        'PwmService'
+        'PwmService',
+        'CommonSearchService'
     ];
     ];
     constructor($q: IQService,
     constructor($q: IQService,
                 $scope: IScope,
                 $scope: IScope,
@@ -63,14 +65,16 @@ export default class HelpDeskSearchTableComponent extends HelpDeskSearchBaseComp
                 IasDialogService: any,
                 IasDialogService: any,
                 localStorageService: LocalStorageService,
                 localStorageService: LocalStorageService,
                 promiseService: PromiseService,
                 promiseService: PromiseService,
-                pwmService: IPwmService) {
+                pwmService: IPwmService,
+                commonSearchService: CommonSearchService) {
         super($q, $scope, $state, $stateParams, $timeout, $translate, configService, helpDeskService, IasDialogService,
         super($q, $scope, $state, $stateParams, $timeout, $translate, configService, helpDeskService, IasDialogService,
-              localStorageService, promiseService, pwmService);
+              localStorageService, promiseService, pwmService, commonSearchService);
     }
     }
 
 
     $onInit() {
     $onInit() {
-        this.initialize();
-        this.fetchData();
+        this.initialize().then(() => {
+            this.fetchData();
+        });
 
 
         // The table columns are dynamic and configured via a service
         // The table columns are dynamic and configured via a service
         this.configService.getColumnConfig().then(
         this.configService.getColumnConfig().then(

+ 30 - 4
client/src/modules/helpdesk/helpdesk-search.component.scss

@@ -31,10 +31,6 @@ help-desk-search-table {
     margin-bottom: 0;
     margin-bottom: 0;
   }
   }
 
 
-  .ias-search {
-    margin-right: 10px;
-  }
-
   .verifications-button {
   .verifications-button {
     margin: 5px 5px 5px 0;
     margin: 5px 5px 5px 0;
   }
   }
@@ -43,6 +39,36 @@ help-desk-search-table {
     flex: 1 1;
     flex: 1 1;
     overflow: auto;
     overflow: auto;
   }
   }
+
+  .helpdesk-search-header {
+    display: flex;
+    align-items: flex-start;
+
+    .basic-search-container {
+      display: flex;
+      align-items: center;
+      margin-bottom: 15px;
+
+      > * + * {
+        margin-left: 10px;
+      }
+    }
+
+    .advanced-search-container {
+      display: flex;
+      flex-direction: column;
+      align-items: flex-start;
+      margin-bottom: 15px;
+
+      > * + * {
+        margin-top: 5px;
+      }
+
+      &+ div {
+        margin-top: 15px;
+      }
+    }
+  }
 }
 }
 
 
 .aligned-input {
 .aligned-input {

+ 3 - 1
client/src/modules/helpdesk/helpdesk.module.ts

@@ -38,6 +38,7 @@ import AutogenChangePasswordController from '../../components/changepassword/aut
 import RandomChangePasswordController from '../../components/changepassword/random-change-password.controller';
 import RandomChangePasswordController from '../../components/changepassword/random-change-password.controller';
 import SuccessChangePasswordController from '../../components/changepassword/success-change-password.controller';
 import SuccessChangePasswordController from '../../components/changepassword/success-change-password.controller';
 import TypeChangePasswordController from '../../components/changepassword/type-change-password.controller';
 import TypeChangePasswordController from '../../components/changepassword/type-change-password.controller';
+import CommonSearchService from '../../services/common-search.service';
 
 
 require('../peoplesearch/peoplesearch.scss');
 require('../peoplesearch/peoplesearch.scss');
 
 
@@ -61,6 +62,7 @@ module(moduleName, [
     .filter('dateFilter', DateFilter)
     .filter('dateFilter', DateFilter)
     .service('ObjectService', ObjectService)
     .service('ObjectService', ObjectService)
     .service('PromiseService', PromiseService)
     .service('PromiseService', PromiseService)
-    .service('LocalStorageService', LocalStorageService);
+    .service('LocalStorageService', LocalStorageService)
+    .service('CommonSearchService', CommonSearchService);
 
 
 export default moduleName;
 export default moduleName;

+ 14 - 1
client/src/modules/helpdesk/routes.ts

@@ -21,6 +21,8 @@
  */
  */
 
 
 
 
+import LocalStorageService from '../../services/local-storage.service';
+
 export default [
 export default [
     '$stateProvider',
     '$stateProvider',
     '$urlRouterProvider',
     '$urlRouterProvider',
@@ -30,7 +32,18 @@ export default [
     ) => {
     ) => {
         $urlRouterProvider.otherwise(
         $urlRouterProvider.otherwise(
             ($injector: angular.auto.IInjectorService, $location: angular.ILocationService) => {
             ($injector: angular.auto.IInjectorService, $location: angular.ILocationService) => {
-                $location.url('search/cards');
+                let $state: angular.ui.IStateService = <angular.ui.IStateService>$injector.get('$state');
+                let localStorageService: LocalStorageService =
+                    <LocalStorageService>$injector.get('LocalStorageService');
+
+                let storedView = localStorageService.getItem(localStorageService.keys.HELPDESK_SEARCH_VIEW);
+
+                if (storedView) {
+                    $state.go(storedView);
+                }
+                else {
+                    $location.url('search/cards');
+                }
             });
             });
 
 
         $stateProvider.state('search', {
         $stateProvider.state('search', {

+ 147 - 30
client/src/modules/peoplesearch/peoplesearch-base.component.ts

@@ -20,7 +20,7 @@
  * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
  * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
  */
  */
 
 
-
+import * as angular from 'angular';
 import {isArray, isString, IPromise, IQService, IScope, ITimeoutService} from 'angular';
 import {isArray, isString, IPromise, IQService, IScope, ITimeoutService} from 'angular';
 import { IPeopleSearchConfigService } from '../../services/peoplesearch-config.service';
 import { IPeopleSearchConfigService } from '../../services/peoplesearch-config.service';
 import { IPeopleService } from '../../services/people.service';
 import { IPeopleService } from '../../services/people.service';
@@ -29,10 +29,14 @@ import LocalStorageService from '../../services/local-storage.service';
 import { IPerson } from '../../models/person.model';
 import { IPerson } from '../../models/person.model';
 import PromiseService from '../../services/promise.service';
 import PromiseService from '../../services/promise.service';
 import SearchResult from '../../models/search-result.model';
 import SearchResult from '../../models/search-result.model';
-
-const SEARCH_TEXT_LOCAL_STORAGE_KEY = 'searchText';
+import {IAdvancedSearchConfig, IAdvancedSearchQuery, IAttributeMetadata} from '../../services/base-config.service';
+import CommonSearchService from '../../services/common-search.service';
 
 
 abstract class PeopleSearchBaseComponent {
 abstract class PeopleSearchBaseComponent {
+    advancedSearch = false;
+    advancedSearchTags = {};
+    advancedSearchEnabled: boolean;
+    advancedSearchMaxRows: number;
     errorMessage: string;
     errorMessage: string;
     inputDebounce: number;
     inputDebounce: number;
     orgChartEnabled: boolean;
     orgChartEnabled: boolean;
@@ -40,6 +44,7 @@ abstract class PeopleSearchBaseComponent {
     searchMessage: string;
     searchMessage: string;
     searchResult: SearchResult;
     searchResult: SearchResult;
     query: string;
     query: string;
+    queries: IAdvancedSearchQuery[];
     searchTextLocalStorageKey: string;
     searchTextLocalStorageKey: string;
     searchViewLocalStorageKey: string;
     searchViewLocalStorageKey: string;
 
 
@@ -53,15 +58,12 @@ abstract class PeopleSearchBaseComponent {
                 protected localStorageService: LocalStorageService,
                 protected localStorageService: LocalStorageService,
                 protected peopleService: IPeopleService,
                 protected peopleService: IPeopleService,
                 protected promiseService: PromiseService,
                 protected promiseService: PromiseService,
-                protected pwmService: IPwmService) {
+                protected pwmService: IPwmService,
+                protected commonSearchService: CommonSearchService) {
         this.searchTextLocalStorageKey = this.localStorageService.keys.SEARCH_TEXT;
         this.searchTextLocalStorageKey = this.localStorageService.keys.SEARCH_TEXT;
         this.searchViewLocalStorageKey = this.localStorageService.keys.SEARCH_VIEW;
         this.searchViewLocalStorageKey = this.localStorageService.keys.SEARCH_VIEW;
 
 
         this.inputDebounce = this.pwmService.ajaxTypingWait;
         this.inputDebounce = this.pwmService.ajaxTypingWait;
-
-        $scope.$watch('$ctrl.query', (newValue: string, oldValue: string) => {
-            this.onSearchTextChange(newValue, oldValue);
-        });
     }
     }
 
 
     getMessage(): string {
     getMessage(): string {
@@ -73,7 +75,34 @@ abstract class PeopleSearchBaseComponent {
     }
     }
 
 
     private gotoState(state: string): void {
     private gotoState(state: string): void {
-        this.$state.go(state, { query: this.query });
+        this.$state.go(state);
+    }
+
+    private initiateSearch() {
+        this.clearSearchMessage();
+        this.clearErrorMessage();
+        this.fetchData();
+    }
+
+    private onAdvancedSearchAttributeChanged(query: IAdvancedSearchQuery) {
+        // Make sure we set the default value if the type is select
+        const attributeMetadata: IAttributeMetadata = this.advancedSearchTags[query.key];
+        if (attributeMetadata.type == 'select') {
+            query.value = this.commonSearchService.getDefaultValue(attributeMetadata);
+        }
+
+        this.commonSearchService.setPsAdvSearchQueries(this.queries);
+        this.initiateSearch();
+    }
+
+    private onAdvancedSearchAttributeValueChanged() {
+        this.commonSearchService.setPsAdvSearchQueries(this.queries);
+        this.initiateSearch();
+    }
+
+    private onAdvancedSearchValueChanged() {
+        this.commonSearchService.setPsAdvSearchQueries(this.queries);
+        this.initiateSearch();
     }
     }
 
 
     private onSearchTextChange(newValue: string, oldValue: string): void {
     private onSearchTextChange(newValue: string, oldValue: string): void {
@@ -82,9 +111,33 @@ abstract class PeopleSearchBaseComponent {
         }
         }
 
 
         this.storeSearchText();
         this.storeSearchText();
-        this.clearSearchMessage();
-        this.clearErrorMessage();
-        this.fetchData();
+        this.initiateSearch();
+    }
+
+    removeSearchTag(tagIndex: number): void {
+        this.queries.splice(tagIndex, 1);
+        this.commonSearchService.setPsAdvSearchQueries(this.queries);
+
+        if (this.queries.length > 0) {
+            this.initiateSearch();
+        }
+        else {
+            this.clearSearch();
+            this.advancedSearch = false;
+            this.commonSearchService.setPsAdvancedSearchActive(this.advancedSearch);
+        }
+    }
+
+    addSearchTag(): void {
+        const firstTagName = Object.keys(this.advancedSearchTags)[0];
+        const attributeMetaData: IAttributeMetadata = this.advancedSearchTags[firstTagName];
+
+        const query: IAdvancedSearchQuery = {
+            key: attributeMetaData.attribute,
+            value: this.commonSearchService.getDefaultValue(attributeMetaData),
+        };
+
+        this.queries.push(query);
     }
     }
 
 
     selectPerson(person: IPerson): void {
     selectPerson(person: IPerson): void {
@@ -138,6 +191,7 @@ abstract class PeopleSearchBaseComponent {
 
 
     protected clearSearch(): void {
     protected clearSearch(): void {
         this.query = null;
         this.query = null;
+        this.queries = [];
         this.searchResult = null;
         this.searchResult = null;
         this.clearErrorMessage();
         this.clearErrorMessage();
         this.clearSearchMessage();
         this.clearSearchMessage();
@@ -154,14 +208,35 @@ abstract class PeopleSearchBaseComponent {
         this.abortPendingRequests();
         this.abortPendingRequests();
         this.searchResult = null;
         this.searchResult = null;
 
 
-        if (!this.query) {
-            this.clearSearch();
-            return null;
-        }
-
         const self = this;
         const self = this;
+        let promise;
 
 
-        let promise = this.peopleService.search(this.query);
+        if (this.advancedSearch) {
+            if (!this.queries || (this.queries.length === 1 && !this.queries[0].key)) {
+                this.clearSearch();
+                return null;
+            }
+
+            const keys = new Set();
+            for (let searchQuery of this.queries) {
+                keys.add(searchQuery.key);
+            }
+
+            if (keys.size < this.queries.length) {
+                this.searchMessage = 'Search attributes must be unique';
+                return null;
+            }
+
+            promise = this.peopleService.advancedSearch(this.queries);
+        }
+        else {
+            if (!this.query) {
+                this.clearSearch();
+                return null;
+            }
+
+            promise = this.peopleService.search(this.query);
+        }
 
 
         this.pendingRequests.push(promise);
         this.pendingRequests.push(promise);
 
 
@@ -197,21 +272,52 @@ abstract class PeopleSearchBaseComponent {
             });
             });
     }
     }
 
 
-    protected initialize(): void {
-        // Determine whether org-chart should appear
-        this.configService.orgChartEnabled().then((orgChartEnabled: boolean) => {
-            this.orgChartEnabled = orgChartEnabled;
-        });
-
-        this.query = this.getSearchText();
+    protected initialize(): IPromise<void> {
+        return this.$q.all(
+            [
+                // Determine whether org-chart should appear
+                this.configService.orgChartEnabled().then((orgChartEnabled: boolean) => {
+                    this.orgChartEnabled = orgChartEnabled;
+                }),
+                this.configService.advancedSearchConfig().then((advancedSearchConfig: IAdvancedSearchConfig) => {
+                    this.advancedSearchEnabled = advancedSearchConfig.enabled;
+                    this.advancedSearchMaxRows = advancedSearchConfig.maxRows;
+
+                    for (let advancedSearchTag of advancedSearchConfig.attributes) {
+                        this.advancedSearchTags[advancedSearchTag.attribute] = advancedSearchTag;
+                    }
+                })
+            ]
+        ).then(result => {
+            const searchQuery = this.getSearchQuery();
+            if (searchQuery) {
+                // A search query has been passed in, disregard the current search state
+                this.query = searchQuery;
+                this.advancedSearch = false;
+                this.storeSearchText();
+                this.commonSearchService.setPsAdvancedSearchActive(this.advancedSearch);
+                this.commonSearchService.setPsAdvSearchQueries([]);
+            } else {
+                this.query = this.getSearchText();
+                this.advancedSearch = this.commonSearchService.isPsAdvancedSearchActive();
+                this.queries = this.commonSearchService.getPsAdvSearchQueries();
+                if (this.queries.length === 0) {
+                    this.addSearchTag();
+                }
+            }
+
+            // Once <ias-search-box> from ng-ias allows the autofocus attribute, we can remove this code
+            this.$timeout(() => {
+                document.getElementsByTagName('input')[0].focus();
+            });
 
 
-        // Once <ias-search-box> from ng-ias allows the autofocus attribute, we can remove this code
-        this.$timeout(() => {
-            document.getElementsByTagName('input')[0].focus();
+            this.$scope.$watch('$ctrl.query', (newValue: string, oldValue: string) => {
+                this.onSearchTextChange(newValue, oldValue);
+            });
         });
         });
     }
     }
 
 
-    private getSearchText(): string {
+    private getSearchQuery(): string {
         let param: string = this.$stateParams['query'];
         let param: string = this.$stateParams['query'];
         // If multiple query parameters are defined, use the first one
         // If multiple query parameters are defined, use the first one
         if (isArray(param)) {
         if (isArray(param)) {
@@ -221,13 +327,24 @@ abstract class PeopleSearchBaseComponent {
             param = param.trim();
             param = param.trim();
         }
         }
 
 
-        return param || this.localStorageService.getItem(this.searchTextLocalStorageKey);
+        return param;
+    }
+
+    private getSearchText(): string {
+        return this.localStorageService.getItem(this.searchTextLocalStorageKey);
     }
     }
 
 
     protected storeSearchText(): void {
     protected storeSearchText(): void {
         this.localStorageService.setItem(this.searchTextLocalStorageKey, this.query || '');
         this.localStorageService.setItem(this.searchTextLocalStorageKey, this.query || '');
     }
     }
 
 
+    enableAdvancedSearch(): void {
+        this.clearSearch();
+        this.addSearchTag();
+        this.advancedSearch = true;
+        this.commonSearchService.setPsAdvancedSearchActive(this.advancedSearch);
+    }
+
     protected toggleView(state: string): void {
     protected toggleView(state: string): void {
         this.storeSearchView(state);
         this.storeSearchView(state);
         this.storeSearchText();
         this.storeSearchText();

+ 57 - 19
client/src/modules/peoplesearch/peoplesearch-cards.component.html

@@ -20,27 +20,65 @@
   ~ Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
   ~ Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
   -->
   -->
 
 
-<div class="ias-header">
-    <h2 id="page-content-title" translate="Title_PeopleSearch">People Search</h2>
-    <ias-search-box id="input" ng-model="$ctrl.query" ng-model-options="{debounce: $ctrl.inputDebounce}"
-                    placeholder="{{'Placeholder_Search' | translate}}" auto-focus>
-    </ias-search-box>
+<div class="peoplesearch-header">
+    <div class="basic-search-container" ng-if="!$ctrl.advancedSearch">
+        <h2 id="page-content-title"  translate="Title_PeopleSearch">People Search</h2>
+        <ias-search-box id="input" ng-model="$ctrl.query"
+                        ng-model-options="{debounce: $ctrl.inputDebounce}"
+                        placeholder="{{'Placeholder_Search' | translate}}" auto-focus>
+        </ias-search-box>
 
 
-    <span class="ias-fill"></span>
+        <ias-button id="advanced-search-icon" class="ias-icon-button" ng-click="$ctrl.enableAdvancedSearch()"
+                    ng-if="$ctrl.advancedSearchEnabled"
+                    ng-attr-title="{{ 'Title_AdvancedSearch' | translate }}">
+            <ias-icon class="ias-selected" icon="search_advanced"></ias-icon>
+        </ias-button>
+    </div>
+    <div class="advanced-search-container" ng-if="$ctrl.advancedSearch">
+        <div class="attribute-row" ng-repeat="query in $ctrl.queries">
+            <select ng-model="query.key" ng-change="$ctrl.onAdvancedSearchAttributeChanged(query)">
+                <option ng-repeat="tag in $ctrl.advancedSearchTags" ng-attr-value="{{tag.attribute}}">{{tag.label}}</option>
+            </select>
+
+            <!--Show a drop-down if the attribute type is 'select'-->
+            <select ng-model="query.value" ng-if="$ctrl.advancedSearchTags[query.key].type === 'select'"
+                    ng-change="$ctrl.onAdvancedSearchValueChanged($event)" class="attribute-value">
+                <option ng-attr-value="{{name}}"
+                        ng-repeat="(name, label) in $ctrl.advancedSearchTags[query.key].options">{{label}}</option>
+            </select>
+
+            <!--Otherwise, just show a regular input field-->
+            <input ng-model="query.value" ng-if="$ctrl.advancedSearchTags[query.key].type !== 'select'"
+                   ng-change="$ctrl.onAdvancedSearchValueChanged($event)" class="attribute-value"
+                   autocomplete="off" ng-model-options="{debounce: $ctrl.inputDebounce}">
 
 
-    <ias-button id="view-tile-icon" class="ias-icon-button ias-selected" ng-disabled="true"
-                ng-attr-title="{{ 'Title_PeopleSearchCard' | translate }}">
-        <ias-icon class="ias-selected" icon="view_tile_thin"></ias-icon>
-    </ias-button>
-    <ias-button id="view-list-icon" class="ias-icon-button" ng-click="$ctrl.gotoTableView()"
-                ng-attr-title="{{ 'Title_PeopleSearchTable' | translate }}">
-        <ias-icon class="ias-selected" icon="view_list_thin"></ias-icon>
-    </ias-button>
-    <div class="icon-divider vertical" ng-if="$ctrl.orgChartEnabled"></div>
-    <ias-button id="orgchart-icon" class="ias-icon-button" ng-click="$ctrl.gotoOrgchart()" ng-if="$ctrl.orgChartEnabled"
-                ng-attr-title="{{ 'Title_OrgChart' | translate }}">
-        <ias-icon class="ias-selected" icon="orgchart_thin"></ias-icon>
-    </ias-button>
+            <ias-button class="ias-icon-button" ng-click="$ctrl.removeSearchTag($index)"
+                        ng-attr-title="{{ 'Button_Remove' | translate }}">
+                <ias-icon icon="close_thin"></ias-icon>
+            </ias-button>
+        </div>
+        <ias-button id="add-attribute-row" class="ias-icon-button" ng-click="$ctrl.addSearchTag()"
+                    ng-if="$ctrl.queries.length < $ctrl.advancedSearchMaxRows"
+                    ng-attr-title="{{ 'Button_AddSearchAttribute' | translate }}">
+            <ias-icon icon="new_thin"></ias-icon>
+        </ias-button>
+    </div>
+    <span class="ias-fill"></span>
+    <div class="ias-header">
+        <ias-button id="view-tile-icon" class="ias-icon-button ias-selected" ng-disabled="true"
+                    ng-attr-title="{{ 'Title_PeopleSearchCard' | translate }}">
+            <ias-icon icon="view_tile_thin"></ias-icon>
+        </ias-button>
+        <ias-button id="view-list-icon" class="ias-icon-button" ng-click="$ctrl.gotoTableView()"
+                    ng-attr-title="{{ 'Title_PeopleSearchTable' | translate }}">
+            <ias-icon icon="view_list_thin"></ias-icon>
+        </ias-button>
+        <div class="icon-divider vertical" ng-if="$ctrl.orgChartEnabled"></div>
+        <ias-button id="orgchart-icon" class="ias-icon-button" ng-click="$ctrl.gotoOrgchart()" ng-if="$ctrl.orgChartEnabled"
+                    ng-attr-title="{{ 'Title_OrgChart' | translate }}">
+            <ias-icon icon="orgchart_thin"></ias-icon>
+        </ias-button>
+    </div>
 </div>
 </div>
 
 
 <div class="search-info-container">
 <div class="search-info-container">

+ 1 - 1
client/src/modules/peoplesearch/peoplesearch-cards.component.scss

@@ -90,4 +90,4 @@ people-search-cards {
       }
       }
     }
     }
   }
   }
-}
+}

+ 10 - 5
client/src/modules/peoplesearch/peoplesearch-cards.component.ts

@@ -32,6 +32,7 @@ import PeopleSearchBaseComponent from './peoplesearch-base.component';
 import { IPerson } from '../../models/person.model';
 import { IPerson } from '../../models/person.model';
 import PromiseService from '../../services/promise.service';
 import PromiseService from '../../services/promise.service';
 import SearchResult from '../../models/search-result.model';
 import SearchResult from '../../models/search-result.model';
+import CommonSearchService from '../../services/common-search.service';
 
 
 export enum PeopleSearchCardsSize {
 export enum PeopleSearchCardsSize {
     Small = 0,
     Small = 0,
@@ -59,7 +60,8 @@ export default class PeopleSearchCardsComponent extends PeopleSearchBaseComponen
         'MfElementSizeService',
         'MfElementSizeService',
         'PeopleService',
         'PeopleService',
         'PromiseService',
         'PromiseService',
-        'PwmService'
+        'PwmService',
+        'CommonSearchService'
     ];
     ];
     constructor(private $element: IAugmentedJQuery,
     constructor(private $element: IAugmentedJQuery,
                 $q: IQService,
                 $q: IQService,
@@ -73,7 +75,8 @@ export default class PeopleSearchCardsComponent extends PeopleSearchBaseComponen
                 private elementSizeService: ElementSizeService,
                 private elementSizeService: ElementSizeService,
                 peopleService: IPeopleService,
                 peopleService: IPeopleService,
                 promiseService: PromiseService,
                 promiseService: PromiseService,
-                pwmService: IPwmService) {
+                pwmService: IPwmService,
+                commonSearchService: CommonSearchService) {
         super($q,
         super($q,
             $scope,
             $scope,
             $state,
             $state,
@@ -84,7 +87,8 @@ export default class PeopleSearchCardsComponent extends PeopleSearchBaseComponen
             localStorageService,
             localStorageService,
             peopleService,
             peopleService,
             promiseService,
             promiseService,
-            pwmService);
+            pwmService,
+            commonSearchService);
     }
     }
 
 
     $onDestroy(): void {
     $onDestroy(): void {
@@ -92,8 +96,9 @@ export default class PeopleSearchCardsComponent extends PeopleSearchBaseComponen
     }
     }
 
 
     $onInit(): void {
     $onInit(): void {
-        this.initialize();
-        this.fetchData();
+        this.initialize().then(() => {
+            this.fetchData();
+        });
 
 
         this.configService.photosEnabled().then((photosEnabled: boolean) => {
         this.configService.photosEnabled().then((photosEnabled: boolean) => {
             this.photosEnabled = photosEnabled;
             this.photosEnabled = photosEnabled;

+ 69 - 31
client/src/modules/peoplesearch/peoplesearch-table.component.html

@@ -20,40 +20,78 @@
   ~ Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
   ~ Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
   -->
   -->
 
 
-<div class="ias-header">
-    <h2 id="page-content-title" translate="Title_PeopleSearch">People Search</h2>
-    <ias-search-box id="input" ng-model="$ctrl.query" ng-model-options="{debounce: $ctrl.inputDebounce}"
-                    placeholder="{{'Placeholder_Search' | translate}}" auto-focus>
-    </ias-search-box>
+<div class="peoplesearch-header">
+    <div class="basic-search-container" ng-if="!$ctrl.advancedSearch">
+        <h2 id="page-content-title"  translate="Title_PeopleSearch">People Search</h2>
+        <ias-search-box id="input" ng-model="$ctrl.query"
+                        ng-model-options="{debounce: $ctrl.inputDebounce}"
+                        placeholder="{{'Placeholder_Search' | translate}}" auto-focus>
+        </ias-search-box>
 
 
-    <span class="ias-fill"></span>
+        <ias-button id="advanced-search-icon" class="ias-icon-button" ng-click="$ctrl.enableAdvancedSearch()"
+                    ng-if="$ctrl.advancedSearchEnabled"
+                    ng-attr-title="{{ 'Title_AdvancedSearch' | translate }}">
+            <ias-icon class="ias-selected" icon="search_advanced"></ias-icon>
+        </ias-button>
+    </div>
+    <div class="advanced-search-container" ng-if="$ctrl.advancedSearch">
+        <div class="attribute-row" ng-repeat="query in $ctrl.queries">
+            <select ng-model="query.key" ng-change="$ctrl.onAdvancedSearchAttributeChanged(query)">
+                <option ng-repeat="tag in $ctrl.advancedSearchTags" ng-attr-value="{{tag.attribute}}">{{tag.label}}</option>
+            </select>
 
 
-    <ias-button id="view-title-button" class="ias-icon-button"
-                ng-click="$ctrl.gotoCardsView()"
-                ng-attr-title="{{ 'Title_PeopleSearchCard' | translate }}">
-        <ias-icon class="ias-selected" icon="view_tile_thin"></ias-icon>
-    </ias-button>
-    <ias-button id="view-list-button" class="ias-icon-button ias-selected" ng-disabled="true"
-                ng-attr-title="{{ 'Title_PeopleSearchTable' | translate }}">
-        <ias-icon class="ias-selected" icon="view_list_thin"></ias-icon>
-    </ias-button>
-    <div class="icon-divider vertical"></div>
-    <ias-button id="view-orgchart-button" class="ias-icon-button" ng-click="$ctrl.gotoOrgchart()"
-                ng-if="$ctrl.orgChartEnabled"
-                ng-attr-title="{{ 'Title_OrgChart' | translate }}">
-        <ias-icon class="ias-selected" icon="orgchart_thin"></ias-icon>
-    </ias-button>
-    <ias-button class="ias-icon-button table-configuration-menu-toggle" ias-toggle="menu1">
-        <ias-icon icon="configure_thick"></ias-icon>
-    </ias-button>
-    <ias-menu name="menu1" ias-align="end end">
-        <div class="ias-input-container">
-            <div class="checkbox-button" ng-repeat="(key, value) in $ctrl.columnConfiguration">
-                <input type="checkbox" ng-checked="value.visible" aria-label="Toggle column visibility" disabled/>
-                <ias-button ng-click="$ctrl.toggleColumnVisible($event, key)">{{value.label}}</ias-button>
-            </div>
+            <!--Show a drop-down if the attribute type is 'select'-->
+            <select ng-model="query.value" ng-if="$ctrl.advancedSearchTags[query.key].type === 'select'"
+                    ng-change="$ctrl.onAdvancedSearchValueChanged($event)" class="attribute-value">
+                <option ng-attr-value="{{name}}"
+                        ng-repeat="(name, label) in $ctrl.advancedSearchTags[query.key].options">{{label}}</option>
+            </select>
+
+            <!--Otherwise, just show a regular input field-->
+            <input ng-model="query.value" ng-if="$ctrl.advancedSearchTags[query.key].type !== 'select'"
+                   ng-change="$ctrl.onAdvancedSearchValueChanged($event)" class="attribute-value"
+                   autocomplete="off" ng-model-options="{debounce: $ctrl.inputDebounce}">
+
+            <ias-button class="ias-icon-button" ng-click="$ctrl.removeSearchTag($index)"
+                        ng-attr-title="{{ 'Button_Remove' | translate }}">
+                <ias-icon icon="close_thin"></ias-icon>
+            </ias-button>
         </div>
         </div>
-    </ias-menu>
+        <ias-button id="add-attribute-row" class="ias-icon-button" ng-click="$ctrl.addSearchTag()"
+                    ng-if="$ctrl.queries.length < $ctrl.advancedSearchMaxRows"
+                    ng-attr-title="{{ 'Button_AddSearchAttribute' | translate }}">
+            <ias-icon icon="new_thin"></ias-icon>
+        </ias-button>
+    </div>
+    <span class="ias-fill"></span>
+    <div class="ias-header">
+        <ias-button id="view-title-button" class="ias-icon-button"
+                    ng-click="$ctrl.gotoCardsView()"
+                    ng-attr-title="{{ 'Title_PeopleSearchCard' | translate }}">
+            <ias-icon class="ias-selected" icon="view_tile_thin"></ias-icon>
+        </ias-button>
+        <ias-button id="view-list-button" class="ias-icon-button ias-selected" ng-disabled="true"
+                    ng-attr-title="{{ 'Title_PeopleSearchTable' | translate }}">
+            <ias-icon class="ias-selected" icon="view_list_thin"></ias-icon>
+        </ias-button>
+        <div class="icon-divider vertical"></div>
+        <ias-button id="view-orgchart-button" class="ias-icon-button" ng-click="$ctrl.gotoOrgchart()"
+                    ng-if="$ctrl.orgChartEnabled"
+                    ng-attr-title="{{ 'Title_OrgChart' | translate }}">
+            <ias-icon class="ias-selected" icon="orgchart_thin"></ias-icon>
+        </ias-button>
+        <ias-button class="ias-icon-button table-configuration-menu-toggle" ias-toggle="menu1">
+            <ias-icon icon="configure_thick"></ias-icon>
+        </ias-button>
+        <ias-menu name="menu1" ias-align="end end">
+            <div class="ias-input-container">
+                <div class="checkbox-button" ng-repeat="(key, value) in $ctrl.columnConfiguration">
+                    <input type="checkbox" ng-checked="value.visible" aria-label="Toggle column visibility" disabled/>
+                    <ias-button ng-click="$ctrl.toggleColumnVisible($event, key)">{{value.label}}</ias-button>
+                </div>
+            </div>
+        </ias-menu>
+    </div>
 </div>
 </div>
 
 
 <div class="search-info-container">
 <div class="search-info-container">

+ 1 - 1
client/src/modules/peoplesearch/peoplesearch-table.component.scss

@@ -42,4 +42,4 @@ people-search-table {
             right: 0;
             right: 0;
         }
         }
     }
     }
-}
+}

+ 10 - 5
client/src/modules/peoplesearch/peoplesearch-table.component.ts

@@ -30,6 +30,7 @@ import LocalStorageService from '../../services/local-storage.service';
 import PeopleSearchBaseComponent from './peoplesearch-base.component';
 import PeopleSearchBaseComponent from './peoplesearch-base.component';
 import PromiseService from '../../services/promise.service';
 import PromiseService from '../../services/promise.service';
 import SearchResult from '../../models/search-result.model';
 import SearchResult from '../../models/search-result.model';
+import CommonSearchService from '../../services/common-search.service';
 
 
 @Component({
 @Component({
     stylesheetUrl: require('./peoplesearch-table.component.scss'),
     stylesheetUrl: require('./peoplesearch-table.component.scss'),
@@ -49,7 +50,8 @@ export default class PeopleSearchTableComponent extends PeopleSearchBaseComponen
         'LocalStorageService',
         'LocalStorageService',
         'PeopleService',
         'PeopleService',
         'PromiseService',
         'PromiseService',
-        'PwmService'
+        'PwmService',
+        'CommonSearchService'
     ];
     ];
     constructor($q: IQService,
     constructor($q: IQService,
                 $scope: IScope,
                 $scope: IScope,
@@ -61,7 +63,8 @@ export default class PeopleSearchTableComponent extends PeopleSearchBaseComponen
                 localStorageService: LocalStorageService,
                 localStorageService: LocalStorageService,
                 peopleService: IPeopleService,
                 peopleService: IPeopleService,
                 promiseService: PromiseService,
                 promiseService: PromiseService,
-                pwmService: IPwmService) {
+                pwmService: IPwmService,
+                commonSearchService: CommonSearchService) {
         super($q,
         super($q,
             $scope,
             $scope,
             $state,
             $state,
@@ -72,12 +75,14 @@ export default class PeopleSearchTableComponent extends PeopleSearchBaseComponen
             localStorageService,
             localStorageService,
             peopleService,
             peopleService,
             promiseService,
             promiseService,
-            pwmService);
+            pwmService,
+            commonSearchService);
     }
     }
 
 
     $onInit(): void {
     $onInit(): void {
-        this.initialize();
-        this.fetchData();
+        this.initialize().then(() => {
+            this.fetchData();
+        });
 
 
         let self = this;
         let self = this;
 
 

+ 3 - 1
client/src/modules/peoplesearch/peoplesearch.module.ts

@@ -34,6 +34,7 @@ import PersonDetailsDialogComponent from './person-details-dialog.component';
 import LocalStorageService from '../../services/local-storage.service';
 import LocalStorageService from '../../services/local-storage.service';
 import PromiseService from '../../services/promise.service';
 import PromiseService from '../../services/promise.service';
 import uxModule from '../../ux/ux.module';
 import uxModule from '../../ux/ux.module';
+import CommonSearchService from '../../services/common-search.service';
 
 
 require('./peoplesearch.scss');
 require('./peoplesearch.scss');
 
 
@@ -53,6 +54,7 @@ module(moduleName, [
     .component('peopleSearchCards', PeopleSearchCardsComponent as IComponentOptions)
     .component('peopleSearchCards', PeopleSearchCardsComponent as IComponentOptions)
     .component('personDetailsDialogComponent', PersonDetailsDialogComponent as IComponentOptions)
     .component('personDetailsDialogComponent', PersonDetailsDialogComponent as IComponentOptions)
     .service('PromiseService', PromiseService)
     .service('PromiseService', PromiseService)
-    .service('LocalStorageService', LocalStorageService);
+    .service('LocalStorageService', LocalStorageService)
+    .service('CommonSearchService', CommonSearchService);
 
 
 export default moduleName;
 export default moduleName;

+ 34 - 0
client/src/modules/peoplesearch/peoplesearch.scss

@@ -47,6 +47,40 @@ body {
     overflow: auto;
     overflow: auto;
   }
   }
 
 
+  .peoplesearch-header {
+    display: flex;
+    align-items: flex-start;
+
+    .basic-search-container {
+      display: flex;
+      align-items: center;
+      margin-bottom: 15px;
+
+      > * + * {
+        margin-left: 10px;
+      }
+    }
+
+    .advanced-search-container {
+      display: flex;
+      flex-direction: column;
+      align-items: flex-start;
+      margin-bottom: 15px;
+
+      > * + * {
+        margin-top: 5px;
+      }
+
+      &+ div {
+        margin-top: 15px;
+      }
+
+      select.attribute-value {
+        min-width: 210px;
+      }
+    }
+  }
+
   .search-info-container {
   .search-info-container {
     text-align: left;
     text-align: left;
 
 

+ 22 - 0
client/src/services/base-config.service.ts

@@ -26,12 +26,34 @@ import {IPwmService} from './pwm.service';
 const COLUMN_CONFIG = 'searchColumns';
 const COLUMN_CONFIG = 'searchColumns';
 const PHOTO_ENABLED = 'enablePhoto';
 const PHOTO_ENABLED = 'enablePhoto';
 
 
+export const ADVANCED_SEARCH_ENABLED = 'enableAdvancedSearch';
+export const ADVANCED_SEARCH_MAX_ATTRIBUTES = 'maxAdvancedSearchAttributes';
+export const ADVANCED_SEARCH_ATTRIBUTES = 'advancedSearchAttributes';
+
 export interface IConfigService {
 export interface IConfigService {
     getColumnConfig(): IPromise<any>;
     getColumnConfig(): IPromise<any>;
     getValue(key: string): IPromise<any>;
     getValue(key: string): IPromise<any>;
     photosEnabled(): IPromise<boolean>;
     photosEnabled(): IPromise<boolean>;
 }
 }
 
 
+export interface IAttributeMetadata {
+    attribute: string;
+    label: string;
+    type: string;
+    options: any;
+}
+
+export interface IAdvancedSearchConfig {
+    enabled: boolean;
+    maxRows: number;
+    attributes: IAttributeMetadata[];
+}
+
+export interface IAdvancedSearchQuery {
+    key: string;
+    value: string;
+}
+
 export abstract class ConfigBaseService implements IConfigService {
 export abstract class ConfigBaseService implements IConfigService {
 
 
     constructor(protected $http: IHttpService,
     constructor(protected $http: IHttpService,

+ 128 - 0
client/src/services/common-search.service.test.ts

@@ -0,0 +1,128 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2018 The PWM Project
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+ */
+
+
+import {IHttpService, ILogService, IQService, IWindowService, module} from 'angular';
+import LocalStorageService from './local-storage.service';
+import CommonSearchService from './common-search.service';
+import {anyNumber, anyString, deepEqual, instance, mock, strictEqual, verify, when} from 'ts-mockito';
+import {IAdvancedSearchQuery} from './base-config.service';
+
+describe('In common-search.service.test.ts', () => {
+    beforeEach(() => {
+        module('app', []);
+    });
+
+    // Define some angular objects we'll grab from angular-mocks
+    let $log: ILogService;
+
+    beforeEach(inject((_$http_, _$log_, _$q_, _$window_) => {
+        $log = _$log_;
+        $log.info('This is an info message');
+    }));
+
+    it('Pulls search queries from local storage, or empty array if undefined or bad data', (done: DoneFn) => {
+        let mockLocalStorageService = mock(LocalStorageService);
+        when(mockLocalStorageService.getItem('undefinedScenario')).thenReturn(undefined);
+        when(mockLocalStorageService.getItem('bogusScenario')).thenReturn('bogus');
+        when(mockLocalStorageService.getItem('notArrayScenario')).thenReturn('{"key":"foo","value":"bar"}');
+        when(mockLocalStorageService.getItem('goodScenario')).thenReturn(JSON.stringify([
+            {
+                key: 'foo',
+                value: 'bar'
+            }
+        ]));
+
+        const commonSearchService = new CommonSearchService(instance(mockLocalStorageService));
+
+        expect(commonSearchService.getAdvSearchQueries('undefinedScenario')).toEqual([]);
+        expect(commonSearchService.getAdvSearchQueries('bogusScenario')).toEqual([]);
+        expect(commonSearchService.getAdvSearchQueries('notArrayScenario')).toEqual([]);
+        expect(commonSearchService.getAdvSearchQueries('goodScenario')).toContain({
+            key: 'foo',
+            value: 'bar'
+        });
+
+        done();
+    });
+
+    it('Stores search queries into local storage, or does nothing if bad data', (done: DoneFn) => {
+        let mockLocalStorageService = mock(LocalStorageService);
+
+        const queries: IAdvancedSearchQuery[] = [
+            {key: 'foo', value: 'one'},
+            {key: 'bar', value: 'two'},
+            {key: 'baz', value: 'three'}
+        ];
+
+        const commonSearchService = new CommonSearchService(instance(mockLocalStorageService));
+
+        commonSearchService.setAdvSearchQueries('nullData', null);
+        commonSearchService.setAdvSearchQueries('undefinedData', undefined);
+        commonSearchService.setAdvSearchQueries('emptyArray', []);
+        commonSearchService.setAdvSearchQueries('lotsOfData', queries);
+
+        verify(mockLocalStorageService.removeItem('nullData')).called();
+        verify(mockLocalStorageService.removeItem('undefinedData')).called();
+        verify(mockLocalStorageService.setItem('emptyArray', '[]')).called();
+        verify(mockLocalStorageService.setItem('emptyArray', anyString())).called();
+
+        done();
+    });
+
+    it('Pulls advanced search active state from local storage, or false if undefined or bad data', (done: DoneFn) => {
+        let mockLocalStorageService = mock(LocalStorageService);
+        when(mockLocalStorageService.getItem('undefinedScenario')).thenReturn(undefined);
+        when(mockLocalStorageService.getItem('bogusScenario')).thenReturn('bogus');
+        when(mockLocalStorageService.getItem('invalidScenario')).thenReturn('{}');
+        when(mockLocalStorageService.getItem('falseScenario')).thenReturn(JSON.stringify(false));
+        when(mockLocalStorageService.getItem('trueScenario')).thenReturn(JSON.stringify(true));
+
+        const commonSearchService = new CommonSearchService(instance(mockLocalStorageService));
+
+        expect(commonSearchService.isAdvSearchActive('undefinedScenario')).toEqual(false);
+        expect(commonSearchService.isAdvSearchActive('bogusScenario')).toEqual(false);
+        expect(commonSearchService.isAdvSearchActive('invalidScenario')).toEqual(false);
+        expect(commonSearchService.isAdvSearchActive('falseScenario')).toEqual(false);
+        expect(commonSearchService.isAdvSearchActive('trueScenario')).toEqual(true);
+
+        done();
+    });
+
+    it('Stores the advanced search active state to local storage', (done: DoneFn) => {
+        let mockLocalStorageService = mock(LocalStorageService);
+
+        const commonSearchService = new CommonSearchService(instance(mockLocalStorageService));
+
+        commonSearchService.setAdvSearchActive('nullData', null);
+        commonSearchService.setAdvSearchActive('undefinedData', undefined);
+        commonSearchService.setAdvSearchActive('trueScenario', true);
+        commonSearchService.setAdvSearchActive('falseScenario', false);
+
+        verify(mockLocalStorageService.removeItem('nullData')).called();
+        verify(mockLocalStorageService.removeItem('undefinedData')).called();
+        verify(mockLocalStorageService.setItem('trueScenario', 'true')).called();
+        verify(mockLocalStorageService.setItem('falseScenario', 'false')).called();
+
+        done();
+    });
+});

+ 132 - 0
client/src/services/common-search.service.ts

@@ -0,0 +1,132 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2018 The PWM Project
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+ */
+
+import LocalStorageService from './local-storage.service';
+import {IAdvancedSearchQuery, IAttributeMetadata} from './base-config.service';
+
+const PS_ADV_SEARCH_ACTIVE = 'psAdvancedSearchActive';
+const PS_ADV_SEARCH_QUERIES = 'psAdvancedSearchQueries';
+const HD_ADV_SEARCH_ACTIVE = 'hdAdvancedSearchActive';
+const HD_ADV_SEARCH_QUERIES = 'hdAdvancedSearchQueries';
+
+export default class CommonSearchService {
+    static $inject = ['LocalStorageService'];
+    constructor(private localStorageService: LocalStorageService) {
+    }
+
+    getDefaultValue(attributeMetaData: IAttributeMetadata) {
+        if (attributeMetaData) {
+            if (attributeMetaData.type === 'select') {
+                const keys: string[] = Object.keys(attributeMetaData.options);
+                if (keys && keys.length > 0) {
+                    return keys[0];
+                }
+            }
+        }
+
+        return '';
+    }
+
+    isPsAdvancedSearchActive(): boolean {
+        return this.isAdvSearchActive(PS_ADV_SEARCH_ACTIVE);
+    }
+
+    setPsAdvancedSearchActive(active: boolean): void {
+        this.setAdvSearchActive(PS_ADV_SEARCH_ACTIVE, active);
+    }
+
+    getPsAdvSearchQueries(): IAdvancedSearchQuery[] {
+        return this.getAdvSearchQueries(PS_ADV_SEARCH_QUERIES);
+    }
+
+    setPsAdvSearchQueries(queries: IAdvancedSearchQuery[]) {
+        this.setAdvSearchQueries(PS_ADV_SEARCH_QUERIES, queries);
+    }
+
+    isHdAdvancedSearchActive(): boolean {
+        return this.isAdvSearchActive(HD_ADV_SEARCH_ACTIVE);
+    }
+
+    setHdAdvancedSearchActive(active: boolean): void {
+        this.setAdvSearchActive(HD_ADV_SEARCH_ACTIVE, active);
+    }
+
+    getHdAdvSearchQueries(): IAdvancedSearchQuery[] {
+        return this.getAdvSearchQueries(HD_ADV_SEARCH_QUERIES);
+    }
+
+    setHdAdvSearchQueries(queries: IAdvancedSearchQuery[]) {
+        this.setAdvSearchQueries(HD_ADV_SEARCH_QUERIES, queries);
+    }
+
+    isAdvSearchActive(storageName: string): boolean {
+        if (storageName) {
+            const storageValue = this.localStorageService.getItem(storageName);
+            if (storageValue) {
+                return (storageValue === 'true');
+            }
+        }
+
+        return false;
+    }
+
+    setAdvSearchActive(storageName: string, active: boolean): void {
+        if (storageName) {
+            // Make sure active is a boolean first
+            if (typeof(active) === typeof(true)) {
+                this.localStorageService.setItem(storageName, JSON.stringify(active));
+            } else {
+                // If we were given undefine or null data, then just remove the named item from local storage
+                this.localStorageService.removeItem(storageName);
+            }
+        }
+    }
+
+    getAdvSearchQueries(storageName: string): IAdvancedSearchQuery[] {
+        if (storageName) {
+            const storageValue = this.localStorageService.getItem(storageName);
+            if (storageValue) {
+                try {
+                    const parsedValue = JSON.parse(storageValue);
+                    if (Array.isArray(parsedValue)) {
+                        return parsedValue;
+                    }
+                } catch (error) {
+                    // Unparseable, an empty array will be returned below
+                }
+            }
+        }
+
+        return [];
+    }
+
+    setAdvSearchQueries(storageName: string, queries: IAdvancedSearchQuery[]) {
+        if (storageName) {
+            if (queries) {
+                this.localStorageService.setItem(storageName, JSON.stringify(queries));
+            } else {
+                // If we were given undefine or null data, then just remove the named item from local storage
+                this.localStorageService.removeItem(storageName);
+            }
+        }
+    }
+}

+ 22 - 1
client/src/services/helpdesk-config.service.ts

@@ -24,7 +24,13 @@
 import { IHttpService, ILogService, IPromise, IQService } from 'angular';
 import { IHttpService, ILogService, IPromise, IQService } from 'angular';
 import IPwmService from './pwm.service';
 import IPwmService from './pwm.service';
 import PwmService from './pwm.service';
 import PwmService from './pwm.service';
-import {ConfigBaseService, IConfigService} from './base-config.service';
+import {ConfigBaseService,
+    IAdvancedSearchConfig,
+    IConfigService,
+    ADVANCED_SEARCH_ENABLED,
+    ADVANCED_SEARCH_MAX_ATTRIBUTES,
+    ADVANCED_SEARCH_ATTRIBUTES
+} from './base-config.service';
 
 
 const CLEAR_RESPONSES_CONFIG = 'clearResponses';
 const CLEAR_RESPONSES_CONFIG = 'clearResponses';
 const CUSTOM_BUTTON_CONFIG = 'actions';
 const CUSTOM_BUTTON_CONFIG = 'actions';
@@ -81,6 +87,7 @@ export interface IHelpDeskConfigService extends IConfigService {
     getVerificationMethods(options?: {includeOptional: boolean}): IPromise<IVerificationMap>;
     getVerificationMethods(options?: {includeOptional: boolean}): IPromise<IVerificationMap>;
     maskPasswordsEnabled(): IPromise<boolean>;
     maskPasswordsEnabled(): IPromise<boolean>;
     verificationsEnabled(): IPromise<boolean>;
     verificationsEnabled(): IPromise<boolean>;
+    advancedSearchConfig(): IPromise<IAdvancedSearchConfig>;
 }
 }
 
 
 export default class HelpDeskConfigService extends ConfigBaseService implements IConfigService, IHelpDeskConfigService {
 export default class HelpDeskConfigService extends ConfigBaseService implements IConfigService, IHelpDeskConfigService {
@@ -164,4 +171,18 @@ export default class HelpDeskConfigService extends ConfigBaseService implements
                 return !!result.required.length;
                 return !!result.required.length;
             });
             });
     }
     }
+
+    advancedSearchConfig(): IPromise<IAdvancedSearchConfig> {
+        return this.$q.all([
+            this.getValue(ADVANCED_SEARCH_ENABLED),
+            this.getValue(ADVANCED_SEARCH_MAX_ATTRIBUTES),
+            this.getValue(ADVANCED_SEARCH_ATTRIBUTES)
+        ]).then((result) => {
+            return {
+                enabled: result[0],
+                maxRows: result[1],
+                attributes: result[2]
+            };
+        });
+    }
 }
 }

+ 25 - 0
client/src/services/helpdesk.service.ts

@@ -26,6 +26,7 @@ import {ILogService, IPromise, IQService, IWindowService} from 'angular';
 import LocalStorageService from './local-storage.service';
 import LocalStorageService from './local-storage.service';
 import ObjectService from './object.service';
 import ObjectService from './object.service';
 import SearchResult from '../models/search-result.model';
 import SearchResult from '../models/search-result.model';
+import {IQuery} from './people.service';
 
 
 const VERIFICATION_PROCESS_ACTIONS = {
 const VERIFICATION_PROCESS_ACTIONS = {
     ATTRIBUTES: 'validateAttributes',
     ATTRIBUTES: 'validateAttributes',
@@ -47,6 +48,7 @@ export interface IHelpDeskService {
     getRandomPassword(userKey: string): IPromise<IRandomPasswordResponse>;
     getRandomPassword(userKey: string): IPromise<IRandomPasswordResponse>;
     getRecentVerifications(): IPromise<IRecentVerifications>;
     getRecentVerifications(): IPromise<IRecentVerifications>;
     search(query: string): IPromise<SearchResult>;
     search(query: string): IPromise<SearchResult>;
+    advancedSearch(queries: IQuery[]): IPromise<SearchResult>;
     sendVerificationToken(userKey: string, choice: string): IPromise<IVerificationTokenResponse>;
     sendVerificationToken(userKey: string, choice: string): IPromise<IVerificationTokenResponse>;
     setPassword(userKey: string, random: boolean, password?: string): IPromise<ISuccessResponse>;
     setPassword(userKey: string, random: boolean, password?: string): IPromise<ISuccessResponse>;
     unlockIntruder(userKey: string): IPromise<ISuccessResponse>;
     unlockIntruder(userKey: string): IPromise<ISuccessResponse>;
@@ -229,6 +231,7 @@ export default class HelpDeskService implements IHelpDeskService {
             + '&pwmFormID=' + this.PWM_GLOBAL['pwmFormID'];
             + '&pwmFormID=' + this.PWM_GLOBAL['pwmFormID'];
 
 
         let data = {
         let data = {
+            mode: 'simple',
             username: query,
             username: query,
             pwmFormID: formID
             pwmFormID: formID
         };
         };
@@ -244,6 +247,28 @@ export default class HelpDeskService implements IHelpDeskService {
             });
             });
     }
     }
 
 
+    advancedSearch(queries: IQuery[]): IPromise<SearchResult> {
+        let formID: string = encodeURIComponent('&pwmFormID=' + this.PWM_GLOBAL['pwmFormID']);
+        let url: string = this.pwmService.getServerUrl('search')
+            + '&pwmFormID=' + this.PWM_GLOBAL['pwmFormID'];
+
+        let data = {
+            mode: 'advanced',
+            pwmFormID: formID,
+            searchValues: queries
+        };
+        return this.pwmService
+            .httpRequest(url, {
+                data: data,
+                preventCache: true
+            })
+            .then((result: any) => {
+                let receivedData: any = result.data;
+                let searchResult: SearchResult = new SearchResult(receivedData);
+                return searchResult;
+            });
+    }
+
     sendVerificationToken(userKey: string, choice: string): IPromise<IVerificationTokenResponse> {
     sendVerificationToken(userKey: string, choice: string): IPromise<IVerificationTokenResponse> {
         let url: string = this.pwmService.getServerUrl('sendVerificationToken');
         let url: string = this.pwmService.getServerUrl('sendVerificationToken');
         let data: any = { userKey: userKey };
         let data: any = { userKey: userKey };

+ 54 - 1
client/src/services/people.service.dev.ts

@@ -22,7 +22,7 @@
 
 
 import { IPromise, IQService, ITimeoutService } from 'angular';
 import { IPromise, IQService, ITimeoutService } from 'angular';
 import { IPerson } from '../models/person.model';
 import { IPerson } from '../models/person.model';
-import { IPeopleService } from './people.service';
+import {IPeopleService, IQuery} from './people.service';
 import IOrgChartData from '../models/orgchart-data.model';
 import IOrgChartData from '../models/orgchart-data.model';
 import SearchResult from '../models/search-result.model';
 import SearchResult from '../models/search-result.model';
 
 
@@ -61,6 +61,33 @@ export default class PeopleService implements IPeopleService {
         }, this);
         }, this);
     }
     }
 
 
+    advancedSearch(queries: IQuery[]): IPromise<SearchResult> {
+        let self = this;
+
+        let deferred = this.$q.defer();
+        let deferredAbort = this.$q.defer();
+
+        let timeoutPromise = this.$timeout(() => {
+
+            let people = this.getAdvancedSearchResults(queries);
+            const sizeExceeded = (people.length > MAX_RESULTS);
+            if (sizeExceeded) {
+                people = people.slice(MAX_RESULTS);
+            }
+
+            deferred.resolve(new SearchResult({sizeExceeded: sizeExceeded, searchResults: people}));
+        }, SIMULATED_RESPONSE_TIME * 6);
+
+        // To simulate an abortable promise, edit SIMULATED_RESPONSE_TIME
+        deferred.promise['_httpTimeout'] = deferredAbort;
+        deferredAbort.promise.then(() => {
+            self.$timeout.cancel(timeoutPromise);
+            deferred.resolve();
+        });
+
+        return deferred.promise as IPromise<SearchResult>;
+    }
+
     autoComplete(query: string): IPromise<IPerson[]> {
     autoComplete(query: string): IPromise<IPerson[]> {
         return this.search(query)
         return this.search(query)
             .then((searchResult: SearchResult) => {
             .then((searchResult: SearchResult) => {
@@ -206,4 +233,30 @@ export default class PeopleService implements IPeopleService {
 
 
         return null;
         return null;
     }
     }
+
+    private getAdvancedSearchResults(queries: IQuery[]): IPerson[] {
+        let people = queries.length ? this.people : [];
+
+        queries.forEach((query: IQuery) => {
+            people = people.filter((person: IPerson) => {
+                if (!query.value) {
+                    return false;
+                }
+
+                let property = person[query.name];
+
+                if (!property) {
+                    return false;
+                }
+
+                if (typeof property === 'object' || typeof property === 'number') {
+                    property = JSON.stringify(property);
+                }
+
+                return property.toLowerCase().indexOf(query.value.toLowerCase()) >= 0;
+            });
+        });
+
+        return people;
+    }
 }
 }

+ 44 - 0
client/src/services/people.service.ts

@@ -28,7 +28,14 @@ import IOrgChartData from '../models/orgchart-data.model';
 import SearchResult from '../models/search-result.model';
 import SearchResult from '../models/search-result.model';
 import {IPeopleSearchConfigService} from './peoplesearch-config.service';
 import {IPeopleSearchConfigService} from './peoplesearch-config.service';
 
 
+export interface IQuery {
+ key: string;
+ value: string;
+}
+
 export interface IPeopleService {
 export interface IPeopleService {
+    advancedSearch(queries: IQuery[]): IPromise<SearchResult>;
+
     autoComplete(query: string): IPromise<IPerson[]>;
     autoComplete(query: string): IPromise<IPerson[]>;
 
 
     getDirectReports(personId: string): IPromise<IPerson[]>;
     getDirectReports(personId: string): IPromise<IPerson[]>;
@@ -63,6 +70,42 @@ export default class PeopleService implements IPeopleService {
         }
         }
     }
     }
 
 
+    advancedSearch(queries: IQuery[]): IPromise<SearchResult> {
+        // Deferred object used for aborting requests. See promise.service.ts for more information
+        let httpTimeout = this.$q.defer();
+
+        let formID: string = encodeURIComponent('&pwmFormID=' + this.PWM_GLOBAL['pwmFormID']);
+        let url: string = this.pwmService.getServerUrl('search')
+            + '&pwmFormID=' + this.PWM_GLOBAL['pwmFormID'];
+        let request = this.$http
+            .post(url, {
+                mode: 'advanced',
+                pwmFormID: formID,
+                searchValues: queries
+            }, {
+                cache: true,
+                timeout: httpTimeout.promise,
+                headers: {'Content-Type': 'multipart/form-data'},
+            });
+
+        let promise = request.then(
+            (response) => {
+                if (response.data['error']) {
+                    return this.handlePwmError(response);
+                }
+
+                let receivedData: any = response.data['data'];
+                let searchResult: SearchResult = new SearchResult(receivedData);
+
+                return searchResult;
+            },
+            this.handleHttpError.bind(this));
+
+        promise['_httpTimeout'] = httpTimeout;
+
+        return promise;
+    }
+
     autoComplete(query: string): IPromise<IPerson[]> {
     autoComplete(query: string): IPromise<IPerson[]> {
         return this.search(query, {'includeDisplayName': true})
         return this.search(query, {'includeDisplayName': true})
             .then((searchResult: SearchResult) => {
             .then((searchResult: SearchResult) => {
@@ -189,6 +232,7 @@ export default class PeopleService implements IPeopleService {
             + '&pwmFormID=' + this.PWM_GLOBAL['pwmFormID'];
             + '&pwmFormID=' + this.PWM_GLOBAL['pwmFormID'];
         let request = this.$http
         let request = this.$http
             .post(url, {
             .post(url, {
+                mode: 'simple',
                 username: query,
                 username: query,
                 pwmFormID: formID
                 pwmFormID: formID
             }, {
             }, {

+ 22 - 1
client/src/services/peoplesearch-config.service.dev.ts

@@ -24,7 +24,7 @@
 import { IPromise, IQService } from 'angular';
 import { IPromise, IQService } from 'angular';
 import {ConfigBaseService} from './base-config.service.dev';
 import {ConfigBaseService} from './base-config.service.dev';
 import {IConfigService} from './base-config.service';
 import {IConfigService} from './base-config.service';
-import {IPeopleSearchConfigService} from './peoplesearch-config.service';
+import {AdvancedSearchConfig, IPeopleSearchConfigService} from './peoplesearch-config.service';
 
 
 
 
 export default class ConfigService
 export default class ConfigService
@@ -56,4 +56,25 @@ export default class ConfigService
     orgChartShowChildCount(): IPromise<boolean> {
     orgChartShowChildCount(): IPromise<boolean> {
         return this.$q.resolve(true);
         return this.$q.resolve(true);
     }
     }
+
+    advancedSearchConfig(): IPromise<AdvancedSearchConfig> {
+        return this.$q.resolve({
+            enabled: true,
+            maxRows: 3,
+            attributes: [
+                {
+                    id: 'title',
+                    attribute: 'Title'
+                },
+                {
+                    id: 'givenName',
+                    attribute: 'Given Name'
+                },
+                {
+                    id: 'sn',
+                    attribute: 'First Name'
+                }
+            ]
+        });
+    }
 }
 }

+ 23 - 1
client/src/services/peoplesearch-config.service.ts

@@ -24,7 +24,14 @@
 import { IHttpService, ILogService, IPromise, IQService } from 'angular';
 import { IHttpService, ILogService, IPromise, IQService } from 'angular';
 import IPwmService from './pwm.service';
 import IPwmService from './pwm.service';
 import PwmService from './pwm.service';
 import PwmService from './pwm.service';
-import {ConfigBaseService, IConfigService} from './base-config.service';
+import {
+    ConfigBaseService,
+    IConfigService,
+    IAdvancedSearchConfig,
+    ADVANCED_SEARCH_ENABLED,
+    ADVANCED_SEARCH_MAX_ATTRIBUTES,
+    ADVANCED_SEARCH_ATTRIBUTES
+} from './base-config.service';
 
 
 const ORGCHART_ENABLED = 'orgChartEnabled';
 const ORGCHART_ENABLED = 'orgChartEnabled';
 const ORGCHART_MAX_PARENTS = 'orgChartMaxParents';
 const ORGCHART_MAX_PARENTS = 'orgChartMaxParents';
@@ -34,6 +41,7 @@ export interface IPeopleSearchConfigService extends IConfigService {
     getOrgChartMaxParents(): IPromise<number>;
     getOrgChartMaxParents(): IPromise<number>;
     orgChartEnabled(): IPromise<boolean>;
     orgChartEnabled(): IPromise<boolean>;
     orgChartShowChildCount(): IPromise<boolean>;
     orgChartShowChildCount(): IPromise<boolean>;
+    advancedSearchConfig(): IPromise<IAdvancedSearchConfig>;
 }
 }
 
 
 export default class PeopleSearchConfigService
 export default class PeopleSearchConfigService
@@ -57,4 +65,18 @@ export default class PeopleSearchConfigService
     orgChartShowChildCount(): IPromise<boolean> {
     orgChartShowChildCount(): IPromise<boolean> {
         return this.getValue(ORGCHART_SHOW_CHILD_COUNT);
         return this.getValue(ORGCHART_SHOW_CHILD_COUNT);
     }
     }
+
+    advancedSearchConfig(): IPromise<IAdvancedSearchConfig> {
+        return this.$q.all([
+            this.getValue(ADVANCED_SEARCH_ENABLED),
+            this.getValue(ADVANCED_SEARCH_MAX_ATTRIBUTES),
+            this.getValue(ADVANCED_SEARCH_ATTRIBUTES)
+        ]).then((result) => {
+            return {
+                enabled: result[0],
+                maxRows: result[1],
+                attributes: result[2]
+            };
+        });
+    }
 }
 }

+ 6 - 5
client/test/karma-test-suite.ts

@@ -26,11 +26,12 @@ import 'angular';
 import 'angular-mocks';
 import 'angular-mocks';
 
 
 // This creates a single bundle with all test cases (*.test.ts), which improves performance
 // This creates a single bundle with all test cases (*.test.ts), which improves performance
-// (i.e. we don't create a webpack bundle for each test):
-var appContext = (require as any).context('../src', true, /\.test\.ts/);
+// (i.e. we don't create a webpack bundle for each test)
 
 
-// If you want to run a specific test, comment out the general line above, and uncomment the specific one below:
-// var appContext = (require as any).context('../src', true, /helpdesk-config\.service\.test\.ts/);
-// var appContext = (require as any).context('../src', true, /helpdesk\.service\.test\.ts/);
+// To run all tests, use this:
+// var appContext = (require as any).context('../src', true, /\.test\.ts/);
+
+// To run a specific test, change the following regular expression and use this:
+var appContext = (require as any).context('../src', true, /common-search.service.test.ts/);
 
 
 appContext.keys().forEach(appContext);
 appContext.keys().forEach(appContext);

+ 7 - 9
client/test/karma.conf.js

@@ -21,7 +21,7 @@
  */
  */
 
 
 var webpack = require('webpack');
 var webpack = require('webpack');
-var webpackConfig = require('../webpack.test.js');
+var webpackConfig = require('../webpack.config.js')({}, {});
 var path = require("path");
 var path = require("path");
 var os = require('os');
 var os = require('os');
 
 
@@ -36,7 +36,7 @@ module.exports = function (config) {
 
 
         // list of files / patterns to load in the browser
         // list of files / patterns to load in the browser
         files: [
         files: [
-            'karma-test-suite.ts'
+            'test/karma-test-suite.ts'
         ],
         ],
 
 
         exclude: [],
         exclude: [],
@@ -53,15 +53,13 @@ module.exports = function (config) {
         },
         },
 
 
         webpack: {
         webpack: {
+            mode: 'development',
+            devtool: 'inline-source-map',
             resolve: webpackConfig.resolve,
             resolve: webpackConfig.resolve,
             module: webpackConfig.module,
             module: webpackConfig.module,
-            plugins: [
-                // Without this, we're not able to debug our tests in the browser:
-                new webpack.SourceMapDevToolPlugin({
-                    filename: null, // if no value is provided the sourcemap is inlined
-                    test: /\.(ts|js)($|\?)/i // process .js and .ts files only
-                })
-            ]
+            optimization: {
+                minimize: false
+            }
         },
         },
 
 
         webpackMiddleware: {
         webpackMiddleware: {

+ 13 - 9
client/webpack.config.js

@@ -11,6 +11,7 @@ const srcDir = path.resolve(__dirname, 'src');
 
 
 module.exports = function (env, argv) {
 module.exports = function (env, argv) {
     const isProductionMode = (argv["mode"] === "production");
     const isProductionMode = (argv["mode"] === "production");
+    const disableMinimize = (env && env.disableMinimize) || false;
 
 
     const commonConfig = {
     const commonConfig = {
         devtool: 'source-map',
         devtool: 'source-map',
@@ -100,15 +101,18 @@ module.exports = function (env, argv) {
                 'peoplesearch.ng': './src/modules/peoplesearch/main',
                 'peoplesearch.ng': './src/modules/peoplesearch/main',
                 'helpdesk.ng': './src/modules/helpdesk/main'
                 'helpdesk.ng': './src/modules/helpdesk/main'
             },
             },
-            plugins: [
-                new UglifyJsPlugin({
-                    sourceMap: true,
-                    uglifyOptions: {
-                        compress: {warnings: false},
-                        comments: false
-                    }
-                })
-            ]
+            optimization:{
+                minimize: !disableMinimize,
+                minimizer: [
+                    new UglifyJsPlugin({
+                        sourceMap: true,
+                        uglifyOptions: {
+                            compress: {warnings: false},
+                            comments: false
+                        }
+                    })
+                ]
+            }
         });
         });
     }
     }
     else {
     else {

+ 14 - 2
server/src/main/java/password/pwm/config/PwmSetting.java

@@ -941,8 +941,8 @@ public enum PwmSetting
             "peopleSearch.enable", PwmSettingSyntax.BOOLEAN, PwmSettingCategory.PEOPLE_SEARCH ),
             "peopleSearch.enable", PwmSettingSyntax.BOOLEAN, PwmSettingCategory.PEOPLE_SEARCH ),
     PEOPLE_SEARCH_QUERY_MATCH(
     PEOPLE_SEARCH_QUERY_MATCH(
             "peopleSearch.queryMatch", PwmSettingSyntax.USER_PERMISSION, PwmSettingCategory.PEOPLE_SEARCH ),
             "peopleSearch.queryMatch", PwmSettingSyntax.USER_PERMISSION, PwmSettingCategory.PEOPLE_SEARCH ),
-    PEOPLE_SEARCH_SEARCH_ATTRIBUTES(
-            "peopleSearch.searchAttributes", PwmSettingSyntax.STRING_ARRAY, PwmSettingCategory.PEOPLE_SEARCH ),
+    PEOPLE_SEARCH_SEARCH_FORM(
+            "peopleSearch.search.form", PwmSettingSyntax.FORM, PwmSettingCategory.PEOPLE_SEARCH ),
     PEOPLE_SEARCH_RESULT_FORM(
     PEOPLE_SEARCH_RESULT_FORM(
             "peopleSearch.result.form", PwmSettingSyntax.FORM, PwmSettingCategory.PEOPLE_SEARCH ),
             "peopleSearch.result.form", PwmSettingSyntax.FORM, PwmSettingCategory.PEOPLE_SEARCH ),
     PEOPLE_SEARCH_DETAIL_FORM(
     PEOPLE_SEARCH_DETAIL_FORM(
@@ -971,6 +971,9 @@ public enum PwmSetting
             "peopleSearch.enableExport", PwmSettingSyntax.BOOLEAN, PwmSettingCategory.PEOPLE_SEARCH ),
             "peopleSearch.enableExport", PwmSettingSyntax.BOOLEAN, PwmSettingCategory.PEOPLE_SEARCH ),
     PEOPLE_SEARCH_IDLE_TIMEOUT_SECONDS(
     PEOPLE_SEARCH_IDLE_TIMEOUT_SECONDS(
             "peopleSearch.idleTimeout", PwmSettingSyntax.DURATION, PwmSettingCategory.PEOPLE_SEARCH ),
             "peopleSearch.idleTimeout", PwmSettingSyntax.DURATION, PwmSettingCategory.PEOPLE_SEARCH ),
+    PEOPLE_SEARCH_ENABLE_ADVANCED_SEARCH(
+            "peopleSearch.advancedSearch.enable", PwmSettingSyntax.BOOLEAN, PwmSettingCategory.PEOPLE_SEARCH ),
+
 
 
 
 
     // edirectory settings
     // edirectory settings
@@ -1016,6 +1019,8 @@ public enum PwmSetting
     HELPDESK_PROFILE_QUERY_MATCH(
     HELPDESK_PROFILE_QUERY_MATCH(
             "helpdesk.queryMatch", PwmSettingSyntax.USER_PERMISSION, PwmSettingCategory.HELPDESK_BASE ),
             "helpdesk.queryMatch", PwmSettingSyntax.USER_PERMISSION, PwmSettingCategory.HELPDESK_BASE ),
     HELPDESK_SEARCH_FORM(
     HELPDESK_SEARCH_FORM(
+            "helpdesk.search.form", PwmSettingSyntax.FORM, PwmSettingCategory.HELPDESK_BASE ),
+    HELPDESK_SEARCH_RESULT_FORM(
             "helpdesk.result.form", PwmSettingSyntax.FORM, PwmSettingCategory.HELPDESK_BASE ),
             "helpdesk.result.form", PwmSettingSyntax.FORM, PwmSettingCategory.HELPDESK_BASE ),
     HELPDESK_SEARCH_FILTERS(
     HELPDESK_SEARCH_FILTERS(
             "helpdesk.search.filters", PwmSettingSyntax.USER_PERMISSION, PwmSettingCategory.HELPDESK_BASE ),
             "helpdesk.search.filters", PwmSettingSyntax.USER_PERMISSION, PwmSettingCategory.HELPDESK_BASE ),
@@ -1066,6 +1071,9 @@ public enum PwmSetting
             "helpdesk.setPassword.maskValue", PwmSettingSyntax.BOOLEAN, PwmSettingCategory.HELPDESK_OPTIONS ),
             "helpdesk.setPassword.maskValue", PwmSettingSyntax.BOOLEAN, PwmSettingCategory.HELPDESK_OPTIONS ),
     HELPDESK_ENABLE_PHOTOS(
     HELPDESK_ENABLE_PHOTOS(
             "helpdesk.enablePhotos", PwmSettingSyntax.BOOLEAN, PwmSettingCategory.HELPDESK_OPTIONS ),
             "helpdesk.enablePhotos", PwmSettingSyntax.BOOLEAN, PwmSettingCategory.HELPDESK_OPTIONS ),
+    HELPDESK_ENABLE_ADVANCED_SEARCH(
+            "helpdesk.advancedSearch.enable", PwmSettingSyntax.BOOLEAN, PwmSettingCategory.HELPDESK_OPTIONS ),
+
 
 
     HELPDESK_VERIFICATION_METHODS(
     HELPDESK_VERIFICATION_METHODS(
             "helpdesk.verificationMethods", PwmSettingSyntax.VERIFICATION_METHOD, PwmSettingCategory.HELPDESK_VERIFICATION ),
             "helpdesk.verificationMethods", PwmSettingSyntax.VERIFICATION_METHOD, PwmSettingCategory.HELPDESK_VERIFICATION ),
@@ -1199,6 +1207,10 @@ public enum PwmSetting
 
 
     // deprecated.
     // deprecated.
 
 
+    // deprecated 2018-10-15
+    PEOPLE_SEARCH_SEARCH_ATTRIBUTES(
+            "peopleSearch.searchAttributes", PwmSettingSyntax.STRING_ARRAY, PwmSettingCategory.PEOPLE_SEARCH ),
+
     // deprecated 2018-02-27
     // deprecated 2018-02-27
     RECOVERY_ENFORCE_MINIMUM_PASSWORD_LIFETIME(
     RECOVERY_ENFORCE_MINIMUM_PASSWORD_LIFETIME(
             "challenge.enforceMinimumPasswordLifetime", PwmSettingSyntax.BOOLEAN, PwmSettingCategory.RECOVERY_OPTIONS ),
             "challenge.enforceMinimumPasswordLifetime", PwmSettingSyntax.BOOLEAN, PwmSettingCategory.RECOVERY_OPTIONS ),

+ 27 - 0
server/src/main/java/password/pwm/config/stored/StoredConfigurationImpl.java

@@ -41,12 +41,14 @@ import password.pwm.config.PwmSettingTemplate;
 import password.pwm.config.PwmSettingTemplateSet;
 import password.pwm.config.PwmSettingTemplateSet;
 import password.pwm.config.StoredValue;
 import password.pwm.config.StoredValue;
 import password.pwm.config.option.ADPolicyComplexity;
 import password.pwm.config.option.ADPolicyComplexity;
+import password.pwm.config.value.FormValue;
 import password.pwm.config.value.NamedSecretValue;
 import password.pwm.config.value.NamedSecretValue;
 import password.pwm.config.value.PasswordValue;
 import password.pwm.config.value.PasswordValue;
 import password.pwm.config.value.PrivateKeyValue;
 import password.pwm.config.value.PrivateKeyValue;
 import password.pwm.config.value.StringArrayValue;
 import password.pwm.config.value.StringArrayValue;
 import password.pwm.config.value.StringValue;
 import password.pwm.config.value.StringValue;
 import password.pwm.config.value.ValueFactory;
 import password.pwm.config.value.ValueFactory;
+import password.pwm.config.value.data.FormConfiguration;
 import password.pwm.error.ErrorInformation;
 import password.pwm.error.ErrorInformation;
 import password.pwm.error.PwmError;
 import password.pwm.error.PwmError;
 import password.pwm.error.PwmException;
 import password.pwm.error.PwmException;
@@ -1408,6 +1410,31 @@ public class StoredConfigurationImpl implements StoredConfiguration
                     storedConfiguration.resetSetting( PwmSetting.RECOVERY_ENFORCE_MINIMUM_PASSWORD_LIFETIME, profileID, actor );
                     storedConfiguration.resetSetting( PwmSetting.RECOVERY_ENFORCE_MINIMUM_PASSWORD_LIFETIME, profileID, actor );
                 }
                 }
             }
             }
+
+            {
+                if ( !storedConfiguration.isDefaultValue( PwmSetting.PEOPLE_SEARCH_SEARCH_ATTRIBUTES ) )
+                {
+                    final List<String> oldValues = ( List<String> ) storedConfiguration.readSetting( PwmSetting.PEOPLE_SEARCH_SEARCH_ATTRIBUTES ).toNativeObject();
+
+                    final List<FormConfiguration> newValues = new ArrayList<>();
+                    for ( final String attribute : oldValues )
+                    {
+                        final FormConfiguration formConfiguration = FormConfiguration.builder()
+                                .name( attribute )
+                                .labels( Collections.singletonMap( "", attribute ) )
+                                .build();
+                        newValues.add( formConfiguration );
+                    }
+
+                    final ValueMetaData existingData = storedConfiguration.readSettingMetadata( PwmSetting.PEOPLE_SEARCH_SEARCH_ATTRIBUTES, null );
+                    final UserIdentity newActor = existingData != null && existingData.getUserIdentity() != null
+                            ? existingData.getUserIdentity()
+                            : actor;
+
+                    storedConfiguration.writeSetting( PwmSetting.PEOPLE_SEARCH_SEARCH_FORM, null, new FormValue( newValues ), newActor );
+                    storedConfiguration.resetSetting( PwmSetting.PEOPLE_SEARCH_SEARCH_ATTRIBUTES, null, actor );
+                }
+            }
         }
         }
     }
     }
 
 

+ 16 - 1
server/src/main/java/password/pwm/http/servlet/helpdesk/HelpdeskClientDataBean.java

@@ -32,6 +32,7 @@ import password.pwm.config.option.MessageSendMethod;
 import password.pwm.config.profile.HelpdeskProfile;
 import password.pwm.config.profile.HelpdeskProfile;
 import password.pwm.config.value.data.ActionConfiguration;
 import password.pwm.config.value.data.ActionConfiguration;
 import password.pwm.config.value.data.FormConfiguration;
 import password.pwm.config.value.data.FormConfiguration;
+import password.pwm.http.servlet.peoplesearch.PeopleSearchClientConfigBean;
 
 
 import java.io.Serializable;
 import java.io.Serializable;
 import java.util.ArrayList;
 import java.util.ArrayList;
@@ -55,6 +56,10 @@ public class HelpdeskClientDataBean implements Serializable
     private Map<String, ActionInformation> actions;
     private Map<String, ActionInformation> actions;
     private Map<String, Collection<IdentityVerificationMethod>> verificationMethods;
     private Map<String, Collection<IdentityVerificationMethod>> verificationMethods;
     private List<FormInformation> verificationForm;
     private List<FormInformation> verificationForm;
+    private int maxAdvancedSearchAttributes;
+    private List<PeopleSearchClientConfigBean.SearchAttribute> advancedSearchAttributes;
+    private boolean enableAdvancedSearch;
+
 
 
     @Value
     @Value
     public static class ActionInformation implements Serializable
     public static class ActionInformation implements Serializable
@@ -70,6 +75,7 @@ public class HelpdeskClientDataBean implements Serializable
         private String label;
         private String label;
     }
     }
 
 
+
     static HelpdeskClientDataBean fromConfig(
     static HelpdeskClientDataBean fromConfig(
             final HelpdeskProfile helpdeskProfile,
             final HelpdeskProfile helpdeskProfile,
             final Locale locale
             final Locale locale
@@ -78,7 +84,7 @@ public class HelpdeskClientDataBean implements Serializable
         final HelpdeskClientDataBean.HelpdeskClientDataBeanBuilder builder = HelpdeskClientDataBean.builder();
         final HelpdeskClientDataBean.HelpdeskClientDataBeanBuilder builder = HelpdeskClientDataBean.builder();
         {
         {
             // search page
             // search page
-            final List<FormConfiguration> searchForm = helpdeskProfile.readSettingAsForm( PwmSetting.HELPDESK_SEARCH_FORM );
+            final List<FormConfiguration> searchForm = helpdeskProfile.readSettingAsForm( PwmSetting.HELPDESK_SEARCH_RESULT_FORM );
             final Map<String, String> searchColumns = new LinkedHashMap<>();
             final Map<String, String> searchColumns = new LinkedHashMap<>();
             for ( final FormConfiguration formConfiguration : searchForm )
             for ( final FormConfiguration formConfiguration : searchForm )
             {
             {
@@ -131,6 +137,15 @@ public class HelpdeskClientDataBean implements Serializable
             }
             }
             builder.verificationForm( formInformations );
             builder.verificationForm( formInformations );
         }
         }
+        {
+            final List<PeopleSearchClientConfigBean.SearchAttribute> searchAttributes = PeopleSearchClientConfigBean.SearchAttribute.searchAttributesFromForm(
+                    locale,
+                    helpdeskProfile.readSettingAsForm( PwmSetting.HELPDESK_SEARCH_FORM ) );
+
+                    builder.enableAdvancedSearch( helpdeskProfile.readSettingAsBoolean( PwmSetting.HELPDESK_ENABLE_ADVANCED_SEARCH ) );
+                    builder.maxAdvancedSearchAttributes( 3 );
+                    builder.advancedSearchAttributes( searchAttributes );
+        }
 
 
 
 
         return builder.build();
         return builder.build();

+ 47 - 0
server/src/main/java/password/pwm/http/servlet/helpdesk/HelpdeskSearchRequestBean.java

@@ -0,0 +1,47 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2018 The PWM Project
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+ */
+
+package password.pwm.http.servlet.helpdesk;
+
+import lombok.Builder;
+import lombok.Value;
+import password.pwm.http.servlet.peoplesearch.SearchRequestBean;
+
+import java.io.Serializable;
+import java.util.List;
+
+@Value
+@Builder
+public class HelpdeskSearchRequestBean implements Serializable
+{
+    @Builder.Default
+    private SearchRequestBean.SearchMode mode = SearchRequestBean.SearchMode.simple;
+
+    private String username;
+    private List<SearchRequestBean.SearchValue> searchValues;
+
+    public List<SearchRequestBean.SearchValue> nonEmptySearchValues()
+    {
+        return SearchRequestBean.filterNonEmptySearchValues( getSearchValues() );
+    }
+
+}

+ 12 - 19
server/src/main/java/password/pwm/http/servlet/helpdesk/HelpdeskSearchResultsBean.java

@@ -22,33 +22,26 @@
 
 
 package password.pwm.http.servlet.helpdesk;
 package password.pwm.http.servlet.helpdesk;
 
 
+import lombok.Builder;
+import lombok.Value;
+
 import java.io.Serializable;
 import java.io.Serializable;
-import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
 
 
+@Value
+@Builder
 public class HelpdeskSearchResultsBean implements Serializable
 public class HelpdeskSearchResultsBean implements Serializable
 {
 {
-    private List<Map<String, Object>> searchResults = new ArrayList<>();
+    private List<Map<String, Object>> searchResults;
     private boolean sizeExceeded;
     private boolean sizeExceeded;
 
 
-    public List<Map<String, Object>> getSearchResults( )
-    {
-        return searchResults;
-    }
-
-    public void setSearchResults( final List<Map<String, Object>> searchResults )
-    {
-        this.searchResults = searchResults;
-    }
-
-    public boolean isSizeExceeded( )
-    {
-        return sizeExceeded;
-    }
-
-    public void setSizeExceeded( final boolean sizeExceeded )
+    static HelpdeskSearchResultsBean emptyResult()
     {
     {
-        this.sizeExceeded = sizeExceeded;
+        return HelpdeskSearchResultsBean.builder()
+            .searchResults( Collections.emptyList() )
+            .sizeExceeded( false )
+            .build();
     }
     }
 }
 }

+ 95 - 29
server/src/main/java/password/pwm/http/servlet/helpdesk/HelpdeskServlet.java

@@ -59,6 +59,7 @@ import password.pwm.http.PwmRequestAttribute;
 import password.pwm.http.PwmSession;
 import password.pwm.http.PwmSession;
 import password.pwm.http.servlet.AbstractPwmServlet;
 import password.pwm.http.servlet.AbstractPwmServlet;
 import password.pwm.http.servlet.ControlledPwmServlet;
 import password.pwm.http.servlet.ControlledPwmServlet;
+import password.pwm.http.servlet.peoplesearch.SearchRequestBean;
 import password.pwm.i18n.Message;
 import password.pwm.i18n.Message;
 import password.pwm.ldap.LdapOperationsHelper;
 import password.pwm.ldap.LdapOperationsHelper;
 import password.pwm.ldap.PhotoDataBean;
 import password.pwm.ldap.PhotoDataBean;
@@ -78,6 +79,7 @@ import password.pwm.util.PasswordData;
 import password.pwm.util.RandomPasswordGenerator;
 import password.pwm.util.RandomPasswordGenerator;
 import password.pwm.util.java.JavaHelper;
 import password.pwm.util.java.JavaHelper;
 import password.pwm.util.java.JsonUtil;
 import password.pwm.util.java.JsonUtil;
+import password.pwm.util.java.StringUtil;
 import password.pwm.util.java.TimeDuration;
 import password.pwm.util.java.TimeDuration;
 import password.pwm.util.logging.PwmLogger;
 import password.pwm.util.logging.PwmLogger;
 import password.pwm.util.macro.MacroMachine;
 import password.pwm.util.macro.MacroMachine;
@@ -98,10 +100,10 @@ import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.OutputStream;
 import java.time.Instant;
 import java.time.Instant;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.List;
 import java.util.Locale;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Map;
@@ -425,24 +427,67 @@ public class HelpdeskServlet extends ControlledPwmServlet
     private ProcessStatus restSearchRequest(
     private ProcessStatus restSearchRequest(
             final PwmRequest pwmRequest
             final PwmRequest pwmRequest
     )
     )
-            throws ChaiUnavailableException, PwmUnrecoverableException, IOException, ServletException
+            throws PwmUnrecoverableException, IOException
     {
     {
         final HelpdeskProfile helpdeskProfile = getHelpdeskProfile( pwmRequest );
         final HelpdeskProfile helpdeskProfile = getHelpdeskProfile( pwmRequest );
-        final Map<String, String> valueMap = pwmRequest.readBodyAsJsonStringMap();
-        final String username = valueMap.get( "username" );
+        final HelpdeskSearchRequestBean searchRequest = JsonUtil.deserialize( pwmRequest.readRequestBodyAsString(), HelpdeskSearchRequestBean.class );
+        final HelpdeskSearchResultsBean searchResultsBean;
+
+        try
+        {
+            searchResultsBean = searchImpl( pwmRequest, helpdeskProfile, searchRequest );
+        }
+        catch ( PwmOperationalException e )
+        {
+            throw new PwmUnrecoverableException( e.getErrorInformation() );
+        }
+
+        final RestResultBean restResultBean = RestResultBean.withData( searchResultsBean );
+        pwmRequest.outputJsonResult( restResultBean );
+        return ProcessStatus.Halt;
+
+    }
+
+    private static HelpdeskSearchResultsBean searchImpl(
+            final PwmRequest pwmRequest,
+            final HelpdeskProfile helpdeskProfile,
+            final HelpdeskSearchRequestBean searchRequest
+    ) throws PwmUnrecoverableException, PwmOperationalException
+    {
 
 
         final boolean useProxy = helpdeskProfile.readSettingAsBoolean( PwmSetting.HELPDESK_USE_PROXY );
         final boolean useProxy = helpdeskProfile.readSettingAsBoolean( PwmSetting.HELPDESK_USE_PROXY );
-        final List<FormConfiguration> searchForm = helpdeskProfile.readSettingAsForm( PwmSetting.HELPDESK_SEARCH_FORM );
+        final List<FormConfiguration> searchForm = helpdeskProfile.readSettingAsForm( PwmSetting.HELPDESK_SEARCH_RESULT_FORM );
         final int maxResults = ( int ) helpdeskProfile.readSettingAsLong( PwmSetting.HELPDESK_RESULT_LIMIT );
         final int maxResults = ( int ) helpdeskProfile.readSettingAsLong( PwmSetting.HELPDESK_RESULT_LIMIT );
 
 
-        if ( username == null || username.isEmpty() )
+        final SearchRequestBean.SearchMode searchMode = searchRequest.getMode() == null
+                ? SearchRequestBean.SearchMode.simple
+                : searchRequest.getMode();
+
+
+
+        switch ( searchMode )
         {
         {
-            final HelpdeskSearchResultsBean emptyResults = new HelpdeskSearchResultsBean();
-            emptyResults.setSearchResults( new ArrayList<>() );
-            emptyResults.setSizeExceeded( false );
-            final RestResultBean restResultBean = RestResultBean.withData( emptyResults );
-            pwmRequest.outputJsonResult( restResultBean );
-            return ProcessStatus.Halt;
+            case simple:
+            {
+                if ( StringUtil.isEmpty( searchRequest.getUsername() ) )
+                {
+                    return HelpdeskSearchResultsBean.emptyResult();
+                }
+            }
+            break;
+
+            case advanced:
+            {
+                if ( JavaHelper.isEmpty( searchRequest.nonEmptySearchValues() ) )
+                {
+                    return HelpdeskSearchResultsBean.emptyResult();
+                }
+            }
+            break;
+
+
+            default:
+                JavaHelper.unhandledSwitchStatement( searchMode );
         }
         }
 
 
         final UserSearchEngine userSearchEngine = pwmRequest.getPwmApplication().getUserSearchEngine();
         final UserSearchEngine userSearchEngine = pwmRequest.getPwmApplication().getUserSearchEngine();
@@ -453,9 +498,7 @@ public class HelpdeskServlet extends ControlledPwmServlet
             final SearchConfiguration.SearchConfigurationBuilder builder = SearchConfiguration.builder();
             final SearchConfiguration.SearchConfigurationBuilder builder = SearchConfiguration.builder();
             builder.contexts( helpdeskProfile.readSettingAsStringArray( PwmSetting.HELPDESK_SEARCH_BASE ) );
             builder.contexts( helpdeskProfile.readSettingAsStringArray( PwmSetting.HELPDESK_SEARCH_BASE ) );
             builder.enableContextValidation( false );
             builder.enableContextValidation( false );
-            builder.username( username );
             builder.enableValueEscaping( false );
             builder.enableValueEscaping( false );
-            builder.filter( HelpdeskServletUtil.getSearchFilter( pwmRequest.getConfig(), helpdeskProfile ) );
             builder.enableSplitWhitespace( true );
             builder.enableSplitWhitespace( true );
 
 
             if ( !useProxy )
             if ( !useProxy )
@@ -465,33 +508,56 @@ public class HelpdeskServlet extends ControlledPwmServlet
                 builder.chaiProvider( getChaiUser( pwmRequest, helpdeskProfile, loggedInUser ).getChaiProvider() );
                 builder.chaiProvider( getChaiUser( pwmRequest, helpdeskProfile, loggedInUser ).getChaiProvider() );
             }
             }
 
 
+            switch ( searchMode )
+            {
+                case simple:
+                {
+                    builder.username( searchRequest.getUsername() );
+                    builder.filter( HelpdeskServletUtil.makeAdvancedSearchFilter( pwmRequest.getConfig(), helpdeskProfile ) );
+                }
+                break;
+
+                case advanced:
+                {
+                    final Map<FormConfiguration, String> formValues = new LinkedHashMap<>();
+                    final Map<String, String> requestSearchValues = SearchRequestBean.searchValueToMap( searchRequest.getSearchValues() );
+                    for ( final FormConfiguration formConfiguration : helpdeskProfile.readSettingAsForm( PwmSetting.HELPDESK_SEARCH_FORM ) )
+                    {
+                        final String attribute = formConfiguration.getName();
+                        final String value = requestSearchValues.get( attribute );
+                        if ( !StringUtil.isEmpty( value ) )
+                        {
+                            formValues.put( formConfiguration, value );
+                        }
+                    }
+
+                    builder.formValues( formValues );
+                    builder.filter( HelpdeskServletUtil.makeAdvancedSearchFilter( pwmRequest.getConfig(), helpdeskProfile, requestSearchValues ) );
+
+                }
+                break;
+
+
+                default:
+                    JavaHelper.unhandledSwitchStatement( searchMode );
+            }
+
             searchConfiguration = builder.build();
             searchConfiguration = builder.build();
         }
         }
 
 
 
 
         final UserSearchResults results;
         final UserSearchResults results;
         final boolean sizeExceeded;
         final boolean sizeExceeded;
-        try
         {
         {
             final Locale locale = pwmRequest.getLocale();
             final Locale locale = pwmRequest.getLocale();
             results = userSearchEngine.performMultiUserSearchFromForm( locale, searchConfiguration, maxResults, searchForm, pwmRequest.getSessionLabel() );
             results = userSearchEngine.performMultiUserSearchFromForm( locale, searchConfiguration, maxResults, searchForm, pwmRequest.getSessionLabel() );
             sizeExceeded = results.isSizeExceeded();
             sizeExceeded = results.isSizeExceeded();
         }
         }
-        catch ( PwmOperationalException e )
-        {
-            final ErrorInformation errorInformation = e.getErrorInformation();
-            LOGGER.error( pwmRequest, errorInformation );
-            final RestResultBean restResultBean = RestResultBean.fromError( errorInformation, pwmRequest );
-            pwmRequest.outputJsonResult( restResultBean );
-            return ProcessStatus.Halt;
-        }
 
 
-        final HelpdeskSearchResultsBean outputData = new HelpdeskSearchResultsBean();
-        outputData.setSearchResults( results.resultsAsJsonOutput( pwmRequest.getPwmApplication(), pwmRequest.getUserInfoIfLoggedIn() ) );
-        outputData.setSizeExceeded( sizeExceeded );
-        final RestResultBean restResultBean = RestResultBean.withData( outputData );
-        pwmRequest.outputJsonResult( restResultBean );
-        return ProcessStatus.Halt;
+        return HelpdeskSearchResultsBean.builder()
+                .searchResults( results.resultsAsJsonOutput( pwmRequest.getPwmApplication(), pwmRequest.getUserInfoIfLoggedIn() ) )
+                .sizeExceeded( sizeExceeded )
+                .build();
     }
     }
 
 
     @ActionHandler( action = "unlockIntruder" )
     @ActionHandler( action = "unlockIntruder" )

+ 72 - 3
server/src/main/java/password/pwm/http/servlet/helpdesk/HelpdeskServletUtil.java

@@ -46,6 +46,7 @@ import password.pwm.svc.event.AuditRecordFactory;
 import password.pwm.svc.event.HelpdeskAuditRecord;
 import password.pwm.svc.event.HelpdeskAuditRecord;
 import password.pwm.svc.stats.Statistic;
 import password.pwm.svc.stats.Statistic;
 import password.pwm.svc.stats.StatisticsManager;
 import password.pwm.svc.stats.StatisticsManager;
+import password.pwm.util.java.StringUtil;
 import password.pwm.util.logging.PwmLogger;
 import password.pwm.util.logging.PwmLogger;
 import password.pwm.util.macro.MacroMachine;
 import password.pwm.util.macro.MacroMachine;
 
 
@@ -54,11 +55,11 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Map;
 
 
-class HelpdeskServletUtil
+public class HelpdeskServletUtil
 {
 {
     private static final PwmLogger LOGGER = PwmLogger.forClass( HelpdeskServletUtil.class );
     private static final PwmLogger LOGGER = PwmLogger.forClass( HelpdeskServletUtil.class );
 
 
-    static String getSearchFilter( final Configuration configuration, final HelpdeskProfile helpdeskProfile )
+    static String makeAdvancedSearchFilter( final Configuration configuration, final HelpdeskProfile helpdeskProfile )
     {
     {
         final String configuredFilter = helpdeskProfile.readSettingAsString( PwmSetting.HELPDESK_SEARCH_FILTER );
         final String configuredFilter = helpdeskProfile.readSettingAsString( PwmSetting.HELPDESK_SEARCH_FILTER );
         if ( configuredFilter != null && !configuredFilter.isEmpty() )
         if ( configuredFilter != null && !configuredFilter.isEmpty() )
@@ -98,6 +99,74 @@ class HelpdeskServletUtil
         return filter.toString();
         return filter.toString();
     }
     }
 
 
+    static String makeAdvancedSearchFilter(
+            final Configuration configuration,
+            final HelpdeskProfile helpdeskProfile,
+            final Map<String, String> attributesInSearchRequest
+    )
+    {
+        final List<String> defaultObjectClasses = configuration.readSettingAsStringArray( PwmSetting.DEFAULT_OBJECT_CLASSES );
+        final List<FormConfiguration> searchAttributes = helpdeskProfile.readSettingAsForm( PwmSetting.HELPDESK_SEARCH_FORM );
+        return makeAdvancedSearchFilter( defaultObjectClasses, searchAttributes, attributesInSearchRequest );
+    }
+
+    public static String makeAdvancedSearchFilter(
+            final List<String> defaultObjectClasses,
+            final List<FormConfiguration> searchAttributes,
+            final Map<String, String> attributesInSearchRequest
+    )
+    {
+        final StringBuilder filter = new StringBuilder();
+
+        //open AND clause for objectclasses and attributes
+        filter.append( "(&" );
+
+        for ( final String objectClass : defaultObjectClasses )
+        {
+            filter.append( "(objectClass=" ).append( objectClass ).append( ")" );
+        }
+
+        // open AND clause for attributes
+        filter.append( "(&" );
+
+        for ( final FormConfiguration formConfiguration : searchAttributes )
+        {
+            if ( formConfiguration != null && formConfiguration.getName() != null )
+            {
+                final String searchAttribute = formConfiguration.getName();
+                final String value = attributesInSearchRequest.get( searchAttribute );
+                if ( !StringUtil.isEmpty( value ) )
+                {
+                    filter.append( "(" ).append( searchAttribute ).append( "=" );
+
+                    switch ( formConfiguration.getType() )
+                    {
+                        case select:
+                        {
+                            // value is specified by admin, so wildcards are not required
+                            filter.append( "%" ).append( searchAttribute ).append( "%)" );
+                        }
+                        break;
+
+                        default:
+                        {
+                            filter.append( "*%" ).append( searchAttribute ).append( "%*)" );
+                        }
+                        break;
+                    }
+                }
+            }
+        }
+
+        // close OR clause
+        filter.append( ")" );
+
+        // close AND clause
+        filter.append( ")" );
+        return filter.toString();
+    }
+
+
     static void checkIfUserIdentityViewable(
     static void checkIfUserIdentityViewable(
             final PwmRequest pwmRequest,
             final PwmRequest pwmRequest,
             final HelpdeskProfile helpdeskProfile,
             final HelpdeskProfile helpdeskProfile,
@@ -105,7 +174,7 @@ class HelpdeskServletUtil
     )
     )
             throws PwmUnrecoverableException
             throws PwmUnrecoverableException
     {
     {
-        final String filterSetting = getSearchFilter( pwmRequest.getConfig(), helpdeskProfile );
+        final String filterSetting = makeAdvancedSearchFilter( pwmRequest.getConfig(), helpdeskProfile );
         String filterString = filterSetting.replace( PwmConstants.VALUE_REPLACEMENT_USERNAME, "*" );
         String filterString = filterSetting.replace( PwmConstants.VALUE_REPLACEMENT_USERNAME, "*" );
         while ( filterString.contains( "**" ) )
         while ( filterString.contains( "**" ) )
         {
         {

+ 3 - 51
server/src/main/java/password/pwm/http/servlet/helpdesk/HelpdeskVerificationStateBean.java

@@ -24,6 +24,7 @@ package password.pwm.http.servlet.helpdesk;
 
 
 import com.novell.ldapchai.exception.ChaiOperationException;
 import com.novell.ldapchai.exception.ChaiOperationException;
 import com.novell.ldapchai.exception.ChaiUnavailableException;
 import com.novell.ldapchai.exception.ChaiUnavailableException;
+import lombok.Value;
 import password.pwm.AppProperty;
 import password.pwm.AppProperty;
 import password.pwm.PwmApplication;
 import password.pwm.PwmApplication;
 import password.pwm.PwmConstants;
 import password.pwm.PwmConstants;
@@ -138,70 +139,21 @@ class HelpdeskVerificationStateBean implements Serializable
         return Collections.unmodifiableList( new ArrayList<>( returnRecords.values() ) );
         return Collections.unmodifiableList( new ArrayList<>( returnRecords.values() ) );
     }
     }
 
 
-
+    @Value
     static class ViewableValidationRecord implements Serializable
     static class ViewableValidationRecord implements Serializable
     {
     {
         private Instant timestamp;
         private Instant timestamp;
         private String profile;
         private String profile;
         private String username;
         private String username;
         private String method;
         private String method;
-
-        ViewableValidationRecord( final Instant timestamp, final String profile, final String username, final String method )
-        {
-            this.timestamp = timestamp;
-            this.profile = profile;
-            this.username = username;
-            this.method = method;
-        }
-
-        public Instant getTimestamp( )
-        {
-            return timestamp;
-        }
-
-        public String getProfile( )
-        {
-            return profile;
-        }
-
-        public String getUsername( )
-        {
-            return username;
-        }
-
-        public String getMethod( )
-        {
-            return method;
-        }
     }
     }
 
 
+    @Value
     static class HelpdeskValidationRecord implements Serializable
     static class HelpdeskValidationRecord implements Serializable
     {
     {
         private Instant timestamp;
         private Instant timestamp;
         private UserIdentity identity;
         private UserIdentity identity;
         private IdentityVerificationMethod method;
         private IdentityVerificationMethod method;
-
-        HelpdeskValidationRecord( final Instant timestamp, final UserIdentity identity, final IdentityVerificationMethod method )
-        {
-            this.timestamp = timestamp;
-            this.identity = identity;
-            this.method = method;
-        }
-
-        public Instant getTimestamp( )
-        {
-            return timestamp;
-        }
-
-        public UserIdentity getIdentity( )
-        {
-            return identity;
-        }
-
-        public IdentityVerificationMethod getMethod( )
-        {
-            return method;
-        }
     }
     }
 
 
     String toClientString( final PwmApplication pwmApplication ) throws PwmUnrecoverableException
     String toClientString( final PwmApplication pwmApplication ) throws PwmUnrecoverableException

+ 56 - 2
server/src/main/java/password/pwm/http/servlet/peoplesearch/PeopleSearchClientConfigBean.java

@@ -24,6 +24,7 @@ package password.pwm.http.servlet.peoplesearch;
 
 
 import lombok.Builder;
 import lombok.Builder;
 import lombok.Value;
 import lombok.Value;
+import password.pwm.PwmApplication;
 import password.pwm.bean.UserIdentity;
 import password.pwm.bean.UserIdentity;
 import password.pwm.config.Configuration;
 import password.pwm.config.Configuration;
 import password.pwm.config.PwmSetting;
 import password.pwm.config.PwmSetting;
@@ -32,8 +33,11 @@ import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.http.PwmRequest;
 import password.pwm.http.PwmRequest;
 
 
 import java.io.Serializable;
 import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Map;
 
 
 @Value
 @Value
@@ -41,14 +45,52 @@ import java.util.Map;
 public class PeopleSearchClientConfigBean implements Serializable
 public class PeopleSearchClientConfigBean implements Serializable
 {
 {
     private Map<String, String> searchColumns;
     private Map<String, String> searchColumns;
+    private boolean enableAdvancedSearch;
     private boolean enablePhoto;
     private boolean enablePhoto;
     private boolean orgChartEnabled;
     private boolean orgChartEnabled;
     private boolean orgChartShowChildCount;
     private boolean orgChartShowChildCount;
     private int orgChartMaxParents;
     private int orgChartMaxParents;
+    private int maxAdvancedSearchAttributes;
+    private List<SearchAttribute> advancedSearchAttributes;
     private boolean enableExport;
     private boolean enableExport;
     private int exportMaxDepth;
     private int exportMaxDepth;
 
 
 
 
+    @Value
+    @Builder
+    public static class SearchAttribute implements Serializable
+    {
+        private String attribute;
+        private String label;
+        private FormConfiguration.Type type;
+        private Map<String, String> options;
+
+        public static List<SearchAttribute> searchAttributesFromForm(
+                final Locale locale,
+                final List<FormConfiguration> formConfigurations
+        )
+        {
+            final List<SearchAttribute> returnList = new ArrayList<>( );
+            for ( final FormConfiguration formConfiguration : formConfigurations )
+            {
+                final String attribute = formConfiguration.getName();
+                final String label = formConfiguration.getLabel( locale );
+
+                final SearchAttribute searchAttribute = SearchAttribute.builder()
+                        .attribute( attribute )
+                        .type( formConfiguration.getType() )
+                        .label( label )
+                        .options( formConfiguration.getSelectOptions() )
+                        .build();
+
+                returnList.add( searchAttribute );
+            }
+
+            return Collections.unmodifiableList( returnList );
+        }
+    }
+
+
     static PeopleSearchClientConfigBean fromConfig(
     static PeopleSearchClientConfigBean fromConfig(
             final PwmRequest pwmRequest,
             final PwmRequest pwmRequest,
             final PeopleSearchConfiguration peopleSearchConfiguration,
             final PeopleSearchConfiguration peopleSearchConfiguration,
@@ -56,23 +98,35 @@ public class PeopleSearchClientConfigBean implements Serializable
     )
     )
             throws PwmUnrecoverableException
             throws PwmUnrecoverableException
     {
     {
-        final Configuration configuration = pwmRequest.getConfig();
+        final PwmApplication pwmApplication = pwmRequest.getPwmApplication();
+        final Configuration configuration = pwmApplication.getConfig();
+        final Locale locale = pwmRequest.getLocale();
+
         final Map<String, String> searchColumns = new LinkedHashMap<>();
         final Map<String, String> searchColumns = new LinkedHashMap<>();
         final List<FormConfiguration> searchForm = configuration.readSettingAsForm( PwmSetting.PEOPLE_SEARCH_RESULT_FORM );
         final List<FormConfiguration> searchForm = configuration.readSettingAsForm( PwmSetting.PEOPLE_SEARCH_RESULT_FORM );
         for ( final FormConfiguration formConfiguration : searchForm )
         for ( final FormConfiguration formConfiguration : searchForm )
         {
         {
             searchColumns.put( formConfiguration.getName(),
             searchColumns.put( formConfiguration.getName(),
-                    formConfiguration.getLabel( pwmRequest.getLocale() ) );
+                    formConfiguration.getLabel( locale ) );
         }
         }
 
 
+
+        final List<SearchAttribute> searchAttributes = SearchAttribute.searchAttributesFromForm( locale, peopleSearchConfiguration.getSearchForm() );
+
         return PeopleSearchClientConfigBean.builder()
         return PeopleSearchClientConfigBean.builder()
                 .searchColumns( searchColumns )
                 .searchColumns( searchColumns )
                 .enablePhoto( peopleSearchConfiguration.isPhotosEnabled( userIdentity, pwmRequest.getSessionLabel() ) )
                 .enablePhoto( peopleSearchConfiguration.isPhotosEnabled( userIdentity, pwmRequest.getSessionLabel() ) )
                 .orgChartEnabled( peopleSearchConfiguration.isOrgChartEnabled() )
                 .orgChartEnabled( peopleSearchConfiguration.isOrgChartEnabled() )
                 .orgChartShowChildCount( peopleSearchConfiguration.isOrgChartShowChildCount() )
                 .orgChartShowChildCount( peopleSearchConfiguration.isOrgChartShowChildCount() )
                 .orgChartMaxParents( peopleSearchConfiguration.getOrgChartMaxParents() )
                 .orgChartMaxParents( peopleSearchConfiguration.getOrgChartMaxParents() )
+
+                .enableAdvancedSearch( peopleSearchConfiguration.isEnableAdvancedSearch() )
+                .maxAdvancedSearchAttributes( 3 )
+                .advancedSearchAttributes( searchAttributes )
+
                 .enableExport( peopleSearchConfiguration.isEnableExportCsv() )
                 .enableExport( peopleSearchConfiguration.isEnableExportCsv() )
                 .exportMaxDepth( peopleSearchConfiguration.getExportCsvMaxDepth() )
                 .exportMaxDepth( peopleSearchConfiguration.getExportCsvMaxDepth() )
+
                 .build();
                 .build();
     }
     }
 }
 }

+ 30 - 0
server/src/main/java/password/pwm/http/servlet/peoplesearch/PeopleSearchConfiguration.java

@@ -29,13 +29,17 @@ import password.pwm.bean.UserIdentity;
 import password.pwm.config.Configuration;
 import password.pwm.config.Configuration;
 import password.pwm.config.PwmSetting;
 import password.pwm.config.PwmSetting;
 import password.pwm.config.profile.LdapProfile;
 import password.pwm.config.profile.LdapProfile;
+import password.pwm.config.value.data.FormConfiguration;
 import password.pwm.config.value.data.UserPermission;
 import password.pwm.config.value.data.UserPermission;
 import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.http.PwmRequest;
 import password.pwm.http.PwmRequest;
 import password.pwm.ldap.LdapPermissionTester;
 import password.pwm.ldap.LdapPermissionTester;
 import password.pwm.util.java.TimeDuration;
 import password.pwm.util.java.TimeDuration;
 
 
+import java.util.Collections;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.List;
+import java.util.Set;
 
 
 public class PeopleSearchConfiguration
 public class PeopleSearchConfiguration
 {
 {
@@ -139,6 +143,28 @@ public class PeopleSearchConfiguration
         return Integer.parseInt( pwmRequest.getConfig().readAppProperty( AppProperty.PEOPLESEARCH_EXPORT_CSV_MAX_ITEMS ) );
         return Integer.parseInt( pwmRequest.getConfig().readAppProperty( AppProperty.PEOPLESEARCH_EXPORT_CSV_MAX_ITEMS ) );
     }
     }
 
 
+    List<FormConfiguration> getSearchForm()
+    {
+        return pwmRequest.getConfig().readSettingAsForm( PwmSetting.PEOPLE_SEARCH_SEARCH_FORM );
+    }
+
+    Set<String> getSearchAttributes()
+    {
+        final List<FormConfiguration> searchForm = getSearchForm();
+
+        return Collections.unmodifiableSet( new LinkedHashSet<>( FormConfiguration.convertToListOfNames( searchForm ) ) );
+    }
+
+    List<FormConfiguration> getResultForm()
+    {
+        return pwmRequest.getConfig().readSettingAsForm( PwmSetting.PEOPLE_SEARCH_RESULT_FORM );
+    }
+
+    int getResultLimit()
+    {
+        return ( int ) pwmRequest.getConfig().readSettingAsLong( PwmSetting.PEOPLE_SEARCH_RESULT_LIMIT );
+    }
+
     public static PeopleSearchConfiguration forRequest(
     public static PeopleSearchConfiguration forRequest(
             final PwmRequest pwmRequest
             final PwmRequest pwmRequest
     )
     )
@@ -146,4 +172,8 @@ public class PeopleSearchConfiguration
         return new PeopleSearchConfiguration( pwmRequest );
         return new PeopleSearchConfiguration( pwmRequest );
     }
     }
 
 
+    public boolean isEnableAdvancedSearch()
+    {
+        return pwmApplication.getConfig().readSettingAsBoolean( PwmSetting.PEOPLE_SEARCH_ENABLE_ADVANCED_SEARCH );
+    }
 }
 }

+ 77 - 37
server/src/main/java/password/pwm/http/servlet/peoplesearch/PeopleSearchDataReader.java

@@ -33,7 +33,6 @@ import password.pwm.AppProperty;
 import password.pwm.PwmApplication;
 import password.pwm.PwmApplication;
 import password.pwm.PwmConstants;
 import password.pwm.PwmConstants;
 import password.pwm.bean.UserIdentity;
 import password.pwm.bean.UserIdentity;
-import password.pwm.config.Configuration;
 import password.pwm.config.PwmSetting;
 import password.pwm.config.PwmSetting;
 import password.pwm.config.value.data.FormConfiguration;
 import password.pwm.config.value.data.FormConfiguration;
 import password.pwm.error.ErrorInformation;
 import password.pwm.error.ErrorInformation;
@@ -42,6 +41,7 @@ import password.pwm.error.PwmOperationalException;
 import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.http.PwmRequest;
 import password.pwm.http.PwmRequest;
 import password.pwm.http.PwmURL;
 import password.pwm.http.PwmURL;
+import password.pwm.http.servlet.helpdesk.HelpdeskServletUtil;
 import password.pwm.i18n.Display;
 import password.pwm.i18n.Display;
 import password.pwm.ldap.LdapOperationsHelper;
 import password.pwm.ldap.LdapOperationsHelper;
 import password.pwm.ldap.LdapPermissionTester;
 import password.pwm.ldap.LdapPermissionTester;
@@ -69,11 +69,11 @@ import java.io.Serializable;
 import java.time.Instant;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Collections;
-import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.List;
 import java.util.Locale;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.Set;
 import java.util.TreeMap;
 import java.util.TreeMap;
 import java.util.concurrent.ArrayBlockingQueue;
 import java.util.concurrent.ArrayBlockingQueue;
@@ -104,12 +104,11 @@ class PeopleSearchDataReader
     }
     }
 
 
     SearchResultBean makeSearchResultBean(
     SearchResultBean makeSearchResultBean(
-            final String searchData,
-            final boolean includeDisplayName
+            final SearchRequestBean searchRequestBean
     )
     )
             throws PwmUnrecoverableException
             throws PwmUnrecoverableException
     {
     {
-        final CacheKey cacheKey = makeCacheKey( SearchResultBean.class.getSimpleName(), searchData + "|" + includeDisplayName );
+        final CacheKey cacheKey = makeCacheKey( SearchResultBean.class.getSimpleName(), JsonUtil.serialize( searchRequestBean ) );
 
 
         {
         {
             // try to serve from cache first
             // try to serve from cache first
@@ -127,12 +126,14 @@ class PeopleSearchDataReader
         }
         }
 
 
         // if not in cache, build results from ldap
         // if not in cache, build results from ldap
-        final SearchResultBean searchResultBean = makeSearchResultsImpl( pwmRequest, searchData, includeDisplayName )
+        final SearchResultBean searchResultBean = makeSearchResultsImpl( searchRequestBean )
                 .toBuilder().fromCache( false ).build();
                 .toBuilder().fromCache( false ).build();
 
 
         StatisticsManager.incrementStat( pwmRequest, Statistic.PEOPLESEARCH_SEARCHES );
         StatisticsManager.incrementStat( pwmRequest, Statistic.PEOPLESEARCH_SEARCHES );
         storeDataInCache( pwmRequest.getPwmApplication(), cacheKey, searchResultBean );
         storeDataInCache( pwmRequest.getPwmApplication(), cacheKey, searchResultBean );
-        LOGGER.trace( pwmRequest, "returning " + searchResultBean.getSearchResults().size() + " results for search request '" + searchData + "'" );
+        LOGGER.trace( pwmRequest, "returning " + searchResultBean.getSearchResults().size()
+                + " results for search request "
+                + JsonUtil.serialize( searchRequestBean ) );
         return searchResultBean;
         return searchResultBean;
     }
     }
 
 
@@ -369,12 +370,6 @@ class PeopleSearchDataReader
                 keyString );
                 keyString );
     }
     }
 
 
-    private static Set<String> getSearchAttributes( final Configuration configuration )
-    {
-        final List<String> searchResultForm = configuration.readSettingAsStringArray( PwmSetting.PEOPLE_SEARCH_SEARCH_ATTRIBUTES );
-        return Collections.unmodifiableSet( new HashSet<>( searchResultForm ) );
-    }
-
     private OrgChartReferenceBean makeOrgChartReferenceForIdentity(
     private OrgChartReferenceBean makeOrgChartReferenceForIdentity(
             final UserIdentity userIdentity
             final UserIdentity userIdentity
     )
     )
@@ -553,7 +548,7 @@ class PeopleSearchDataReader
     )
     )
             throws PwmUnrecoverableException
             throws PwmUnrecoverableException
     {
     {
-        final Set<String> searchAttributes = getSearchAttributes( pwmRequest.getConfig() );
+        final Set<String> searchAttributes = peopleSearchConfiguration.getSearchAttributes();
         final Map<String, AttributeDetailBean> returnObj = new LinkedHashMap<>();
         final Map<String, AttributeDetailBean> returnObj = new LinkedHashMap<>();
         for ( final FormConfiguration formConfiguration : detailForm )
         for ( final FormConfiguration formConfiguration : detailForm )
         {
         {
@@ -648,7 +643,7 @@ class PeopleSearchDataReader
         final Instant startTime = Instant.now();
         final Instant startTime = Instant.now();
         final CacheLoader<Boolean> cacheLoader = () ->
         final CacheLoader<Boolean> cacheLoader = () ->
         {
         {
-            final String filterSetting = getSearchFilter( pwmRequest.getConfig() );
+            final String filterSetting = makeSimpleSearchFilter( );
             String filterString = filterSetting.replace( PwmConstants.VALUE_REPLACEMENT_USERNAME, "*" );
             String filterString = filterSetting.replace( PwmConstants.VALUE_REPLACEMENT_USERNAME, "*" );
             while ( filterString.contains( "**" ) )
             while ( filterString.contains( "**" ) )
             {
             {
@@ -674,16 +669,16 @@ class PeopleSearchDataReader
         }
         }
     }
     }
 
 
-    private static String getSearchFilter( final Configuration configuration )
+    private String makeSimpleSearchFilter()
     {
     {
-        final String configuredFilter = configuration.readSettingAsString( PwmSetting.PEOPLE_SEARCH_SEARCH_FILTER );
+        final String configuredFilter = pwmRequest.getConfig().readSettingAsString( PwmSetting.PEOPLE_SEARCH_SEARCH_FILTER );
         if ( configuredFilter != null && !configuredFilter.isEmpty() )
         if ( configuredFilter != null && !configuredFilter.isEmpty() )
         {
         {
             return configuredFilter;
             return configuredFilter;
         }
         }
 
 
-        final List<String> defaultObjectClasses = configuration.readSettingAsStringArray( PwmSetting.DEFAULT_OBJECT_CLASSES );
-        final Set<String> searchAttributes = getSearchAttributes( configuration );
+        final List<String> defaultObjectClasses = pwmRequest.getConfig().readSettingAsStringArray( PwmSetting.DEFAULT_OBJECT_CLASSES );
+        final Set<String> searchAttributes = peopleSearchConfiguration.getSearchAttributes();
         final StringBuilder filter = new StringBuilder();
         final StringBuilder filter = new StringBuilder();
 
 
         //open AND clause for objectclasses and attributes
         //open AND clause for objectclasses and attributes
@@ -709,6 +704,14 @@ class PeopleSearchDataReader
         return filter.toString();
         return filter.toString();
     }
     }
 
 
+    private String makeAdvancedFilter( final Map<String, String> attributesInSearchRequest )
+    {
+        final List<String> defaultObjectClasses = pwmRequest.getConfig().readSettingAsStringArray( PwmSetting.DEFAULT_OBJECT_CLASSES );
+        final List<FormConfiguration> searchAttributes = peopleSearchConfiguration.getSearchForm();
+
+        return HelpdeskServletUtil.makeAdvancedSearchFilter( defaultObjectClasses, searchAttributes, attributesInSearchRequest );
+    }
+
     private boolean useProxy( )
     private boolean useProxy( )
     {
     {
 
 
@@ -772,48 +775,85 @@ class PeopleSearchDataReader
     }
     }
 
 
     private SearchResultBean makeSearchResultsImpl(
     private SearchResultBean makeSearchResultsImpl(
-            final PwmRequest pwmRequest,
-            final String username,
-            final boolean includeDisplayName
+            final SearchRequestBean searchRequest
     )
     )
             throws PwmUnrecoverableException
             throws PwmUnrecoverableException
     {
     {
-        final Instant startTime = Instant.now();
+        Objects.requireNonNull( searchRequest );
 
 
-        if ( username == null || username.length() < 1 )
-        {
-            return SearchResultBean.builder().searchResults( Collections.emptyList() ).build();
-        }
+        final Instant startTime = Instant.now();
 
 
-        final boolean useProxy = useProxy();
-        final UserSearchEngine userSearchEngine = pwmRequest.getPwmApplication().getUserSearchEngine();
+        final SearchRequestBean.SearchMode searchMode = searchRequest.getMode() == null
+                ? SearchRequestBean.SearchMode.simple
+                : searchRequest.getMode();
 
 
         final SearchConfiguration searchConfiguration;
         final SearchConfiguration searchConfiguration;
         {
         {
             final SearchConfiguration.SearchConfigurationBuilder builder = SearchConfiguration.builder();
             final SearchConfiguration.SearchConfigurationBuilder builder = SearchConfiguration.builder();
             builder.contexts( pwmRequest.getConfig().readSettingAsStringArray( PwmSetting.PEOPLE_SEARCH_SEARCH_BASE ) );
             builder.contexts( pwmRequest.getConfig().readSettingAsStringArray( PwmSetting.PEOPLE_SEARCH_SEARCH_BASE ) );
             builder.enableContextValidation( false );
             builder.enableContextValidation( false );
-            builder.username( username );
             builder.enableValueEscaping( false );
             builder.enableValueEscaping( false );
-            builder.filter( getSearchFilter( pwmRequest.getConfig() ) );
             builder.enableSplitWhitespace( true );
             builder.enableSplitWhitespace( true );
 
 
-            if ( !useProxy )
+            if ( !useProxy() )
             {
             {
                 builder.ldapProfile( pwmRequest.getPwmSession().getUserInfo().getUserIdentity().getLdapProfileID() );
                 builder.ldapProfile( pwmRequest.getPwmSession().getUserInfo().getUserIdentity().getLdapProfileID() );
                 builder.chaiProvider( pwmRequest.getPwmSession().getSessionManager().getChaiProvider() );
                 builder.chaiProvider( pwmRequest.getPwmSession().getSessionManager().getChaiProvider() );
             }
             }
+
+            switch ( searchMode )
+            {
+                case simple:
+                {
+                    if ( StringUtil.isEmpty( searchRequest.getUsername() ) )
+                    {
+                        return SearchResultBean.builder().searchResults( Collections.emptyList() ).build();
+                    }
+
+                    builder.filter( makeSimpleSearchFilter() );
+                    builder.username( searchRequest.getUsername() );
+                }
+                break;
+
+                case advanced:
+                {
+                    if ( JavaHelper.isEmpty( searchRequest.nonEmptySearchValues() ) )
+                    {
+                        return SearchResultBean.builder().searchResults( Collections.emptyList() ).build();
+                    }
+
+                    final Map<FormConfiguration, String> formValues = new LinkedHashMap<>();
+                    final Map<String, String> requestSearchValues = SearchRequestBean.searchValueToMap( searchRequest.getSearchValues() );
+                    for ( final FormConfiguration formConfiguration : peopleSearchConfiguration.getSearchForm() )
+                    {
+                        final String attribute = formConfiguration.getName();
+                        final String value = requestSearchValues.get( attribute );
+                        if ( !StringUtil.isEmpty( value ) )
+                        {
+                            formValues.put( formConfiguration, value );
+                        }
+                    }
+
+                    builder.filter( makeAdvancedFilter( requestSearchValues ) );
+                    builder.formValues( formValues );
+                }
+                break;
+
+                default:
+                    JavaHelper.unhandledSwitchStatement( searchMode );
+            }
+
             searchConfiguration = builder.build();
             searchConfiguration = builder.build();
         }
         }
 
 
+        final UserSearchEngine userSearchEngine = pwmRequest.getPwmApplication().getUserSearchEngine();
+
         final UserSearchResults results;
         final UserSearchResults results;
         final boolean sizeExceeded;
         final boolean sizeExceeded;
         try
         try
         {
         {
-            final List<FormConfiguration> searchForm = pwmRequest.getConfig().readSettingAsForm(
-                    PwmSetting.PEOPLE_SEARCH_RESULT_FORM );
-            final int maxResults = ( int ) pwmRequest.getConfig().readSettingAsLong(
-                    PwmSetting.PEOPLE_SEARCH_RESULT_LIMIT );
+            final List<FormConfiguration> searchForm = peopleSearchConfiguration.getResultForm();
+            final int maxResults = peopleSearchConfiguration.getResultLimit();
             final Locale locale = pwmRequest.getLocale();
             final Locale locale = pwmRequest.getLocale();
             results = userSearchEngine.performMultiUserSearchFromForm( locale, searchConfiguration, maxResults, searchForm, pwmRequest.getSessionLabel() );
             results = userSearchEngine.performMultiUserSearchFromForm( locale, searchConfiguration, maxResults, searchForm, pwmRequest.getSessionLabel() );
             sizeExceeded = results.isSizeExceeded();
             sizeExceeded = results.isSizeExceeded();
@@ -826,7 +866,7 @@ class PeopleSearchDataReader
         }
         }
 
 
         final List<Map<String, Object>> resultOutput = new ArrayList<>( results.resultsAsJsonOutput( pwmRequest.getPwmApplication(), null ) );
         final List<Map<String, Object>> resultOutput = new ArrayList<>( results.resultsAsJsonOutput( pwmRequest.getPwmApplication(), null ) );
-        if ( includeDisplayName )
+        if ( searchRequest.isIncludeDisplayName() )
         {
         {
             for ( final Map<String, Object> map : resultOutput )
             for ( final Map<String, Object> map : resultOutput )
             {
             {

+ 5 - 11
server/src/main/java/password/pwm/http/servlet/peoplesearch/PeopleSearchServlet.java

@@ -55,7 +55,6 @@ import java.io.IOException;
 import java.io.OutputStream;
 import java.io.OutputStream;
 import java.util.Collection;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Collections;
-import java.util.Map;
 
 
 public abstract class PeopleSearchServlet extends ControlledPwmServlet
 public abstract class PeopleSearchServlet extends ControlledPwmServlet
 {
 {
@@ -120,7 +119,7 @@ public abstract class PeopleSearchServlet extends ControlledPwmServlet
             final PwmRequest pwmRequest
             final PwmRequest pwmRequest
 
 
     )
     )
-            throws ChaiUnavailableException, PwmUnrecoverableException, IOException, ServletException
+            throws PwmUnrecoverableException, IOException
     {
     {
         final PeopleSearchConfiguration peopleSearchConfiguration = PeopleSearchConfiguration.forRequest( pwmRequest );
         final PeopleSearchConfiguration peopleSearchConfiguration = PeopleSearchConfiguration.forRequest( pwmRequest );
 
 
@@ -140,24 +139,19 @@ public abstract class PeopleSearchServlet extends ControlledPwmServlet
     private ProcessStatus restSearchRequest(
     private ProcessStatus restSearchRequest(
             final PwmRequest pwmRequest
             final PwmRequest pwmRequest
     )
     )
-            throws ChaiUnavailableException, PwmUnrecoverableException, IOException, ServletException
+            throws PwmUnrecoverableException, IOException
     {
     {
-        final Map<String, Object> jsonBodyMap = pwmRequest.readBodyAsJsonMap( PwmHttpRequestWrapper.Flag.BypassValidation );
-        final String username = jsonBodyMap.get( "username" ) == null
-                ? null
-                : jsonBodyMap.get( "username" ).toString();
-
-        final boolean includeDisplayName = pwmRequest.readParameterAsBoolean( "includeDisplayName" );
+        final SearchRequestBean searchRequest = JsonUtil.deserialize( pwmRequest.readRequestBodyAsString(), SearchRequestBean.class );
 
 
         final PeopleSearchDataReader peopleSearchDataReader = new PeopleSearchDataReader( pwmRequest );
         final PeopleSearchDataReader peopleSearchDataReader = new PeopleSearchDataReader( pwmRequest );
 
 
-        final SearchResultBean searchResultBean = peopleSearchDataReader.makeSearchResultBean( username, includeDisplayName );
+        final SearchResultBean searchResultBean = peopleSearchDataReader.makeSearchResultBean( searchRequest );
         final RestResultBean restResultBean = RestResultBean.withData( searchResultBean );
         final RestResultBean restResultBean = RestResultBean.withData( searchResultBean );
 
 
         addExpiresHeadersToResponse( pwmRequest );
         addExpiresHeadersToResponse( pwmRequest );
         pwmRequest.outputJsonResult( restResultBean );
         pwmRequest.outputJsonResult( restResultBean );
 
 
-        LOGGER.trace( pwmRequest, "returning " + searchResultBean.getSearchResults().size() + " results for search request '" + username + "'" );
+        LOGGER.trace( pwmRequest, "returning " + searchResultBean.getSearchResults().size() + " results for search request " + JsonUtil.serialize( searchRequest ) );
         return ProcessStatus.Halt;
         return ProcessStatus.Halt;
     }
     }
 
 

+ 86 - 0
server/src/main/java/password/pwm/http/servlet/peoplesearch/SearchRequestBean.java

@@ -0,0 +1,86 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2018 The PWM Project
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+ */
+
+package password.pwm.http.servlet.peoplesearch;
+
+import lombok.Builder;
+import lombok.Value;
+import password.pwm.util.java.StringUtil;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Value
+@Builder
+public class SearchRequestBean implements Serializable
+{
+    @Builder.Default
+    private SearchMode mode = SearchMode.simple;
+
+    private String username;
+    private List<SearchValue> searchValues;
+    private boolean includeDisplayName;
+
+    public enum SearchMode
+    {
+        simple,
+        advanced,
+    }
+
+    @Value
+    public static class SearchValue implements Serializable
+    {
+        private String key;
+        private String value;
+    }
+
+    public static Map<String, String> searchValueToMap( final List<SearchValue> input )
+    {
+        final Map<String, String> returnMap = new LinkedHashMap<>();
+        for ( final SearchValue searchValue : input )
+        {
+            returnMap.put( searchValue.getKey(), searchValue.getValue() );
+        }
+        return Collections.unmodifiableMap( returnMap );
+    }
+
+    public List<SearchValue> nonEmptySearchValues()
+    {
+        return filterNonEmptySearchValues( getSearchValues() );
+    }
+
+    public static List<SearchValue> filterNonEmptySearchValues( final List<SearchValue> input )
+    {
+        final List<SearchValue> returnList = input == null
+                ? new ArrayList<>()
+                : new ArrayList<>( input );
+
+        returnList.removeIf( searchValue -> StringUtil.isEmpty( searchValue.getKey() )
+                || StringUtil.isEmpty( searchValue.getValue() ) );
+
+        return Collections.unmodifiableList( returnList );
+    }
+}

+ 47 - 1
server/src/main/resources/password/pwm/config/PwmSetting.xml

@@ -3150,7 +3150,7 @@
             <value>{"type":"ldapQuery","ldapProfileID":"all","ldapQuery":"(objectClass=*)"}</value>
             <value>{"type":"ldapQuery","ldapProfileID":"all","ldapQuery":"(objectClass=*)"}</value>
         </default>
         </default>
     </setting>
     </setting>
-    <setting hidden="false" key="peopleSearch.searchAttributes" level="1" required="false">
+    <setting hidden="true" key="peopleSearch.searchAttributes" level="1" required="false">
         <ldapPermission actor="self_other" access="read"/>
         <ldapPermission actor="self_other" access="read"/>
         <default>
         <default>
             <value>givenName</value>
             <value>givenName</value>
@@ -3159,6 +3159,20 @@
             <value>mail</value>
             <value>mail</value>
         </default>
         </default>
     </setting>
     </setting>
+    <setting hidden="false" key="peopleSearch.search.form" level="1" required="true">
+        <flag>Form_HideStandardOptions</flag>
+        <ldapPermission actor="self_other" access="read"/>
+        <default>
+            <value>{"name":"givenName","minimumLength":1,"maximumLength":64,"type":"text","required":true,"confirmationRequired":false,"readonly":false,"labels":{"":"First Name"},"regexErrors":{"":""},"description":{"":""},"selectOptions":{}}</value>
+            <value>{"name":"sn","minimumLength":1,"maximumLength":64,"type":"text","required":true,"confirmationRequired":false,"readonly":false,"labels":{"":"Last Name"},"regexErrors":{"":""},"description":{"":""},"selectOptions":{}}</value>
+            <value>{"name":"title","minimumLength":1,"maximumLength":64,"type":"text","required":true,"confirmationRequired":false,"readonly":false,"labels":{"":"Title"},"regexErrors":{"":""},"description":{"":""},"selectOptions":{}}</value>
+            <value>{"name":"mail","minimumLength":1,"maximumLength":64,"type":"text","required":true,"confirmationRequired":false,"readonly":false,"labels":{"":"Email"},"regexErrors":{"":""},"description":{"":""},"selectOptions":{}}</value>
+        </default>
+        <options>
+            <option value="text">text</option>
+            <option value="select">select</option>
+        </options>
+    </setting>
     <setting hidden="false" key="peopleSearch.searchFilter" level="2" required="false">
     <setting hidden="false" key="peopleSearch.searchFilter" level="2" required="false">
         <default/>
         <default/>
     </setting>
     </setting>
@@ -3268,6 +3282,11 @@
             <value>0</value>
             <value>0</value>
         </default>
         </default>
     </setting>
     </setting>
+    <setting hidden="false" key="peopleSearch.advancedSearch.enable" level="1">
+        <default>
+            <value>false</value>
+        </default>
+    </setting>
     <setting hidden="false" key="peopleSearch.orgChart.parentAttribute" level="1">
     <setting hidden="false" key="peopleSearch.orgChart.parentAttribute" level="1">
         <ldapPermission actor="self_other" access="read"/>
         <ldapPermission actor="self_other" access="read"/>
         <default>
         <default>
@@ -3392,6 +3411,28 @@
     <setting hidden="false" key="helpdesk.filter" level="1">
     <setting hidden="false" key="helpdesk.filter" level="1">
         <default/>
         <default/>
     </setting>
     </setting>
+    <setting hidden="false" key="helpdesk.search.form" level="1" required="true">
+        <flag>Form_HideStandardOptions</flag>
+        <ldapPermission actor="helpdesk" access="read"/>
+        <default>
+            <value>{"name":"cn","minimumLength":1,"maximumLength":64,"type":"text","required":true,"confirmationRequired":false,"readonly":false,"labels":{"":"Username"},"regexErrors":{"":""},"description":{"":""},"selectOptions":{}}</value>
+            <value>{"name":"givenName","minimumLength":1,"maximumLength":64,"type":"text","required":true,"confirmationRequired":false,"readonly":false,"labels":{"":"First Name"},"regexErrors":{"":""},"description":{"":""},"selectOptions":{}}</value>
+            <value>{"name":"sn","minimumLength":1,"maximumLength":64,"type":"text","required":true,"confirmationRequired":false,"readonly":false,"labels":{"":"Last Name"},"regexErrors":{"":""},"description":{"":""},"selectOptions":{}}</value>
+            <value>{"name":"mail","minimumLength":1,"maximumLength":64,"type":"email","required":true,"confirmationRequired":false,"readonly":false,"labels":{"":"Email"},"regexErrors":{"":""},"description":{"":""},"selectOptions":{}}</value>
+            <value>{"name":"workforceID","minimumLength":1,"maximumLength":64,"type":"text","required":true,"confirmationRequired":false,"readonly":false,"labels":{"":"Workforce ID"},"regexErrors":{"":""},"description":{"":""},"selectOptions":{}}</value>
+        </default>
+        <default template="AD">
+            <value>{"name":"sAMAccountName","minimumLength":1,"maximumLength":64,"type":"text","required":true,"confirmationRequired":false,"readonly":false,"labels":{"":"Username"},"regexErrors":{"":""},"description":{"":""},"selectOptions":{}}</value>
+            <value>{"name":"givenName","minimumLength":1,"maximumLength":64,"type":"text","required":true,"confirmationRequired":false,"readonly":false,"labels":{"":"First Name"},"regexErrors":{"":""},"description":{"":""},"selectOptions":{}}</value>
+            <value>{"name":"sn","minimumLength":1,"maximumLength":64,"type":"text","required":true,"confirmationRequired":false,"readonly":false,"labels":{"":"Last Name"},"regexErrors":{"":""},"description":{"":""},"selectOptions":{}}</value>
+            <value>{"name":"mail","minimumLength":1,"maximumLength":64,"type":"email","required":true,"confirmationRequired":false,"readonly":false,"labels":{"":"Email"},"regexErrors":{"":""},"description":{"":""},"selectOptions":{}}</value>
+            <value>{"name":"userPrincipalName","minimumLength":1,"maximumLength":64,"type":"text","required":true,"confirmationRequired":false,"readonly":false,"labels":{"":"UPN"},"regexErrors":{"":""},"description":{"":""},"selectOptions":{}}</value>
+        </default>
+        <options>
+            <option value="text">text</option>
+            <option value="select">select</option>
+        </options>
+    </setting>
     <setting hidden="false" key="helpdesk.result.form" level="1" required="true">
     <setting hidden="false" key="helpdesk.result.form" level="1" required="true">
         <flag>Form_HideOptions</flag>
         <flag>Form_HideOptions</flag>
         <ldapPermission actor="helpdesk" access="read"/>
         <ldapPermission actor="helpdesk" access="read"/>
@@ -3659,6 +3700,11 @@
             <value>true</value>
             <value>true</value>
         </default>
         </default>
     </setting>
     </setting>
+    <setting hidden="false" key="helpdesk.advancedSearch.enable" level="1">
+        <default>
+            <value>true</value>
+        </default>
+    </setting>
     <setting hidden="false" key="helpdesk.verificationMethods" level="1">
     <setting hidden="false" key="helpdesk.verificationMethods" level="1">
         <flag>Verification_HideMinimumOptional</flag>
         <flag>Verification_HideMinimumOptional</flag>
         <default>
         <default>

+ 10 - 2
server/src/main/resources/password/pwm/i18n/PwmSetting.properties

@@ -384,6 +384,7 @@ Setting_Description_guest.maxValidDays=Specify the maximum number of days before
 Setting_Description_guest.update.form=Specify the attributes and fields to update the New Guest form creation.
 Setting_Description_guest.update.form=Specify the attributes and fields to update the New Guest form creation.
 Setting_Description_guest.writeAttributes=Add actions @PwmAppName@ performs after it creates a guest user.  You can use macros.
 Setting_Description_guest.writeAttributes=Add actions @PwmAppName@ performs after it creates a guest user.  You can use macros.
 Setting_Description_helpdesk.actions=Add actions available to the Help Desk actor.  You can use macros.
 Setting_Description_helpdesk.actions=Add actions available to the Help Desk actor.  You can use macros.
+Setting_Description_helpdesk.advancedSearch.enable=Enable advanced searching user interface.  Allows operators to specify individual attributes for searching.
 Setting_Description_helpdesk.clearOtp.button=Eanble this option to allow the Help Desk operator to clear out a user's stored one-time password settings by clicking a button.
 Setting_Description_helpdesk.clearOtp.button=Eanble this option to allow the Help Desk operator to clear out a user's stored one-time password settings by clicking a button.
 Setting_Description_helpdesk.clearResponses=Enable this option to allow the Help Desk operator to clear out a user's stored responses after changing the user's password.
 Setting_Description_helpdesk.clearResponses=Enable this option to allow the Help Desk operator to clear out a user's stored responses after changing the user's password.
 Setting_Description_helpdesk.clearResponses.button=Enable this option to allow the Help Desk operator to clear out a user's stored responses by clicking a button.
 Setting_Description_helpdesk.clearResponses.button=Enable this option to allow the Help Desk operator to clear out a user's stored responses by clicking a button.
@@ -395,7 +396,7 @@ Setting_Description_helpdesk.enable=Enable this option to enable the Help Desk m
 Setting_Description_helpdesk.enablePhotos=Enable photos in helpdesk search screen 
 Setting_Description_helpdesk.enablePhotos=Enable photos in helpdesk search screen 
 Setting_Description_helpdesk.enableUnlock=Enable this option to enable the Help Desk module users to unlock an LDAP intruder locked account.
 Setting_Description_helpdesk.enableUnlock=Enable this option to enable the Help Desk module users to unlock an LDAP intruder locked account.
 Setting_Description_helpdesk.enforcePasswordPolicy=Enable this option to require that the passwords set by Help Desk must meet the same password policy that normally constrains the user.
 Setting_Description_helpdesk.enforcePasswordPolicy=Enable this option to require that the passwords set by Help Desk must meet the same password policy that normally constrains the user.
-Setting_Description_helpdesk.filter=Specify the LDAP search filter to query the directory.  Substitute <i>%USERNAME%</i> for user supplied user name.  If not specified, @PwmAppName@ auto calculates a search filter based on the Help Desk Search Form.<p>Examples<ul><li>Edirectory\: <code>(&(objectClass\=Person)(|((cn\=*%USERNAME%*)(uid\=*%USERNAME%*)(givenName\=*%USERNAME%*)(sn\=*%USERNAME%*))))</code></li><li>Active Directory\: <code>(&(objectClass\=Person)(|((cn\=*%USERNAME%*)(uid\=*%USERNAME%*)(sAMAccountName\=*%USERNAME%*)(userprincipalname\=*%USERNAME%*)(givenName\=*%USERNAME%*)(sn\=*%USERNAME%*))))</code></li></ul>
+Setting_Description_helpdesk.filter=Specify the LDAP search filter to query the directory.  Substitute <i>%USERNAME%</i> for user supplied user name.  If not specified, @PwmAppName@ auto calculates a search filter based on the Help Desk Search Results.<p>Examples<ul><li>Edirectory\: <code>(&(objectClass\=Person)(|((cn\=*%USERNAME%*)(uid\=*%USERNAME%*)(givenName\=*%USERNAME%*)(sn\=*%USERNAME%*))))</code></li><li>Active Directory\: <code>(&(objectClass\=Person)(|((cn\=*%USERNAME%*)(uid\=*%USERNAME%*)(sAMAccountName\=*%USERNAME%*)(userprincipalname\=*%USERNAME%*)(givenName\=*%USERNAME%*)(sn\=*%USERNAME%*))))</code></li></ul>
 Setting_Description_helpdesk.forcePwExpiration=Enable this option to force the system to expire the password for the users when the help desk operator sets a user's password.
 Setting_Description_helpdesk.forcePwExpiration=Enable this option to force the system to expire the password for the users when the help desk operator sets a user's password.
 Setting_Description_helpdesk.idleTimeout=Specify the number of seconds after which @PwmAppName@ unauthenticates an authenticated session.   @PwmAppName@ sets the session idle timeout to this value after a user successfully accesses the Help Desk module.
 Setting_Description_helpdesk.idleTimeout=Specify the number of seconds after which @PwmAppName@ unauthenticates an authenticated session.   @PwmAppName@ sets the session idle timeout to this value after a user successfully accesses the Help Desk module.
 Setting_Description_helpdesk.otp.verify=Enable this option to enable the OTP Verification.
 Setting_Description_helpdesk.otp.verify=Enable this option to enable the OTP Verification.
@@ -405,6 +406,7 @@ Setting_Description_helpdesk.result.form=Add the fields @PwmAppName@ shows as a
 Setting_Description_helpdesk.result.limit=Specify a limit of the results of help desk searches.
 Setting_Description_helpdesk.result.limit=Specify a limit of the results of help desk searches.
 Setting_Description_helpdesk.searchBase=Specify one or more LDAP search bases. If empty, @PwmAppName@ uses the default LDAP search base.
 Setting_Description_helpdesk.searchBase=Specify one or more LDAP search bases. If empty, @PwmAppName@ uses the default LDAP search base.
 Setting_Description_helpdesk.search.filters=Specify a filter to limit the help desk search.
 Setting_Description_helpdesk.search.filters=Specify a filter to limit the help desk search.
+Setting_Description_helpdesk.search.form=Specify the attributes used in searching.
 Setting_Description_helpdesk.sendPassword=Enable this option to send the password to the user using the method selected under <code>@PwmSettingReference\:recovery.sendNewPassword.sendMethod@</code>.
 Setting_Description_helpdesk.sendPassword=Enable this option to send the password to the user using the method selected under <code>@PwmSettingReference\:recovery.sendNewPassword.sendMethod@</code>.
 Setting_Description_helpdesk.setPassword.maskValue=Enable this option to have @PwmAppName@ mask the password for a user when they are entering it.
 Setting_Description_helpdesk.setPassword.maskValue=Enable this option to have @PwmAppName@ mask the password for a user when they are entering it.
 Setting_Description_helpdesk.setPassword.mode=Select the mode to allow Help Desk administrators to set passwords.  (Note the logged-in user must have the proper LDAP permissions.)
 Setting_Description_helpdesk.setPassword.mode=Select the mode to allow Help Desk administrators to set passwords.  (Note the logged-in user must have the proper LDAP permissions.)
@@ -575,6 +577,7 @@ Setting_Description_passwordSync.enableReplicaCheck=Enable this option to check
 Setting_Description_passwordSyncMaxWaitTime=Specify how long, during a password change, the system waits for the password to be synchronized to all configured LDAP servers.  In cases where the synchronization might take an extraordinary amount of time, this setting prevents the page from timing out.<br/><br/>Specify the value in seconds.
 Setting_Description_passwordSyncMaxWaitTime=Specify how long, during a password change, the system waits for the password to be synchronized to all configured LDAP servers.  In cases where the synchronization might take an extraordinary amount of time, this setting prevents the page from timing out.<br/><br/>Specify the value in seconds.
 Setting_Description_passwordSyncMinWaitTime=Specify how long, during a password change, the system waits before forwarding the user.  This gives any background synchronization processes time to execute before the user executes the next operation.<br/><br/>Specify the value in seconds.
 Setting_Description_passwordSyncMinWaitTime=Specify how long, during a password change, the system waits before forwarding the user.  This gives any background synchronization processes time to execute before the user executes the next operation.<br/><br/>Specify the value in seconds.
 Setting_Description_password.wordlist.wordSize=Specify the minimum number of characters in the password that @PwmAppName@ checks against the Word List dictionary. For example, if the password the system checks is "wordlist" and this setting is set to 6, then the combinations "wordli", "wordlis", "wordlist", "ordlis", "ordlist", and "rdlist" are all checked against the configured dictionary. If any of these values are equal to any word in the Word List dictionary, then the system considers the password to match the Word List and rejects it. If this value is set to zero or the password to check is smaller than the value specified here, then the system checks the entire password against the Word List but not any smaller parts of it.
 Setting_Description_password.wordlist.wordSize=Specify the minimum number of characters in the password that @PwmAppName@ checks against the Word List dictionary. For example, if the password the system checks is "wordlist" and this setting is set to 6, then the combinations "wordli", "wordlis", "wordlist", "ordlis", "ordlist", and "rdlist" are all checked against the configured dictionary. If any of these values are equal to any word in the Word List dictionary, then the system considers the password to match the Word List and rejects it. If this value is set to zero or the password to check is smaller than the value specified here, then the system checks the entire password against the Word List but not any smaller parts of it.
+Setting_Description_peopleSearch.advancedSearch.enable=Enable advanced searching user interface.  Allows users to specify individual attributes for searching.
 Setting_Description_peopleSearch.detail.form=Specify attributes to show in the detail view of an individual person's record.
 Setting_Description_peopleSearch.detail.form=Specify attributes to show in the detail view of an individual person's record.
 Setting_Description_peopleSearch.displayName.cardLabels=Specify the display labels for the user panel in the People Search detail and on the organizational chart views.  You can use LDAP attribute value such as <code>@LDAP\:givenName@</code> macros.
 Setting_Description_peopleSearch.displayName.cardLabels=Specify the display labels for the user panel in the People Search detail and on the organizational chart views.  You can use LDAP attribute value such as <code>@LDAP\:givenName@</code> macros.
 Setting_Description_peopleSearch.displayName.user=Specify the display name for userDN type records.  Use macros to control the presentation such as the LDAP attribute macro <code>@LDAP\:givenName@</code>.
 Setting_Description_peopleSearch.displayName.user=Specify the display name for userDN type records.  Use macros to control the presentation such as the LDAP attribute macro <code>@LDAP\:givenName@</code>.
@@ -595,6 +598,7 @@ Setting_Description_peopleSearch.queryMatch=Define an LDAP directory filter that
 Setting_Description_peopleSearch.result.form=Specify the attributes the People Search module shows in the search results table during searches.
 Setting_Description_peopleSearch.result.form=Specify the attributes the People Search module shows in the search results table during searches.
 Setting_Description_peopleSearch.result.limit=Specify the maximum number of records @PwmAppName@ returns while searching.
 Setting_Description_peopleSearch.result.limit=Specify the maximum number of records @PwmAppName@ returns while searching.
 Setting_Description_peopleSearch.searchAttributes=Add a list of LDAP attributes to search when generating an automatic search filter for the setting <a data-gotoSettingLink\="peopleSearch.searchFilter">@PwmSettingReference\:peopleSearch.searchFilter@</a>.  @PwmAppName@ also uses it to determine which fields in the user detail form it shows in the "Like" search option.
 Setting_Description_peopleSearch.searchAttributes=Add a list of LDAP attributes to search when generating an automatic search filter for the setting <a data-gotoSettingLink\="peopleSearch.searchFilter">@PwmSettingReference\:peopleSearch.searchFilter@</a>.  @PwmAppName@ also uses it to determine which fields in the user detail form it shows in the "Like" search option.
+Setting_Description_peopleSearch.search.form=Add a list of LDAP attributes to search when generating an automatic search filter for the setting <a data-gotoSettingLink\="peopleSearch.searchFilter">@PwmSettingReference\:peopleSearch.searchFilter@</a>.  @PwmAppName@ also uses it to determine which fields in the user detail form it shows in the "Like" search option.
 Setting_Description_peopleSearch.searchBase=Specify the LDAP search bases for the People Search module. If empty, @PwmAppName@ uses the default LDAP search bases.
 Setting_Description_peopleSearch.searchBase=Specify the LDAP search bases for the People Search module. If empty, @PwmAppName@ uses the default LDAP search bases.
 Setting_Description_peopleSearch.searchFilter=Specify the LDAP search filter the People Search module uses to query the directory.  Substitute <i>%USERNAME%</i> for user-supplied user names. If blank, @PwmAppName@ auto-generates the search filter based on the values in the setting <a data-gotoSettingLink\="peopleSearch.searchAttributes">@PwmSettingReference\:peopleSearch.searchAttributes@</a>.\n        <br/><br>\n        Example\: <code>(&(objectClass\=Person)(|(givenName\=*%USERNAME%*)(sn\=*%USERNAME%*)(mail\=*%USERNAME%*)(telephoneNumber\=*%USERNAME%*)))</code>\n\n
 Setting_Description_peopleSearch.searchFilter=Specify the LDAP search filter the People Search module uses to query the directory.  Substitute <i>%USERNAME%</i> for user-supplied user names. If blank, @PwmAppName@ auto-generates the search filter based on the values in the setting <a data-gotoSettingLink\="peopleSearch.searchAttributes">@PwmSettingReference\:peopleSearch.searchAttributes@</a>.\n        <br/><br>\n        Example\: <code>(&(objectClass\=Person)(|(givenName\=*%USERNAME%*)(sn\=*%USERNAME%*)(mail\=*%USERNAME%*)(telephoneNumber\=*%USERNAME%*)))</code>\n\n
 Setting_Description_peopleSearch.useProxy=Enable this option to use the LDAP proxy account to perform searches. For proper security in most environments, do <b>not</b> enable this setting.
 Setting_Description_peopleSearch.useProxy=Enable this option to use the LDAP proxy account to perform searches. For proper security in most environments, do <b>not</b> enable this setting.
@@ -889,6 +893,7 @@ Setting_Label_guest.maxValidDays=Maximum Duration of Account Validity
 Setting_Label_guest.update.form=Update Guest Form
 Setting_Label_guest.update.form=Update Guest Form
 Setting_Label_guest.writeAttributes=Guest Creation Actions
 Setting_Label_guest.writeAttributes=Guest Creation Actions
 Setting_Label_helpdesk.actions=Help Desk Actor Actions
 Setting_Label_helpdesk.actions=Help Desk Actor Actions
+Setting_Label_helpdesk.advancedSearch.enable=Enable Advanced Search
 Setting_Label_helpdesk.clearOtp.button=Enable Clear One Time Password Settings Button
 Setting_Label_helpdesk.clearOtp.button=Enable Clear One Time Password Settings Button
 Setting_Label_helpdesk.clearResponses.button=Enable Clear Responses Button
 Setting_Label_helpdesk.clearResponses.button=Enable Clear Responses Button
 Setting_Label_helpdesk.clearResponses=Clear Responses on Password Set
 Setting_Label_helpdesk.clearResponses=Clear Responses on Password Set
@@ -906,10 +911,11 @@ Setting_Label_helpdesk.idleTimeout=Idle Timeout Seconds for Help Desk Users
 Setting_Label_helpdesk.otp.verify=Enable OTP Verification Button
 Setting_Label_helpdesk.otp.verify=Enable OTP Verification Button
 Setting_Label_helpdesk.profile.list=Help Desk Profile
 Setting_Label_helpdesk.profile.list=Help Desk Profile
 Setting_Label_helpdesk.queryMatch=Help Desk Profile Match
 Setting_Label_helpdesk.queryMatch=Help Desk Profile Match
-Setting_Label_helpdesk.result.form=Help Desk Search Form
+Setting_Label_helpdesk.result.form=Help Desk Search Results
 Setting_Label_helpdesk.result.limit=Help Desk Search Result Limit
 Setting_Label_helpdesk.result.limit=Help Desk Search Result Limit
 Setting_Label_helpdesk.searchBase=LDAP Search Base
 Setting_Label_helpdesk.searchBase=LDAP Search Base
 Setting_Label_helpdesk.search.filters=Help Desk Profile Search Filter
 Setting_Label_helpdesk.search.filters=Help Desk Profile Search Filter
+Setting_Label_helpdesk.search.form=Help Desk Search Attributes
 Setting_Label_helpdesk.sendPassword=Send Password to User
 Setting_Label_helpdesk.sendPassword=Send Password to User
 Setting_Label_helpdesk.setPassword.maskValue=Mask Password Value
 Setting_Label_helpdesk.setPassword.maskValue=Mask Password Value
 Setting_Label_helpdesk.setPassword.mode=Set Password UI Mode
 Setting_Label_helpdesk.setPassword.mode=Set Password UI Mode
@@ -1080,6 +1086,7 @@ Setting_Label_passwordSync.enableReplicaCheck=Password Sync Enable Replication C
 Setting_Label_passwordSyncMaxWaitTime=Password Change Maximum Wait Time
 Setting_Label_passwordSyncMaxWaitTime=Password Change Maximum Wait Time
 Setting_Label_passwordSyncMinWaitTime=Password Change Minimum Wait Time
 Setting_Label_passwordSyncMinWaitTime=Password Change Minimum Wait Time
 Setting_Label_password.wordlist.wordSize=Word List Word Size Check
 Setting_Label_password.wordlist.wordSize=Word List Word Size Check
+Setting_Label_peopleSearch.advancedSearch.enable=Enable Advanced Search
 Setting_Label_peopleSearch.detail.form=Search Detail Attributes
 Setting_Label_peopleSearch.detail.form=Search Detail Attributes
 Setting_Label_peopleSearch.displayName.cardLabels=Person Detail Display Labels
 Setting_Label_peopleSearch.displayName.cardLabels=Person Detail Display Labels
 Setting_Label_peopleSearch.displayName.user=UserDN Name Display
 Setting_Label_peopleSearch.displayName.user=UserDN Name Display
@@ -1100,6 +1107,7 @@ Setting_Label_peopleSearch.queryMatch=Permitted Users
 Setting_Label_peopleSearch.result.form=Search Result Attributes
 Setting_Label_peopleSearch.result.form=Search Result Attributes
 Setting_Label_peopleSearch.result.limit=Search Result Limit
 Setting_Label_peopleSearch.result.limit=Search Result Limit
 Setting_Label_peopleSearch.searchAttributes=Search Attributes
 Setting_Label_peopleSearch.searchAttributes=Search Attributes
+Setting_Label_peopleSearch.search.form=Search Attributes
 Setting_Label_peopleSearch.searchBase=LDAP Search base
 Setting_Label_peopleSearch.searchBase=LDAP Search base
 Setting_Label_peopleSearch.searchFilter=People Search LDAP Filter
 Setting_Label_peopleSearch.searchFilter=People Search LDAP Filter
 Setting_Label_peopleSearch.useProxy=Use Proxy Account
 Setting_Label_peopleSearch.useProxy=Use Proxy Account

+ 7 - 4
webapp/src/main/webapp/public/resources/js/configeditor-settings-form.js

@@ -225,6 +225,8 @@ FormTableHandler.showOptionsDialog = function(keyName, iteration) {
     var type = PWM_VAR['clientSettingCache'][keyName][iteration]['type'];
     var type = PWM_VAR['clientSettingCache'][keyName][iteration]['type'];
     var settings = PWM_SETTINGS['settings'][keyName];
     var settings = PWM_SETTINGS['settings'][keyName];
     var currentValue = PWM_VAR['clientSettingCache'][keyName][iteration];
     var currentValue = PWM_VAR['clientSettingCache'][keyName][iteration];
+    var options = PWM_SETTINGS['settings'][keyName]['options'];
+
 
 
     var hideStandardOptions = PWM_MAIN.JSLibrary.arrayContains(settings['flags'],'Form_HideStandardOptions') || type === 'photo';
     var hideStandardOptions = PWM_MAIN.JSLibrary.arrayContains(settings['flags'],'Form_HideStandardOptions') || type === 'photo';
     var showRequired = PWM_MAIN.JSLibrary.arrayContains(settings['flags'],'Form_ShowRequiredOption');
     var showRequired = PWM_MAIN.JSLibrary.arrayContains(settings['flags'],'Form_ShowRequiredOption');
@@ -286,10 +288,11 @@ FormTableHandler.showOptionsDialog = function(keyName, iteration) {
         bodyText += '</tr><tr>';
         bodyText += '</tr><tr>';
         bodyText += '<td id="' + inputID + '-label-js" class="key" title="' + PWM_CONFIG.showString('Tooltip_FormOptions_Javascript') + '">JavaScript (Depreciated)</td><td><input type="text" class="configStringInput" style="width:300px" id="' + inputID + 'javascript' + '"/></td>';
         bodyText += '<td id="' + inputID + '-label-js" class="key" title="' + PWM_CONFIG.showString('Tooltip_FormOptions_Javascript') + '">JavaScript (Depreciated)</td><td><input type="text" class="configStringInput" style="width:300px" id="' + inputID + 'javascript' + '"/></td>';
         bodyText += '</tr><tr>';
         bodyText += '</tr><tr>';
-        if (currentValue['type'] === 'select') {
-            bodyText += '<td class="key">Select Options</td><td><button class="btn" id="' + inputID + 'editOptionsButton"><span class="btn-icon pwm-icon pwm-icon-list-ul"/> Edit</button></td>';
-            bodyText += '</tr>';
-        }
+    }
+
+    if ('select' in options) {
+        bodyText += '<td class="key">Select Options</td><td><button class="btn" id="' + inputID + 'editOptionsButton"><span class="btn-icon pwm-icon pwm-icon-list-ul"/> Edit</button></td>';
+        bodyText += '</tr>';
     }
     }
 
 
     if (showSource) {
     if (showSource) {

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác