Browse Source

Merge branch 'master' of github.com:pwm-project/pwm into ng-helpdesk

Joseph White 7 năm trước cách đây
mục cha
commit
dc8d2bc44c
37 tập tin đã thay đổi với 673 bổ sung436 xóa
  1. 4 4
      client/package-lock.json
  2. 1 0
      client/package.json
  3. 2 2
      client/src/peoplesearch/person-card.component.html
  4. 1 4
      onejar/pom.xml
  5. 3 15
      server/pom.xml
  6. 4 3
      server/src/main/java/password/pwm/AppProperty.java
  7. 1 0
      server/src/main/java/password/pwm/bean/SessionLabel.java
  8. 3 22
      server/src/main/java/password/pwm/config/Configuration.java
  9. 27 2
      server/src/main/java/password/pwm/health/ConfigurationChecker.java
  10. 1 0
      server/src/main/java/password/pwm/health/HealthMessage.java
  11. 0 1
      server/src/main/java/password/pwm/http/JspUrl.java
  12. 0 2
      server/src/main/java/password/pwm/http/servlet/PwmServletDefinition.java
  13. 125 1
      server/src/main/java/password/pwm/http/servlet/admin/AdminServlet.java
  14. 0 103
      server/src/main/java/password/pwm/http/servlet/configmanager/ConfigManagerPwNotifyServlet.java
  15. 2 2
      server/src/main/java/password/pwm/http/servlet/forgottenpw/ForgottenPasswordServlet.java
  16. 114 21
      server/src/main/java/password/pwm/svc/pwnotify/PwNotifyEngine.java
  17. 130 17
      server/src/main/java/password/pwm/svc/pwnotify/PwNotifyService.java
  18. 17 4
      server/src/main/java/password/pwm/svc/pwnotify/PwNotifySettings.java
  19. 1 0
      server/src/main/java/password/pwm/svc/pwnotify/StoredJobState.java
  20. 3 0
      server/src/main/java/password/pwm/svc/stats/Statistic.java
  21. 12 0
      server/src/main/java/password/pwm/util/LocaleHelper.java
  22. 0 2
      server/src/main/java/password/pwm/util/cli/MainClass.java
  23. 0 53
      server/src/main/java/password/pwm/util/cli/commands/PasswordExpireNotificationCommand.java
  24. 2 0
      server/src/main/java/password/pwm/util/db/DatabaseAccessor.java
  25. 14 0
      server/src/main/java/password/pwm/util/db/DatabaseAccessorImpl.java
  26. 10 1
      server/src/main/java/password/pwm/util/java/TimeDuration.java
  27. 3 0
      server/src/main/resources/password/pwm/AppProperty.properties
  28. 6 0
      server/src/main/resources/password/pwm/i18n/Admin.properties
  29. 2 1
      server/src/main/resources/password/pwm/i18n/Health.properties
  30. 4 4
      server/src/main/resources/password/pwm/i18n/PwmSetting.properties
  31. 83 47
      server/src/main/webapp/WEB-INF/jsp/admin-dashboard.jsp
  32. 0 116
      server/src/main/webapp/WEB-INF/jsp/configmanager-pwnotify.jsp
  33. 0 6
      server/src/main/webapp/WEB-INF/jsp/fragment/configmanager-nav.jsp
  34. 1 1
      server/src/main/webapp/WEB-INF/jsp/fragment/footer.jsp
  35. 87 0
      server/src/main/webapp/public/resources/js/admin.js
  36. 6 2
      server/src/main/webapp/public/resources/js/configeditor-settings-form.js
  37. 4 0
      server/src/main/webapp/public/resources/style.css

+ 4 - 4
client/package-lock.json

@@ -7231,9 +7231,9 @@
       }
     },
     "moment": {
-      "version": "2.18.1",
-      "resolved": "https://registry.npmjs.org/moment/-/moment-2.18.1.tgz",
-      "integrity": "sha1-w2GT3Tzhwu7SrbfIAtu8d6gbHA8=",
+      "version": "2.21.0",
+      "resolved": "https://registry.npmjs.org/moment/-/moment-2.21.0.tgz",
+      "integrity": "sha512-TCZ36BjURTeFTM/CwRcViQlfkMvL1/vFISuNLO5GkcVm1+QHfbSiNqZuWeMFjj1/3+uAjXswgRk30j1kkLYJBQ==",
       "dev": true
     },
     "move-concurrently": {
@@ -13415,7 +13415,7 @@
         "filesize": "3.5.10",
         "lodash": "4.17.4",
         "mkdirp": "0.5.1",
-        "moment": "2.18.1"
+        "moment": "2.21.0"
       },
       "dependencies": {
         "debug": {

+ 1 - 0
client/package.json

@@ -53,6 +53,7 @@
         "karma-sourcemap-loader": "0.3.7",
         "karma-spec-reporter": "0.0.32",
         "karma-webpack": "2.0.9",
+        "moment": "2.21.0",
         "ngtemplate-loader": "2.0.1",
         "node-sass": "4.7.2",
         "phantomjs": "2.1.7",

+ 2 - 2
client/src/peoplesearch/person-card.component.html

@@ -41,13 +41,13 @@
         <h3 class="single-line" ng-bind="$ctrl.person.displayNames[0]"
             ng-attr-title="{{$ctrl.person.displayNames[0]}}"></h3>
         <div class="single-line" ng-bind="displayName" ng-attr-title="{{displayName}}"
-            ng-repeat="displayName in $ctrl.person.displayNames.slice(1, 8)"></div>
+            ng-repeat="displayName in $ctrl.person.displayNames.slice(1, 8) track by $index"></div>
     </div>
 
     <div class="ias-tile-content" ng-class="{'direct-reports': $ctrl.numDirectReportsVisible}" ng-switch-default>
         <h3 class="single-line" ng-bind="$ctrl.person.displayNames[0]"
             ng-attr-title="{{$ctrl.person.displayNames[0]}}"></h3>
         <div class="single-line" ng-bind="displayName" ng-attr-title="{{displayName}}"
-             ng-repeat="displayName in $ctrl.person.displayNames.slice(1, 4)"></div>
+             ng-repeat="displayName in $ctrl.person.displayNames.slice(1, 4) track by $index"></div>
     </div>
 </div>

+ 1 - 4
onejar/pom.xml

@@ -16,13 +16,10 @@
     <name>PWM Password Self Service: Executable Jar</name>
 
     <properties>
-        <tomcat.version>9.0.5</tomcat.version>
+        <tomcat.version>9.0.6</tomcat.version>
         <maven.compiler.source>1.8</maven.compiler.source>
         <maven.compiler.target>1.8</maven.compiler.target>
-
-
         <warArtifactID>pwm-${project.version}.war</warArtifactID>
-
     </properties>
 
     <build>

+ 3 - 15
server/pom.xml

@@ -743,7 +743,7 @@
         <dependency>
             <groupId>org.jetbrains.xodus</groupId>
             <artifactId>xodus-environment</artifactId>
-            <version>1.2.1</version>
+            <version>1.2.2</version>
         </dependency>
         <dependency>
             <groupId>org.slf4j</groupId>
@@ -816,7 +816,7 @@
             <version>1.0.0</version>
         </dependency>
         <dependency>
-            <groupId>org.webjars.npm</groupId>
+            <groupId>org.webjars.bower</groupId>
             <artifactId>angular</artifactId>
             <version>1.6.9</version>
         </dependency>
@@ -831,26 +831,14 @@
             <version>1.0.14</version>
         </dependency>
         <dependency>
-            <groupId>org.webjars.npm</groupId>
+            <groupId>org.webjars.bower</groupId>
             <artifactId>angular-translate</artifactId>
             <version>2.17.0</version>
-            <exclusions>
-                <exclusion>
-                    <groupId>org.webjars.npm</groupId>
-                    <artifactId>angular</artifactId>
-                </exclusion>
-            </exclusions>
         </dependency>
         <dependency>
             <groupId>org.webjars.bower</groupId>
             <artifactId>textAngular</artifactId>
             <version>1.5.16</version>
-            <exclusions>
-                <exclusion>
-                    <groupId>org.webjars.bower</groupId>
-                    <artifactId>angular</artifactId>
-                </exclusion>
-            </exclusions>
         </dependency>
     </dependencies>
 

+ 4 - 3
server/src/main/java/password/pwm/AppProperty.java

@@ -255,9 +255,10 @@ public enum AppProperty
     PASSWORD_STRENGTH_THRESHOLD_GOOD                ( "password.strength.threshold.good" ),
     PASSWORD_STRENGTH_THRESHOLD_WEAK                ( "password.strength.threshold.weak" ),
     PASSWORD_STRENGTH_THRESHOLD_VERY_WEAK           ( "password.strength.threshold.veryWeak" ),
-
-    PWNOTIFY__MAX_LDAP_SEARCH_SIZE                  ( "pwNotify.maxLdapSearchSize" ),
-
+    PWNOTIFY_BATCH_COUNT                            ( "pwNotify.batch.count" ),
+    PWNOTIFY_BATCH_DELAY_TIME_MULTIPLIER            ( "pwNotify.batch.delayTimeMultiplier" ),
+    PWNOTIFY_MAX_LDAP_SEARCH_SIZE                   ( "pwNotify.maxLdapSearchSize" ),
+    PWNOTIFY_MAX_SKIP_RERUN_WINDOW_SECONDS          ( "pwNotify.maxSkipRerunWindowSeconds" ),
     PEOPLESEARCH_MAX_VALUE_VERIFYUSERDN             ( "peoplesearch.values.verifyUserDN" ),
     PEOPLESEARCH_VALUE_MAXCOUNT                     ( "peoplesearch.values.maxCount" ),
     PEOPLESEARCH_VIEW_DETAIL_LINKS                  ( "peoplesearch.view.detail.links" ),

+ 1 - 0
server/src/main/java/password/pwm/bean/SessionLabel.java

@@ -40,6 +40,7 @@ public class SessionLabel implements Serializable
     public static final SessionLabel REPORTING_SESSION_LABEL = new SessionLabel( SESSION_LABEL_SESSION_ID, null, "reporting", null, null );
     public static final SessionLabel AUDITING_SESSION_LABEL = new SessionLabel( SESSION_LABEL_SESSION_ID, null, "auditing", null, null );
     public static final SessionLabel TELEMETRY_SESSION_LABEL = new SessionLabel( SESSION_LABEL_SESSION_ID, null, "telemetry", null, null );
+    public static final SessionLabel PWNOTIFY_SESSION_LABEL = new SessionLabel( SESSION_LABEL_SESSION_ID, null, "pwnotify", null, null );
 
     private final String sessionID;
     private final UserIdentity userIdentity;

+ 3 - 22
server/src/main/java/password/pwm/config/Configuration.java

@@ -918,15 +918,15 @@ public class Configuration implements SettingReader
 
     public boolean hasDbConfigured( )
     {
-        if ( readSettingAsString( PwmSetting.DATABASE_CLASS ) == null || readSettingAsString( PwmSetting.DATABASE_CLASS ).length() < 1 )
+        if ( StringUtil.isEmpty( readSettingAsString( PwmSetting.DATABASE_CLASS ) ) )
         {
             return false;
         }
-        if ( readSettingAsString( PwmSetting.DATABASE_URL ) == null || readSettingAsString( PwmSetting.DATABASE_URL ).length() < 1 )
+        if ( StringUtil.isEmpty( readSettingAsString( PwmSetting.DATABASE_URL ) ) )
         {
             return false;
         }
-        if ( readSettingAsString( PwmSetting.DATABASE_USERNAME ) == null || readSettingAsString( PwmSetting.DATABASE_USERNAME ).length() < 1 )
+        if ( StringUtil.isEmpty( readSettingAsString( PwmSetting.DATABASE_USERNAME ) ) )
         {
             return false;
         }
@@ -1003,25 +1003,6 @@ public class Configuration implements SettingReader
             }
             return writeMethods;
         }
-
-        public boolean shouldHaveDbConfigured( )
-        {
-            final PwmSetting[] settingsToCheck = new PwmSetting[] {
-                    PwmSetting.FORGOTTEN_PASSWORD_READ_PREFERENCE,
-                    PwmSetting.FORGOTTEN_PASSWORD_WRITE_PREFERENCE,
-                    PwmSetting.INTRUDER_STORAGE_METHOD,
-                    PwmSetting.EVENTS_USER_STORAGE_METHOD,
-            };
-
-            for ( final PwmSetting loopSetting : settingsToCheck )
-            {
-                if ( getResponseStorageLocations( loopSetting ).contains( DataStorageMethod.DB ) )
-                {
-                    return true;
-                }
-            }
-            return false;
-        }
     }
 
     private StoredValue readStoredValue( final PwmSetting setting )

+ 27 - 2
server/src/main/java/password/pwm/health/ConfigurationChecker.java

@@ -53,6 +53,7 @@ import java.net.URISyntaxException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Set;
@@ -324,9 +325,32 @@ public class ConfigurationChecker implements HealthChecker
             final List<HealthRecord> records = new ArrayList<>();
             if ( !config.hasDbConfigured() )
             {
-                if ( config.helper().shouldHaveDbConfigured() )
+                final Set<PwmSetting> causalSettings = new LinkedHashSet<>();
                 {
-                    records.add( HealthRecord.forMessage( HealthMessage.Config_MissingDB ) );
+                    final PwmSetting[] settingsToCheck = new PwmSetting[] {
+                            PwmSetting.FORGOTTEN_PASSWORD_READ_PREFERENCE,
+                            PwmSetting.FORGOTTEN_PASSWORD_WRITE_PREFERENCE,
+                            PwmSetting.INTRUDER_STORAGE_METHOD,
+                            PwmSetting.EVENTS_USER_STORAGE_METHOD,
+                    };
+
+                    for ( final PwmSetting loopSetting : settingsToCheck )
+                    {
+                        if ( config.getResponseStorageLocations( loopSetting ).contains( DataStorageMethod.DB ) )
+                        {
+                            causalSettings.add( loopSetting );
+                        }
+                    }
+                }
+
+                if ( config.readSettingAsBoolean( PwmSetting.PW_EXPY_NOTIFY_ENABLE ) )
+                {
+                    causalSettings.add( PwmSetting.PW_EXPY_NOTIFY_ENABLE );
+                }
+
+                for ( final PwmSetting setting : causalSettings )
+                {
+                    records.add( HealthRecord.forMessage( HealthMessage.Config_MissingDB, setting.toMenuLocationDebug( null, locale ) ) );
                 }
             }
 
@@ -343,6 +367,7 @@ public class ConfigurationChecker implements HealthChecker
                         HealthMessage.Config_UsingLocalDBResponseStorage,
                         PwmSetting.OTP_SECRET_WRITE_PREFERENCE.toMenuLocationDebug( null, locale ) ) );
             }
+
             return records;
         }
     }

+ 1 - 0
server/src/main/java/password/pwm/health/HealthMessage.java

@@ -43,6 +43,7 @@ public enum HealthMessage
     LDAP_TestUserReadPwError( HealthStatus.WARN, HealthTopic.LDAP ),
     LDAP_TestUserOK( HealthStatus.GOOD, HealthTopic.LDAP ),
     Email_SendFailure( HealthStatus.WARN, HealthTopic.Email ),
+    PwNotify_Failure( HealthStatus.WARN, HealthTopic.Email ),
     MissingResource( HealthStatus.DEBUG, HealthTopic.Integrity ),
     BrokenMethod( HealthStatus.DEBUG, HealthTopic.Integrity ),
     Appliance_PendingUpdates( HealthStatus.CAUTION, HealthTopic.Appliance ),

+ 0 - 1
server/src/main/java/password/pwm/http/JspUrl.java

@@ -92,7 +92,6 @@ public enum JspUrl
     CONFIG_MANAGER_PERMISSIONS( "configmanager-permissions.jsp" ),
     CONFIG_MANAGER_MODE_CONFIGURATION( "configmanager.jsp" ),
     CONFIG_MANAGER_WORDLISTS( "configmanager-wordlists.jsp" ),
-    CONFIG_MANAGER_PWNOTIFY( "configmanager-pwnotify.jsp" ),
     CONFIG_MANAGER_CERTIFICATES( "configmanager-certificates.jsp" ),
     CONFIG_MANAGER_LOCALDB( "configmanager-localdb.jsp" ),
     CONFIG_MANAGER_LOGIN( "configmanager-login.jsp" ),

+ 0 - 2
server/src/main/java/password/pwm/http/servlet/PwmServletDefinition.java

@@ -50,7 +50,6 @@ import password.pwm.http.servlet.configeditor.ConfigEditorServlet;
 import password.pwm.http.servlet.configguide.ConfigGuideServlet;
 import password.pwm.http.servlet.configmanager.ConfigManagerCertificatesServlet;
 import password.pwm.http.servlet.configmanager.ConfigManagerLocalDBServlet;
-import password.pwm.http.servlet.configmanager.ConfigManagerPwNotifyServlet;
 import password.pwm.http.servlet.configmanager.ConfigManagerServlet;
 import password.pwm.http.servlet.configmanager.ConfigManagerWordlistServlet;
 import password.pwm.http.servlet.newuser.NewUserServlet;
@@ -93,7 +92,6 @@ public enum PwmServletDefinition
     ConfigManager_Wordlists( ConfigManagerWordlistServlet.class, ConfigManagerBean.class ),
     ConfigManager_LocalDB( ConfigManagerLocalDBServlet.class, ConfigManagerBean.class ),
     ConfigManager_Certificates( ConfigManagerCertificatesServlet.class, ConfigManagerBean.class ),
-    ConfigManager_PwNotify( ConfigManagerPwNotifyServlet.class, ConfigManagerBean.class ),
 
     NewUser( NewUserServlet.class, NewUserBean.class ),
     ActivateUser( ActivateUserServlet.class, ActivateUserBean.class ),

+ 125 - 1
server/src/main/java/password/pwm/http/servlet/admin/AdminServlet.java

@@ -23,12 +23,15 @@
 package password.pwm.http.servlet.admin;
 
 import com.novell.ldapchai.exception.ChaiUnavailableException;
+import lombok.Value;
 import password.pwm.AppProperty;
 import password.pwm.Permission;
 import password.pwm.PwmApplication;
 import password.pwm.PwmConstants;
 import password.pwm.bean.UserIdentity;
 import password.pwm.bean.pub.SessionStateInfoBean;
+import password.pwm.config.Configuration;
+import password.pwm.config.PwmSetting;
 import password.pwm.error.ErrorInformation;
 import password.pwm.error.PwmError;
 import password.pwm.error.PwmException;
@@ -43,6 +46,7 @@ import password.pwm.http.PwmRequest;
 import password.pwm.http.PwmRequestAttribute;
 import password.pwm.http.PwmURL;
 import password.pwm.http.bean.AdminBean;
+import password.pwm.http.bean.DisplayElement;
 import password.pwm.http.servlet.AbstractPwmServlet;
 import password.pwm.http.servlet.ControlledPwmServlet;
 import password.pwm.http.servlet.PwmServletDefinition;
@@ -51,11 +55,15 @@ import password.pwm.ldap.search.UserSearchEngine;
 import password.pwm.svc.event.AuditEvent;
 import password.pwm.svc.event.AuditRecord;
 import password.pwm.svc.intruder.RecordType;
+import password.pwm.svc.pwnotify.PwNotifyService;
+import password.pwm.svc.pwnotify.StoredJobState;
 import password.pwm.svc.report.ReportColumnFilter;
 import password.pwm.svc.report.ReportCsvUtility;
 import password.pwm.svc.report.ReportService;
 import password.pwm.svc.report.UserCacheRecord;
 import password.pwm.svc.stats.StatisticsManager;
+import password.pwm.util.LocaleHelper;
+import password.pwm.util.db.DatabaseException;
 import password.pwm.util.java.ClosableIterator;
 import password.pwm.util.java.JavaHelper;
 import password.pwm.util.java.JsonUtil;
@@ -71,6 +79,7 @@ import javax.servlet.ServletException;
 import javax.servlet.annotation.WebServlet;
 import java.io.IOException;
 import java.io.OutputStream;
+import java.io.Serializable;
 import java.io.Writer;
 import java.lang.management.ManagementFactory;
 import java.lang.management.ThreadInfo;
@@ -83,6 +92,7 @@ import java.util.HashMap;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.TreeMap;
 import java.util.concurrent.TimeUnit;
 
@@ -115,7 +125,10 @@ public class AdminServlet extends ControlledPwmServlet
         auditData( HttpMethod.GET ),
         sessionData( HttpMethod.GET ),
         intruderData( HttpMethod.GET ),
-        statistics( HttpMethod.GET ),;
+        statistics( HttpMethod.GET ),
+        startPwNotifyJob( HttpMethod.POST ),
+        readPwNotifyStatus( HttpMethod.POST ),
+        readPwNotifyLog( HttpMethod.POST ),;
 
         private final Collection<HttpMethod> method;
 
@@ -682,5 +695,116 @@ public class AdminServlet extends ControlledPwmServlet
         }
     }
 
+    @ActionHandler( action = "readPwNotifyStatus" )
+    public ProcessStatus restreadPwNotifyStatus( final PwmRequest pwmRequest ) throws IOException, DatabaseException, PwmUnrecoverableException
+    {
+        int key = 0;
+        if ( !pwmRequest.getConfig().readSettingAsBoolean( PwmSetting.PW_EXPY_NOTIFY_ENABLE ) )
+        {
+            final DisplayElement displayElement = new DisplayElement( String.valueOf( key++ ), DisplayElement.Type.string, "Status",
+                    "Password Notification Feature is not enabled.  See setting: "
+                    + PwmSetting.PW_EXPY_NOTIFY_ENABLE.toMenuLocationDebug( null, pwmRequest.getLocale() ) );
+            pwmRequest.outputJsonResult( RestResultBean.withData( new PwNotifyStatusBean( Collections.singletonList( displayElement ), false ) ) );
+            return ProcessStatus.Halt;
+        }
+
+
+        {
+            ErrorInformation errorInformation = null;
+            try
+            {
+                if ( !pwmRequest.getPwmApplication().getDatabaseService().getAccessor().isConnected() )
+                {
+                    errorInformation = new ErrorInformation( PwmError.ERROR_DB_UNAVAILABLE, "database is not connected" );
+                }
+            }
+            catch ( PwmUnrecoverableException e )
+            {
+                errorInformation = e.getErrorInformation();
+            }
+
+            if ( errorInformation != null )
+            {
+                final DisplayElement displayElement = new DisplayElement( String.valueOf( key++ ), DisplayElement.Type.string, "Status",
+                        "Database must be functioning to view Password Notify status.  Current database error: "
+                                + errorInformation.toDebugStr() );
+                pwmRequest.outputJsonResult( RestResultBean.withData( new PwNotifyStatusBean( Collections.singletonList( displayElement ), false ) ) );
+                return ProcessStatus.Halt;
+            }
+        }
+
+        final List<DisplayElement> statusData = new ArrayList<>( );
+        final Configuration config = pwmRequest.getConfig();
+        final Locale locale = pwmRequest.getLocale();
+        final PwNotifyService pwNotifyService = pwmRequest.getPwmApplication().getPwNotifyService();
+        final StoredJobState storedJobState = pwNotifyService.getJobState();
+        final boolean canRunOnthisServer = pwNotifyService.canRunOnThisServer();
+
+        statusData.add( new DisplayElement( String.valueOf( key++ ), DisplayElement.Type.string,
+                "Currently Processing (on this server)", LocaleHelper.booleanString( pwNotifyService.isRunning(), locale, config ) ) );
+
+        statusData.add( new DisplayElement( String.valueOf( key++ ), DisplayElement.Type.string,
+                "This Server is the Job Processor", LocaleHelper.booleanString( canRunOnthisServer, locale, config ) ) );
+
+        if ( canRunOnthisServer )
+        {
+            statusData.add( new DisplayElement( String.valueOf( key++ ), DisplayElement.Type.timestamp,
+                    "Next Job Scheduled Time", LocaleHelper.instantString( pwNotifyService.getNextExecutionTime(), locale, config ) ) );
+        }
+
+        if ( storedJobState != null )
+        {
+            statusData.add( new DisplayElement( String.valueOf( key++ ), DisplayElement.Type.timestamp,
+                    "Last Job Start Time", LocaleHelper.instantString( storedJobState.getLastStart(), locale, config ) ) );
+
+            statusData.add( new DisplayElement( String.valueOf( key++ ), DisplayElement.Type.timestamp,
+                    "Last Job Completion Time", LocaleHelper.instantString( storedJobState.getLastCompletion(), locale, config ) ) );
+
+            if ( storedJobState.getLastStart() != null && storedJobState.getLastCompletion() != null )
+            {
+                statusData.add( new DisplayElement( String.valueOf( key++ ), DisplayElement.Type.timestamp,
+                        "Last Job Duration", TimeDuration.between( storedJobState.getLastStart(), storedJobState.getLastCompletion() ).asLongString( locale ) ) );
+            }
+
+            statusData.add( new DisplayElement( String.valueOf( key++ ), DisplayElement.Type.string,
+                    "Last Job Server Instance",  storedJobState.getServerInstance() ) );
+
+            if ( storedJobState.getLastError() != null )
+            {
+                statusData.add( new DisplayElement( String.valueOf( key++ ), DisplayElement.Type.string,
+                        "Last Job Error",  storedJobState.getLastError().toDebugStr() ) );
+            }
+        }
+
+        final boolean startButtonEnabled = !pwNotifyService.isRunning() && canRunOnthisServer;
+        final PwNotifyStatusBean pwNotifyStatusBean = new PwNotifyStatusBean( statusData, startButtonEnabled );
+        pwmRequest.outputJsonResult( RestResultBean.withData( pwNotifyStatusBean ) );
+        return ProcessStatus.Halt;
+    }
+
+    @ActionHandler( action = "readPwNotifyLog" )
+    public ProcessStatus restreadPwNotifyLog( final PwmRequest pwmRequest ) throws IOException, DatabaseException, PwmUnrecoverableException
+    {
+        final PwNotifyService pwNotifyService = pwmRequest.getPwmApplication().getPwNotifyService();
+
+        pwmRequest.outputJsonResult( RestResultBean.withData( pwNotifyService.debugLog() ) );
+        return ProcessStatus.Halt;
+    }
+
+    @ActionHandler( action = "startPwNotifyJob" )
+    public ProcessStatus restStartPwNotifyJob( final PwmRequest pwmRequest ) throws IOException
+    {
+        pwmRequest.getPwmApplication().getPwNotifyService().executeJob();
+        pwmRequest.outputJsonResult( RestResultBean.forSuccessMessage( pwmRequest, Message.Success_Unknown ) );
+        return ProcessStatus.Halt;
+    }
+
+    @Value
+    public static class PwNotifyStatusBean implements Serializable
+    {
+        private List<DisplayElement> statusData;
+        private boolean enableStartButton;
+    }
 
 }
+

+ 0 - 103
server/src/main/java/password/pwm/http/servlet/configmanager/ConfigManagerPwNotifyServlet.java

@@ -1,103 +0,0 @@
-/*
- * 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.configmanager;
-
-import com.novell.ldapchai.exception.ChaiUnavailableException;
-import password.pwm.PwmConstants;
-import password.pwm.error.PwmUnrecoverableException;
-import password.pwm.http.HttpMethod;
-import password.pwm.http.JspUrl;
-import password.pwm.http.ProcessStatus;
-import password.pwm.http.PwmRequest;
-import password.pwm.http.servlet.ControlledPwmServlet;
-import password.pwm.http.servlet.PwmServletDefinition;
-import password.pwm.i18n.Message;
-import password.pwm.util.logging.PwmLogger;
-import password.pwm.ws.server.RestResultBean;
-
-import javax.servlet.ServletException;
-import javax.servlet.annotation.WebServlet;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Collections;
-
-@WebServlet(
-        name = "ConfigManagerPwNotifyServlet",
-        urlPatterns = {
-                PwmConstants.URL_PREFIX_PRIVATE + "/config/manager/pwnotify",
-        }
-)
-public class ConfigManagerPwNotifyServlet extends ControlledPwmServlet
-{
-    private static final PwmLogger LOGGER = PwmLogger.forClass( ConfigManagerPwNotifyServlet.class );
-
-    public enum PwNotifyAction implements ProcessAction
-    {
-        startJob( HttpMethod.POST ),;
-
-        private final HttpMethod method;
-
-        PwNotifyAction( final HttpMethod method )
-        {
-            this.method = method;
-        }
-
-        public Collection<HttpMethod> permittedMethods( )
-        {
-            return Collections.singletonList( method );
-        }
-    }
-
-    @Override
-    protected PwmServletDefinition getServletDefinition( )
-    {
-        return super.getServletDefinition();
-    }
-
-    @Override
-    public Class<? extends ProcessAction> getProcessActionsClass( )
-    {
-        return PwNotifyAction.class;
-    }
-
-    @Override
-    protected void nextStep( final PwmRequest pwmRequest ) throws PwmUnrecoverableException, IOException, ChaiUnavailableException, ServletException
-    {
-        pwmRequest.forwardToJsp( JspUrl.CONFIG_MANAGER_PWNOTIFY );
-    }
-
-    @Override
-    public ProcessStatus preProcessCheck( final PwmRequest pwmRequest ) throws PwmUnrecoverableException, IOException, ServletException
-    {
-        return ProcessStatus.Continue;
-    }
-
-    @ActionHandler( action = "startJob" )
-    public ProcessStatus restStartJob( final PwmRequest pwmRequest ) throws IOException
-    {
-        pwmRequest.getPwmApplication().getPwNotifyService().runJob();
-        pwmRequest.outputJsonResult( RestResultBean.forSuccessMessage( pwmRequest, Message.Success_Unknown ) );
-        return ProcessStatus.Continue;
-    }
-}
-

+ 2 - 2
server/src/main/java/password/pwm/http/servlet/forgottenpw/ForgottenPasswordServlet.java

@@ -1029,8 +1029,8 @@ public class ForgottenPasswordServlet extends ControlledPwmServlet
                 );
                 if ( remainingAvailableOptionalMethods.isEmpty() )
                 {
-                    final String errorMsg = "additional optional verification methods are needed, however all available optional verification methods have been satisified by user";
-                    final ErrorInformation errorInformation = new ErrorInformation( PwmError.ERROR_TOKEN_MISSING_CONTACT, errorMsg );
+                    final String errorMsg = "additional optional verification methods are needed, however all available optional verification methods have been satisfied by user";
+                    final ErrorInformation errorInformation = new ErrorInformation( PwmError.ERROR_RECOVERY_SEQUENCE_INCOMPLETE, errorMsg );
                     LOGGER.error( pwmRequest, errorInformation );
                     pwmRequest.respondWithError( errorInformation );
                     return;

+ 114 - 21
server/src/main/java/password/pwm/svc/pwnotify/PwNotifyEngine.java

@@ -31,12 +31,18 @@ import password.pwm.bean.EmailItemBean;
 import password.pwm.bean.SessionLabel;
 import password.pwm.bean.UserIdentity;
 import password.pwm.config.PwmSetting;
+import password.pwm.config.value.data.UserPermission;
 import password.pwm.error.PwmError;
 import password.pwm.error.PwmOperationalException;
 import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.ldap.LdapOperationsHelper;
+import password.pwm.ldap.LdapPermissionTester;
 import password.pwm.ldap.UserInfo;
 import password.pwm.ldap.UserInfoFactory;
+import password.pwm.svc.stats.EventRateMeter;
+import password.pwm.svc.stats.Statistic;
+import password.pwm.svc.stats.StatisticsManager;
+import password.pwm.util.java.ConditionalTaskExecutor;
 import password.pwm.util.java.JavaHelper;
 import password.pwm.util.java.TimeDuration;
 import password.pwm.util.logging.PwmLogger;
@@ -44,29 +50,43 @@ import password.pwm.util.macro.MacroMachine;
 
 import java.io.IOException;
 import java.io.Writer;
+import java.math.BigDecimal;
 import java.time.Duration;
 import java.time.Instant;
 import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Iterator;
+import java.util.List;
 import java.util.Locale;
+import java.util.concurrent.TimeUnit;
 
 public class PwNotifyEngine
 {
-
     private static final PwmLogger LOGGER = PwmLogger.forClass( PwNotifyEngine.class );
 
     private static final SessionLabel SESSION_LABEL = SessionLabel.PW_EXP_NOTICE_LABEL;
 
-
     private final PwNotifySettings settings;
     private final PwmApplication pwmApplication;
     private final Writer debugWriter;
     private final StringBuffer internalLog = new StringBuffer(  );
+    private final List<UserPermission> permissionList;
 
-    private volatile boolean running;
+    private final ConditionalTaskExecutor debugOutputTask = new ConditionalTaskExecutor(
+            this::periodicDebugOutput,
+            new ConditionalTaskExecutor.TimeDurationPredicate( 1, TimeUnit.MINUTES )
+    );
 
+    private EventRateMeter eventRateMeter = new EventRateMeter( new TimeDuration( 5, TimeUnit.MINUTES ) );
+
+    private int examinedCount = 0;
+    private int noticeCount = 0;
+    private Instant startTime;
+
+    private volatile boolean running;
 
-    public PwNotifyEngine(
+    PwNotifyEngine(
             final PwmApplication pwmApplication,
             final Writer debugWriter
     )
@@ -74,6 +94,7 @@ public class PwNotifyEngine
         this.pwmApplication = pwmApplication;
         this.settings = PwNotifySettings.fromConfiguration( pwmApplication.getConfig() );
         this.debugWriter = debugWriter;
+        this.permissionList = pwmApplication.getConfig().readSettingAsUserPermission( PwmSetting.PW_EXPY_NOTIFY_PERMISSION );
     }
 
     public boolean isRunning()
@@ -86,27 +107,49 @@ public class PwNotifyEngine
         return internalLog.toString();
     }
 
-    private void checkIfRunningOnMaster( final String msg ) throws PwmUnrecoverableException
+    private boolean checkIfRunningOnMaster( )
     {
         if ( !pwmApplication.getPwmEnvironment().isInternalRuntimeInstance() )
         {
-            if ( pwmApplication.getClusterService() != null && !pwmApplication.getClusterService().isMaster() )
+            if ( pwmApplication.getClusterService() != null && pwmApplication.getClusterService().isMaster() )
             {
-                throw PwmUnrecoverableException.newException( PwmError.ERROR_SERVICE_NOT_AVAILABLE, msg );
+                return true;
             }
         }
+
+        return false;
     }
 
-    public void executeJob( )
+    boolean canRunOnThisServer()
+    {
+        return checkIfRunningOnMaster();
+    }
+
+    void executeJob( )
             throws ChaiUnavailableException, ChaiOperationException, PwmOperationalException, PwmUnrecoverableException
     {
+        startTime = Instant.now();
+        examinedCount = 0;
+        noticeCount = 0;
         try
         {
-            checkIfRunningOnMaster( "job can run only on a server that is currently the cluster master" );
+            internalLog.delete( 0, internalLog.length() );
             running = true;
 
-            final Instant startTime = Instant.now();
-            internalLog.delete( 0, internalLog.length() );
+            if ( !canRunOnThisServer() )
+            {
+                return;
+            }
+
+            if ( JavaHelper.isEmpty( permissionList ) )
+            {
+                log( "no users are included in permission list setting "
+                        + PwmSetting.PW_EXPY_NOTIFY_PERMISSION.toMenuLocationDebug( null, null )
+                        + ", exiting."
+                );
+                return;
+            }
+
             log( "starting job, beginning ldap search" );
             final Iterator<UserIdentity> workQueue = LdapOperationsHelper.readAllUsersFromLdap(
                     pwmApplication,
@@ -114,20 +157,43 @@ public class PwNotifyEngine
                     null,
                     settings.getMaxLdapSearchSize()
             );
+
             log( "ldap search complete, examining users..." );
-            int examinedCount = 0;
-            int noticeCount = 0;
             while ( workQueue.hasNext() )
             {
-                checkIfRunningOnMaster( "job interrupted, server is no longer the cluster master." );
+                if ( !checkIfRunningOnMaster() )
+                {
+                    final String msg = "job interrupted, server is no longer the cluster master.";
+                    log( msg );
+                    throw PwmUnrecoverableException.newException( PwmError.ERROR_SERVICE_NOT_AVAILABLE, msg );
+                }
+
+                checkIfRunningOnMaster(  );
                 examinedCount++;
-                final UserIdentity userIdentity = workQueue.next();
-                if ( processUserIdentity( userIdentity ) )
+
+                final List<UserIdentity> batch = new ArrayList<>(  );
+                final int batchSize = settings.getBatchCount();
+
+                while ( batch.size() < batchSize && workQueue.hasNext() )
                 {
-                    noticeCount++;
+                    batch.add( workQueue.next() );
                 }
+
+                final Instant startBatch = Instant.now();
+                examinedCount += batch.size();
+                noticeCount += processBatch( batch );
+                eventRateMeter.markEvents( batchSize );
+                final TimeDuration batchTime = TimeDuration.fromCurrent( startBatch );
+                final TimeDuration pauseTime = new TimeDuration(
+                        settings.getBatchTimeMultiplier().multiply( new BigDecimal( batchTime.getTotalMilliseconds() ) ).longValue(),
+                        TimeUnit.MILLISECONDS );
+                pauseTime.pause();
+
+                debugOutputTask.conditionallyExecuteTask();
             }
-            log( "job complete, " + examinedCount + " users evaluated in " + TimeDuration.fromCurrent( startTime ).asCompactString() );
+            log( "job complete, " + examinedCount + " users evaluated in " + TimeDuration.fromCurrent( startTime ).asCompactString()
+                    + ", sent " + noticeCount + " notices."
+            );
         }
         finally
         {
@@ -135,11 +201,37 @@ public class PwNotifyEngine
         }
     }
 
+    private void periodicDebugOutput()
+    {
+        log( "job in progress, " + examinedCount + " users evaluated in " + TimeDuration.fromCurrent( startTime ).asCompactString()
+                + ", sent " + noticeCount + " notices."
+        );
+    }
+
+    private int processBatch( final Collection<UserIdentity> batch )
+            throws PwmUnrecoverableException
+    {
+        int count = 0;
+        for ( final UserIdentity userIdentity : batch )
+        {
+            if ( processUserIdentity( userIdentity ) )
+            {
+                count++;
+            }
+        }
+        return count;
+    }
+
     private boolean processUserIdentity(
             final UserIdentity userIdentity
     )
             throws PwmUnrecoverableException
     {
+        if ( !LdapPermissionTester.testUserPermissions( pwmApplication, SessionLabel.SYSTEM_LABEL, userIdentity, permissionList ) )
+        {
+            return false;
+        }
+
         final ChaiUser theUser = pwmApplication.getProxiedChaiUser( userIdentity );
         final Instant passwordExpirationTime = LdapOperationsHelper.readPasswordExpirationTime( theUser );
 
@@ -222,7 +314,7 @@ public class PwNotifyEngine
         return true;
     }
 
-    void sendNoticeEmail( final UserIdentity userIdentity )
+    private void sendNoticeEmail( final UserIdentity userIdentity )
             throws PwmUnrecoverableException
     {
         final Locale userLocale = PwmConstants.DEFAULT_LOCALE;
@@ -237,6 +329,7 @@ public class PwNotifyEngine
                 userIdentity, userLocale
         );
 
+        StatisticsManager.incrementStat( pwmApplication, Statistic.PWNOTIFY_EMAILS_SENT );
         pwmApplication.getEmailQueue().submitEmail( emailItemBean, userInfoBean, macroMachine );
     }
 
@@ -256,7 +349,7 @@ public class PwNotifyEngine
             }
             catch ( IOException e )
             {
-                LOGGER.warn( "unexpected IO error writing to debugWriter: " + e.getMessage() );
+                LOGGER.warn( SessionLabel.PWNOTIFY_SESSION_LABEL, "unexpected IO error writing to debugWriter: " + e.getMessage() );
             }
         }
 
@@ -274,6 +367,6 @@ public class PwNotifyEngine
             }
         }
 
-        LOGGER.trace( output );
+        LOGGER.trace( SessionLabel.PWNOTIFY_SESSION_LABEL, output );
     }
 }

+ 130 - 17
server/src/main/java/password/pwm/svc/pwnotify/PwNotifyService.java

@@ -23,13 +23,17 @@
 package password.pwm.svc.pwnotify;
 
 import password.pwm.PwmApplication;
+import password.pwm.bean.SessionLabel;
 import password.pwm.config.PwmSetting;
 import password.pwm.error.ErrorInformation;
 import password.pwm.error.PwmError;
 import password.pwm.error.PwmException;
 import password.pwm.error.PwmUnrecoverableException;
+import password.pwm.health.HealthMessage;
 import password.pwm.health.HealthRecord;
 import password.pwm.svc.PwmService;
+import password.pwm.svc.stats.Statistic;
+import password.pwm.svc.stats.StatisticsManager;
 import password.pwm.util.db.DatabaseException;
 import password.pwm.util.db.DatabaseTable;
 import password.pwm.util.java.JavaHelper;
@@ -38,7 +42,10 @@ import password.pwm.util.java.StringUtil;
 import password.pwm.util.java.TimeDuration;
 import password.pwm.util.logging.PwmLogger;
 
+import java.time.Duration;
 import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
@@ -46,13 +53,14 @@ import java.util.concurrent.TimeUnit;
 
 public class PwNotifyService implements PwmService
 {
-
     private static final PwmLogger LOGGER = PwmLogger.forClass( PwNotifyService.class );
 
     private ScheduledExecutorService executorService;
     private PwmApplication pwmApplication;
     private STATUS status = STATUS.NEW;
     private PwNotifyEngine engine;
+    private PwNotifySettings settings;
+    private Instant nextExecutionTime;
 
     @Override
     public STATUS status( )
@@ -68,7 +76,7 @@ public class PwNotifyService implements PwmService
         final String strValue = pwmApplication.getDatabaseService().getAccessor().get( DatabaseTable.PW_NOTIFY, DB_STATE_STRING );
         if ( StringUtil.isEmpty( strValue ) )
         {
-            return new StoredJobState( null, null, null, null );
+            return new StoredJobState( null, null, null, null, false );
         }
         return JsonUtil.deserialize( strValue, StoredJobState.class );
     }
@@ -107,10 +115,11 @@ public class PwNotifyService implements PwmService
         if ( !pwmApplication.getConfig().readSettingAsBoolean( PwmSetting.PW_EXPY_NOTIFY_ENABLE ) )
         {
             status = STATUS.CLOSED;
-            LOGGER.trace( "will remain closed, pw notify feature is not enabled" );
+            LOGGER.trace( SessionLabel.PWNOTIFY_SESSION_LABEL, "will remain closed, pw notify feature is not enabled" );
             return;
         }
 
+        settings = PwNotifySettings.fromConfiguration( pwmApplication.getConfig() );
         engine = new PwNotifyEngine( pwmApplication, null );
 
         executorService = Executors.newSingleThreadScheduledExecutor(
@@ -119,24 +128,84 @@ public class PwNotifyService implements PwmService
                         true
                 ) );
 
+        executorService.scheduleWithFixedDelay( new PwNotifyJob(), 1, 1, TimeUnit.MINUTES );
+
+        status = STATUS.OPEN;
+    }
+
+    public Instant getNextExecutionTime( )
+    {
+        return nextExecutionTime;
+    }
+
+    private void scheduleNextJobExecution()
+    {
+        try
         {
-            final long jobOffsetSeconds = pwmApplication.getConfig().readSettingAsLong( PwmSetting.PW_EXPY_NOTIFY_JOB_OFFSET );
-            final Instant nextZuluZeroTime = JavaHelper.nextZuluZeroTime();
-            final long secondsUntilNextDredge = jobOffsetSeconds + TimeDuration.fromCurrent( nextZuluZeroTime ).getTotalSeconds();
-            executorService.scheduleAtFixedRate( new DailyJobRunning(), secondsUntilNextDredge, TimeDuration.DAY.getTotalSeconds(), TimeUnit.SECONDS );
-            LOGGER.debug( "scheduled daily execution, next task will be at " + nextZuluZeroTime.toString() );
+            nextExecutionTime = figureNextJobExecutionTime();
+            LOGGER.debug( SessionLabel.PWNOTIFY_SESSION_LABEL, "scheduled next job execution at " + nextExecutionTime.toString() );
+        }
+        catch ( Exception e )
+        {
+            LOGGER.error( SessionLabel.PWNOTIFY_SESSION_LABEL, "error calculating next job execution time: " + e.getMessage() );
         }
     }
 
+    private Instant figureNextJobExecutionTime() throws DatabaseException, PwmUnrecoverableException
+    {
+        final StoredJobState storedJobState = readStoredJobState();
+        if ( storedJobState != null )
+        {
+            // never run, or last job not successful.
+            if ( storedJobState.getLastCompletion() == null || storedJobState.getLastError() != null )
+            {
+                return Instant.now().plus( 1, ChronoUnit.MINUTES );
+            }
+
+            // more than 24hr ago.
+            if ( Duration.between( Instant.now(), storedJobState.getLastCompletion() ).abs().getSeconds() > settings.getMaximumSkipWindow().getTotalSeconds() )
+            {
+                return Instant.now();
+            }
+        }
+
+        final Instant nextZuluZeroTime = JavaHelper.nextZuluZeroTime();
+        final Instant adjustedNextZuluZeroTime = nextZuluZeroTime.plus( settings.getZuluOffset().getTotalSeconds(), ChronoUnit.SECONDS );
+        final Instant previousAdjustedZuluZeroTime = adjustedNextZuluZeroTime.minus( 1, ChronoUnit.DAYS );
+
+        if ( previousAdjustedZuluZeroTime.isAfter( Instant.now() ) )
+        {
+            return previousAdjustedZuluZeroTime;
+        }
+        return adjustedNextZuluZeroTime;
+    }
+
     @Override
     public void close( )
     {
+        status = STATUS.CLOSED;
         JavaHelper.closeAndWaitExecutor( executorService, new TimeDuration( 5, TimeUnit.SECONDS ) );
     }
 
     @Override
     public List<HealthRecord> healthCheck( )
     {
+        try
+        {
+            final StoredJobState storedJobState = readStoredJobState();
+            if ( storedJobState != null )
+            {
+                final ErrorInformation errorInformation = storedJobState.getLastError();
+                if ( errorInformation != null )
+                {
+                    return Collections.singletonList( HealthRecord.forMessage( HealthMessage.PwNotify_Failure, errorInformation.toDebugStr() ) );
+                }
+            }
+        }
+        catch ( DatabaseException | PwmUnrecoverableException e  )
+        {
+            LOGGER.error( SessionLabel.PWNOTIFY_SESSION_LABEL, "error while generating health information: " + e.getMessage() );
+        }
         return null;
     }
 
@@ -146,23 +215,67 @@ public class PwNotifyService implements PwmService
         return null;
     }
 
-    public void runJob( )
+    public void executeJob( )
     {
-        executorService.schedule( new DailyJobRunning(), 1, TimeUnit.SECONDS );
+        if ( status != STATUS.OPEN )
+        {
+            LOGGER.trace( SessionLabel.PWNOTIFY_SESSION_LABEL, "ignoring job request start, service is not open" );
+            return;
+        }
+
+        if ( !isRunning() )
+        {
+            nextExecutionTime = Instant.now();
+            executorService.schedule( new PwNotifyJob(), 1, TimeUnit.SECONDS );
+        }
     }
 
-    class DailyJobRunning implements Runnable
+    public boolean canRunOnThisServer()
+    {
+        return engine.canRunOnThisServer();
+    }
+
+    class PwNotifyJob implements Runnable
     {
         @Override
         public void run( )
+        {
+            if ( !canRunOnThisServer() )
+            {
+                nextExecutionTime = null;
+                return;
+            }
+
+            if ( nextExecutionTime == null )
+            {
+                scheduleNextJobExecution();
+            }
+
+            if ( nextExecutionTime != null && nextExecutionTime.isBefore( Instant.now() ) )
+            {
+                try
+                {
+                    doJob();
+                    scheduleNextJobExecution();
+                }
+                catch ( Exception e )
+                {
+                    LOGGER.error( SessionLabel.PWNOTIFY_SESSION_LABEL, "unexpected error running job: " + e.getMessage() );
+                }
+            }
+        }
+
+        private void doJob( )
         {
             final Instant start = Instant.now();
             try
             {
-                writeStoredJobState( new StoredJobState() );
+                writeStoredJobState( new StoredJobState( Instant.now(), null, pwmApplication.getInstanceID(), null, false ) );
+                StatisticsManager.incrementStat( pwmApplication, Statistic.PWNOTIFY_JOBS );
                 engine.executeJob();
+
                 final Instant finish = Instant.now();
-                final StoredJobState storedJobState = new StoredJobState( start, finish, pwmApplication.getInstanceID(), null );
+                final StoredJobState storedJobState = new StoredJobState( start, finish, pwmApplication.getInstanceID(), null, true );
                 writeStoredJobState( storedJobState );
             }
             catch ( Exception e )
@@ -179,7 +292,8 @@ public class PwNotifyService implements PwmService
 
                 final Instant finish = Instant.now();
                 final String instanceID = pwmApplication.getInstanceID();
-                final StoredJobState storedJobState = new StoredJobState( start, finish, instanceID, errorInformation );
+                final StoredJobState storedJobState = new StoredJobState( start, finish, instanceID, errorInformation, false );
+
                 try
                 {
                     writeStoredJobState( storedJobState );
@@ -188,10 +302,9 @@ public class PwNotifyService implements PwmService
                 {
                     //no hope
                 }
-                LOGGER.debug( "error executing scheduled job: " + e.getMessage() );
+                StatisticsManager.incrementStat( pwmApplication, Statistic.PWNOTIFY_JOB_ERRORS );
+                LOGGER.debug( SessionLabel.PWNOTIFY_SESSION_LABEL, "error executing scheduled job: " + e.getMessage() );
             }
         }
     }
-
-
 }

+ 17 - 4
server/src/main/java/password/pwm/svc/pwnotify/PwNotifySettings.java

@@ -22,38 +22,51 @@
 
 package password.pwm.svc.pwnotify;
 
+import lombok.Builder;
 import lombok.Value;
+import password.pwm.AppProperty;
 import password.pwm.config.Configuration;
 import password.pwm.config.PwmSetting;
 import password.pwm.util.java.TimeDuration;
 
 import java.io.Serializable;
+import java.math.BigDecimal;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
 
 @Value
+@Builder
 public class PwNotifySettings implements Serializable
 {
     private final List<Integer> notificationIntervals;
     private final TimeDuration maximumSkipWindow;
+    private final TimeDuration zuluOffset;
     private final int maxLdapSearchSize;
+    private final int batchCount;
+    private final BigDecimal batchTimeMultiplier;
 
     static PwNotifySettings fromConfiguration( final Configuration configuration )
     {
-        final List<Integer> timeDurations = new ArrayList<>(  );
+        final PwNotifySettingsBuilder builder = PwNotifySettings.builder();
         {
+            final List<Integer> timeDurations = new ArrayList<>(  );
             final List<String> stringValues = configuration.readSettingAsStringArray( PwmSetting.PW_EXPY_NOTIFY_INTERVAL );
             for ( final String value : stringValues )
             {
                 timeDurations.add( Integer.parseInt( value ) );
             }
             Collections.sort( timeDurations );
+            builder.notificationIntervals( Collections.unmodifiableList( timeDurations ) );
         }
-        final TimeDuration maxSkipWindow = new TimeDuration( 24, TimeUnit.HOURS );
-        final int maxLdapSearchSize = 1_000_000;
 
-        return new PwNotifySettings( Collections.unmodifiableList( timeDurations ), maxSkipWindow, maxLdapSearchSize );
+        builder.zuluOffset( new TimeDuration( configuration.readSettingAsLong( PwmSetting.PW_EXPY_NOTIFY_JOB_OFFSET ), TimeUnit.SECONDS ) );
+        builder.batchCount( Integer.parseInt( configuration.readAppProperty( AppProperty.PWNOTIFY_BATCH_COUNT ) ) );
+        builder.maxLdapSearchSize( Integer.parseInt( configuration.readAppProperty( AppProperty.PWNOTIFY_MAX_LDAP_SEARCH_SIZE ) ) );
+        builder.batchTimeMultiplier( new BigDecimal( configuration.readAppProperty( AppProperty.PWNOTIFY_BATCH_DELAY_TIME_MULTIPLIER ) ) );
+        builder.maximumSkipWindow( new TimeDuration(
+                Long.parseLong( configuration.readAppProperty( AppProperty.PWNOTIFY_MAX_SKIP_RERUN_WINDOW_SECONDS ) ), TimeUnit.SECONDS ) );
+        return builder.build();
     }
 }

+ 1 - 0
server/src/main/java/password/pwm/svc/pwnotify/StoredJobState.java

@@ -39,4 +39,5 @@ public class StoredJobState implements Serializable
     private Instant lastCompletion = null;
     private String serverInstance = null;
     private ErrorInformation lastError = null;
+    private boolean jobSuccess;
 }

+ 3 - 0
server/src/main/java/password/pwm/svc/stats/Statistic.java

@@ -92,6 +92,9 @@ public enum Statistic
     PEOPLESEARCH_SEARCHES( Type.INCREMENTOR, "PeopleSearchSearches", new ConfigSettingDetail( PwmSetting.PEOPLE_SEARCH_ENABLE ) ),
     PEOPLESEARCH_DETAILS( Type.INCREMENTOR, "PeopleSearchDetails", new ConfigSettingDetail( PwmSetting.PEOPLE_SEARCH_ENABLE ) ),
     PEOPLESEARCH_ORGCHART( Type.INCREMENTOR, "PeopleSearchOrgChart", new ConfigSettingDetail( PwmSetting.PEOPLE_SEARCH_ENABLE ) ),
+    PWNOTIFY_JOBS ( Type.INCREMENTOR, "PwNotifyJobs", null ),
+    PWNOTIFY_JOB_ERRORS ( Type.INCREMENTOR, "PwNotifyJobErrors", null ),
+    PWNOTIFY_EMAILS_SENT ( Type.INCREMENTOR, "PwNotifyJobEmailsSent", null ),
     HELPDESK_PASSWORD_SET( Type.INCREMENTOR, "HelpdeskPasswordSet", null ),
     HELPDESK_USER_LOOKUP( Type.INCREMENTOR, "HelpdeskUserLookup", null ),
     HELPDESK_TOKENS_SENT( Type.INCREMENTOR, "HelpdeskTokenSent", null ),

+ 12 - 0
server/src/main/java/password/pwm/util/LocaleHelper.java

@@ -41,6 +41,7 @@ import password.pwm.http.PwmRequest;
 import password.pwm.i18n.Display;
 import password.pwm.i18n.PwmDisplayBundle;
 import password.pwm.i18n.PwmLocaleBundle;
+import password.pwm.util.java.JavaHelper;
 import password.pwm.util.java.Percent;
 import password.pwm.util.java.StringUtil;
 import password.pwm.util.logging.PwmLogger;
@@ -48,6 +49,7 @@ import password.pwm.util.macro.MacroMachine;
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -375,6 +377,16 @@ public class LocaleHelper
         return Display.getLocalizedMessage( locale, key, configuration );
     }
 
+    public static String instantString ( final Instant input, final Locale locale, final Configuration configuration )
+    {
+        if ( input == null )
+        {
+            return LocaleHelper.getLocalizedMessage( locale, Display.Value_NotApplicable, configuration );
+        }
+        return JavaHelper.toIsoDate( input );
+    }
+
+
     @Getter
     public static class LocaleStats
     {

+ 0 - 2
server/src/main/java/password/pwm/util/cli/MainClass.java

@@ -58,7 +58,6 @@ import password.pwm.util.cli.commands.ImportLocalDBCommand;
 import password.pwm.util.cli.commands.ImportResponsesCommand;
 import password.pwm.util.cli.commands.LdapSchemaExtendCommand;
 import password.pwm.util.cli.commands.LocalDBInfoCommand;
-import password.pwm.util.cli.commands.PasswordExpireNotificationCommand;
 import password.pwm.util.cli.commands.ResponseStatsCommand;
 import password.pwm.util.cli.commands.ShellCommand;
 import password.pwm.util.cli.commands.TokenInfoCommand;
@@ -127,7 +126,6 @@ public class MainClass
         commandList.add( new ShellCommand() );
         commandList.add( new ConfigResetHttpsCommand() );
         commandList.add( new HelpCommand() );
-        commandList.add( new PasswordExpireNotificationCommand() );
 
         final Map<String, CliCommand> sortedMap = new TreeMap<>();
         for ( final CliCommand command : commandList )

+ 0 - 53
server/src/main/java/password/pwm/util/cli/commands/PasswordExpireNotificationCommand.java

@@ -1,53 +0,0 @@
-/*
- * 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.util.cli.commands;
-
-import password.pwm.PwmApplication;
-import password.pwm.svc.pwnotify.PwNotifyEngine;
-import password.pwm.util.cli.CliParameters;
-
-import java.util.Collections;
-
-public class PasswordExpireNotificationCommand extends AbstractCliCommand
-{
-    public void doCommand( )
-            throws Exception
-    {
-        final PwmApplication pwmApplication = cliEnvironment.getPwmApplication();
-        final PwNotifyEngine engine = new PwNotifyEngine( pwmApplication, this.cliEnvironment.getDebugWriter() );
-        engine.executeJob();
-    }
-
-    @Override
-    public CliParameters getCliParameters( )
-    {
-        final CliParameters cliParameters = new CliParameters();
-        cliParameters.commandName = "PasswordNotificationJob";
-        cliParameters.description = "Run the password expiration notification batch process";
-        cliParameters.options = Collections.emptyList();
-        cliParameters.needsPwmApplication = true;
-        cliParameters.needsLocalDB = true;
-        cliParameters.readOnly = false;
-        return cliParameters;
-    }
-}

+ 2 - 0
server/src/main/java/password/pwm/util/db/DatabaseAccessor.java

@@ -92,4 +92,6 @@ public interface DatabaseAccessor
     @DbOperation
     int size( DatabaseTable table ) throws
             DatabaseException;
+
+    boolean isConnected( );
 }

+ 14 - 0
server/src/main/java/password/pwm/util/db/DatabaseAccessorImpl.java

@@ -617,4 +617,18 @@ class DatabaseAccessorImpl implements DatabaseAccessor
             throw new IllegalStateException( "call to perform database operation but accessor has been closed" );
         }
     }
+
+    public boolean isConnected()
+    {
+        try
+        {
+            return connection.isValid( 5000 );
+        }
+        catch ( SQLException e )
+        {
+            LOGGER.error( "error while checking database connection: " + e.getMessage() );
+        }
+
+        return false;
+    }
 }

+ 10 - 1
server/src/main/java/password/pwm/util/java/TimeDuration.java

@@ -50,7 +50,6 @@ import java.util.concurrent.TimeUnit;
  */
 public class TimeDuration implements Comparable, Serializable
 {
-
     public static final TimeDuration ZERO = new TimeDuration( 0 );
     public static final TimeDuration MILLISECOND = new TimeDuration( 1, TimeUnit.MILLISECONDS );
     public static final TimeDuration SECOND = new TimeDuration( 1, TimeUnit.SECONDS );
@@ -100,6 +99,11 @@ public class TimeDuration implements Comparable, Serializable
         return new TimeDuration( System.currentTimeMillis(), instant.toEpochMilli() );
     }
 
+    public static TimeDuration between( final Instant start, final Instant finish )
+    {
+        return new TimeDuration( start, finish );
+    }
+
     public static String compactFromCurrent( final Instant instant )
     {
         return TimeDuration.fromCurrent( instant ).asCompactString();
@@ -517,6 +521,11 @@ public class TimeDuration implements Comparable, Serializable
      */
     public static TimeDuration pause( final long sleepTimeMS )
     {
+        if ( sleepTimeMS < 1 )
+        {
+            return TimeDuration.ZERO;
+        }
+
         final long startTime = System.currentTimeMillis();
         do
         {

+ 3 - 0
server/src/main/resources/password/pwm/AppProperty.properties

@@ -239,7 +239,10 @@ peoplesearch.values.maxCount=100
 peoplesearch.view.detail.links=
 peoplesearch.orgChart.enableChildCount=true
 peoplesearch.orgChart.maxParents=50
+pwNotify.batch.count=100
+pwNotify.batch.delayTimeMultiplier=1.0
 pwNotify.maxLdapSearchSize=1000000
+pwNotify.maxSkipRerunWindowSeconds=86400
 queue.email.retryTimeoutMs=10000
 queue.email.maxCount=100000
 queue.email.maxThreads=0

+ 6 - 0
server/src/main/resources/password/pwm/i18n/Admin.properties

@@ -234,6 +234,12 @@ Statistic_Label.PeopleSearchDetails=PeopleSearch Detail Views
 Statistic_Description.PeopleSearchDetails=Number of detailed user views executed using the people search module.
 Statistic_Label.PeopleSearchOrgChart=PeopleSearch Org Chart Views
 Statistic_Description.PeopleSearchOrgChart=Number of organisational chart views executed using the people search module.
+Statistic_Label.PwNotifyJobs=Password Notification Jobs Started
+Statistic_Description.PwNotifyJobs=Number of password expiration notification jobs that have been started.
+Statistic_Label.PwNotifyJobErrors=Password Notification Job Errors
+Statistic_Description.PwNotifyJobErrors=Number of password expiration notification jobs that have ended with an error.
+Statistic_Label.PwNotifyJobEmailsSent=Password Notification Job Emails Sent
+Statistic_Description.PwNotifyJobEmailsSent=Number of emails that have been sent by password expiration notification jobs.
 Statistic_Label.HelpdeskPasswordSet=Help Desk Password Resets
 Statistic_Description.HelpdeskPasswordSet=Number of password modifications initiated using the Help Desk module.
 Statistic_Label.HelpdeskUserLookup=Help Desk User Lookups

+ 2 - 1
server/src/main/resources/password/pwm/i18n/Health.properties

@@ -33,6 +33,7 @@ HealthMessage_LDAP_TestUserOK=LDAP test user account is functioning normally for
 HealthMessage_LDAP_AD_Unsecure=%1% is not configured as a secure connection.  Active Directory requires a secure connection to allow password changes.
 HealthMessage_LDAP_AD_StaticIP=%1% should be configured using a dns hostname instead of an IP address.  Active Directory can sometimes have errors when using an IP address for configuration.
 HealthMessage_Email_SendFailure=Unable to send email due to error: %1%
+PwNotify_Failure=Error while sending password notification emails: %1%
 HealthMessage_MissingResource=missing resource: bundle=%1%, locale=%2%, key=%3%
 HealthMessage_BrokenMethod=broken method invocation for '%1%', error: %2%
 HealthMessage_Appliance_PendingUpdates=Appliance updates are available.
@@ -47,7 +48,7 @@ HealthMessage_Config_ParseError=%1% error parsing setting %2%: %3%
 HealthMessage_Config_UsingLocalDBResponseStorage=The setting %1% is configured to store user data in the LocalDB.  This should never be used in a production environment.
 HealthMessage_Config_WeakPassword=%1% strength of password is weak (%2%/100); increase password length/complexity for proper security
 HealthMessage_Config_LDAPUnsecure=%1% url is configured for non-secure connection: %2%
-HealthMessage_Config_MissingDB=The configuration uses database storage, but database connection settings are not set
+HealthMessage_Config_MissingDB=The configuration requires database storage due to setting %1%, but database connection settings are not set
 HealthMessage_Config_MissingLDAPResponseAttr=%1% includes ldap storage, but %2% is not configured
 HealthMessage_Config_URLNotSecure=URL is not secure (https) for setting %1%
 HealthMessage_Config_NoRecoveryEnabled=No forgotten password recovery options are enabled

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

@@ -602,10 +602,10 @@ Setting_Description_pwm.securityKey=<p>Specify a Security Key used for cryptogra
 Setting_Description_pwm.seedlist.location=Specify the location of the seed list in the form of a valid URL. When @PwmAppName@ randomly generates passwords, it can generate a "friendly", random password suggestions to users.  It does this by using a "seed" word or words, and then modifying that word randomly until it is sufficiently complex and meets the configured rules computed for the user.<br/><br/>The value must be a valid URL, using the protocol "file" (local file system), "http", or "https".
 Setting_Description_pwm.selfURL=<p>The URL to this application, as seen by users. @PwmAppName@ uses the value in email macros and other user-facing communications.</p><p>The URL must use a valid fully qualified hostname. Do not use a network address.</p><p>In simple environments, the URL will be the base of the URL in the browser you are currently using to view this page, however in more complex environments the URL will typically be an upstream proxy, gateway or network device.</p><p>The URL should include the path to the base application, typically <code>/@Case:lower:[[@PwmAppName@]]@</code>.</p>
 Setting_Description_pwm.wordlist.location=Specify a word list file URL for dictionary checking to prevent users from using commonly used words as passwords.   Using word lists is an important part of password security.  Word lists are used by intruders to guess common passwords.   The default word list included contains commonly used English passwords.  <br/><br/>The first time a startup occurs with a new word list setting, it takes some time to compile the word list into a database.  See the status screen and logs for progress information.  The word list file format is one or more text files containing a single word per line, enclosed in a ZIP file.  The String <i>\!\#comment\:</i> at the beginning of a line indicates a comment. <br/><br/>The value must be a valid URL, using the protocol "file" (local file system), "http", or "https".
-Setting_Description_pwNotify.enable=Enable Password Expiration Notification
-Setting_Description_pwNotify.queryString=Expiration Notification User Match
-Setting_Description_pwNotify.intervals=Expiration Notification Intervals
-Setting_Description_pwNotify.job.offSet=Job Offset
+Setting_Description_pwNotify.enable=<p>Enable password expiration notification service.  Operation of this service requires that a remote database be configured.  Status of this service can be viewed on the <code>Administration -> Dashboard -> Password Notification</code> page.  The service will nominally execute once per day on the cluster master server.</p><p>If a job is missed because of an @PwmAppName@, LDAP, or database service interuption it will be run within the next 24 hours as soon as service is restored.  Running a job more than once will not result in duplicate emails sent to the user.</p>
+Setting_Description_pwNotify.queryString=Users that will receive password expiration notifications.
+Setting_Description_pwNotify.intervals=Expiration Notification Day Intervals.  The number of days before a user's password expiration before which an email notice will be set. 
+Setting_Description_pwNotify.job.offSet=GMT job offset time.  The expiration notice job will normally be executed at 0:00 GMT.  This value can be adjusted to change the standard time of day the job is run.  
 Setting_Description_recovery.action=Add actions to take when the user completes the forgotten password process.
 Setting_Description_recovery.allowWhenLocked=Enable this option to allow users to use the forgotten password feature when the account is intruder locked in LDAP.  This feature is not available when a user is using NMAS stored responses.
 Setting_Description_recovery.bogus.user.enable=Enable this option to have forgotten password act as though invalid user searches are valid, and present such users with a bogus forgotten password policy.  This can help prevent username discovery.

+ 83 - 47
server/src/main/webapp/WEB-INF/jsp/admin-dashboard.jsp

@@ -56,6 +56,7 @@
         <h1 id="page-content-title"><pwm:display key="Title_Dashboard" bundle="Admin"/></h1>
         <%@ include file="fragment/admin-nav.jsp" %>
         <div id="DashboardTabContainer" class="tab-container" style="width: 100%; height: 100%;">
+
             <input name="tabs" type="radio" id="tab-1" checked="checked" class="input"/>
             <label for="tab-1" class="label">Status</label>
             <div id="StatusTab" class="tab-content-pane" title="Status" >
@@ -243,53 +244,53 @@
             <label for="tab-4" class="label">Services</label>
             <div id="ServicesTab" class="tab-content-pane" title="Services">
                 <div style="max-height: 600px; overflow: auto;">
-                <table class="nomargin">
-                    <tr>
-                        <th style="font-weight:bold;">
-                            Service
-                        </td>
-                        <td style="font-weight:bold;">
-                            Status
-                        </td>
-                        <td style="font-weight:bold;">
-                            Storage
-                        </td>
-                        <td style="font-weight:bold;">
-                            Health
-                        </td>
-                    </tr>
-                    <% for (final AppDashboardData.ServiceData loopService : appDashboardData.getServices()) { %>
-                    <tr id="serviceName-<%=loopService.getName()%>">
-                        <td>
-                            <%= loopService.getName() %>
-                            <% if (!JavaHelper.isEmpty(loopService.getDebugData())) { %>
-                            &nbsp;
-                            <div class="btn-icon pwm-icon pwm-icon-list-alt"></div>
-                            <% } %>
-                        </td>
-                        <td>
-                            <%= loopService.getStatus() %>
-                        </td>
-                        <td>
-                            <% for (final DataStorageMethod loopMethod : loopService.getStorageMethod()) { %>
-                            <%=loopMethod.toString()%>
-                            <br/>
-                            <% } %>
-                        </td>
-                        <td>
-                            <% if (!JavaHelper.isEmpty(loopService.getHealth())) { %>
-                            <% for (final HealthRecord loopRecord : loopService.getHealth()) { %>
-                            <%= loopRecord.getTopic(locale, dashboard_pwmApplication.getConfig()) %> - <%= loopRecord.getStatus().toString() %> - <%= loopRecord.getDetail(locale,
-                                dashboard_pwmApplication.getConfig()) %>
-                            <br/>
-                            <% } %>
-                            <% } else { %>
-                            No Issues
-                            <% } %>
-                        </td>
-                    </tr>
-                    <% } %>
-                </table>
+                    <table class="nomargin">
+                        <tr>
+                            <th style="font-weight:bold;">
+                                Service
+                            </td>
+                            <td style="font-weight:bold;">
+                                Status
+                            </td>
+                            <td style="font-weight:bold;">
+                                Storage
+                            </td>
+                            <td style="font-weight:bold;">
+                                Health
+                            </td>
+                        </tr>
+                        <% for (final AppDashboardData.ServiceData loopService : appDashboardData.getServices()) { %>
+                        <tr id="serviceName-<%=loopService.getName()%>">
+                            <td>
+                                <%= loopService.getName() %>
+                                <% if (!JavaHelper.isEmpty(loopService.getDebugData())) { %>
+                                &nbsp;
+                                <div class="btn-icon pwm-icon pwm-icon-list-alt"></div>
+                                <% } %>
+                            </td>
+                            <td>
+                                <%= loopService.getStatus() %>
+                            </td>
+                            <td>
+                                <% for (final DataStorageMethod loopMethod : loopService.getStorageMethod()) { %>
+                                <%=loopMethod.toString()%>
+                                <br/>
+                                <% } %>
+                            </td>
+                            <td>
+                                <% if (!JavaHelper.isEmpty(loopService.getHealth())) { %>
+                                <% for (final HealthRecord loopRecord : loopService.getHealth()) { %>
+                                <%= loopRecord.getTopic(locale, dashboard_pwmApplication.getConfig()) %> - <%= loopRecord.getStatus().toString() %> - <%= loopRecord.getDetail(locale,
+                                    dashboard_pwmApplication.getConfig()) %>
+                                <br/>
+                                <% } %>
+                                <% } else { %>
+                                No Issues
+                                <% } %>
+                            </td>
+                        </tr>
+                        <% } %>
+                    </table>
                 </div>
             </div>
 
@@ -443,6 +444,37 @@
             </div>
             <% } %>
 
+            <pwm:if test="<%=PwmIfTest.booleanSetting%>" setting="<%=PwmSetting.PW_EXPY_NOTIFY_ENABLE%>">
+            <input name="tabs" type="radio" id="tab-8" class="input"/>
+            <label for="tab-8" class="label">Password Notification</label>
+            <div id="Status" class="tab-content-pane" title="Password Notification">
+                <table id="table-pwNotifyStatus">
+                </table>
+                <div class="footnote">
+                    <pwm:display key="Notice_DynamicRefresh" bundle="Admin"/>
+                </div>
+
+                <br/>
+                <table><tr><td class="title">Local Debug Log</td></tr>
+                    <tr>
+                        <td>
+                            <div style="max-height: 500px; max-width: 580px; overflow: auto; white-space: pre" id="div-pwNotifyDebugLog"></div>
+                        </td>
+                    </tr>
+                </table>
+                <div class="buttonbar" style="width:100%">
+                    <button type="submit" class="btn" id="button-refreshPwNotifyStatus">
+                        <pwm:if test="<%=PwmIfTest.showIcons%>"><span class="btn-icon pwm-icon pwm-icon-refresh"></span></pwm:if>
+                        Refresh Log
+                    </button>
+                    <button id="button-executePwNotifyJob" type="button" class="btn">
+                        <pwm:if test="<%=PwmIfTest.showIcons%>"><span class="btn-icon pwm-icon pwm-icon-play"></span></pwm:if>
+                        Start Job
+                    </button>
+                </div>
+            </div>
+            </pwm:if>
+
             <div class="tab-end"></div>
         </div>
     </div>
@@ -477,6 +509,10 @@
             });
             <% } %>
             <% } %>
+
+            <pwm:if test="<%=PwmIfTest.booleanSetting%>" setting="<%=PwmSetting.PW_EXPY_NOTIFY_ENABLE%>">
+            PWM_ADMIN.initPwNotifyPage();
+            </pwm:if>
         });
     </script>
 </pwm:script>

+ 0 - 116
server/src/main/webapp/WEB-INF/jsp/configmanager-pwnotify.jsp

@@ -1,116 +0,0 @@
-<%--
- ~ 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
---%>
-
-<%@ page import="password.pwm.i18n.Config" %>
-<%@ page import="password.pwm.svc.cluster.ClusterService" %>
-<%@ page import="password.pwm.svc.pwnotify.PwNotifyService" %>
-<%@ page import="password.pwm.svc.pwnotify.StoredJobState" %>
-<%@ page import="password.pwm.util.LocaleHelper" %>
-<%@ page import="password.pwm.util.java.StringUtil" %>
-
-<!DOCTYPE html>
-<%@ page language="java" session="true" isThreadSafe="true" contentType="text/html" %>
-<% JspUtility.setFlag(pageContext, PwmRequestFlag.INCLUDE_CONFIG_CSS);%>
-<%@ taglib uri="pwm" prefix="pwm" %>
-<html lang="<pwm:value name="<%=PwmValue.localeCode%>"/>" dir="<pwm:value name="<%=PwmValue.localeDir%>"/>">
-<%@ include file="fragment/header.jsp" %>
-<body class="nihilo">
-<link href="<pwm:context/><pwm:url url='/public/resources/configmanagerStyle.css'/>" rel="stylesheet" type="text/css"/>
-<div id="wrapper">
-    <jsp:include page="fragment/header-body.jsp">
-        <jsp:param name="pwm.PageName" value="<%=LocaleHelper.getLocalizedMessage(Config.Title_ConfigManager, JspUtility.getPwmRequest(pageContext))%>"/>
-    </jsp:include>
-    <div id="centerbody">
-        <h1 id="page-content-title"><%=LocaleHelper.getLocalizedMessage(Config.Title_ConfigManager, JspUtility.getPwmRequest(pageContext))%></h1>
-        <%@ include file="fragment/configmanager-nav.jsp" %>
-        <pwm:if test="<%=PwmIfTest.booleanSetting%>" setting="<%=PwmSetting.PW_EXPY_NOTIFY_ENABLE%>" negate="true">
-            Password Notification Feature is not enabled.  See ConfigEditor: <%=PwmSetting.PW_EXPY_NOTIFY_ENABLE.toMenuLocationDebug(null,null)%>
-        </pwm:if>
-        <pwm:if test="<%=PwmIfTest.booleanSetting%>" setting="<%=PwmSetting.PW_EXPY_NOTIFY_ENABLE%>">
-            <% final ClusterService clusterService = JspUtility.getPwmRequest( pageContext ).getPwmApplication().getClusterService(); %>
-            <% final PwNotifyService service = JspUtility.getPwmRequest(pageContext).getPwmApplication().getPwNotifyService();%>
-            <% final StoredJobState storedJobState = service.getJobState(); %>
-            <table>
-                <tr><td colspan="2" class="title">Password Expiration Notification Status</td></tr>
-                <tr><td>Currently Running (on this server) </td><td><%=JspUtility.freindlyWrite(pageContext, service.isRunning())%></td></tr>
-                <tr><td>This Server is Cluster Master</td><td><%=JspUtility.freindlyWrite(pageContext, clusterService.isMaster())%></td></tr>
-                <% if (storedJobState != null)  { %>
-                <tr><td>Last Job Start Time </td><td><span class="timestamp"><%=JspUtility.freindlyWrite(pageContext, storedJobState.getLastStart())%></span></td></tr>
-                <tr><td>Last Job Completion Time </td><td><span class="timestamp"><%=JspUtility.freindlyWrite(pageContext, storedJobState.getLastCompletion())%></span></td></tr>
-                <tr><td>Last Job Server Instance</td><td><%=JspUtility.freindlyWrite(pageContext, storedJobState.getServerInstance())%></td></tr>
-                <% if (storedJobState.getLastError() != null) { %>
-                <tr><td>Last Job Error</td><td><%=JspUtility.freindlyWrite(pageContext, storedJobState.getLastError().toDebugStr())%></td></tr>
-                <% } %>
-                <% } %>
-            </table>
-            <br/><br/>
-            <table><tr><td class="title">Debug Log</td></tr>
-                <tr><td><div style="max-height: 500px; overflow: auto">
-                    <% if (StringUtil.isEmpty( service.debugLog())) { %>
-                    <span class="footnote">Job has not been run on this server since startup.</span>
-                    <% } else { %>
-                    <div style="white-space: nowrap;  "><%=StringUtil.escapeHtml(service.debugLog()).replace("\n","<br/>")%></div>
-                    <% } %>
-                </div></td> </tr>
-            </table>
-
-            <div class="buttonbar" style="width:100%">
-                <form action="<pwm:current-url/>" method="get" id="form-refresh">
-                    <button type="submit" name="change" class="btn" id="button-refresh">
-                        <pwm:if test="<%=PwmIfTest.showIcons%>"><span class="btn-icon pwm-icon pwm-icon-refresh"></span></pwm:if>
-                        Refresh
-                    </button>
-                </form>
-                <button id="button-runJob" type="button" class="btn">
-                    <pwm:if test="<%=PwmIfTest.showIcons%>"><span class="btn-icon pwm-icon pwm-icon-play"></span></pwm:if>
-                    Start Job
-                </button>
-            </div>
-
-        </pwm:if>
-        <br/>
-    </div>
-    <div class="push"></div>
-</div>
-<pwm:script>
-    <script type="text/javascript">
-
-        PWM_GLOBAL['startupFunctions'].push(function () {
-            PWM_MAIN.addEventHandler('button-runJob','click',function(){
-                PWM_MAIN.showWaitDialog({loadFunction:function(){
-                        var url = PWM_MAIN.addParamToUrl(window.location.pathname, 'processAction','startJob');
-                        PWM_MAIN.ajaxRequest(url,function(data){
-                            PWM_MAIN.showDialog({title:'Job Started',text:data['successMessage']})
-                        });
-                    }
-                });
-            });
-        });
-
-    </script>
-</pwm:script>
-<pwm:script-ref url="/public/resources/js/configmanager.js"/>
-<pwm:script-ref url="/public/resources/js/uilibrary.js"/>
-<pwm:script-ref url="/public/resources/js/admin.js"/>
-<div><%@ include file="fragment/footer.jsp" %></div>
-</body>
-</html>

+ 0 - 6
server/src/main/webapp/WEB-INF/jsp/fragment/configmanager-nav.jsp

@@ -49,12 +49,6 @@
             LocalDB
         </button>
     </form>
-    <form action="<pwm:context/><%=PwmServletDefinition.ConfigManager_PwNotify.servletUrl()%>" method="get">
-        <button type="submit" class="navbutton">
-            <pwm:if test="<%=PwmIfTest.showIcons%>"><span class="btn-icon pwm-icon pwm-icon-envelope"></span></pwm:if>
-            Password Expiration Notification
-        </button>
-    </form>
 </div>
 <br/>
 

+ 1 - 1
server/src/main/webapp/WEB-INF/jsp/fragment/footer.jsp

@@ -30,7 +30,7 @@
     <pwm:script-ref url="/public/resources/webjars/angular/angular.min.js" />
     <pwm:script-ref url="/public/resources/webjars/angular-aria/angular-aria.min.js" />
     <pwm:script-ref url="/public/resources/webjars/angular-ui-router/release/angular-ui-router.min.js" />
-    <pwm:script-ref url="/public/resources/webjars/angular-translate/dist/angular-translate.min.js" />
+    <pwm:script-ref url="/public/resources/webjars/angular-translate/angular-translate.min.js" />
     <pwm:script-ref url="/public/resources/webjars/pwm-client/vendor/ng-ias.js" />
 </pwm:if>
 

+ 87 - 0
server/src/main/webapp/public/resources/js/admin.js

@@ -734,6 +734,93 @@ PWM_ADMIN.makeHealthHtml = function(healthData, showTimestamp, showRefresh) {
     return htmlBody;
 };
 
+PWM_ADMIN.initPwNotifyPage = function() {
+    PWM_MAIN.addEventHandler('button-executePwNotifyJob','click',function(){
+        PWM_MAIN.showWaitDialog({loadFunction:function(){
+                var url = PWM_MAIN.addParamToUrl(window.location.pathname, 'processAction','startPwNotifyJob');
+                PWM_MAIN.ajaxRequest(url,function(data){
+                    setTimeout(function(){
+                        PWM_MAIN.showDialog({title:'Job Started',text:data['successMessage'],okAction:function(){
+                                PWM_ADMIN.loadPwNotifyStatus();
+                            }
+                        });
+                    },3000);
+                });
+            }
+        });
+    });
+
+    PWM_MAIN.addEventHandler('button-refreshPwNotifyStatus','click',function(){
+        PWM_MAIN.showWaitDialog({loadFunction:function() {
+                PWM_MAIN.getObject('button-refreshPwNotifyStatus').disabled = true;
+                PWM_ADMIN.loadPwNotifyStatus();
+                PWM_ADMIN.loadPwNotifyLog();
+                setTimeout(function () {
+                    PWM_MAIN.closeWaitDialog();
+                    PWM_MAIN.getObject('button-refreshPwNotifyStatus').disabled = false;
+                },500);
+            }
+        });
+    });
+
+    PWM_ADMIN.loadPwNotifyStatus();
+    setTimeout(function(){
+        PWM_ADMIN.loadPwNotifyStatus();
+    },5000);
+
+    PWM_ADMIN.loadPwNotifyLog();
+};
+
+PWM_ADMIN.loadPwNotifyStatus = function () {
+    var processData = function (data) {
+        var statusData = data['data']['statusData'];
+        var htmlData = '<tr><td colspan="2" class="title">Password Expiration Notification Status</td></tr>';
+        for (var item in statusData) {
+            (function(key){
+                var item = statusData[key];
+                htmlData += '<tr><td>' + item['label'] + '</td><td>';
+                if ( item['type'] === 'timestamp') {
+                    htmlData += '<span id="pwNotifyStatusRow-' + key + '" class="timestamp">' + item['value'] + '</span>';
+                } else {
+                    htmlData += item['value'];
+                }
+                htmlData += '</td></tr>';
+            })(item);
+        }
+
+        PWM_MAIN.getObject('table-pwNotifyStatus').innerHTML = htmlData;
+
+        for (var item in statusData) {
+            (function(key){
+                var item = statusData[key];
+                if ( item['type'] === 'timestamp') {
+                    PWM_MAIN.TimestampHandler.initElement(PWM_MAIN.getObject('pwNotifyStatusRow-' + key));
+                }
+            })(item);
+        }
+
+        PWM_MAIN.getObject('button-executePwNotifyJob').disabled = !data['data']['enableStartButton'];
+    };
+    var url = PWM_MAIN.addParamToUrl(window.location.href,'processAction','readPwNotifyStatus');
+    PWM_MAIN.ajaxRequest(url, processData);
+
+};
+
+PWM_ADMIN.loadPwNotifyLog = function () {
+    var processData = function (data) {
+        var debugData = data['data'];
+        if (debugData && debugData.length > 0) {
+            PWM_MAIN.getObject('div-pwNotifyDebugLog').innerHTML = '';
+            PWM_MAIN.getObject('div-pwNotifyDebugLog').appendChild(document.createTextNode(debugData));
+        } else {
+            PWM_MAIN.getObject('div-pwNotifyDebugLog').innerHTML = '<span class="footnote">Job has not been run on this server since startup.</span>';
+        }
+    };
+    var url = PWM_MAIN.addParamToUrl(window.location.href,'processAction','readPwNotifyLog');
+    PWM_MAIN.ajaxRequest(url, processData);
+
+};
+
 PWM_ADMIN.detailView = function(evt, headers, grid){
     var row = grid.row(evt);
     var text = '<table>';

+ 6 - 2
server/src/main/webapp/public/resources/js/configeditor-settings-form.js

@@ -29,7 +29,12 @@ FormTableHandler.newRowValue = {
     labels:{'':''},
     regexErrors:{'':''},
     selectOptions:{},
-    description:{'':''}
+    description:{'':''},
+    type:'text',
+    placeholder:'',
+    javascript:'',
+    regex:'',
+    source:'ldap'
 };
 
 FormTableHandler.init = function(keyName) {
@@ -209,7 +214,6 @@ FormTableHandler.addRow = function(keyName) {
             PWM_VAR['clientSettingCache'][keyName][currentSize + 1] = FormTableHandler.newRowValue;
             PWM_VAR['clientSettingCache'][keyName][currentSize + 1].name = value;
             PWM_VAR['clientSettingCache'][keyName][currentSize + 1].labels = {'':value};
-            PWM_VAR['clientSettingCache'][keyName][currentSize + 1].type = 'text';
             FormTableHandler.write(keyName,function(){
                 FormTableHandler.init(keyName);
             });

+ 4 - 0
server/src/main/webapp/public/resources/style.css

@@ -1687,3 +1687,7 @@ table.ias-table, table.ias-table td {
 .pwm-icon-status_warn_thick:before {
     content: "\f192";
 }
+
+.ias-fill {
+    display: inline-block;
+}