ソースを参照

WIP new WebAdmin: IP lists pages

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 1 年間 前
コミット
8180b75ef1

+ 8 - 8
go.mod

@@ -10,10 +10,10 @@ require (
 	github.com/alexedwards/argon2id v1.0.0
 	github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964
 	github.com/aws/aws-sdk-go-v2 v1.24.1
-	github.com/aws/aws-sdk-go-v2/config v1.26.5
+	github.com/aws/aws-sdk-go-v2/config v1.26.6
 	github.com/aws/aws-sdk-go-v2/credentials v1.16.16
 	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11
-	github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.13
+	github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.14
 	github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.19.6
 	github.com/aws/aws-sdk-go-v2/service/s3 v1.48.0
 	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.26.2
@@ -32,7 +32,7 @@ require (
 	github.com/go-sql-driver/mysql v1.7.1
 	github.com/golang/mock v1.6.0
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
-	github.com/google/uuid v1.5.0
+	github.com/google/uuid v1.6.0
 	github.com/hashicorp/go-hclog v1.6.2
 	github.com/hashicorp/go-plugin v1.6.0
 	github.com/hashicorp/go-retryablehttp v0.7.5
@@ -88,7 +88,7 @@ require (
 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect
 	github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect
 	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 // indirect
 	github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.10 // indirect
@@ -171,10 +171,10 @@ require (
 	golang.org/x/tools v0.17.0 // indirect
 	golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
 	google.golang.org/appengine v1.6.8 // indirect
-	google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20240116215550-a9fa1716bcac // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac // indirect
-	google.golang.org/grpc v1.60.1 // indirect
+	google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20240123012728-ef4313101c80 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 // indirect
+	google.golang.org/grpc v1.61.0 // indirect
 	google.golang.org/protobuf v1.32.0 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect

+ 18 - 18
go.sum

@@ -37,20 +37,20 @@ github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3
 github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs=
 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo=
-github.com/aws/aws-sdk-go-v2/config v1.26.5 h1:lodGSevz7d+kkFJodfauThRxK9mdJbyutUxGq1NNhvw=
-github.com/aws/aws-sdk-go-v2/config v1.26.5/go.mod h1:DxHrz6diQJOc9EwDslVRh84VjjrE17g+pVZXUeSxaDU=
+github.com/aws/aws-sdk-go-v2/config v1.26.6 h1:Z/7w9bUqlRI0FFQpetVuFYEsjzE3h7fpU6HuGmfPL/o=
+github.com/aws/aws-sdk-go-v2/config v1.26.6/go.mod h1:uKU6cnDmYCvJ+pxO9S4cWDb2yWWIH5hra+32hVh1MI4=
 github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8=
 github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0=
 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8=
 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.13 h1:8Nt4LBUEKV0FxLBO2BmRzDKax3hp2LRMKySMBwL4vMc=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.13/go.mod h1:t5QEDu/FBJJM4kslbQlTSpYtnhoWDNmHSsgQojIxE0o=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.14 h1:ogP1WgyvN/qxPJkgtFMD7G2eKb5p/61Jomx+nIHXUQ4=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.14/go.mod h1:nYd/WmIrXlBHW/5QwrZP81/Gz08wKi87nV6EI1kmqx4=
 github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4=
 github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4=
 github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw=
 github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 h1:n3GDfwqF2tzEkXlv5cuy4iy7LpKDtqDMcNLfZDu9rls=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY=
 github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10 h1:5oE2WzJE56/mVveuDZPJESKlg/00AaS2pY2QZcnxg4M=
 github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10/go.mod h1:FHbKWQtRBYUz4vO5WBWjzMD2by126ny5y/1EoaWoLfI=
 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw=
@@ -91,8 +91,8 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj
 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
-github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4 h1:/inchEIKaYC1Akx+H+gqO04wryn5h75LSazbRlnya1k=
-github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20231109132714-523115ebc101 h1:7To3pQ+pZo0i3dsWEbinPNFs5gPSBOsJtx3wTT94VBY=
+github.com/cncf/xds/go v0.0.0-20231109132714-523115ebc101/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cockroachdb/cockroach-go/v2 v2.3.6 h1:Wlv9TzkrG9V7i6u8dEtmXPrBzvfFp+CgJNs696rAajM=
 github.com/cockroachdb/cockroach-go/v2 v2.3.6/go.mod h1:1wNJ45eSXW9AnOc3skntW9ZUZz6gxrQK3cOj3rK+BC8=
 github.com/coreos/go-oidc/v3 v3.9.0 h1:0J/ogVOd4y8P0f0xUh8l9t07xRP/d8tccvjHl2dcsSo=
@@ -208,8 +208,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
-github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8=
 github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU=
 github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
@@ -531,19 +531,19 @@ google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJ
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac h1:ZL/Teoy/ZGnzyrqK/Optxxp2pmVh+fmJ97slxSRyzUg=
-google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:+Rvu7ElI+aLzyDQhpHMFMMltsD6m7nqpuWDd2CwJw3k=
-google.golang.org/genproto/googleapis/api v0.0.0-20240116215550-a9fa1716bcac h1:OZkkudMUu9LVQMCoRUbI/1p5VCo9BOrlvkqMvWtqa6s=
-google.golang.org/genproto/googleapis/api v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:B5xPO//w8qmBDjGReYLpR6UJPnkldGkCSMoH/2vxJeg=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac h1:nUQEQmH/csSvFECKYRv6HWEyypysidKl2I6Qpsglq/0=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:daQN87bsDqDoe316QbbvX60nMoJQa4r6Ds0ZuoAe5yA=
+google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 h1:KAeGQVN3M9nD0/bQXnr/ClcEMJ968gUXJQ9pwfSynuQ=
+google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80/go.mod h1:cc8bqMqtv9gMOr0zHg2Vzff5ULhhL2IXP4sbcn32Dro=
+google.golang.org/genproto/googleapis/api v0.0.0-20240123012728-ef4313101c80 h1:Lj5rbfG876hIAYFjqiJnPHfhXbv+nzTWfm04Fg/XSVU=
+google.golang.org/genproto/googleapis/api v0.0.0-20240123012728-ef4313101c80/go.mod h1:4jWUdICTdgc3Ibxmr8nAJiiLHwQBY0UI0XZcEMaFKaA=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 h1:AjyfHzEPEFp/NpvfN5g+KDla3EMojjhRVZc1i7cj+oM=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
 google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
 google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
 google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
-google.golang.org/grpc v1.60.1 h1:26+wFr+cNqSGFcOXcabYC0lUVJVRa2Sb2ortSK7VrEU=
-google.golang.org/grpc v1.60.1/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM=
+google.golang.org/grpc v1.61.0 h1:TOvOcuXn30kRao+gfcvsebNEa5iZIiLkisYEkf7R7o0=
+google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=

+ 4 - 1
internal/dataprovider/bolt.go

@@ -2827,7 +2827,10 @@ func (p *BoltProvider) addIPListEntry(entry *IPListEntry) error {
 			return err
 		}
 		if e := bucket.Get([]byte(entry.getKey())); e != nil {
-			return fmt.Errorf("entry %q already exists", entry.IPOrNet)
+			return util.NewI18nError(
+				fmt.Errorf("entry %q already exists", entry.IPOrNet),
+				util.I18nErrorDuplicatedIPNet,
+			)
 		}
 		entry.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		entry.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())

+ 1 - 0
internal/dataprovider/dataprovider.go

@@ -151,6 +151,7 @@ const (
 const (
 	fieldUsername = 1
 	fieldName     = 2
+	fieldIPNet    = 3
 )
 
 var (

+ 3 - 3
internal/dataprovider/iplist.go

@@ -214,7 +214,7 @@ func (e *IPListEntry) validate() error {
 		// parse as IP
 		parsed, err := netip.ParseAddr(e.IPOrNet)
 		if err != nil {
-			return util.NewValidationError(fmt.Sprintf("invalid IP %q", e.IPOrNet))
+			return util.NewI18nError(util.NewValidationError(fmt.Sprintf("invalid IP %q", e.IPOrNet)), util.I18nErrorIpInvalid)
 		}
 		if parsed.Is4() {
 			e.IPOrNet += "/32"
@@ -226,7 +226,7 @@ func (e *IPListEntry) validate() error {
 	}
 	prefix, err := netip.ParsePrefix(e.IPOrNet)
 	if err != nil {
-		return util.NewValidationError(fmt.Sprintf("invalid network %q: %v", e.IPOrNet, err))
+		return util.NewI18nError(util.NewValidationError(fmt.Sprintf("invalid network %q: %v", e.IPOrNet, err)), util.I18nErrorNetInvalid)
 	}
 	prefix = prefix.Masked()
 	if prefix.Addr().Is4In6() {
@@ -235,7 +235,7 @@ func (e *IPListEntry) validate() error {
 	// TODO: to remove when the in memory ranger switch to netip
 	_, _, err = net.ParseCIDR(e.IPOrNet)
 	if err != nil {
-		return util.NewValidationError(fmt.Sprintf("invalid network: %v", err))
+		return util.NewI18nError(util.NewValidationError(fmt.Sprintf("invalid network: %v", err)), util.I18nErrorNetInvalid)
 	}
 	if prefix.Addr().Is4() || prefix.Addr().Is4In6() {
 		e.IPType = ipTypeV4

+ 4 - 1
internal/dataprovider/memory.go

@@ -2672,7 +2672,10 @@ func (p *MemoryProvider) addIPListEntry(entry *IPListEntry) error {
 	}
 	_, err := p.ipListEntryExistsInternal(entry)
 	if err == nil {
-		return fmt.Errorf("entry %q already exists", entry.IPOrNet)
+		return util.NewI18nError(
+			fmt.Errorf("entry %q already exists", entry.IPOrNet),
+			util.I18nErrorDuplicatedIPNet,
+		)
 	}
 	entry.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 	entry.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())

+ 8 - 3
internal/dataprovider/mysql.go

@@ -708,7 +708,7 @@ func (p *MySQLProvider) ipListEntryExists(ipOrNet string, listType IPListType) (
 }
 
 func (p *MySQLProvider) addIPListEntry(entry *IPListEntry) error {
-	return sqlCommonAddIPListEntry(entry, p.dbHandle)
+	return p.normalizeError(sqlCommonAddIPListEntry(entry, p.dbHandle), fieldIPNet)
 }
 
 func (p *MySQLProvider) updateIPListEntry(entry *IPListEntry) error {
@@ -834,9 +834,14 @@ func (p *MySQLProvider) normalizeError(err error, fieldType int) error {
 	if errors.As(err, &mysqlErr) {
 		switch mysqlErr.Number {
 		case 1062:
-			message := util.I18nErrorDuplicatedName
-			if fieldType == fieldUsername {
+			var message string
+			switch fieldType {
+			case fieldUsername:
 				message = util.I18nErrorDuplicatedUsername
+			case fieldIPNet:
+				message = util.I18nErrorDuplicatedIPNet
+			default:
+				message = util.I18nErrorDuplicatedName
 			}
 			return util.NewI18nError(
 				fmt.Errorf("%w: %s", ErrDuplicatedKey, err.Error()),

+ 8 - 3
internal/dataprovider/pgsql.go

@@ -721,7 +721,7 @@ func (p *PGSQLProvider) ipListEntryExists(ipOrNet string, listType IPListType) (
 }
 
 func (p *PGSQLProvider) addIPListEntry(entry *IPListEntry) error {
-	return sqlCommonAddIPListEntry(entry, p.dbHandle)
+	return p.normalizeError(sqlCommonAddIPListEntry(entry, p.dbHandle), fieldIPNet)
 }
 
 func (p *PGSQLProvider) updateIPListEntry(entry *IPListEntry) error {
@@ -853,9 +853,14 @@ func (p *PGSQLProvider) normalizeError(err error, fieldType int) error {
 	if errors.As(err, &pgsqlErr) {
 		switch pgsqlErr.Code {
 		case "23505":
-			message := util.I18nErrorDuplicatedName
-			if fieldType == fieldUsername {
+			var message string
+			switch fieldType {
+			case fieldUsername:
 				message = util.I18nErrorDuplicatedUsername
+			case fieldIPNet:
+				message = util.I18nErrorDuplicatedIPNet
+			default:
+				message = util.I18nErrorDuplicatedName
 			}
 			return util.NewI18nError(
 				fmt.Errorf("%w: %s", ErrDuplicatedKey, err.Error()),

+ 8 - 3
internal/dataprovider/sqlite.go

@@ -629,7 +629,7 @@ func (p *SQLiteProvider) ipListEntryExists(ipOrNet string, listType IPListType)
 }
 
 func (p *SQLiteProvider) addIPListEntry(entry *IPListEntry) error {
-	return sqlCommonAddIPListEntry(entry, p.dbHandle)
+	return p.normalizeError(sqlCommonAddIPListEntry(entry, p.dbHandle), fieldIPNet)
 }
 
 func (p *SQLiteProvider) updateIPListEntry(entry *IPListEntry) error {
@@ -753,9 +753,14 @@ func (p *SQLiteProvider) normalizeError(err error, fieldType int) error {
 	if e, ok := err.(sqlite3.Error); ok {
 		switch e.ExtendedCode {
 		case 1555, 2067:
-			message := util.I18nErrorDuplicatedName
-			if fieldType == fieldUsername {
+			var message string
+			switch fieldType {
+			case fieldUsername:
 				message = util.I18nErrorDuplicatedUsername
+			case fieldIPNet:
+				message = util.I18nErrorDuplicatedIPNet
+			default:
+				message = util.I18nErrorDuplicatedName
 			}
 			return util.NewI18nError(
 				fmt.Errorf("%w: %s", ErrDuplicatedKey, err.Error()),

+ 16 - 19
internal/httpd/webadmin.go

@@ -102,11 +102,8 @@ const (
 	pageEventRulesTitle      = "Event rules"
 	pageEventActionsTitle    = "Event actions"
 	pageMaintenanceTitle     = "Maintenance"
-	pageDefenderTitle        = "Auto Blocklist"
-	pageIPListsTitle         = "IP Lists"
 	pageEventsTitle          = "Logs"
 	pageConfigsTitle         = "Configurations"
-	pageSetupTitle           = "Create first admin user"
 	defaultQueryLimit        = 1000
 	inversePatternType       = "inverse"
 )
@@ -259,7 +256,7 @@ type ipListsPage struct {
 type ipListPage struct {
 	basePage
 	Entry *dataprovider.IPListEntry
-	Error string
+	Error *util.I18nError
 	Mode  genericPageMode
 }
 
@@ -460,17 +457,17 @@ func loadAdminTemplates(templatesPath string) {
 		filepath.Join(templatesPath, templateAdminDir, templateMaintenance),
 	}
 	defenderPaths := []string{
-		filepath.Join(templatesPath, templateCommonDir, templateCommonCSS),
+		filepath.Join(templatesPath, templateCommonDir, templateCommonBase),
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateDefender),
 	}
 	ipListsPaths := []string{
-		filepath.Join(templatesPath, templateCommonDir, templateCommonCSS),
+		filepath.Join(templatesPath, templateCommonDir, templateCommonBase),
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateIPLists),
 	}
 	ipListPaths := []string{
-		filepath.Join(templatesPath, templateCommonDir, templateCommonCSS),
+		filepath.Join(templatesPath, templateCommonDir, templateCommonBase),
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateIPList),
 	}
@@ -997,20 +994,20 @@ func (s *httpdServer) renderUserPage(w http.ResponseWriter, r *http.Request, use
 }
 
 func (s *httpdServer) renderIPListPage(w http.ResponseWriter, r *http.Request, entry dataprovider.IPListEntry,
-	mode genericPageMode, error string,
+	mode genericPageMode, err error,
 ) {
 	var title, currentURL string
 	switch mode {
 	case genericPageModeAdd:
-		title = "Add a new IP List entry"
+		title = util.I18nAddIPListTitle
 		currentURL = fmt.Sprintf("%s/%d", webIPListPath, entry.Type)
 	case genericPageModeUpdate:
-		title = "Update IP List entry"
+		title = util.I18nUpdateIPListTitle
 		currentURL = fmt.Sprintf("%s/%d/%s", webIPListPath, entry.Type, url.PathEscape(entry.IPOrNet))
 	}
 	data := ipListPage{
 		basePage: s.getBasePageData(title, currentURL, r),
-		Error:    error,
+		Error:    getI18nError(err),
 		Entry:    &entry,
 		Mode:     mode,
 	}
@@ -2955,7 +2952,7 @@ func (s *httpdServer) handleWebUpdateAdminPost(w http.ResponseWriter, r *http.Re
 func (s *httpdServer) handleWebDefenderPage(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	data := defenderHostsPage{
-		basePage:         s.getBasePageData(pageDefenderTitle, webDefenderPath, r),
+		basePage:         s.getBasePageData(util.I18nDefenderTitle, webDefenderPath, r),
 		DefenderHostsURL: webDefenderHostsPath,
 	}
 
@@ -3986,7 +3983,7 @@ func (s *httpdServer) handleWebIPListsPage(w http.ResponseWriter, r *http.Reques
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	rtlStatus, rtlProtocols := common.Config.GetRateLimitersStatus()
 	data := ipListsPage{
-		basePage:              s.getBasePageData(pageIPListsTitle, webIPListsPath, r),
+		basePage:              s.getBasePageData(util.I18nIPListsTitle, webIPListsPath, r),
 		RateLimitersStatus:    rtlStatus,
 		RateLimitersProtocols: strings.Join(rtlProtocols, ", "),
 		IsAllowListEnabled:    common.Config.IsAllowListEnabled(),
@@ -4002,7 +3999,7 @@ func (s *httpdServer) handleWebAddIPListEntryGet(w http.ResponseWriter, r *http.
 		s.renderBadRequestPage(w, r, err)
 		return
 	}
-	s.renderIPListPage(w, r, dataprovider.IPListEntry{Type: listType}, genericPageModeAdd, "")
+	s.renderIPListPage(w, r, dataprovider.IPListEntry{Type: listType}, genericPageModeAdd, nil)
 }
 
 func (s *httpdServer) handleWebAddIPListEntryPost(w http.ResponseWriter, r *http.Request) {
@@ -4014,7 +4011,7 @@ func (s *httpdServer) handleWebAddIPListEntryPost(w http.ResponseWriter, r *http
 	}
 	entry, err := getIPListEntryFromPostFields(r, listType)
 	if err != nil {
-		s.renderIPListPage(w, r, entry, genericPageModeAdd, err.Error())
+		s.renderIPListPage(w, r, entry, genericPageModeAdd, err)
 		return
 	}
 	entry.Type = listType
@@ -4030,7 +4027,7 @@ func (s *httpdServer) handleWebAddIPListEntryPost(w http.ResponseWriter, r *http
 	}
 	err = dataprovider.AddIPListEntry(&entry, claims.Username, ipAddr, claims.Role)
 	if err != nil {
-		s.renderIPListPage(w, r, entry, genericPageModeAdd, err.Error())
+		s.renderIPListPage(w, r, entry, genericPageModeAdd, err)
 		return
 	}
 	http.Redirect(w, r, webIPListsPath, http.StatusSeeOther)
@@ -4045,7 +4042,7 @@ func (s *httpdServer) handleWebUpdateIPListEntryGet(w http.ResponseWriter, r *ht
 	}
 	entry, err := dataprovider.IPListEntryExists(ipOrNet, listType)
 	if err == nil {
-		s.renderIPListPage(w, r, entry, genericPageModeUpdate, "")
+		s.renderIPListPage(w, r, entry, genericPageModeUpdate, nil)
 	} else if errors.Is(err, util.ErrNotFound) {
 		s.renderNotFoundPage(w, r, err)
 	} else {
@@ -4075,7 +4072,7 @@ func (s *httpdServer) handleWebUpdateIPListEntryPost(w http.ResponseWriter, r *h
 	}
 	updatedEntry, err := getIPListEntryFromPostFields(r, listType)
 	if err != nil {
-		s.renderIPListPage(w, r, entry, genericPageModeUpdate, err.Error())
+		s.renderIPListPage(w, r, entry, genericPageModeUpdate, err)
 		return
 	}
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
@@ -4087,7 +4084,7 @@ func (s *httpdServer) handleWebUpdateIPListEntryPost(w http.ResponseWriter, r *h
 	updatedEntry.IPOrNet = ipOrNet
 	err = dataprovider.UpdateIPListEntry(&updatedEntry, claims.Username, ipAddr, claims.Role)
 	if err != nil {
-		s.renderIPListPage(w, r, entry, genericPageModeUpdate, err.Error())
+		s.renderIPListPage(w, r, entry, genericPageModeUpdate, err)
 		return
 	}
 	http.Redirect(w, r, webIPListsPath, http.StatusSeeOther)

+ 7 - 0
internal/util/i18n.go

@@ -63,6 +63,10 @@ const (
 	I18nSessionsTitle                  = "title.connections"
 	I18nRolesTitle                     = "title.roles"
 	I18nAdminsTitle                    = "title.admins"
+	I18nIPListsTitle                   = "title.ip_lists"
+	I18nAddIPListTitle                 = "title.add_ip_list"
+	I18nUpdateIPListTitle              = "title.update_ip_list"
+	I18nDefenderTitle                  = "title.defender"
 	I18nErrorSetupInstallCode          = "setup.install_code_mismatch"
 	I18nInvalidAuth                    = "general.invalid_auth_request"
 	I18nError429Message                = "general.error429"
@@ -204,6 +208,7 @@ const (
 	I18nTemplateFolderTitle            = "title.template_folder"
 	I18nErrorDuplicatedUsername        = "general.duplicated_username"
 	I18nErrorDuplicatedName            = "general.duplicated_name"
+	I18nErrorDuplicatedIPNet           = "ip_list.duplicated"
 	I18nErrorRoleAdminPerms            = "admin.role_permissions"
 	I18nBackupOK                       = "general.backup_ok"
 	I18nErrorFolderTemplate            = "virtual_folders.template_no_folder"
@@ -218,6 +223,8 @@ const (
 	I18nErrorAdminSelfPerms            = "admin.self_permissions"
 	I18nErrorAdminSelfDisable          = "admin.self_disable"
 	I18nErrorAdminSelfRole             = "admin.self_role"
+	I18nErrorIpInvalid                 = "ip_list.ip_invalid"
+	I18nErrorNetInvalid                = "ip_list.net_invalid"
 )
 
 // NewI18nError returns a I18nError wrappring the provided error

+ 33 - 2
static/locales/en/translation.json

@@ -58,7 +58,9 @@
         "add_role": "Add role",
         "update_role": "Update role",
         "add_admin": "Add admin",
-        "update_admin": "Update admin"
+        "update_admin": "Update admin",
+        "add_ip_list": "Add IP list entry",
+        "update_ip_list": "Update IP list entry"
     },
     "setup": {
         "desc": "To start using SFTPGo you need to create an administrator user",
@@ -229,7 +231,10 @@
         "members": "Members",
         "members_summary": "Users: {{users}}. Admins: {{admins}}",
         "status": "Status",
-        "last_login": "Last login"
+        "last_login": "Last login",
+        "previous": "Previous",
+        "next": "Next",
+        "type": "Type"
     },
     "fs": {
         "view_file": "View file \"{{- path}}\"",
@@ -710,5 +715,31 @@
     },
     "role": {
         "view_manage": "View and manage roles"
+    },
+    "ip_list": {
+        "view_manage": "View and manage IP lists",
+        "defender_list": "Defender",
+        "allow_list": "Allow list",
+        "ratelimiters_safe_list": "Rate limiters safe list",
+        "ip_net": "IP/Network",
+        "protocols": "Protocols",
+        "mode": "Mode",
+        "any": "Any",
+        "allow": "Allow",
+        "deny": "Deny",
+        "ip_net_help": "IP address or network in CIDR format, example: \"192.168.1.1 or 10.8.0.100/32 or 2001:db8:1234::/48\"",
+        "ip_invalid": "Invalid IP address",
+        "net_invalid": "Invalid network",
+        "duplicated": "The specified IP/network already exists",
+        "search": "IP/Network or initial part",
+        "defender_disabled": "Defender disabled in your configuration",
+        "allow_list_disabled": "Allow list disabled in your configuration",
+        "ratelimiters_disabled": "Rate limiters disabled in your configuration"
+    },
+    "defender": {
+        "view_manage": "View and manage auto blocklist",
+        "ip": "IP address",
+        "ban_time": "Blocked until",
+        "score": "Score"
     }
 }

+ 33 - 2
static/locales/it/translation.json

@@ -58,7 +58,9 @@
         "add_role": "Aggiungi ruolo",
         "update_role": "Aggiorna ruolo",
         "add_admin": "Aggiungi amministratore",
-        "update_admin": "Aggiorna amministratore"
+        "update_admin": "Aggiorna amministratore",
+        "add_ip_list": "Aggiungi elemento a lista IP",
+        "update_ip_list": "Aggiorna elemento lista IP"
     },
     "setup": {
         "desc": "Per iniziare a utilizzare SFTPGo devi creare un utente amministratore",
@@ -229,7 +231,10 @@
         "members": "Membri",
         "members_summary": "Utenti: {{users}}. Amministratori: {{admins}}",
         "status": "Stato",
-        "last_login": "Ultimo accesso"
+        "last_login": "Ultimo accesso",
+        "previous": "Precedente",
+        "next": "Successivo",
+        "type": "Tipo"
     },
     "fs": {
         "view_file": "Visualizza file \"{{- path}}\"",
@@ -710,5 +715,31 @@
     },
     "role": {
         "view_manage": "Visualizza e gestisci ruoli"
+    },
+    "ip_list": {
+        "view_manage": "Visualizza e gestisci liste IP",
+        "defender_list": "Defender",
+        "allow_list": "Lista IP consentiti",
+        "ratelimiters_safe_list": "Lista IP esclusi dai rate limiters",
+        "ip_net": "IP/Rete",
+        "protocols": "Protocolli",
+        "mode": "Modalità",
+        "any": "Qualunque",
+        "allow": "Permesso",
+        "deny": "Non permesso",
+        "ip_net_help": "Indirizzo IP o rete in formato CIDR, ad esempio: \"192.168.1.1 o 10.8.0.100/32 o 2001:db8:1234::/48\"",
+        "ip_invalid": "Indirizzo IP non valido",
+        "net_invalid": "Rete non valida",
+        "duplicated": "L'IP/Rete specificato esiste già",
+        "search": "IP/Rete o parte iniziale",
+        "defender_disabled": "Defender disabilitato in configurazione",
+        "allow_list_disabled": "Lista IP consentiti disabilitata in configurazione",
+        "ratelimiters_disabled": "Rate limiters disabilitati in configurazione"
+    },
+    "defender": {
+        "view_manage": "Visualizza e gestisci la blocklist automatica",
+        "ip": "Indirizzo IP",
+        "ban_time": "Bloccato fino a",
+        "score": "Punteggio"
     }
 }

+ 1 - 1
templates/webadmin/connections.html

@@ -70,7 +70,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
 <script {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}} src="{{.StaticURL}}/assets/plugins/custom/datatables/datatables.bundle.js"></script>
 <script type="text/javascript" {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}}>
 
-function disconnectAction(connectionID, node) {
+    function disconnectAction(connectionID, node) {
         ModalAlert.fire({
             text: $.t('connections.disconnect_confirm'),
             icon: "warning",

+ 254 - 201
templates/webadmin/defender.html

@@ -1,231 +1,284 @@
 <!--
-Copyright (C) 2019 Nicola Murino
+Copyright (C) 2024 Nicola Murino
 
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as published
-by the Free Software Foundation, version 3.
+This WebUI uses the KeenThemes Mega Bundle, a proprietary theme:
 
-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 Affero General Public License for more details.
+https://keenthemes.com/products/templates-mega-bundle
 
-You should have received a copy of the GNU Affero General Public License
-along with this program. If not, see <https://www.gnu.org/licenses/>.
+KeenThemes HTML/CSS/JS components are allowed for use only within the
+SFTPGo product and restricted to be used in a resealable HTML template
+that can compete with KeenThemes products anyhow.
+
+This WebUI is allowed for use only within the SFTPGo product and
+therefore cannot be used in derivative works/products without an
+explicit grant from the SFTPGo Team (support@sftpgo.com).
 -->
 {{template "base" .}}
 
-{{define "title"}}{{.Title}}{{end}}
-
-{{define "extra_css"}}
-<link href="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.css" rel="stylesheet">
-<link href="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.css" rel="stylesheet">
-<link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet">
-<link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet">
-<link href="{{.StaticURL}}/vendor/datatables/select.bootstrap4.min.css" rel="stylesheet">
-{{end}}
-
-{{define "page_body"}}
-<div id="errorMsg" class="alert alert-warning fade show" style="display: none;" role="alert">
-    <span id="errorTxt"></span>
-    <button type="button" class="close" aria-label="Close" onclick="dismissErrorMsg();">
-      <span aria-hidden="true">&times;</span>
-    </button>
-</div>
-<script type="text/javascript">
-    function dismissErrorMsg(){
-        $('#errorMsg').hide();
-    }
-</script>
-<div class="card shadow mb-4">
-    <div class="card-header py-3">
-        <h6 class="m-0 font-weight-bold text-primary">View and manage auto blocklist</h6>
+{{- define "extra_css"}}
+<link href="{{.StaticURL}}/assets/plugins/custom/datatables/datatables.bundle.css" rel="stylesheet" type="text/css"/>
+{{- end}}
+
+{{- define "page_body"}}
+{{- template "errmsg" ""}}
+<div class="card shadow-sm">
+    <div class="card-header bg-light">
+        <h3 data-i18n="defender.view_manage" class="card-title section-title">View and manage auto blocklis</h3>
     </div>
-    <div class="card-body">
-        <div class="table-responsive">
-            <table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
+    <div id="card_body" class="card-body">
+        <div id="loader" class="align-items-center text-center my-10">
+            <span class="spinner-border w-15px h-15px text-muted align-middle me-2"></span>
+            <span data-i18n="general.loading" class="text-gray-700">Loading...</span>
+        </div>
+        <div id="card_content" class="d-none">
+            <div class="d-flex flex-stack flex-wrap mb-5">
+                <div class="d-flex align-items-center position-relative my-2">
+                    <i class="ki-solid ki-magnifier fs-1 position-absolute ms-6"></i>
+                    <input name="search" data-i18n="[placeholder]general.search" type="text" data-table-filter="search"
+                        class="form-control rounded-1 w-250px ps-15 me-5" placeholder="Search" />
+                </div>
+                <div class="d-flex justify-content-end my-2" data-table-toolbar="base">
+                    <a href="{{.DefenderURL}}" class="btn btn-primary">
+                        <i class="ki-solid ki-arrows-circle fs-2"></i>
+                        <span data-i18n="general.refresh">Refresh</span>
+                    </a>
+                </div>
+            </div>
+
+            <table id="dataTable" class="table align-middle table-row-dashed fs-6 gy-5">
                 <thead>
-                    <tr>
-                        <th>ID</th>
-                        <th>IP</th>
-                        <th>Ban time</th>
-                        <th>Score</th>
+                    <tr class="text-start text-muted fw-bold fs-6 gs-0">
+                        <th data-i18n="defender.ip">IP</th>
+                        <th data-i18n="defender.ban_time">Blocked until</th>
+                        <th data-i18n="defender.scopre">Score</th>
+                        <th></th>
                     </tr>
                 </thead>
+                <tbody id="table_body" class="text-gray-800 fw-semibold"></tbody>
             </table>
+
         </div>
     </div>
 </div>
-{{end}}
-
-{{define "dialog"}}
-<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel"
-    aria-hidden="true">
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title" id="deleteModalLabel">
-                    Confirmation required
-                </h5>
-                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
-                    <span aria-hidden="true">&times;</span>
-                </button>
-            </div>
-            <div class="modal-body">Do you want to remove the selected entry?</div>
-            <div class="modal-footer">
-                <button class="btn btn-secondary" type="button" data-dismiss="modal">
-                    Cancel
-                </button>
-                <a class="btn btn-warning" href="#" onclick="deleteAction()">
-                    Delete
-                </a>
-            </div>
-        </div>
-    </div>
-</div>
-{{end}}
-
-{{define "extra_js"}}
-<script src="{{.StaticURL}}/vendor/datatables/jquery.dataTables.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/dataTables.buttons.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/dataTables.fixedHeader.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/dataTables.responsive.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/dataTables.select.min.js"></script>
-<script type="text/javascript">
-
-function deleteAction() {
-        let table = $('#dataTable').DataTable();
-        table.button('delete:name').enable(false);
-        let id = table.row({ selected: true }).data()["id"];
-        let path = '{{.DefenderHostsURL}}' + "/" + fixedEncodeURIComponent(id);
-        $('#deleteModal').modal('hide');
-        $('#errorMsg').hide();
-
-        $.ajax({
-            url: path,
-            type: 'DELETE',
-            dataType: 'json',
-            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
-            timeout: 15000,
-            success: function (result) {
-                window.location.href = '{{.DefenderURL}}';
-            },
-            error: function ($xhr, textStatus, errorThrown) {
-                let txt = "Unable to delete the selected entry";
-                if ($xhr) {
-                    let json = $xhr.responseJSON;
-                    if (json) {
-                        if (json.message){
-                            txt += ": " + json.message;
-                        } else {
-                            txt += ": " + json.error;
+{{- end}}
+
+{{- define "extra_js"}}
+<script {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}} src="{{.StaticURL}}/assets/plugins/custom/datatables/datatables.bundle.js"></script>
+<script type="text/javascript" {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}}>
+
+    function deleteAction(id) {
+        ModalAlert.fire({
+            text: $.t('general.delete_confirm_generic'),
+            icon: "warning",
+            confirmButtonText: $.t('general.delete_confirm_btn'),
+            cancelButtonText: $.t('general.cancel'),
+            customClass: {
+                confirmButton: "btn btn-danger",
+                cancelButton: 'btn btn-secondary'
+            }
+        }).then((result) => {
+            if (result.isConfirmed){
+                $('#loading_message').text("");
+                KTApp.showPageLoading();
+                let path = '{{.DefenderHostsURL}}' + "/" + encodeURIComponent(id);
+
+                axios.delete(path, {
+                    timeout: 15000,
+                    headers: {
+                        'X-CSRF-TOKEN': '{{.CSRFToken}}'
+                    },
+                    validateStatus: function (status) {
+                        return status == 200;
+                    }
+                }).then(function(response){
+                    location.reload();
+                }).catch(function(error){
+                    KTApp.hidePageLoading();
+                    let errorMessage;
+                    if (error && error.response) {
+                        switch (error.response.status) {
+                            case 403:
+                                errorMessage = "general.delete_error_403";
+                                break;
+                            case 404:
+                                errorMessage = "general.delete_error_404";
+                                break;
                         }
                     }
-                }
-                $('#errorTxt').text(txt);
-                $('#errorMsg').show();
+                    if (!errorMessage){
+                        errorMessage = "general.delete_error_generic";
+                    }
+                    ModalAlert.fire({
+                        text: $.t(errorMessage),
+                        icon: "warning",
+                        confirmButtonText: $.t('general.ok'),
+                        customClass: {
+                            confirmButton: "btn btn-primary"
+                        }
+                    });
+                });
             }
         });
     }
 
-    $(document).ready(function () {
-        $.fn.dataTable.ext.buttons.refresh = {
-            text: '<i class="fas fa-sync-alt"></i>',
-            name: 'refresh',
-            titleAttr: "Refresh",
-            action: function (e, dt, node, config) {
-                location.reload();
-            }
-        };
-
-        $.fn.dataTable.ext.buttons.delete = {
-            text: '<i class="fas fa-trash"></i>',
-            name: 'delete',
-            titleAttr: "Delete",
-            action: function (e, dt, node, config) {
-                $('#deleteModal').modal('show');
-            },
-            enabled: false
-        };
-
-        let table = $('#dataTable').DataTable({
-            "ajax": {
-                "url": "{{.DefenderHostsURL}}",
-                "dataSrc": "",
-                "error": function ($xhr, textStatus, errorThrown) {
-                    $(".dataTables_processing").hide();
-                    let txt = "Failed to get auto blocklist";
-                    if ($xhr) {
-                        let json = $xhr.responseJSON;
-                        if (json) {
-                            if (json.message){
-                                txt += ": " + json.message;
-                            } else {
-                                txt += ": " + json.error;
+    var datatable = function(){
+        var dt;
+
+        var initDatatable = function () {
+            $('#errorMsg').addClass("d-none");
+            dt = $('#dataTable').DataTable({
+                ajax: {
+                    url: "{{.DefenderHostsURL}}",
+                    dataSrc: "",
+                    error: function ($xhr, textStatus, errorThrown) {
+                        $(".dataTables_processing").hide();
+                        let txt = "";
+                        if ($xhr) {
+                            let json = $xhr.responseJSON;
+                            if (json) {
+                                if (json.message){
+                                    txt = json.message;
+                                }
                             }
                         }
+                        if (!txt){
+                            txt = "general.error500";
+                        }
+                        setI18NData($('#errorTxt'), txt);
+                        $('#errorMsg').removeClass("d-none");
                     }
-                    $('#errorTxt').text(txt);
-                    $('#errorMsg').show();
-                }
-            },
-            "deferRender": true,
-            "processing": true,
-            "columns": [
-                { "data": "id" },
-                { "data": "ip" },
-                {
-                    "data": "ban_time",
-                    "defaultContent": ""
                 },
-                {
-                    "data": "score",
-                    "defaultContent": ""
-                }
-            ],
-            "select": {
-                "style": "single",
-                "blurable": true
-            },
-            "buttons": [],
-            "lengthChange": false,
-            "columnDefs": [
-                {
-                    "targets": [0],
-                    "visible": false,
-                    "searchable": false
+                columns: [
+                    {
+                        data: "ip",
+                        defaultContent: "",
+                        render: function(data, type, row) {
+                            if (type === 'display') {
+                                return escapeHTML(data);
+                            }
+                            return data;
+                        }
+                    },
+                    {
+                        data: "ban_time",
+                        searchable: false,
+                        defaultContent: "",
+                        render: function(data, type, row) {
+                            if (type === 'display') {
+                                if (data){
+                                    let parsed = Date.parse(data);
+                                    return $.t('general.datetime', {
+                                        val: parseInt(parsed, 10),
+                                        formatParams: {
+                                            val: { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' },
+                                        }
+                                    });
+                                }
+                                return ""
+                            }
+                            return data;
+                        }
+                    },
+                    {
+                        data: "score",
+                        defaultContent: 0,
+                        render: function(data, type, row) {
+                            if (data){
+                                return data;
+                            }
+                            return "";
+                        }
+                    },
+                    {
+                        data: "id",
+                        searchable: false,
+                        orderable: false,
+                        className: 'text-end',
+                        render: function (data, type, row) {
+                            if (type === 'display') {
+                                //{{- if .LoggedUser.HasPermission "manage_defender"}}
+                                return `<div class="d-flex justify-content-end">
+                                            <div class="ms-2">
+                                                <a href="#" class="btn btn-sm btn-icon btn-light-danger" data-table-action="delete_row">
+                                                    <i class="ki-solid ki-cross fs-1"></i>
+                                                </a>
+                                            </div>
+                                    </div>`;
+                                //{{- end}}
+                            }
+                            return "";
+                        }
+                    },
+                ],
+                deferRender: true,
+                stateSave: true,
+                stateDuration: 0,
+                stateLoadParams: function (settings, data) {
+                        if (data.search.search){
+                            const filterSearch = document.querySelector('[data-table-filter="search"]');
+                            filterSearch.value = data.search.search;
+                        }
+                    },
+                language: {
+                    info: $.t('datatable.info'),
+                    infoEmpty: $.t('datatable.info_empty'),
+                    infoFiltered: $.t('datatable.info_filtered'),
+                    loadingRecords: "",
+                    processing: $.t('datatable.processing'),
+                    zeroRecords: "",
+                    emptyTable: $.t('datatable.no_records')
                 },
-            ],
-            "scrollX": false,
-            "scrollY": false,
-            "responsive": true,
-            "language": {
-                "loadingRecords": "",
-                "emptyTable": "No records found"
-            },
-            "initComplete": function (settings, json) {
-                {{if .LoggedAdmin.HasPermission "manage_defender"}}
-                table.button().add(0, 'delete');
-                {{end}}
-                table.button().add(0, 'pageLength');
-                table.button().add(0, 'refresh');
-                table.buttons().container().appendTo('.col-md-6:eq(0)', table.table().container());
-            },
-            "order": [[2, 'desc'],[3,'desc']]
-        });
+                order: [[0, 'asc']],
+                initComplete: function(settings, json) {
+                    $('#loader').addClass("d-none");
+                    $('#card_content').removeClass("d-none");
+                    let api = $.fn.dataTable.Api(settings);
+                    api.columns.adjust().draw("page");
+                    drawAction();
+                }
+            });
 
-        new $.fn.dataTable.FixedHeader(table);
-        $.fn.dataTable.ext.errMode = 'none';
+            dt.on('draw', drawAction);
+        }
 
-        {{if .LoggedAdmin.HasPermission "manage_defender"}}
-        table.on('select deselect', function () {
-            let selectedRows = table.rows({ selected: true }).count();
-            table.button('delete:name').enable(selectedRows == 1);
-        });
-        {{end}}
+        function drawAction() {
+            KTMenu.createInstances();
+            handleRowActions();
+            $('#table_body').localize();
+        }
+
+        var handleDatatableActions = function () {
+            const filterSearch = $(document.querySelector('[data-table-filter="search"]'));
+            filterSearch.off("keyup");
+            filterSearch.on('keyup', function (e) {
+                dt.rows().deselect();
+                dt.search(e.target.value, true, false).draw();
+            });
+        }
+
+        function handleRowActions() {
+            const deleteButtons = document.querySelectorAll('[data-table-action="delete_row"]');
+            deleteButtons.forEach(d => {
+                let el = $(d);
+                el.off("click");
+                el.on("click", function(e){
+                    e.preventDefault();
+                    const parent = e.target.closest('tr');
+                    deleteAction(dt.row(parent).data().id);
+                });
+            });
+        }
+
+        return {
+            init: function () {
+                initDatatable();
+                handleDatatableActions();
+            }
+        }
+    }();
+
+    $(document).on("i18nshow", function(){
+        datatable.init();
     });
 </script>
-{{end}}
+{{- end}}

+ 70 - 71
templates/webadmin/iplist.html

@@ -1,79 +1,66 @@
 <!--
-Copyright (C) 2019 Nicola Murino
+Copyright (C) 2024 Nicola Murino
 
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as published
-by the Free Software Foundation, version 3.
+This WebUI uses the KeenThemes Mega Bundle, a proprietary theme:
 
-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 Affero General Public License for more details.
+https://keenthemes.com/products/templates-mega-bundle
 
-You should have received a copy of the GNU Affero General Public License
-along with this program. If not, see <https://www.gnu.org/licenses/>.
+KeenThemes HTML/CSS/JS components are allowed for use only within the
+SFTPGo product and restricted to be used in a resealable HTML template
+that can compete with KeenThemes products anyhow.
+
+This WebUI is allowed for use only within the SFTPGo product and
+therefore cannot be used in derivative works/products without an
+explicit grant from the SFTPGo Team (support@sftpgo.com).
 -->
 {{template "base" .}}
 
-{{define "title"}}{{.Title}}{{end}}
-
-{{define "extra_css"}}
-<link href="{{.StaticURL}}/vendor/bootstrap-select/css/bootstrap-select.min.css" rel="stylesheet">
-{{end}}
-
-{{define "page_body"}}
-<!-- Page Heading -->
-<div class="card shadow mb-4">
-    <div class="card-header py-3">
-        <h6 class="m-0 font-weight-bold text-primary">{{.Title}}</h6>
+{{- define "page_body"}}
+<div class="card shadow-sm">
+    <div class="card-header bg-light">
+        <h3 data-i18n="{{.Title}}" class="card-title section-title"></h3>
     </div>
     <div class="card-body">
-        {{if .Error}}
-        <div class="alert alert-warning alert-dismissible fade show" role="alert">
-            {{.Error}}
-            <button type="button" class="close" data-dismiss="alert" aria-label="Close">
-                <span aria-hidden="true">&times;</span>
-            </button>
-        </div>
-        {{end}}
+        {{- template "errmsg" .Error}}
         <form id="iplist_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
+
             <div class="form-group row">
-                <label for="idIPOrNet" class="col-sm-2 col-form-label">IP/Network</label>
-                <div class="col-sm-10">
-                    <input type="text" class="form-control" id="idIPOrNet" name="ipornet" placeholder=""
-                        value="{{.Entry.IPOrNet}}" maxlength="50" autocomplete="nope" aria-describedby="ipOrNetHelpBlock" required {{if eq .Mode 2}}readonly{{end}}>
-                        {{if ne .Mode 2}}
-                        <small id="ipOrNetHelpBlock" class="form-text text-muted">
-                            IP address or network in CIDR format, example: "192.168.1.1 or 10.8.0.100/32 or 2001:db8:1234::/48"
-                        </small>
-                        {{end}}
+                <label for="idType" data-i18n="general.type" class="col-md-3 col-form-label">Type</label>
+                <div class="col-md-9">
+                    <input id="idType" type="text" {{if eq .Entry.Type 1}}data-i18n="[value]ip_list.allow_list"{{end}}{{if eq .Entry.Type 2}}data-i18n="[value]ip_list.defender_list"{{end}}{{if eq .Entry.Type 3}}data-i18n="[value]ip_list.ratelimiters_safe_list"{{end}} name="type" maxlength="50"
+                        class="form-control-plaintext readonly-input" readonly />
                 </div>
             </div>
 
-            <div class="form-group row">
-                <label for="idType" class="col-sm-2 col-form-label">Type</label>
-                <div class="col-sm-10">
-                    <input type="text" class="form-control" id="idType" name="type" placeholder=""
-                        value="{{.Entry.Type.AsString}}" maxlength="50" readonly>
+            <div class="form-group row mt-10">
+                <label for="idIPOrNet" data-i18n="ip_list.ip_net" class="col-md-3 col-form-label">IP/Network</label>
+                <div class="col-md-9">
+                    <input id="idIPOrNet" type="text" name="ipornet" value="{{.Entry.IPOrNet}}" maxlength="50" autocomplete="off"
+                        required {{if eq .Mode 2}}class="form-control-plaintext readonly-input" readonly{{else}}class="form-control" aria-describedby="idIPOrNetHelp"{{end}} />
+                    {{- if ne .Mode 2}}
+                    <div id="idIPOrNetHelp" class="form-text" data-i18n="ip_list.ip_net_help"></div>
+                    {{- end}}
                 </div>
             </div>
 
-            {{if eq .Entry.Type 2}}
-            <div class="form-group row">
-                <label for="idMode" class="col-sm-2 col-form-label">Mode</label>
-                <div class="col-sm-10">
-                    <select class="form-control selectpicker" id="idMode" name="mode">
-                        <option value="2" {{if eq .Entry.Mode 2 }}selected{{end}}>Deny</option>
-                        <option value="1" {{if eq .Entry.Mode 1 }}selected{{end}}>Allow</option>
+            {{- if eq .Entry.Type 2}}
+            <div class="form-group row mt-10">
+                <label for="idMode" data-i18n="ip_list.mode" class="col-md-3 col-form-label">Mode</label>
+                <div class="col-md-9">
+                    <select id="idMode" name="mode" class="form-select" data-control="i18n-select2" data-hide-search="true">
+                        <option value="2" data-i18n="ip_list.deny" {{if eq .Entry.Mode 2 }}selected{{end}}>Deny</option>
+                        <option value="1" data-i18n="ip_list.allow" {{if eq .Entry.Mode 1 }}selected{{end}}>Allow</option>
                     </select>
                 </div>
             </div>
-            {{end}}
+            {{- end}}
 
-            <div class="form-group row">
-                <label for="idProtocols" class="col-sm-2 col-form-label">Protocols</label>
-                <div class="col-sm-10">
-                    <select class="form-control selectpicker" id="idProtocols" name="protocols" multiple title="Any">
+            <div class="form-group row mt-10">
+                <label for="idProtocols" data-i18n="ip_list.protocols" class="col-md-3 col-form-label">
+                    Protocols
+                </label>
+                <div class="col-md-9">
+                    <select id="idProtocols" name="protocols" data-i18n="[data-placeholder]ip_list.any" class="form-select" data-control="i18n-select2" data-close-on-select="false" multiple>
                         <option value="1" {{if .Entry.HasProtocol "SSH" }}selected{{end}}>SSH</option>
                         <option value="2" {{if .Entry.HasProtocol "FTP" }}selected{{end}}>FTP</option>
                         <option value="4" {{if .Entry.HasProtocol "DAV" }}selected{{end}}>DAV</option>
@@ -82,26 +69,38 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
                 </div>
             </div>
 
-            <div class="form-group row">
-                <label for="idDescription" class="col-sm-2 col-form-label">Note</label>
-                <div class="col-sm-10">
-                    <input type="text" class="form-control" id="idDescription" name="description" placeholder=""
-                        value="{{.Entry.Description}}" maxlength="512" aria-describedby="descriptionHelpBlock">
-                    <small id="descriptionHelpBlock" class="form-text text-muted">
-                        Optional note
-                    </small>
+            <div class="form-group row mt-10">
+                <label for="idDescription" data-i18n="general.description" class="col-md-3 col-form-label">Description</label>
+                <div class="col-md-9">
+                    <input id="idDescription" type="text" class="form-control" name="description" value="{{.Entry.Description}}" maxlength="512">
                 </div>
             </div>
 
-            <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
-            <div class="col-sm-12 text-right px-0">
-                <button type="submit" class="btn btn-primary mt-3 ml-3 px-5" name="form_action" value="submit">Submit</button>
+            <div class="d-flex justify-content-end mt-12">
+                <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
+                <button type="submit" id="form_submit" class="btn btn-primary px-10" name="form_action" value="submit">
+                    <span data-i18n="general.submit" class="indicator-label">
+                        Submit
+                    </span>
+                    <span data-i18n="general.wait" class="indicator-progress">
+                        Please wait...
+                        <span class="spinner-border spinner-border-sm align-middle ms-2"></span>
+                    </span>
+                </button>
             </div>
+
         </form>
     </div>
 </div>
-{{end}}
-
-{{define "extra_js"}}
-<script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
-{{end}}
+{{- end}}
+{{- define "extra_js"}}
+<script type="text/javascript" {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}}>
+    $(document).on("i18nshow", function(){
+        $('#iplist_form').submit(function (event) {
+			let submitButton = document.querySelector('#form_submit');
+			submitButton.setAttribute('data-kt-indicator', 'on');
+			submitButton.disabled = true;
+        });
+    });
+</script>
+{{- end}}

+ 444 - 458
templates/webadmin/iplists.html

@@ -1,515 +1,501 @@
 <!--
-Copyright (C) 2019 Nicola Murino
+Copyright (C) 2024 Nicola Murino
 
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as published
-by the Free Software Foundation, version 3.
+This WebUI uses the KeenThemes Mega Bundle, a proprietary theme:
 
-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 Affero General Public License for more details.
+https://keenthemes.com/products/templates-mega-bundle
 
-You should have received a copy of the GNU Affero General Public License
-along with this program. If not, see <https://www.gnu.org/licenses/>.
+KeenThemes HTML/CSS/JS components are allowed for use only within the
+SFTPGo product and restricted to be used in a resealable HTML template
+that can compete with KeenThemes products anyhow.
+
+This WebUI is allowed for use only within the SFTPGo product and
+therefore cannot be used in derivative works/products without an
+explicit grant from the SFTPGo Team (support@sftpgo.com).
 -->
 {{template "base" .}}
 
-{{define "title"}}{{.Title}}{{end}}
-
-{{define "extra_css"}}
-<link href="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.css" rel="stylesheet">
-<link href="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.css" rel="stylesheet">
-<link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet">
-<link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet">
-<link href="{{.StaticURL}}/vendor/datatables/select.bootstrap4.min.css" rel="stylesheet">
-<link href="{{.StaticURL}}/vendor/bootstrap-select/css/bootstrap-select.min.css" rel="stylesheet">
-{{end}}
-
-{{define "page_body"}}
-<div id="errorMsg" class="alert alert-warning fade show" style="display: none;" role="alert">
-    <span id="errorTxt"></span>
-    <button type="button" class="close" aria-label="Close" onclick="dismissErrorMsg();">
-      <span aria-hidden="true">&times;</span>
-    </button>
-</div>
-<script type="text/javascript">
-    function dismissErrorMsg(){
-        $('#errorMsg').hide();
-    }
-</script>
-<div class="card shadow mb-4">
-    <div class="card-header py-3">
-        <h6 class="m-0 font-weight-bold text-primary">View and manage IP Lists</h6>
+{{- define "extra_css"}}
+<link href="{{.StaticURL}}/assets/plugins/custom/datatables/datatables.bundle.css" rel="stylesheet" type="text/css"/>
+{{- end}}
+
+{{- define "page_body"}}
+{{- template "errmsg" ""}}
+<div class="card shadow-sm">
+    <div class="card-header bg-light">
+        <h3 data-i18n="ip_list.view_manage" class="card-title section-title">View and manage IP Lists</h3>
     </div>
-    <div class="card-body">
-        {{if not .HasDefender}}
-        <div id="defender-info" class="card mb-3 border-left-info" style="display: none;">
-            <div class="card-body">Defender disabled in your configuration</div>
-        </div>
-        {{end}}
-        {{if not .IsAllowListEnabled}}
-        <div id="allowlist-info" class="card mb-3 border-left-info" style="display: none;">
-            <div class="card-body">Allowlist disabled in your configuration</div>
+    <div id="card_body" class="card-body">
+        <div id="loader" class="align-items-center text-center my-10">
+            <span class="spinner-border w-15px h-15px text-muted align-middle me-2"></span>
+            <span data-i18n="general.loading" class="text-gray-700">Loading...</span>
         </div>
-        {{end}}
-        {{if not .RateLimitersStatus}}
-        <div id="ratelimited-info" class="card mb-3 border-left-info" style="display: none;">
-            <div class="card-body">Ratelimiters disabled in your configuration</div>
-        </div>
-        {{end}}
-        <div class="form-row">
-            <div class="form-group col-md-3">
-                <select class="form-control selectpicker" id="idListType" name="list_type" onchange="onListChanged(this.value)">
-                    <option value="2">Defender</option>
-                    <option value="1">Allow list</option>
-                    <option value="3">Rate limiters safe list</option>
-                </select>
-            </div>
-            <div class="form-group col-md-5">
-            </div>
-            <div class="form-group col-md-4">
-                <div class="input-group">
-                    <input type="text" class="form-control bg-light border-0" id="idIp" name="ip" placeholder="IP/Network or initial part" aria-describedby="search-button">
-                    <div class="input-group-append">
-                        <button id="search-button" class="btn btn-primary" type="button" onclick="onSearchClicked()">
-                            <i class="fas fa-search fa-sm"></i>
+        <div id="card_content" class="d-none">
+            <div class="d-flex flex-stack flex-wrap mb-5">
+                <div class="d-flex align-items-center position-relative my-2">
+                    <div class="input-group">
+                        <input name="search" id="idSearch" data-i18n="[placeholder]ip_list.search" type="text" class="form-control rounded-left w-250px" placeholder="Search" />
+                        <button id="search_button" type="button" class="btn btn-primary">
+                            <i class="ki-solid ki-magnifier fs-2"></i>
                         </button>
                     </div>
                 </div>
+
+                <div class="d-flex justify-content-end my-2" data-table-toolbar="base">
+                    <div>
+                        <select id="idListType" name="list_type" class="form-select me-3" data-control="i18n-select2" data-hide-search="true">
+                            <option data-i18n="ip_list.defender_list" value="2">Defender</option>
+                            <option data-i18n="ip_list.allow_list" value="1">Allow list</option>
+                            <option data-i18n="ip_list.ratelimiters_safe_list" value="3">Rate limiters safe list</option>
+                        </select>
+                    </div>
+
+                    <a href="#" id="idAdd" class="btn btn-primary ms-5">
+                        <i class="ki-duotone ki-plus fs-2"></i>
+                        <span data-i18n="general.add">Add</span>
+                    </a>
+                </div>
             </div>
-        </div>
 
-        <div class="table-responsive">
-            <table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
+            <table id="dataTable" class="table align-middle table-row-dashed fs-6 gy-5">
                 <thead>
-                    <tr>
-                        <th>IP/Network</th>
-                        <th>Protocols</th>
-                        <th>Mode</th>
-                        <th>Note</th>
+                    <tr class="text-start text-muted fw-bold fs-6 gs-0">
+                        <th data-i18n="ip_list.ip_net">IP/Network</th>
+                        <th data-i18n="ip_list.protocols">Protocols</th>
+                        <th data-i18n="ip_list.mode">Mode</th>
+                        <th data-i18n="general.description">Description</th>
+                        <th class="min-w-100px"></th>
                     </tr>
                 </thead>
+                <tbody id="table_body" class="text-gray-800 fw-semibold"></tbody>
             </table>
-        </div>
 
-        <div id="paginationContainer" class="m-4 d-none">
-            <nav aria-label="Pagination">
-                <ul class="pagination justify-content-end">
-                    <li id="pageItemPrev" class="page-item disabled"><a id="pagePrevious" class="page-link" href="#" onclick="prevClicked()">Previous</a></li>
-                    <li id="pageItemNext" class="page-item disabled"><a id="pageNext" class="page-link" href="#" onclick="nextClicked()">Next</a></li>
-                </ul>
-            </nav>
-        </div>
-
-    </div>
-</div>
-{{end}}
-
-{{define "dialog"}}
-<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel"
-    aria-hidden="true">
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title" id="deleteModalLabel">
-                    Confirmation required
-                </h5>
-                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
-                    <span aria-hidden="true">&times;</span>
-                </button>
-            </div>
-            <div class="modal-body">Do you want to remove the selected entry?</div>
-            <div class="modal-footer">
-                <button class="btn btn-secondary" type="button" data-dismiss="modal">
-                    Cancel
-                </button>
-                <a class="btn btn-warning" href="#" onclick="deleteAction()">
-                    Delete
-                </a>
+            <div id="paginationContainer" class="d-flex mt-4 mb-4 justify-content-end d-none">
+                <div class="btn-group" role="group" aria-label="Pagination">
+                    <button id="pagePrevious" data-i18n="general.previous" type="button" class="btn btn-outline btn-active-primary disabled">Previous</button>
+                    <button id="pageNext" data-i18n="general.next" type="button" class="btn btn-outline btn-active-primary disabled">Next</button>
+                </div>
             </div>
+
         </div>
     </div>
 </div>
-{{end}}
-
-{{define "extra_js"}}
-<script src="{{.StaticURL}}/vendor/datatables/jquery.dataTables.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/dataTables.buttons.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/dataTables.fixedHeader.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/dataTables.responsive.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/dataTables.select.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/ellipsis.js"></script>
-<script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
-<script type="text/javascript">
-
-const prefListTypeName = 'sftpgo_pref_{{.LoggedAdmin.Username}}_iplist_type';
-const prefListFilter = 'sftpgo_pref_{{.LoggedAdmin.Username}}_iplist_search_filter';
-const listType = getListType();
-const listFilter = getSearchFilter();
-
-if (listType === '1' || listType === '3'){
-    $('#idListType').val(listType);
-} else {
-    $('#idListType').val('2');
-}
-
-if (listFilter){
-    $('#idIp').val(listFilter);
-} else {
-    $('#idIp').val('');
-}
-
-const pageSize = 15;
-const paginationData = new Map();
-
-function saveListType(val) {
-    localStorage.setItem(prefListTypeName, val);
-}
-
-function getListType() {
-    return localStorage.getItem(prefListTypeName);
-}
-
-function saveSearchFilter() {
-    let val = $("#idIp").val();
-    if (val){
-        localStorage.setItem(prefListFilter, val);
-    } else {
-        localStorage.removeItem(prefListFilter);
+{{- end}}
+
+{{- define "extra_js"}}
+<script {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}} src="{{.StaticURL}}/assets/plugins/custom/datatables/datatables.bundle.js"></script>
+<script type="text/javascript" {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}}>
+
+    const prefListTypeName = 'sftpgo_pref_{{.LoggedUser.Username}}_iplist_type';
+    const prefListFilter = 'sftpgo_pref_{{.LoggedUser.Username}}_iplist_search_filter';
+    const listType = getListType();
+    const listFilter = getSearchFilter();
+
+    const pageSize = 15;
+    const paginationData = new Map();
+
+    function saveListType(val) {
+        localStorage.setItem(prefListTypeName, val);
     }
-}
-
-function getSearchFilter() {
-    return localStorage.getItem(prefListFilter);
-}
-
-function resetPagination() {
-    $('#pageItemPrev').addClass("disabled");
-    $('#pageItemNext').addClass("disabled");
-    $('#paginationContainer').addClass("d-none");
-    paginationData.delete("firstIpOrNet");
-    paginationData.delete("lastIpOrNet");
-    paginationData.set("prevClicked",false);
-    paginationData.set("nextClicked",false);
-}
-
-function prevClicked(){
-    paginationData.set("prevClicked",true);
-    paginationData.set("nextClicked",false);
-    doSearch();
-}
-
-function nextClicked(){
-    paginationData.set("prevClicked",false);
-    paginationData.set("nextClicked",true);
-    doSearch();
-}
-
-function handleResponseData(data) {
-    let length = data.length;
-    let isNext = paginationData.get("nextClicked");
-    let isPrev = paginationData.get("prevClicked");
-
-    if (length > pageSize) {
-        data.pop();
-        length--;
-        if (isPrev || isNext){
-            $('#pageItemPrev').removeClass("disabled");
-        }
-        $('#pageItemNext').removeClass("disabled");
-    } else {
-        if (isPrev){
-            $('#pageItemPrev').addClass("disabled");
-            $('#pageItemNext').removeClass("disabled");
-        } else if (isNext){
-            $('#pageItemPrev').removeClass("disabled");
-            $('#pageItemNext').addClass("disabled");
+
+    function getListType() {
+        return localStorage.getItem(prefListTypeName);
+    }
+
+    function saveSearchFilter() {
+        let val = $("#idSearch").val();
+        if (val){
+            localStorage.setItem(prefListFilter, val);
         } else {
-            $('#pageItemNext').addClass("disabled");
+            localStorage.removeItem(prefListFilter);
         }
     }
-    if (isPrev){
-        data = data.reverse();
-    }
-    if (length > 0){
-        paginationData.set("firstIpOrNet",data[0].ipornet);
-        paginationData.set("lastIpOrNet",data[length-1].ipornet);
-        $('#paginationContainer').removeClass("d-none");
-    } else {
-        resetPagination();
-    }
 
-    return data;
-}
-
-function getSearchURL(){
-    let listType = fixedEncodeURIComponent($("#idListType").val());
-    let filter = encodeURIComponent($("#idIp").val());
-    let limit = pageSize + 1;
-    let from = "";
-    let order = "ASC"
-    if (paginationData.get("nextClicked") && paginationData.has("lastIpOrNet")){
-        from = encodeURIComponent(paginationData.get("lastIpOrNet"));
+    function getSearchFilter() {
+        return localStorage.getItem(prefListFilter);
     }
-    if (paginationData.get("prevClicked") && paginationData.has("firstIpOrNet")){
-        from = encodeURIComponent(paginationData.get("firstIpOrNet"));
-        order = "DESC";
+
+    function resetPagination() {
+        $('#pagePrevious').addClass("disabled");
+        $('#pageNext').addClass("disabled");
+        $('#paginationContainer').addClass("d-none");
+        paginationData.delete("firstIpOrNet");
+        paginationData.delete("lastIpOrNet");
+        paginationData.set("prevClicked",false);
+        paginationData.set("nextClicked",false);
     }
-    return "{{.IPListsURL}}"+`/${listType}?filter=${filter}&from=${from}&limit=${limit}&order=${order}`;
-}
-
-function deleteAction() {
-    let table = $('#dataTable').DataTable();
-    table.button('delete:name').enable(false);
-    let selectedRow = table.row({ selected: true }).data();
-    let path = '{{.IPListURL}}' + "/" + fixedEncodeURIComponent(selectedRow["type"])+"/"+ fixedEncodeURIComponent(selectedRow["ipornet"]);
-    $('#deleteModal').modal('hide');
-    $('#errorMsg').hide();
-
-    $.ajax({
-        url: path,
-        type: 'DELETE',
-        dataType: 'json',
-        headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
-        timeout: 15000,
-        success: function (result) {
-            window.location.href = '{{.IPListsURL}}';
-        },
-        error: function ($xhr, textStatus, errorThrown) {
-            let txt = "Unable to delete the selected entry";
-            if ($xhr) {
-                let json = $xhr.responseJSON;
-                if (json) {
-                    if (json.message){
-                        txt += ": " + json.message;
-                    } else {
-                        txt += ": " + json.error;
-                    }
-                }
+
+    function handleResponseData(data) {
+        let length = data.length;
+        let isNext = paginationData.get("nextClicked");
+        let isPrev = paginationData.get("prevClicked");
+
+        if (length > pageSize) {
+            data.pop();
+            length--;
+            if (isPrev || isNext){
+                $('#pagePrevious').removeClass("disabled");
+            }
+            $('#pageNext').removeClass("disabled");
+        } else {
+            if (isPrev){
+                $('#pagePrevious').addClass("disabled");
+                $('#pageNext').removeClass("disabled");
+            } else if (isNext){
+                $('#pagePrevious').removeClass("disabled");
+                $('#pageNext').addClass("disabled");
+            } else {
+                $('#pageNext').addClass("disabled");
             }
-            $('#errorTxt').text(txt);
-            $('#errorMsg').show();
         }
-    });
-}
-
-function setTableColumnVisibility(val){
-    let column = $('#dataTable').DataTable().column(2);
+        if (isPrev){
+            data = data.reverse();
+        }
+        if (length > 0){
+            paginationData.set("firstIpOrNet",data[0].ipornet);
+            paginationData.set("lastIpOrNet",data[length-1].ipornet);
+            $('#paginationContainer').removeClass("d-none");
+        } else {
+            resetPagination();
+        }
 
-    switch (val){
-        case '2':
-            column.visible(true);
-            break;
-        default:
-            column.visible(false);
-    }
-}
-
-function updateListTypeInfo(val) {
-    let info1 = $('#allowlist-info');
-    let info2 = $('#defender-info');
-    let info3 = $('#ratelimited-info');
-    if (info1){
-        info1.hide();
+        return data;
     }
-    if (info2){
-        info2.hide();
+
+    function getSearchURL(){
+        let listType = encodeURIComponent($("#idListType").val());
+        let filter = encodeURIComponent($("#idSearch").val());
+        let limit = pageSize + 1;
+        let from = "";
+        let order = "ASC"
+        if (paginationData.get("nextClicked") && paginationData.has("lastIpOrNet")){
+            from = encodeURIComponent(paginationData.get("lastIpOrNet"));
+        }
+        if (paginationData.get("prevClicked") && paginationData.has("firstIpOrNet")){
+            from = encodeURIComponent(paginationData.get("firstIpOrNet"));
+            order = "DESC";
+        }
+        return "{{.IPListsURL}}"+`/${listType}?filter=${filter}&from=${from}&limit=${limit}&order=${order}`;
     }
-    if (info3){
-        info3.hide();
+
+    function checkSelectedListType(val) {
+        switch (val){
+            case "1":
+                //{{- if not .IsAllowListEnabled}}
+                showToast(0, 'ip_list.allow_list_disabled');
+                //{{- end}}
+                break;
+            case "2":
+                //{{- if not .HasDefender}}
+                showToast(0, 'ip_list.defender_disabled');
+                //{{- end}}
+                break;
+            case "3":
+                //{{- if not .RateLimitersStatus}}
+                showToast(0, 'ip_list.ratelimiters_disabled');
+                //{{- end}}
+                break;
+        }
     }
-    switch (val){
-        case '1':
-            if (info1){
-                info1.show();
-            }
-            break;
-        case '2':
-            if (info2){
-                info2.show();
-            }
-            break;
-        case '3':
-            if (info3){
-                info3.show();
+
+    function deleteAction(listType, ipNet) {
+        ModalAlert.fire({
+            text: $.t('general.delete_confirm_generic'),
+            icon: "warning",
+            confirmButtonText: $.t('general.delete_confirm_btn'),
+            cancelButtonText: $.t('general.cancel'),
+            customClass: {
+                confirmButton: "btn btn-danger",
+                cancelButton: 'btn btn-secondary'
             }
-            break;
-    }
-}
-
-function onListChanged(val){
-    saveListType(val);
-    updateListTypeInfo(val);
-    setTableColumnVisibility(val);
-    let table = $('#dataTable').DataTable();
-    table.clear().draw();
-    table.ajax.url(getSearchURL()).load();
-}
-
-function onSearchClicked(){
-    resetPagination();
-    doSearch();
-    saveSearchFilter();
-}
-
-function doSearch(){
-    let table = $('#dataTable').DataTable();
-    table.clear().draw();
-    table.ajax.url(getSearchURL()).load();
-}
-
-$(document).ready(function () {
-    $.fn.dataTable.ext.buttons.add = {
-        text: '<i class="fas fa-plus"></i>',
-        name: 'add',
-        titleAttr: "Add",
-        action: function (e, dt, node, config) {
-            window.location.href = '{{.IPListURL}}'+"/"+fixedEncodeURIComponent($("#idListType").val());
-        }
-    };
-
-    $.fn.dataTable.ext.buttons.edit = {
-        text: '<i class="fas fa-pen"></i>',
-        name: 'edit',
-        titleAttr: "Edit",
-        action: function (e, dt, node, config) {
-            let selectedRow = table.row({ selected: true }).data();
-            let path = '{{.IPListURL}}' + "/" + fixedEncodeURIComponent(selectedRow["type"])+"/"+ fixedEncodeURIComponent(selectedRow["ipornet"]);
-            window.location.href = path;
-        },
-        enabled: false
-    };
-
-    $.fn.dataTable.ext.buttons.delete = {
-        text: '<i class="fas fa-trash"></i>',
-        name: 'delete',
-        titleAttr: "Delete",
-        action: function (e, dt, node, config) {
-            $('#deleteModal').modal('show');
-        },
-        enabled: false
-    };
-
-    let table = $('#dataTable').DataTable({
-        "ajax": {
-            "url": getSearchURL(),
-            "dataSrc": handleResponseData,
-            "error": function ($xhr, textStatus, errorThrown) {
-                $(".dataTables_processing").hide();
-                let txt = "Failed to get IP list";
-                if ($xhr) {
-                    let json = $xhr.responseJSON;
-                    if (json) {
-                        if (json.message){
-                            txt += ": " + json.message;
-                        } else {
-                            txt += ": " + json.error;
+        }).then((result) => {
+            if (result.isConfirmed){
+                $('#loading_message').text("");
+                KTApp.showPageLoading();
+                let path = '{{.IPListURL}}' + "/" + encodeURIComponent(listType)+ "/" + encodeURIComponent(ipNet);
+                axios.delete(path, {
+                    timeout: 15000,
+                    headers: {
+                        'X-CSRF-TOKEN': '{{.CSRFToken}}'
+                    },
+                    validateStatus: function (status) {
+                        return status == 200;
+                    }
+                }).then(function(response){
+                    location.reload();
+                }).catch(function(error){
+                    KTApp.hidePageLoading();
+                    let errorMessage;
+                    if (error && error.response) {
+                        switch (error.response.status) {
+                            case 403:
+                                errorMessage = "general.delete_error_403";
+                                break;
+                            case 404:
+                                errorMessage = "general.delete_error_404";
+                                break;
                         }
                     }
-                }
-                $('#errorTxt').text(txt);
-                $('#errorMsg').show();
+                    if (!errorMessage){
+                        errorMessage = "general.delete_error_generic";
+                    }
+                    ModalAlert.fire({
+                        text: $.t(errorMessage),
+                        icon: "warning",
+                        confirmButtonText: $.t('general.ok'),
+                        customClass: {
+                            confirmButton: "btn btn-primary"
+                        }
+                    });
+                });
             }
-        },
-        "deferRender": true,
-        "processing": true,
-        "columns": [
-            { "data": "ipornet" },
-            {
-                "data": "protocols",
-                "render": function (data, type, row) {
-                    if (type === 'display') {
-                        if (data == 0){
-                            return "Any";
+        });
+    }
+
+    var datatable = function(){
+        var dt;
+
+        var initDatatable = function () {
+            $('#errorMsg').addClass("d-none");
+            dt = $('#dataTable').DataTable({
+                ajax: {
+                    url: getSearchURL(),
+                    dataSrc: handleResponseData,
+                    error: function ($xhr, textStatus, errorThrown) {
+                        $(".dataTables_processing").hide();
+                        let txt = "";
+                        if ($xhr) {
+                            let json = $xhr.responseJSON;
+                            if (json) {
+                                if (json.message){
+                                    txt = json.message;
+                                }
+                            }
                         }
-                        const protocols = [];
-                        if ((data & 1) != 0){
-                            protocols.push('SSH');
+                        if (!txt){
+                            txt = "general.error500";
                         }
-                        if ((data & 2) != 0){
-                            protocols.push('FTP');
+                        setI18NData($('#errorTxt'), txt);
+                        $('#errorMsg').removeClass("d-none");
+                    }
+                },
+                columns: [
+                    {
+                        data: "ipornet",
+                        render: function(data, type, row) {
+                            if (type === 'display') {
+                                return escapeHTML(data);
+                            }
+                            return data;
                         }
-                        if ((data & 4) != 0){
-                            protocols.push('DAV');
+                    },
+                    {
+                        data: "protocols",
+                        render: function(data, type, row) {
+                            if (type === 'display') {
+                                if (data == 0){
+                                    return $.t('ip_list.any');
+                                }
+                                const protocols = [];
+                                if ((data & 1) != 0){
+                                    protocols.push('SSH');
+                                }
+                                if ((data & 2) != 0){
+                                    protocols.push('FTP');
+                                }
+                                if ((data & 4) != 0){
+                                    protocols.push('DAV');
+                                }
+                                if ((data & 8) != 0){
+                                    protocols.push('HTTP');
+                                }
+                                return protocols.join(', ');
+                            }
+                            return data;
                         }
-                        if ((data & 8) != 0){
-                            protocols.push('HTTP');
+                    },
+                    {
+                        data: "mode",
+                        render: function(data, type, row) {
+                            if (type === 'display') {
+                                if (data == 1){
+                                    return $.t('ip_list.allow');
+                                }
+                                return $.t('ip_list.deny');
+                            }
+                            return data;
                         }
-                        return protocols.join(', ');
-                    }
-                    return data;
-                }
-            },
-            {
-                "data": "mode",
-                "render": function (data, type, row) {
-                    if (type === 'display') {
-                        if (data == 1){
-                            return "Allow";
+                    },
+                    {
+                        data: "description",
+                        visible: false,
+                        render: function(data, type, row) {
+                            if (type === 'display') {
+                                return escapeHTML(data);
+                            }
+                            return data;
                         }
-                        return "Deny";
-                    }
-                    return data;
-                }
-            },
-            {
-                "data": "description",
-                "render": function (data, type, row) {
-                    if (type === 'display') {
-                        if (!data){
+                    },
+                    {
+                        data: "",
+                        searchable: false,
+                        orderable: false,
+                        className: 'text-end',
+                        render: function (data, type, row) {
+                            if (type === 'display') {
+                                return `<button class="btn btn-light btn-active-light-primary btn-flex btn-center btn-sm rotate" data-kt-menu-trigger="click" data-kt-menu-placement="bottom-end">
+                                                    <span data-i18n="general.actions" class="fs-6">Actions</span>
+										            <i class="ki-duotone ki-down fs-5 ms-1 rotate-180"></i>
+                                                </button>
+                                                <div class="menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-gray-700 menu-state-bg-light-primary fw-semibold fs-6 w-200px py-4" data-kt-menu="true">
+                                                    <div class="menu-item px-3">
+										                <a data-i18n="general.edit" href="#" class="menu-link px-3" data-table-action="edit_row">Edit</a>
+										            </div>
+                                                    <div class="menu-item px-3">
+                                                        <a data-i18n="general.delete" href="#" class="menu-link text-danger px-3" data-table-action="delete_row">Delete</a>
+										            </div>
+                                                </div>`;
+                            }
                             return "";
                         }
-                        let ellipsisFn = $.fn.dataTable.render.ellipsis(70, true, true);
-                        return ellipsisFn(data,type);
-                    }
-                    return data;
+                    },
+                ],
+                deferRender: true,
+                processing: true,
+                lengthChange: false,
+                searching: false,
+                paging: false,
+                info: false,
+                ordering: false,
+                language: {
+                    info: $.t('datatable.info'),
+                    infoEmpty: $.t('datatable.info_empty'),
+                    infoFiltered: $.t('datatable.info_filtered'),
+                    loadingRecords: "",
+                    processing: $.t('datatable.processing'),
+                    zeroRecords: "",
+                    emptyTable: $.t('datatable.no_records')
+                },
+                initComplete: function(settings, json) {
+                    handleColumnVisibility($('#idListType').val());
+                    $('#loader').addClass("d-none");
+                    $('#card_content').removeClass("d-none");
+                    let api = $.fn.dataTable.Api(settings);
+                    api.columns.adjust().draw("page");
+                    drawAction();
                 }
+            });
+
+            dt.on('draw', drawAction);
+        }
+
+        function drawAction() {
+            KTMenu.createInstances();
+            handleRowActions();
+            $('#table_body').localize();
+        }
+
+        function handleColumnVisibility(val) {
+            switch (val){
+                case '2':
+                dt.column(2).visible(true);
+                break;
+            default:
+                dt.column(2).visible(false);
             }
-        ],
-        "select": {
-            "style": "single",
-            "blurable": true
-        },
-        "buttons": [],
-        "lengthChange": false,
-        "columnDefs": [],
-        "responsive": true,
-        "searching": false,
-        "paging": false,
-        "info": false,
-        "ordering": false,
-        "language": {
-            "loadingRecords": "",
-            "emptyTable": "No entries found"
-        },
-        "initComplete": function (settings, json) {
-            table.button().add(0, 'delete');
-            table.button().add(0, 'edit');
-            table.button().add(0, 'add');
-
-            table.buttons().container().appendTo('.col-md-6:eq(0)', table.table().container());
         }
-    });
 
-    new $.fn.dataTable.FixedHeader(table);
-    $.fn.dataTable.ext.errMode = 'none';
+        function doSearch(){
+            dt.clear().draw();
+            dt.ajax.url(getSearchURL()).load();
+        }
 
-    table.on('select deselect', function () {
-        let selectedRows = table.rows({ selected: true }).count();
-        table.button('delete:name').enable(selectedRows == 1);
-        table.button('edit:name').enable(selectedRows == 1);
-    });
+        var handleDatatableActions = function () {
+            $('#idListType').on("change", function(e){
+                let val = $(this).find("option:selected").attr('value');
+                saveListType(val);
+                handleColumnVisibility(val);
+                doSearch();
+                checkSelectedListType(val);
+            });
+
+            $('#search_button').on("click", function(){
+                resetPagination();
+                doSearch();
+                saveSearchFilter();
+            });
+
+            $('#pagePrevious').on("click", function(e){
+                e.preventDefault();
+                this.blur();
+                paginationData.set("prevClicked",true);
+                paginationData.set("nextClicked",false);
+                doSearch();
+            });
+            $('#pageNext').on("click", function(e){
+                e.preventDefault();
+                this.blur();
+                paginationData.set("prevClicked",false);
+                paginationData.set("nextClicked",true);
+                doSearch();
+            });
+        }
+
+        function handleRowActions() {
+            const editButtons = document.querySelectorAll('[data-table-action="edit_row"]');
+            editButtons.forEach(d => {
+                let el = $(d);
+                el.off("click");
+                el.on("click", function(e){
+                    e.preventDefault();
+                    let rowData = dt.row(e.target.closest('tr')).data();
+                    window.location.replace('{{.IPListURL}}' + "/" + encodeURIComponent(rowData['type'])+"/"+encodeURIComponent(rowData['ipornet']));
+                });
+            });
+
+            const deleteButtons = document.querySelectorAll('[data-table-action="delete_row"]');
+            deleteButtons.forEach(d => {
+                let el = $(d);
+                el.off("click");
+                el.on("click", function(e){
+                    e.preventDefault();
+                    const parent = e.target.closest('tr');
+                    let rowData = dt.row(parent).data();
+                    deleteAction(rowData['type'],rowData['ipornet']);
+                });
+            });
+        }
 
-    resetPagination();
+        return {
+            init: function () {
+                initDatatable();
+                handleDatatableActions();
+            }
+        }
+    }();
 
-    let listType = $('#idListType').val();
-    setTableColumnVisibility(listType);
-    updateListTypeInfo(listType);
-});
+    $(document).on("i18nload", function(){
+        resetPagination();
+        if (listType === '1' || listType === '3'){
+            $('#idListType').val(listType);
+        } else {
+            $('#idListType').val('2');
+        }
+        $('#idListType').trigger('change');
 
+        if (listFilter){
+            $('#idSearch').val(listFilter);
+        } else {
+            $('#idSearch').val('');
+        }
+        $("#idAdd").on("click", function(){
+            window.location.href = '{{.IPListURL}}'+"/"+encodeURIComponent($("#idListType").val());
+        });
+    });
+
+    $(document).on("i18nshow", function(){
+        datatable.init();
+        checkSelectedListType(listType);
+    });
 </script>
-{{end}}
+{{- end}}