diff --git a/Core/Resgrid.Config/TelemetryConfig.cs b/Core/Resgrid.Config/TelemetryConfig.cs
index 7e4cf0dc..df61da32 100644
--- a/Core/Resgrid.Config/TelemetryConfig.cs
+++ b/Core/Resgrid.Config/TelemetryConfig.cs
@@ -16,6 +16,9 @@ public static class TelemetryConfig
public static string AptabaseBigBoardApiKey = "";
public static string AptabaseDispatchApiKey = "";
+ public static string CountlyUrl = "";
+ public static string CountlyWebKey = "";
+
public static string GetAnalyticsKey()
{
if (ExporterType == TelemetryExporters.PostHog)
diff --git a/Core/Resgrid.Framework/PasswordComplexityAttribute.cs b/Core/Resgrid.Framework/PasswordComplexityAttribute.cs
new file mode 100644
index 00000000..031174e3
--- /dev/null
+++ b/Core/Resgrid.Framework/PasswordComplexityAttribute.cs
@@ -0,0 +1,66 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+
+namespace Resgrid.Framework
+{
+ ///
+ /// Validation attribute for password complexity requirements
+ ///
+ [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
+ public sealed class PasswordComplexityAttribute : ValidationAttribute
+ {
+ public int MinLength { get; set; } = 8;
+ public bool RequireUppercase { get; set; } = true;
+ public bool RequireLowercase { get; set; } = true;
+ public bool RequireDigit { get; set; } = true;
+ public bool RequireSpecialChar { get; set; } = false;
+
+ public PasswordComplexityAttribute()
+ {
+ ErrorMessage = "Password does not meet complexity requirements";
+ }
+
+ public override bool IsValid(object value)
+ {
+ if (value is not string password)
+ {
+ return false;
+ }
+
+ var result = StringHelpers.VerifyPasswordComplexity(
+ password,
+ MinLength,
+ RequireUppercase,
+ RequireLowercase,
+ RequireDigit,
+ RequireSpecialChar);
+
+ return result.IsValid;
+ }
+
+ protected override ValidationResult IsValid(object value, ValidationContext validationContext)
+ {
+ if (value is not string password)
+ {
+ return new ValidationResult("Invalid password format");
+ }
+
+ var result = StringHelpers.VerifyPasswordComplexity(
+ password,
+ MinLength,
+ RequireUppercase,
+ RequireLowercase,
+ RequireDigit,
+ RequireSpecialChar);
+
+ if (result.IsValid)
+ {
+ return ValidationResult.Success;
+ }
+
+ var errorMessage = string.Join(", ", result.Errors);
+ return new ValidationResult(errorMessage, new[] { validationContext.MemberName });
+ }
+ }
+}
diff --git a/Core/Resgrid.Framework/StringHelpers.cs b/Core/Resgrid.Framework/StringHelpers.cs
index 62626a2b..872be8ce 100644
--- a/Core/Resgrid.Framework/StringHelpers.cs
+++ b/Core/Resgrid.Framework/StringHelpers.cs
@@ -12,6 +12,13 @@
namespace Resgrid.Framework
{
+ ///
+ /// Result of password complexity verification
+ ///
+ public sealed record PasswordComplexityResult(
+ bool IsValid,
+ List Errors);
+
public static class StringHelpers
{
///
@@ -247,5 +254,79 @@ public static string SanitizeCoordinatesString(string source)
return resultStrBuilder.ToString();
}
+ ///
+ /// Verifies password complexity against security requirements
+ ///
+ /// The password to verify
+ /// Minimum password length (default: 8)
+ /// Require at least one uppercase letter (default: true)
+ /// Require at least one lowercase letter (default: true)
+ /// Require at least one digit (default: true)
+ /// Require at least one special character (default: false)
+ /// PasswordComplexityResult indicating validity and any errors
+ public static PasswordComplexityResult VerifyPasswordComplexity(
+ string password,
+ int minLength = 8,
+ bool requireUppercase = true,
+ bool requireLowercase = true,
+ bool requireDigit = true,
+ bool requireSpecialChar = false)
+ {
+ var errors = new List();
+
+ if (string.IsNullOrWhiteSpace(password))
+ {
+ errors.Add("Password cannot be empty");
+ return new PasswordComplexityResult(false, errors);
+ }
+
+ // Check minimum length
+ if (password.Length < minLength)
+ {
+ errors.Add($"Password must be at least {minLength} characters long");
+ }
+
+ // Check for uppercase letter
+ if (requireUppercase && !password.Any(char.IsUpper))
+ {
+ errors.Add("Password must include an uppercase letter");
+ }
+
+ // Check for lowercase letter
+ if (requireLowercase && !password.Any(char.IsLower))
+ {
+ errors.Add("Password must include a lowercase letter");
+ }
+
+ // Check for digit
+ if (requireDigit && !password.Any(char.IsDigit))
+ {
+ errors.Add("Password must include a number (digit)");
+ }
+
+ // Check for special character
+ if (requireSpecialChar)
+ {
+ var specialChars = "!@#$%^&*()_+-=[]{}|;:,.<>?";
+ if (!password.Any(c => specialChars.Contains(c)))
+ {
+ errors.Add("Password must include a special character");
+ }
+ }
+
+ return new PasswordComplexityResult(errors.Count == 0, errors);
+ }
+
+ ///
+ /// Validates password complexity using default Resgrid requirements
+ ///
+ /// The password to validate
+ /// True if password meets complexity requirements
+ public static bool IsValidPassword(string password)
+ {
+ var result = VerifyPasswordComplexity(password);
+ return result.IsValid;
+ }
+
}
}
diff --git a/Core/Resgrid.Services/PushService.cs b/Core/Resgrid.Services/PushService.cs
index 18107d0b..a64bd17d 100644
--- a/Core/Resgrid.Services/PushService.cs
+++ b/Core/Resgrid.Services/PushService.cs
@@ -35,7 +35,7 @@ public PushService(IPushLogsService pushLogsService, INotificationProvider notif
public async Task Register(PushUri pushUri)
{
- if (pushUri == null || String.IsNullOrWhiteSpace(pushUri.DeviceId) || string.IsNullOrWhiteSpace(pushUri.PushLocation))
+ if (pushUri == null || string.IsNullOrWhiteSpace(pushUri.DeviceId) || string.IsNullOrWhiteSpace(pushUri.PushLocation))
return false;
var code = pushUri.PushLocation;
@@ -61,7 +61,7 @@ public async Task UnRegister(PushUri pushUri)
public async Task RegisterUnit(PushUri pushUri)
{
- if (pushUri == null || !pushUri.UnitId.HasValue || string.IsNullOrWhiteSpace(pushUri.PushLocation))
+ if (pushUri == null || !pushUri.UnitId.HasValue || string.IsNullOrWhiteSpace(pushUri.DeviceId) || string.IsNullOrWhiteSpace(pushUri.PushLocation))
return false;
var unitId = pushUri.UnitId.Value;
diff --git a/Tests/Resgrid.Tests/Framework/PasswordComplexityExamples.cs b/Tests/Resgrid.Tests/Framework/PasswordComplexityExamples.cs
new file mode 100644
index 00000000..9255e9a0
--- /dev/null
+++ b/Tests/Resgrid.Tests/Framework/PasswordComplexityExamples.cs
@@ -0,0 +1,90 @@
+using System;
+using System.Linq;
+using Resgrid.Framework;
+
+namespace Resgrid.Tests.Framework
+{
+ ///
+ /// Example usage and tests for password complexity verification
+ ///
+ public static class PasswordComplexityExamples
+ {
+ ///
+ /// Demonstrates how to use the password complexity verification
+ ///
+ public static void RunExamples()
+ {
+ // Test cases
+ var testPasswords = new[]
+ {
+ "abc123", // Too short, no uppercase
+ "ABC123", // No lowercase
+ "abcDEF", // No digits
+ "Abc123", // Valid according to default requirements
+ "Password123", // Valid
+ "MyPassword1", // Valid
+ "", // Empty
+ "Abc123!@#" // Valid with special chars
+ };
+
+ Console.WriteLine("Password Complexity Verification Examples:");
+ Console.WriteLine("=========================================");
+
+ foreach (var password in testPasswords)
+ {
+ var result = StringHelpers.VerifyPasswordComplexity(password);
+ Console.WriteLine($"Password: '{password}'");
+ Console.WriteLine($"Valid: {result.IsValid}");
+ if (!result.IsValid)
+ {
+ Console.WriteLine($"Errors: {string.Join(", ", result.Errors)}");
+ }
+ Console.WriteLine();
+ }
+
+ // Test with custom requirements matching current RegisterViewModel
+ Console.WriteLine("Resgrid Default Requirements:");
+ Console.WriteLine("============================");
+
+ var resgridDefaults = new[]
+ {
+ "abc123", // Should fail
+ "Password1", // Should pass
+ "MySecurePass123" // Should pass
+ };
+
+ foreach (var password in resgridDefaults)
+ {
+ var result = StringHelpers.VerifyPasswordComplexity(
+ password,
+ minLength: 8,
+ requireUppercase: true,
+ requireLowercase: true,
+ requireDigit: true,
+ requireSpecialChar: false);
+
+ Console.WriteLine($"Password: '{password}'");
+ Console.WriteLine($"Valid: {result.IsValid}");
+ if (!result.IsValid)
+ {
+ Console.WriteLine($"Errors: {string.Join(", ", result.Errors)}");
+ }
+ Console.WriteLine();
+ }
+ }
+
+ ///
+ /// Simple validation method for quick checks using Resgrid defaults
+ ///
+ public static bool ValidatePasswordForResgrid(string password)
+ {
+ return StringHelpers.VerifyPasswordComplexity(
+ password,
+ minLength: 8,
+ requireUppercase: true,
+ requireLowercase: true,
+ requireDigit: true,
+ requireSpecialChar: false).IsValid;
+ }
+ }
+}
diff --git a/Web/Resgrid.Web/Areas/User/Models/AddPersonModel.cs b/Web/Resgrid.Web/Areas/User/Models/AddPersonModel.cs
index 3a6c1b6b..02657046 100644
--- a/Web/Resgrid.Web/Areas/User/Models/AddPersonModel.cs
+++ b/Web/Resgrid.Web/Areas/User/Models/AddPersonModel.cs
@@ -1,9 +1,10 @@
-using System;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using Resgrid.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Identity;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
-using Microsoft.AspNetCore.Mvc.Rendering;
-using Resgrid.Model.Identity;
-using Resgrid.Model;
namespace Resgrid.Web.Areas.User.Models
{
@@ -42,7 +43,13 @@ public class AddPersonModel: BaseUserModel
[DataType(DataType.EmailAddress)]
public string Email { get; set; }
- [StringLength(100, ErrorMessage = "The {0} must be at least {2} characters long.", MinimumLength = 6)]
+ [StringLength(100, ErrorMessage = "The password must be at least 8 characters long", MinimumLength = 8)]
+ [PasswordComplexity(
+ MinLength = 8,
+ RequireUppercase = true,
+ RequireLowercase = true,
+ RequireDigit = true,
+ RequireSpecialChar = false)]
[DataType(DataType.Password)]
[Display(Name = "Password")]
[Required]
diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml
index 07ccffcf..a73bec22 100644
--- a/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml
+++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml
@@ -58,6 +58,19 @@
})
}
+
+ @if (!String.IsNullOrWhiteSpace(Resgrid.Config.TelemetryConfig.CountlyUrl) && !String.IsNullOrWhiteSpace(Resgrid.Config.TelemetryConfig.CountlyWebKey))
+ {
+
+
+ }
diff --git a/Web/Resgrid.Web/Models/AccountViewModels/LoginViewModel.cs b/Web/Resgrid.Web/Models/AccountViewModels/LoginViewModel.cs
index cfbd9027..79f3078a 100644
--- a/Web/Resgrid.Web/Models/AccountViewModels/LoginViewModel.cs
+++ b/Web/Resgrid.Web/Models/AccountViewModels/LoginViewModel.cs
@@ -5,10 +5,12 @@ namespace Resgrid.Web.Models.AccountViewModels
public class LoginViewModel
{
[Required]
- public string Username { get; set; }
+ [StringLength(250, ErrorMessage = "The username must be at least 2 characters long and contain only alphanumeric characters.", MinimumLength = 2)]
+ public string Username { get; set; }
[Required]
- [DataType(DataType.Password)]
+ [StringLength(100, ErrorMessage = "The password must be at least 8 characters long, include a number (digit) and an uppercase letter", MinimumLength = 4)]
+ [DataType(DataType.Password)]
public string Password { get; set; }
[Display(Name = "Remember me?")]
diff --git a/Web/Resgrid.Web/Models/AccountViewModels/RegisterViewModel.cs b/Web/Resgrid.Web/Models/AccountViewModels/RegisterViewModel.cs
index a7c9a4f9..c95d79e0 100644
--- a/Web/Resgrid.Web/Models/AccountViewModels/RegisterViewModel.cs
+++ b/Web/Resgrid.Web/Models/AccountViewModels/RegisterViewModel.cs
@@ -1,4 +1,5 @@
using Resgrid.WebCore.Models;
+using Resgrid.Framework;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
@@ -36,7 +37,13 @@ public class RegisterViewModel: GoogleReCaptchaModelBase
public string Email { get; set; }
[Required]
- [StringLength(100, ErrorMessage = "The passowrd must be at least 8 characters long, include a number (digit) and an uppercase letter", MinimumLength = 8)]
+ [StringLength(100, ErrorMessage = "The password must be at least 8 characters long", MinimumLength = 8)]
+ [PasswordComplexity(
+ MinLength = 8,
+ RequireUppercase = true,
+ RequireLowercase = true,
+ RequireDigit = true,
+ RequireSpecialChar = false)]
[DataType(DataType.Password)]
[Display(Name = "Password")]
public string Password { get; set; }
diff --git a/Web/Resgrid.Web/Startup.cs b/Web/Resgrid.Web/Startup.cs
index 1c534eb0..e2c27c3d 100644
--- a/Web/Resgrid.Web/Startup.cs
+++ b/Web/Resgrid.Web/Startup.cs
@@ -350,29 +350,35 @@ public void ConfigureServices(IServiceCollection services)
pipeline.MinifyCssFiles("/css/**/*.css");
// Public (external website) public style bundles
- pipeline.AddCssBundle("/css/pub-bundle.css", "css/style.css", "css/animate.css", "lib/font-awesome/css/font-awesome.min.css");
+ pipeline.AddCssBundle("/css/pub-bundle.css",
+ "css/style.css", "css/animate.css", "lib/font-awesome/css/font-awesome.min.css");
// Angular App code
- pipeline.AddJavaScriptBundle("/js/ng/app.js", "js/ng/vendor.js", "js/ng/runtime.js", "js/ng/polyfills.js", "js/ng/main.js");
+ pipeline.AddJavaScriptBundle("/js/ng/app.js",
+ "js/ng/vendor.js", "js/ng/runtime.js", "js/ng/polyfills.js", "js/ng/main.js");
// Internal app style bundle
- pipeline.AddCssBundle("/css/int-bundle.css", "lib/font-awesome/css/font-awesome.min.css", "lib/metisMenu/dist/metisMenu.min.css", "lib/bootstrap-tour/build/css/bootstrap-tour.min.css",
+ pipeline.AddCssBundle("/css/int-bundle.css",
+ "lib/font-awesome/css/font-awesome.min.css", "lib/metisMenu/dist/metisMenu.min.css", "lib/bootstrap-tour/build/css/bootstrap-tour.min.css",
"css/animate.css", "lib/select2/dist/css/select2.min.css", "clib/kendo/styles/kendo.common.min.css", "clib/kendo/styles/kendo.material.min.css",
"lib/toastr/toastr.min.css", "lib/jqueryui/themes/cupertino/jquery-ui.css", "lib/awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css",
"clib/picEdit/css/picedit.min.css", "clib/bootstrap-wizard/bootstrap-wizard.css", "lib/quill/dist/quill.snow.css", "lib/leaflet/dist/leaflet.css",
"lib/fullcalendar/dist/fullcalendar.min.css", "lib/bstreeview/dist/css/bstreeview.min.css",
"lib/selectize/selectize/dist/css/selectize.default.css", "lib/claviska/jquery-minicolors/jquery.minicolors.css", "lib/algolia/autocomplete-theme-classic/dist/theme.css",
- "lib/bootstrap-select/dist/css/bootstrap-select.css", "clib/data-tables/datatables.css", "lib/jquery-datetimepicker/build/jquery.datetimepicker.min.css", "css/style.css");
+ "lib/bootstrap-select/dist/css/bootstrap-select.css", "clib/data-tables/datatables.css", "lib/jquery-datetimepicker/build/jquery.datetimepicker.min.css",
+ "lib/deltablot/dropzone/dist/dropzone.css", "css/style.css");
// Internal app js bundle
- pipeline.AddJavaScriptBundle("/js/int-bundle.js", "lib/metisMenu/dist/metisMenu.min.js", "lib/slimScroll/jquery.slimscroll.js", "lib/pace/pace.js",
+ pipeline.AddJavaScriptBundle("/js/int-bundle.js",
+ "lib/metisMenu/dist/metisMenu.min.js", "lib/slimScroll/jquery.slimscroll.js", "lib/pace/pace.js",
"lib/select2/dist/js/select2.full.js", "clib/kendo/js/kendo.web.min.js", "lib/bootstrap-tour/build/js/bootstrap-tour.min.js", "lib/toastr/toastr.min.js",
/*"clib/markerwithlabel/markerwithlabel.js",*/ "clib/ujs/jquery-ujs.js", "lib/jquery-validate/dist/jquery.validate.min.js", "lib/jqueryui/jquery-ui.min.js",
"lib/jquery-validation-unobtrusive/dist/jquery.validate.unobtrusive.min.js", "lib/signalr/dist/browser/signalr.js", "clib/picEdit/js/picedit.min.js",
"lib/sweetalert/dist/sweetalert.min.js", "clib/bootstrap-wizard/bootstrap-wizard.min.js", "lib/quill/dist/quill.min.js", "lib/moment/min/moment.min.js",
"lib/fullcalendar/dist/fullcalendar.min.js", "lib/leaflet/dist/leaflet.js", "lib/bstreeview/dist/js/bstreeview.min.js",
"lib/selectize/selectize/dist/js/standalone/selectize.min.js", "lib/claviska/jquery-minicolors/jquery.minicolors.min.js", "lib/algolia/autocomplete-js/dist/umd/index.production.js",
- "lib/bootstrap-select/dist/js/bootstrap-select.js", "clib/data-tables/datatables.js", "lib/jquery-datetimepicker/build/jquery.datetimepicker.full.min.js", "js/site.min.js");
+ "lib/bootstrap-select/dist/js/bootstrap-select.js", "clib/data-tables/datatables.js", "lib/jquery-datetimepicker/build/jquery.datetimepicker.full.min.js",
+ "lib/deltablot/dropzone/dist/dropzone.js", "js/site.min.js");
});
diff --git a/Web/Resgrid.Web/libman.json b/Web/Resgrid.Web/libman.json
index dd56a946..ca650e88 100644
--- a/Web/Resgrid.Web/libman.json
+++ b/Web/Resgrid.Web/libman.json
@@ -284,6 +284,19 @@
{
"library": "printthis@0.1.5",
"destination": "wwwroot/lib/printthis/"
+ },
+ {
+ "library": "@deltablot/dropzone@7.3.1",
+ "destination": "wwwroot/lib/deltablot/dropzone/"
+ },
+ {
+ "library": "countly-sdk-web@25.4.1",
+ "destination": "wwwroot/lib/countly-sdk-web/",
+ "files": [
+ "lib/countly.d.ts",
+ "lib/countly.js",
+ "lib/countly.min.js"
+ ]
}
]
}
\ No newline at end of file
diff --git a/Web/Resgrid.Web/wwwroot/js/app/common/analytics/resgrid.common.analytics.js b/Web/Resgrid.Web/wwwroot/js/app/common/analytics/resgrid.common.analytics.js
index 4119b59a..5e1d53b0 100644
--- a/Web/Resgrid.Web/wwwroot/js/app/common/analytics/resgrid.common.analytics.js
+++ b/Web/Resgrid.Web/wwwroot/js/app/common/analytics/resgrid.common.analytics.js
@@ -12,6 +12,20 @@ var resgrid;
{ email: email, name: name, createdOn: new Date(createdOn * 1000), departmentId: departmentId, departmentName: departmentName } // optional: set additional person properties
);
}
+
+ if (typeof Countly !== "undefined") {
+ Countly.q.push(['set_id', userId]);
+ Countly.q.push(['user_details', {
+ "name": name,
+ "email": email,
+ "organization": departmentName,
+ "custom": {
+ "createdOn": new Date(createdOn * 1000),
+ "departmentId": departmentId
+ }]);
+ Countly.q.push(['track_sessions']);
+ Countly.q.push(['track_pageview']);
+ }
}
}
analytics.register = register;
@@ -20,6 +34,16 @@ var resgrid;
if (typeof posthog !== "undefined") {
posthog.capture(event)
}
+
+ if (typeof Aptabase !== 'undefined') {
+ Aptabase.trackEvent(event);
+ }
+
+ if (typeof Countly !== 'undefined') {
+ Countly.q.push(['add_event', {
+ "key": event
+ }]);
+ }
}
}
analytics.track = track;