Merge 1.5.2 changes back to master.
diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml
new file mode 100644
index 0000000..a7733ca
--- /dev/null
+++ b/.github/workflows/pr-build.yml
@@ -0,0 +1,29 @@
+name: Pull request CI build
+
+# Run build for all pull requests
+on:
+  pull_request:
+
+# Limit to only one build for a given PR source branch at a time,
+# cancelling any in-progress builds
+concurrency:
+  group: guacamole-client-pr-${{ github.head_ref }}
+  cancel-in-progress: true
+
+jobs:
+
+  docker_build:
+    name: Run docker build
+    runs-on: ubuntu-latest
+    steps:
+
+      - name: Check out code
+        uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+          persist-credentials: false
+
+      - name: Build Docker container
+        shell: sh
+        run: |
+          docker build --pull --no-cache --force-rm .
diff --git a/.ratignore b/.ratignore
index ae1ea9c..eac248a 100644
--- a/.ratignore
+++ b/.ratignore
@@ -1,3 +1,4 @@
+.github/**
 CONTRIBUTING
 doc/licenses/*/**/*
 doc/**/html/*.html
diff --git a/doc/licenses/base64-js-1.5.1/LICENSE b/doc/licenses/base64-js-1.5.1/LICENSE
new file mode 100644
index 0000000..9143d4c
--- /dev/null
+++ b/doc/licenses/base64-js-1.5.1/LICENSE
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2014 Jameson Little
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+Footer
diff --git a/doc/licenses/base64-js-1.5.1/README b/doc/licenses/base64-js-1.5.1/README
new file mode 100644
index 0000000..6cacd8f
--- /dev/null
+++ b/doc/licenses/base64-js-1.5.1/README
@@ -0,0 +1,8 @@
+base64-js (https://github.com/beatgammit/base64-js)
+---------------------------------------------
+
+    Version: 1.5.1
+    From: 'Jameson Little' (https://github.com/beatgammit/)
+    License(s):
+        MIT (bundled/base640-js-1.5.1/LICENSE)
+
diff --git a/doc/licenses/base64-js-1.5.1/dep-coordinates.txt b/doc/licenses/base64-js-1.5.1/dep-coordinates.txt
new file mode 100644
index 0000000..1d66ee8
--- /dev/null
+++ b/doc/licenses/base64-js-1.5.1/dep-coordinates.txt
@@ -0,0 +1 @@
+base64-js:1.5.1
diff --git a/doc/licenses/bouncycastle-pkix-fips-1.0.7/LICENSE b/doc/licenses/bouncycastle-pkix-fips-1.0.7/LICENSE
new file mode 100644
index 0000000..a02bc17
--- /dev/null
+++ b/doc/licenses/bouncycastle-pkix-fips-1.0.7/LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2000 - 2021 The Legion of the Bouncy Castle Inc.
+(https://www.bouncycastle.org)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/doc/licenses/bouncycastle-pkix-fips-1.0.7/README b/doc/licenses/bouncycastle-pkix-fips-1.0.7/README
new file mode 100644
index 0000000..b362257
--- /dev/null
+++ b/doc/licenses/bouncycastle-pkix-fips-1.0.7/README
@@ -0,0 +1,8 @@
+BouncyCastle PKIX APIs, FIPS Distribution (https://www.bouncycastle.org/fips-java)
+-----------------------------------------------------------------------
+
+    Version: 1.0.7
+    From: 'The Legion of Bouncy Castle' (https://www.bouncycastle.org)
+    License(s):
+        MIT (bundled/bouncycastle-pkix-fips-1.0.7/LICENSE)
+
diff --git a/doc/licenses/bouncycastle-pkix-fips-1.0.7/dep-coordinates.txt b/doc/licenses/bouncycastle-pkix-fips-1.0.7/dep-coordinates.txt
new file mode 100644
index 0000000..23ca14e
--- /dev/null
+++ b/doc/licenses/bouncycastle-pkix-fips-1.0.7/dep-coordinates.txt
@@ -0,0 +1 @@
+org.bouncycastle:bcpkix-fips:jar:1.0.7
diff --git a/doc/licenses/buffer-4.9.2/LICENSE b/doc/licenses/buffer-4.9.2/LICENSE
new file mode 100644
index 0000000..d6bf75d
--- /dev/null
+++ b/doc/licenses/buffer-4.9.2/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) Feross Aboukhadijeh, and other contributors.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/doc/licenses/buffer-4.9.2/README b/doc/licenses/buffer-4.9.2/README
new file mode 100644
index 0000000..7caad56
--- /dev/null
+++ b/doc/licenses/buffer-4.9.2/README
@@ -0,0 +1,7 @@
+buffer (https://github.com/feross/buffer)
+---------------------------------------------
+
+    Version: 4.9.2
+    From: 'Feross Aboukhadijeh' (https://github.com/feross)
+    License(s):
+        MIT (bundled/buffer-4.9.2/LICENSE)
diff --git a/doc/licenses/buffer-4.9.2/dep-coordinates.txt b/doc/licenses/buffer-4.9.2/dep-coordinates.txt
new file mode 100644
index 0000000..30f193d
--- /dev/null
+++ b/doc/licenses/buffer-4.9.2/dep-coordinates.txt
@@ -0,0 +1 @@
+buffer:4.9.2
diff --git a/doc/licenses/caffeine-2.9.3/README b/doc/licenses/caffeine-2.9.3/README
new file mode 100644
index 0000000..c51ba74
--- /dev/null
+++ b/doc/licenses/caffeine-2.9.3/README
@@ -0,0 +1,8 @@
+Caffeine (https://github.com/ben-manes/caffeine)
+------------------------------------------------
+
+    Version: 2.9.3
+    From: 'Ben Manes' (https://github.com/ben-manes)
+    License(s):
+        Apache v2.0
+
diff --git a/doc/licenses/caffeine-2.9.3/dep-coordinates.txt b/doc/licenses/caffeine-2.9.3/dep-coordinates.txt
new file mode 100644
index 0000000..feda8aa
--- /dev/null
+++ b/doc/licenses/caffeine-2.9.3/dep-coordinates.txt
@@ -0,0 +1 @@
+com.github.ben-manes.caffeine:caffeine:jar:2.9.3
diff --git a/doc/licenses/checker-qual-3.19.0/LICENSE.txt b/doc/licenses/checker-qual-3.19.0/LICENSE.txt
new file mode 100644
index 0000000..9837c6b
--- /dev/null
+++ b/doc/licenses/checker-qual-3.19.0/LICENSE.txt
@@ -0,0 +1,22 @@
+Checker Framework qualifiers
+Copyright 2004-present by the Checker Framework developers
+
+MIT License:
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/doc/licenses/checker-qual-3.19.0/README b/doc/licenses/checker-qual-3.19.0/README
new file mode 100644
index 0000000..4a00db7
--- /dev/null
+++ b/doc/licenses/checker-qual-3.19.0/README
@@ -0,0 +1,8 @@
+Checker Framework qualifiers (https://checkerframework.org/)
+------------------------------------------------------------
+
+    Version: 3.19.0
+    From: 'Checker Framework developers' (https://checkerframework.org/)
+    License(s):
+        MIT (bundled/checker-qual-3.19.0/LICENSE.txt)
+
diff --git a/doc/licenses/checker-qual-3.19.0/dep-coordinates.txt b/doc/licenses/checker-qual-3.19.0/dep-coordinates.txt
new file mode 100644
index 0000000..322f395
--- /dev/null
+++ b/doc/licenses/checker-qual-3.19.0/dep-coordinates.txt
@@ -0,0 +1 @@
+org.checkerframework:checker-qual:jar:3.19.0
diff --git a/doc/licenses/core-util-is-1.0.3/LICENSE b/doc/licenses/core-util-is-1.0.3/LICENSE
new file mode 100644
index 0000000..d8d7f94
--- /dev/null
+++ b/doc/licenses/core-util-is-1.0.3/LICENSE
@@ -0,0 +1,19 @@
+Copyright Node.js contributors. All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to
+deal in the Software without restriction, including without limitation the
+rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+sell copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+IN THE SOFTWARE.
diff --git a/doc/licenses/core-util-is-1.0.3/README b/doc/licenses/core-util-is-1.0.3/README
new file mode 100644
index 0000000..7db6591
--- /dev/null
+++ b/doc/licenses/core-util-is-1.0.3/README
@@ -0,0 +1,7 @@
+core-util-is (https://github.com/isaacs/core-util-is)
+---------------------------------------------
+
+    Version: 1.0.3
+    From: 'Node.js contributors'
+    License(s):
+        MIT (bundled/core-util-is-1.0.3/LICENSE)
diff --git a/doc/licenses/core-util-is-1.0.3/dep-coordinates.txt b/doc/licenses/core-util-is-1.0.3/dep-coordinates.txt
new file mode 100644
index 0000000..f4b1c7a
--- /dev/null
+++ b/doc/licenses/core-util-is-1.0.3/dep-coordinates.txt
@@ -0,0 +1 @@
+core-util-is:1.0.3
diff --git a/doc/licenses/csv-6.2.5/LICENSE b/doc/licenses/csv-6.2.5/LICENSE
new file mode 100644
index 0000000..918eaf0
--- /dev/null
+++ b/doc/licenses/csv-6.2.5/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2010 Adaltas
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/doc/licenses/csv-6.2.5/README b/doc/licenses/csv-6.2.5/README
new file mode 100644
index 0000000..8a8d6de
--- /dev/null
+++ b/doc/licenses/csv-6.2.5/README
@@ -0,0 +1,7 @@
+node-csv (https://github.com/adaltas/node-csv)
+---------------------------------------------
+
+    Version: 6.2.5
+    From: 'Adaltas' (https://github.com/adaltas)
+    License(s):
+        MIT (bundled/csv-6.2.5/LICENSE)
diff --git a/doc/licenses/csv-6.2.5/dep-coordinates.txt b/doc/licenses/csv-6.2.5/dep-coordinates.txt
new file mode 100644
index 0000000..dc0f226
--- /dev/null
+++ b/doc/licenses/csv-6.2.5/dep-coordinates.txt
@@ -0,0 +1,2 @@
+csv:6.2.5
+csv-parse:5.3.3
diff --git a/doc/licenses/error-prone-2.10.0/README b/doc/licenses/error-prone-2.10.0/README
new file mode 100644
index 0000000..f286aed
--- /dev/null
+++ b/doc/licenses/error-prone-2.10.0/README
@@ -0,0 +1,8 @@
+Error Prone (https://errorprone.info/)
+--------------------------------------
+
+    Version: 2.10.0
+    From: 'Google Inc.' (http://www.google.com/)
+    License(s):
+        Apache v2.0
+
diff --git a/doc/licenses/error-prone-2.10.0/dep-coordinates.txt b/doc/licenses/error-prone-2.10.0/dep-coordinates.txt
new file mode 100644
index 0000000..9473dfc
--- /dev/null
+++ b/doc/licenses/error-prone-2.10.0/dep-coordinates.txt
@@ -0,0 +1 @@
+com.google.errorprone:error_prone_annotations:jar:2.10.0
diff --git a/doc/licenses/events-3.3.0/LICENSE b/doc/licenses/events-3.3.0/LICENSE
new file mode 100644
index 0000000..52ed3b0
--- /dev/null
+++ b/doc/licenses/events-3.3.0/LICENSE
@@ -0,0 +1,22 @@
+MIT
+
+Copyright Joyent, Inc. and other Node contributors.
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to permit
+persons to whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/doc/licenses/events-3.3.0/README b/doc/licenses/events-3.3.0/README
new file mode 100644
index 0000000..b53924d
--- /dev/null
+++ b/doc/licenses/events-3.3.0/README
@@ -0,0 +1,7 @@
+events (https://github.com/browserify/events)
+---------------------------------------------
+
+    Version: 3.3.0
+    From: 'Node.js contributors, Joyent, Inc., and other Node contributors'
+    License(s):
+        MIT (bundled/events-3.3.0/LICENSE)
diff --git a/doc/licenses/events-3.3.0/dep-coordinates.txt b/doc/licenses/events-3.3.0/dep-coordinates.txt
new file mode 100644
index 0000000..f644fcc
--- /dev/null
+++ b/doc/licenses/events-3.3.0/dep-coordinates.txt
@@ -0,0 +1 @@
+events:3.3.0
diff --git a/doc/licenses/ieee754-1.2.1/LICENSE b/doc/licenses/ieee754-1.2.1/LICENSE
new file mode 100644
index 0000000..5aac82c
--- /dev/null
+++ b/doc/licenses/ieee754-1.2.1/LICENSE
@@ -0,0 +1,11 @@
+Copyright 2008 Fair Oaks Labs, Inc.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/doc/licenses/ieee754-1.2.1/README b/doc/licenses/ieee754-1.2.1/README
new file mode 100644
index 0000000..f15f632
--- /dev/null
+++ b/doc/licenses/ieee754-1.2.1/README
@@ -0,0 +1,7 @@
+ieee754 (https://github.com/feross/ieee754)
+---------------------------------------------
+
+    Version: 1.2.1
+    From: 'Fair Oaks Labs, Inc'
+    License(s):
+        MIT (bundled/ieee754-1.2.1/LICENSE)
diff --git a/doc/licenses/ieee754-1.2.1/dep-coordinates.txt b/doc/licenses/ieee754-1.2.1/dep-coordinates.txt
new file mode 100644
index 0000000..4fb7d77
--- /dev/null
+++ b/doc/licenses/ieee754-1.2.1/dep-coordinates.txt
@@ -0,0 +1 @@
+ieee754:1.2.1
diff --git a/doc/licenses/inherits-2.0.4/LICENSE b/doc/licenses/inherits-2.0.4/LICENSE
new file mode 100644
index 0000000..052085c
--- /dev/null
+++ b/doc/licenses/inherits-2.0.4/LICENSE
@@ -0,0 +1,15 @@
+The ISC License
+
+Copyright (c) Isaac Z. Schlueter
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/doc/licenses/inherits-2.0.4/README b/doc/licenses/inherits-2.0.4/README
new file mode 100644
index 0000000..9d0b401
--- /dev/null
+++ b/doc/licenses/inherits-2.0.4/README
@@ -0,0 +1,8 @@
+inherits (https://github.com/isaacs/inherits)
+---------------------------------------------
+
+    Version: 2.0.4
+    From: 'Isaac Z. Schlueter' (https://github.com/isaacs)
+    License(s):
+        ISC (bundled/inherits-2.0.4/LICENSE)
+
diff --git a/doc/licenses/inherits-2.0.4/dep-coordinates.txt b/doc/licenses/inherits-2.0.4/dep-coordinates.txt
new file mode 100644
index 0000000..4a17c13
--- /dev/null
+++ b/doc/licenses/inherits-2.0.4/dep-coordinates.txt
@@ -0,0 +1 @@
+inherits:2.0.4
diff --git a/doc/licenses/isarray-1.0.0/LICENSE b/doc/licenses/isarray-1.0.0/LICENSE
new file mode 100644
index 0000000..de32266
--- /dev/null
+++ b/doc/licenses/isarray-1.0.0/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/doc/licenses/isarray-1.0.0/README b/doc/licenses/isarray-1.0.0/README
new file mode 100644
index 0000000..3fde38c
--- /dev/null
+++ b/doc/licenses/isarray-1.0.0/README
@@ -0,0 +1,8 @@
+isarray (https://github.com/juliangruber/isarray)
+---------------------------------------------
+
+    Version: 1.0.0
+    From: 'Julian Gruber' (https://github.com/juliangruber)
+    License(s):
+        MIT (bundled/isarray-1.0.0/LICENSE)
+
diff --git a/doc/licenses/isarray-1.0.0/dep-coordinates.txt b/doc/licenses/isarray-1.0.0/dep-coordinates.txt
new file mode 100644
index 0000000..d444bdb
--- /dev/null
+++ b/doc/licenses/isarray-1.0.0/dep-coordinates.txt
@@ -0,0 +1 @@
+isarray:1.0.0
diff --git a/doc/licenses/process-nextick-args-2.0.1/README b/doc/licenses/process-nextick-args-2.0.1/README
new file mode 100644
index 0000000..84417dc
--- /dev/null
+++ b/doc/licenses/process-nextick-args-2.0.1/README
@@ -0,0 +1,8 @@
+process-nextick-args (https://github.com/calvinmetcalf/process-nextick-args)
+---------------------------------------------
+
+    Version: 2.0.1
+    From: 'Calvin Metcalf' (https://github.com/calvinmetcalf)
+    License(s):
+        MIT (bundled/process-nextick-args-2.0.1/license.md)
+
diff --git a/doc/licenses/process-nextick-args-2.0.1/dep-coordinates.txt b/doc/licenses/process-nextick-args-2.0.1/dep-coordinates.txt
new file mode 100644
index 0000000..7c68c4b
--- /dev/null
+++ b/doc/licenses/process-nextick-args-2.0.1/dep-coordinates.txt
@@ -0,0 +1 @@
+process-nextick-args:2.0.1
diff --git a/doc/licenses/process-nextick-args-2.0.1/license.md b/doc/licenses/process-nextick-args-2.0.1/license.md
new file mode 100644
index 0000000..c67e353
--- /dev/null
+++ b/doc/licenses/process-nextick-args-2.0.1/license.md
@@ -0,0 +1,19 @@
+# Copyright (c) 2015 Calvin Metcalf
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+**THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.**
diff --git a/doc/licenses/readable-stream-2.3.7/LICENSE b/doc/licenses/readable-stream-2.3.7/LICENSE
new file mode 100644
index 0000000..2873b3b
--- /dev/null
+++ b/doc/licenses/readable-stream-2.3.7/LICENSE
@@ -0,0 +1,47 @@
+Node.js is licensed for use as follows:
+
+"""
+Copyright Node.js contributors. All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to
+deal in the Software without restriction, including without limitation the
+rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+sell copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+IN THE SOFTWARE.
+"""
+
+This license applies to parts of Node.js originating from the
+https://github.com/joyent/node repository:
+
+"""
+Copyright Joyent, Inc. and other Node contributors. All rights reserved.
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to
+deal in the Software without restriction, including without limitation the
+rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+sell copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+IN THE SOFTWARE.
+"""
diff --git a/doc/licenses/readable-stream-2.3.7/README b/doc/licenses/readable-stream-2.3.7/README
new file mode 100644
index 0000000..6e09569
--- /dev/null
+++ b/doc/licenses/readable-stream-2.3.7/README
@@ -0,0 +1,8 @@
+readable-stream (https://github.com/nodejs/readable-stream)
+---------------------------------------------
+
+    Version: 2.3.7
+    From: 'Node.js contributors, Joyent, Inc., and other Node contributors'
+    License(s):
+        MIT (bundled/readable-stream-2.3.7/LICENSE)
+
diff --git a/doc/licenses/readable-stream-2.3.7/dep-coordinates.txt b/doc/licenses/readable-stream-2.3.7/dep-coordinates.txt
new file mode 100644
index 0000000..f645a21
--- /dev/null
+++ b/doc/licenses/readable-stream-2.3.7/dep-coordinates.txt
@@ -0,0 +1 @@
+readable-stream:2.3.7
diff --git a/doc/licenses/safe-buffer-5.1.2/LICENSE b/doc/licenses/safe-buffer-5.1.2/LICENSE
new file mode 100644
index 0000000..0c068ce
--- /dev/null
+++ b/doc/licenses/safe-buffer-5.1.2/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) Feross Aboukhadijeh
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/doc/licenses/safe-buffer-5.1.2/README b/doc/licenses/safe-buffer-5.1.2/README
new file mode 100644
index 0000000..26cf5dc
--- /dev/null
+++ b/doc/licenses/safe-buffer-5.1.2/README
@@ -0,0 +1,8 @@
+safe-buffer (https://github.com/feross/safe-buffer)
+---------------------------------------------
+
+    Version: 5.1.2
+    From: 'Feross Aboukhadijeh' (https://github.com/feross)
+    License(s):
+        MIT (bundled/safe-buffer-5.1.2/LICENSE)
+
diff --git a/doc/licenses/safe-buffer-5.1.2/dep-coordinates.txt b/doc/licenses/safe-buffer-5.1.2/dep-coordinates.txt
new file mode 100644
index 0000000..594ab08
--- /dev/null
+++ b/doc/licenses/safe-buffer-5.1.2/dep-coordinates.txt
@@ -0,0 +1 @@
+safe-buffer:5.1.2
diff --git a/doc/licenses/setimmediate-1.0.5/LICENSE.txt b/doc/licenses/setimmediate-1.0.5/LICENSE.txt
new file mode 100644
index 0000000..32b20de
--- /dev/null
+++ b/doc/licenses/setimmediate-1.0.5/LICENSE.txt
@@ -0,0 +1,20 @@
+Copyright (c) 2012 Barnesandnoble.com, llc, Donavon West, and Domenic Denicola
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/doc/licenses/setimmediate-1.0.5/README b/doc/licenses/setimmediate-1.0.5/README
new file mode 100644
index 0000000..8bd3389
--- /dev/null
+++ b/doc/licenses/setimmediate-1.0.5/README
@@ -0,0 +1,8 @@
+setImmediate.js (https://github.com/YuzuJS/setImmediate)
+---------------------------------------------
+
+    Version: 1.0.5
+    From: 'Yuzu (by Barnes & Noble Education)' (https://github.com/YuzuJS)
+    License(s):
+        MIT (bundled/setimmediate-1.0.5/LICENSE.txt)
+
diff --git a/doc/licenses/setimmediate-1.0.5/dep-coordinates.txt b/doc/licenses/setimmediate-1.0.5/dep-coordinates.txt
new file mode 100644
index 0000000..d2111b4
--- /dev/null
+++ b/doc/licenses/setimmediate-1.0.5/dep-coordinates.txt
@@ -0,0 +1 @@
+setimmediate:1.0.5
diff --git a/doc/licenses/stream-browserify-2.0.2/LICENSE b/doc/licenses/stream-browserify-2.0.2/LICENSE
new file mode 100644
index 0000000..3e7d0c0
--- /dev/null
+++ b/doc/licenses/stream-browserify-2.0.2/LICENSE
@@ -0,0 +1,20 @@
+MIT License
+
+Copyright (c) James Halliday
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/doc/licenses/stream-browserify-2.0.2/README b/doc/licenses/stream-browserify-2.0.2/README
new file mode 100644
index 0000000..4116876
--- /dev/null
+++ b/doc/licenses/stream-browserify-2.0.2/README
@@ -0,0 +1,8 @@
+stream-browserify (https://github.com/browserify/stream-browserify)
+---------------------------------------------
+
+    Version: 2.0.2
+    From: 'James Halliday'
+    License(s):
+        MIT (bundled/stream-browserify-2.0.2/LICENSE)
+
diff --git a/doc/licenses/stream-browserify-2.0.2/dep-coordinates.txt b/doc/licenses/stream-browserify-2.0.2/dep-coordinates.txt
new file mode 100644
index 0000000..7bc88e9
--- /dev/null
+++ b/doc/licenses/stream-browserify-2.0.2/dep-coordinates.txt
@@ -0,0 +1 @@
+stream-browserify:2.0.2
diff --git a/doc/licenses/string_decoder-1.1.1/LICENSE b/doc/licenses/string_decoder-1.1.1/LICENSE
new file mode 100644
index 0000000..2873b3b
--- /dev/null
+++ b/doc/licenses/string_decoder-1.1.1/LICENSE
@@ -0,0 +1,47 @@
+Node.js is licensed for use as follows:
+
+"""
+Copyright Node.js contributors. All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to
+deal in the Software without restriction, including without limitation the
+rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+sell copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+IN THE SOFTWARE.
+"""
+
+This license applies to parts of Node.js originating from the
+https://github.com/joyent/node repository:
+
+"""
+Copyright Joyent, Inc. and other Node contributors. All rights reserved.
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to
+deal in the Software without restriction, including without limitation the
+rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+sell copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+IN THE SOFTWARE.
+"""
diff --git a/doc/licenses/string_decoder-1.1.1/README b/doc/licenses/string_decoder-1.1.1/README
new file mode 100644
index 0000000..48ee14c
--- /dev/null
+++ b/doc/licenses/string_decoder-1.1.1/README
@@ -0,0 +1,8 @@
+string_decoder (https://github.com/nodejs/string_decoder)
+---------------------------------------------
+
+    Version: 1.1.1
+    From: 'Node.js contributors, Joyent, Inc., and other Node contributors'
+    License(s):
+        MIT (bundled/string_decoder-1.1.1/LICENSE)
+
diff --git a/doc/licenses/string_decoder-1.1.1/dep-coordinates.txt b/doc/licenses/string_decoder-1.1.1/dep-coordinates.txt
new file mode 100644
index 0000000..bbdc75d
--- /dev/null
+++ b/doc/licenses/string_decoder-1.1.1/dep-coordinates.txt
@@ -0,0 +1 @@
+string_decoder:1.1.1
diff --git a/doc/licenses/timers-browserify-2.0.12/LICENSE.md b/doc/licenses/timers-browserify-2.0.12/LICENSE.md
new file mode 100644
index 0000000..f2bdf52
--- /dev/null
+++ b/doc/licenses/timers-browserify-2.0.12/LICENSE.md
@@ -0,0 +1,23 @@
+# timers-browserify
+
+This project uses the [MIT](http://jryans.mit-license.org/) license:
+
+    Copyright © 2012 J. Ryan Stinnett <jryans@gmail.com>
+
+    Permission is hereby granted, free of charge, to any person obtaining a
+    copy of this software and associated documentation files (the “Software”),
+    to deal in the Software without restriction, including without limitation
+    the rights to use, copy, modify, merge, publish, distribute, sublicense,
+    and/or sell copies of the Software, and to permit persons to whom the
+    Software is furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in
+    all copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+    FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+    DEALINGS IN THE SOFTWARE.
diff --git a/doc/licenses/timers-browserify-2.0.12/README b/doc/licenses/timers-browserify-2.0.12/README
new file mode 100644
index 0000000..9ed3898
--- /dev/null
+++ b/doc/licenses/timers-browserify-2.0.12/README
@@ -0,0 +1,8 @@
+timers-browserify (https://github.com/browserify/timers-browserify)
+---------------------------------------------
+
+    Version: 2.0.12
+    From: 'J. Ryan Stinnett' (https://github.com/jryans)
+    License(s):
+        MIT (bundled/timers-browserify-2.0.12/LICENSE.md)
+
diff --git a/doc/licenses/timers-browserify-2.0.12/dep-coordinates.txt b/doc/licenses/timers-browserify-2.0.12/dep-coordinates.txt
new file mode 100644
index 0000000..b1dfbee
--- /dev/null
+++ b/doc/licenses/timers-browserify-2.0.12/dep-coordinates.txt
@@ -0,0 +1 @@
+timers-browserify:2.0.12
diff --git a/doc/licenses/util-deprecate-1.0.2/LICENSE b/doc/licenses/util-deprecate-1.0.2/LICENSE
new file mode 100644
index 0000000..6a60e8c
--- /dev/null
+++ b/doc/licenses/util-deprecate-1.0.2/LICENSE
@@ -0,0 +1,24 @@
+(The MIT License)
+
+Copyright (c) 2014 Nathan Rajlich <nathan@tootallnate.net>
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
diff --git a/doc/licenses/util-deprecate-1.0.2/README b/doc/licenses/util-deprecate-1.0.2/README
new file mode 100644
index 0000000..9384470
--- /dev/null
+++ b/doc/licenses/util-deprecate-1.0.2/README
@@ -0,0 +1,8 @@
+util-deprecate (https://github.com/TooTallNate/util-deprecate)
+---------------------------------------------
+
+    Version: 1.0.2
+    From: 'Nathan Rajlich' (https://github.com/TooTallNate)
+    License(s):
+        MIT (bundled/util-deprecate-1.0.2/LICENSE)
+
diff --git a/doc/licenses/util-deprecate-1.0.2/dep-coordinates.txt b/doc/licenses/util-deprecate-1.0.2/dep-coordinates.txt
new file mode 100644
index 0000000..f8c3024
--- /dev/null
+++ b/doc/licenses/util-deprecate-1.0.2/dep-coordinates.txt
@@ -0,0 +1 @@
+util-deprecate:1.0.2
diff --git a/extensions/guacamole-auth-ban/.ratignore b/extensions/guacamole-auth-ban/.ratignore
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/extensions/guacamole-auth-ban/.ratignore
diff --git a/extensions/guacamole-auth-ban/pom.xml b/extensions/guacamole-auth-ban/pom.xml
new file mode 100644
index 0000000..d42ec34
--- /dev/null
+++ b/extensions/guacamole-auth-ban/pom.xml
@@ -0,0 +1,81 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you under the Apache License, Version 2.0 (the
+    "License"); you may not use this file except in compliance
+    with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing,
+    software distributed under the License is distributed on an
+    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+    KIND, either express or implied.  See the License for the
+    specific language governing permissions and limitations
+    under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
+                        http://maven.apache.org/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>org.apache.guacamole</groupId>
+    <artifactId>guacamole-auth-ban</artifactId>
+    <packaging>jar</packaging>
+    <version>1.5.2</version>
+    <name>guacamole-auth-ban</name>
+    <url>http://guacamole.apache.org/</url>
+
+    <parent>
+        <groupId>org.apache.guacamole</groupId>
+        <artifactId>extensions</artifactId>
+        <version>1.5.2</version>
+        <relativePath>../</relativePath>
+    </parent>
+
+    <dependencies>
+
+        <!-- Java servlet API -->
+        <dependency>
+            <groupId>javax.servlet</groupId>
+            <artifactId>servlet-api</artifactId>
+            <version>2.5</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- Guacamole Extension API -->
+        <dependency>
+            <groupId>org.apache.guacamole</groupId>
+            <artifactId>guacamole-ext</artifactId>
+            <version>1.5.2</version>
+            <scope>provided</scope>
+
+            <!-- Exclude transitive dependencies that will be overridden by
+                newer versions required by Caffeine -->
+            <exclusions>
+                <exclusion>
+                    <groupId>org.checkerframework</groupId>
+                    <artifactId>checker-qual</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>com.google.errorprone</groupId>
+                    <artifactId>error_prone_annotations</artifactId>
+                </exclusion>
+            </exclusions>
+
+        </dependency>
+
+        <!-- Guava Base Libraries -->
+        <dependency>
+            <groupId>com.github.ben-manes.caffeine</groupId>
+            <artifactId>caffeine</artifactId>
+            <version>2.9.3</version>
+        </dependency>
+
+    </dependencies>
+
+</project>
diff --git a/extensions/guacamole-auth-ban/src/main/assembly/dist.xml b/extensions/guacamole-auth-ban/src/main/assembly/dist.xml
new file mode 100644
index 0000000..d046ae6
--- /dev/null
+++ b/extensions/guacamole-auth-ban/src/main/assembly/dist.xml
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you under the Apache License, Version 2.0 (the
+    "License"); you may not use this file except in compliance
+    with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing,
+    software distributed under the License is distributed on an
+    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+    KIND, either express or implied.  See the License for the
+    specific language governing permissions and limitations
+    under the License.
+-->
+<assembly
+    xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0
+                        http://maven.apache.org/xsd/assembly-1.1.0.xsd">
+    
+    <id>dist</id>
+    <baseDirectory>${project.artifactId}-${project.version}</baseDirectory>
+
+    <!-- Output tar.gz -->
+    <formats>
+        <format>tar.gz</format>
+    </formats>
+
+    <!-- Include licenses and extension .jar -->
+    <fileSets>
+
+        <!-- Include licenses -->
+        <fileSet>
+            <outputDirectory></outputDirectory>
+            <directory>target/licenses</directory>
+        </fileSet>
+
+        <!-- Include extension .jar -->
+        <fileSet>
+            <directory>target</directory>
+            <outputDirectory></outputDirectory>
+            <includes>
+                <include>*.jar</include>
+            </includes>
+        </fileSet>
+
+    </fileSets>
+
+</assembly>
diff --git a/extensions/guacamole-auth-ban/src/main/java/org/apache/guacamole/auth/ban/BanningAuthenticationListener.java b/extensions/guacamole-auth-ban/src/main/java/org/apache/guacamole/auth/ban/BanningAuthenticationListener.java
new file mode 100644
index 0000000..c1ba8a5
--- /dev/null
+++ b/extensions/guacamole-auth-ban/src/main/java/org/apache/guacamole/auth/ban/BanningAuthenticationListener.java
@@ -0,0 +1,200 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.ban;
+
+import org.apache.guacamole.auth.ban.status.AuthenticationFailureTracker;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.ban.status.InMemoryAuthenticationFailureTracker;
+import org.apache.guacamole.auth.ban.status.NullAuthenticationFailureTracker;
+import org.apache.guacamole.environment.Environment;
+import org.apache.guacamole.environment.LocalEnvironment;
+import org.apache.guacamole.net.auth.credentials.GuacamoleInsufficientCredentialsException;
+import org.apache.guacamole.net.event.AuthenticationFailureEvent;
+import org.apache.guacamole.net.event.AuthenticationSuccessEvent;
+import org.apache.guacamole.net.event.listener.Listener;
+import org.apache.guacamole.net.event.AuthenticationRequestReceivedEvent;
+import org.apache.guacamole.properties.IntegerGuacamoleProperty;
+import org.apache.guacamole.properties.LongGuacamoleProperty;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Listener implementation which automatically tracks authentication failures
+ * such that further authentication attempts may be automatically blocked if
+ * they match configured criteria.
+ */
+public class BanningAuthenticationListener implements Listener {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(BanningAuthenticationListener.class);
+
+    /**
+     * The maximum number of failed authentication attempts allowed before an
+     * address is temporarily banned.
+     */
+    private static final IntegerGuacamoleProperty MAX_ATTEMPTS = new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() {
+            return "ban-max-invalid-attempts";
+        }
+
+    };
+
+    /**
+     * The length of time that each address should be banned after reaching the
+     * maximum number of failed authentication attempts, in seconds.
+     */
+    private static final IntegerGuacamoleProperty IP_BAN_DURATION = new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() {
+            return "ban-address-duration";
+        }
+
+    };
+
+    /**
+     * The maximum number of failed authentication attempts tracked at any
+     * given time. Once this number of addresses is exceeded, the oldest
+     * authentication attempts are rotated off on an LRU basis.
+     */
+    private static final LongGuacamoleProperty MAX_ADDRESSES = new LongGuacamoleProperty() {
+
+        @Override
+        public String getName() {
+            return "ban-max-addresses";
+        }
+
+    };
+
+    /**
+     * The default maximum number of failed authentication attempts allowed
+     * before an address is temporarily banned.
+     */
+    private static final int DEFAULT_MAX_ATTEMPTS = 5;
+
+    /**
+     * The default length of time that each address should be banned after
+     * reaching the maximum number of failed authentication attempts, in
+     * seconds.
+     */
+    private static final int DEFAULT_IP_BAN_DURATION = 300;
+
+    /**
+     * The maximum number of failed authentication attempts tracked at any
+     * given time. Once this number of addresses is exceeded, the oldest
+     * authentication attempts are rotated off on an LRU basis.
+     */
+    private static final long DEFAULT_MAX_ADDRESSES = 10485760;
+
+    /**
+     * Tracker of addresses that have repeatedly failed authentication.
+     */
+    private final AuthenticationFailureTracker tracker;
+
+    /**
+     * Creates a new BanningAuthenticationListener which automatically bans
+     * further authentication attempts from addresses that have repeatedly
+     * failed to authenticate. The ban duration and maximum number of failed
+     * attempts allowed before banning are configured within
+     * guacamole.properties.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs parsing the configuration properties used by this
+     *     extension.
+     */
+    public BanningAuthenticationListener() throws GuacamoleException {
+
+        Environment environment = LocalEnvironment.getInstance();
+        int maxAttempts = environment.getProperty(MAX_ATTEMPTS, DEFAULT_MAX_ATTEMPTS);
+        int banDuration = environment.getProperty(IP_BAN_DURATION, DEFAULT_IP_BAN_DURATION);
+        long maxAddresses = environment.getProperty(MAX_ADDRESSES, DEFAULT_MAX_ADDRESSES);
+
+        // Configure auth failure tracking behavior and inform administrator of
+        // ultimate result
+        if (maxAttempts <= 0) {
+            this.tracker = new NullAuthenticationFailureTracker();
+            logger.info("Maximum failed authentication attempts has been set "
+                    + "to {}. Automatic banning of brute-force authentication "
+                    + "attempts will be disabled.", maxAttempts);
+        }
+        else if (banDuration <= 0) {
+            this.tracker = new NullAuthenticationFailureTracker();
+            logger.info("Ban duration for addresses that repeatedly fail "
+                    + "authentication has been set to {}. Automatic banning "
+                    + "of brute-force authentication attempts will be "
+                    + "disabled.", banDuration);
+        }
+        else if (maxAddresses <= 0) {
+            this.tracker = new NullAuthenticationFailureTracker();
+            logger.info("Maximum number of tracked addresses has been set to "
+                    + "{}. Automatic banning of brute-force authentication "
+                    + "attempts will be disabled.", maxAddresses);
+        }
+        else {
+            this.tracker = new InMemoryAuthenticationFailureTracker(maxAttempts, banDuration, maxAddresses);
+            logger.info("Addresses will be automatically banned for {} "
+                    + "seconds after {} failed authentication attempts. Up "
+                    + "to {} unique addresses will be tracked/banned at any "
+                    + "given time.", banDuration, maxAttempts, maxAddresses);
+        }
+
+    }
+
+    @Override
+    public void handleEvent(Object event) throws GuacamoleException {
+
+        // Notify auth tracker of each request received BEFORE the request is
+        // processed ...
+        if (event instanceof AuthenticationRequestReceivedEvent) {
+            AuthenticationRequestReceivedEvent request = (AuthenticationRequestReceivedEvent) event;
+            tracker.notifyAuthenticationRequestReceived(request.getCredentials());
+        }
+
+        // ... as well as every explicit failure ...
+        else if (event instanceof AuthenticationFailureEvent) {
+
+            AuthenticationFailureEvent failure = (AuthenticationFailureEvent) event;
+
+            // Requests for additional credentials are not failures per se,
+            // but continuations of a multi-request authentication attempt that
+            // has not yet succeeded OR failed
+            if (failure.getFailure() instanceof GuacamoleInsufficientCredentialsException) {
+                tracker.notifyAuthenticationRequestReceived(failure.getCredentials());
+                return;
+            }
+
+            // Consider all other errors to be failed auth attempts
+            tracker.notifyAuthenticationFailed(failure.getCredentials());
+
+        }
+
+        // ... and explicit success.
+        else if (event instanceof AuthenticationSuccessEvent) {
+            AuthenticationSuccessEvent success = (AuthenticationSuccessEvent) event;
+            tracker.notifyAuthenticationSuccess(success.getCredentials());
+        }
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-ban/src/main/java/org/apache/guacamole/auth/ban/status/AuthenticationFailureStatus.java b/extensions/guacamole-auth-ban/src/main/java/org/apache/guacamole/auth/ban/status/AuthenticationFailureStatus.java
new file mode 100644
index 0000000..3292d11
--- /dev/null
+++ b/extensions/guacamole-auth-ban/src/main/java/org/apache/guacamole/auth/ban/status/AuthenticationFailureStatus.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.ban.status;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * The current status of an authentication failure, including the number of
+ * times the failure has occurred.
+ */
+public class AuthenticationFailureStatus {
+
+    /**
+     * The timestamp of the last authentication failure, as returned by
+     * System.nanoTime().
+     */
+    private long lastFailure;
+
+    /**
+     * The number of failures that have occurred.
+     */
+    private final AtomicInteger failureCount;
+
+    /**
+     * The maximum number of failures that may occur before the user/address
+     * causing the failures is blocked.
+     */
+    private final int maxAttempts;
+
+    /**
+     * The amount of time that a user/address must remain blocked after they
+     * have reached the maximum number of failures. Unlike the value provided
+     * at construction time, this value is maintained in nanoseconds.
+     */
+    private final long duration;
+
+    /**
+     * Creates an AuthenticationFailureStatus that is initialized to zero
+     * failures and is subject to the given restrictions. Additional failures
+     * may be flagged after creation with {@link #notifyFailed()}.
+     *
+     * @param maxAttempts
+     *     The maximum number of failures that may occur before the
+     *     user/address causing the failures is blocked.
+     *     
+     * @param duration
+     *     The amount of time, in seconds, that a user/address must remain
+     *     blocked after they have reached the maximum number of failures.
+     */
+    public AuthenticationFailureStatus(int maxAttempts, int duration) {
+        this.lastFailure = System.nanoTime();
+        this.failureCount = new AtomicInteger(0);
+        this.maxAttempts = maxAttempts;
+        this.duration = TimeUnit.SECONDS.toNanos(duration);
+    }
+
+    /**
+     * Updates this authentication failure, noting that the failure it
+     * represents has recurred.
+     */
+    public void notifyFailed() {
+        lastFailure = System.nanoTime();
+        failureCount.incrementAndGet();
+    }
+
+    /**
+     * Returns whether this authentication failure is recent enough that it
+     * should still be tracked. This function will return false for
+     * authentication failures that have not recurred for at least the duration
+     * provided at construction time.
+     *
+     * @return
+     *     true if this authentication failure is recent enough that it should
+     *     still be tracked, false otherwise.
+     */
+    public boolean isValid() {
+        return System.nanoTime() - lastFailure <= duration;
+    }
+
+    /**
+     * Returns whether the user/address causing this authentication failure
+     * should be blocked based on the restrictions provided at construction
+     * time.
+     *
+     * @return
+     *     true if the user/address causing this failure should be blocked,
+     *     false otherwise.
+     */
+    public boolean isBlocked() {
+        return isValid() && failureCount.get() >= maxAttempts;
+    }
+
+    /**
+     * Returns the total number of authentication failures that have been
+     * recorded through creating this object and invoking
+     * {@link #notifyFailed()}.
+     *
+     * @return
+     *     The total number of failures that have occurred.
+     */
+    public int getFailures() {
+        return failureCount.get();
+    }
+
+}
diff --git a/extensions/guacamole-auth-ban/src/main/java/org/apache/guacamole/auth/ban/status/AuthenticationFailureTracker.java b/extensions/guacamole-auth-ban/src/main/java/org/apache/guacamole/auth/ban/status/AuthenticationFailureTracker.java
new file mode 100644
index 0000000..9ea7f2c
--- /dev/null
+++ b/extensions/guacamole-auth-ban/src/main/java/org/apache/guacamole/auth/ban/status/AuthenticationFailureTracker.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.ban.status;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.Credentials;
+
+/**
+ * Tracks past authentication results, automatically blocking the IP addresses
+ * of machines that repeatedly fail to authenticate.
+ */
+public interface AuthenticationFailureTracker {
+
+    /**
+     * Reports that an authentication request has been received, but it is
+     * either not yet known whether the request has succeeded or failed. If the
+     * associated address is currently being blocked, an exception will be
+     * thrown.
+     *
+     * @param credentials
+     *     The credentials associated with the authentication request.
+     *
+     * @throws GuacamoleException
+     *     If the authentication request is being blocked due to brute force
+     *     prevention rules.
+     */
+    void notifyAuthenticationRequestReceived(Credentials credentials)
+            throws GuacamoleException;
+
+    /**
+     * Reports that an authentication request has been received and has
+     * succeeded. If the associated address is currently being blocked, an
+     * exception will be thrown.
+     *
+     * @param credentials
+     *     The credentials associated with the successful authentication
+     *     request.
+     *
+     * @throws GuacamoleException
+     *     If the authentication request is being blocked due to brute force
+     *     prevention rules.
+     */
+    void notifyAuthenticationSuccess(Credentials credentials)
+            throws GuacamoleException;
+
+    /**
+     * Reports that an authentication request has been received and has
+     * failed. If the associated address is currently being blocked, an
+     * exception will be thrown.
+     *
+     * @param credentials
+     *     The credentials associated with the failed authentication request.
+     *
+     * @throws GuacamoleException
+     *     If the authentication request is being blocked due to brute force
+     *     prevention rules.
+     */
+    void notifyAuthenticationFailed(Credentials credentials)
+            throws GuacamoleException;
+
+}
diff --git a/extensions/guacamole-auth-ban/src/main/java/org/apache/guacamole/auth/ban/status/InMemoryAuthenticationFailureTracker.java b/extensions/guacamole-auth-ban/src/main/java/org/apache/guacamole/auth/ban/status/InMemoryAuthenticationFailureTracker.java
new file mode 100644
index 0000000..58ed4e0
--- /dev/null
+++ b/extensions/guacamole-auth-ban/src/main/java/org/apache/guacamole/auth/ban/status/InMemoryAuthenticationFailureTracker.java
@@ -0,0 +1,196 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.ban.status;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.GuacamoleServerException;
+import org.apache.guacamole.language.TranslatableGuacamoleClientTooManyException;
+import org.apache.guacamole.net.auth.Credentials;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * AuthenticationFailureTracker implementation that tracks the failure status
+ * of each IP address in memory. The maximum amount of memory consumed is
+ * bounded by the configured maximum number of addresses tracked.
+ */
+public class InMemoryAuthenticationFailureTracker implements AuthenticationFailureTracker {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(InMemoryAuthenticationFailureTracker.class);
+
+    /**
+     * All authentication failures currently being tracked, stored by the
+     * associated IP address.
+     */
+    private final Cache<String, AuthenticationFailureStatus> failures;
+
+    /**
+     * The maximum number of failed authentication attempts allowed before an
+     * address is temporarily banned.
+     */
+    private final int maxAttempts;
+
+    /**
+     * The length of time that each address should be banned after reaching the
+     * maximum number of failed authentication attempts, in seconds.
+     */
+    private final int banDuration;
+
+    /**
+     * Creates a new AuthenticationFailureTracker that automatically blocks
+     * authentication attempts based on the provided blocking criteria.
+     *
+     * @param maxAttempts
+     *     The maximum number of failed authentication attempts allowed before
+     *     an address is temporarily banned.
+     *
+     * @param banDuration
+     *     The length of time that each address should be banned after reaching
+     *     the maximum number of failed authentication attempts, in seconds.
+     *
+     * @param maxAddresses
+     *     The maximum number of unique IP addresses that should be tracked
+     *     before discarding older tracked failures.
+     */
+    public InMemoryAuthenticationFailureTracker(int maxAttempts, int banDuration,
+            long maxAddresses) {
+
+        this.maxAttempts = maxAttempts;
+        this.banDuration = banDuration;
+
+        // Limit maximum number of tracked addresses to configured upper bound
+        this.failures = Caffeine.newBuilder()
+                .maximumSize(maxAddresses)
+                .build();
+
+    }
+
+    /**
+     * Reports that the given address has just failed to authenticate and
+     * returns the AuthenticationFailureStatus that represents that failure. If
+     * the address isn't already being tracked, it will begin being tracked as
+     * of this call. If the address is already tracked, the returned
+     * AuthenticationFailureStatus will represent past authentication failures,
+     * as well.
+     *
+     * @param address
+     *     The address that has failed to authenticate.
+     *
+     * @return
+     *     An AuthenticationFailureStatus that represents this latest
+     *     authentication failure for the given address, as well as any past
+     *     failures.
+     */
+    private AuthenticationFailureStatus getAuthenticationFailure(String address) {
+
+        AuthenticationFailureStatus status = failures.get(address,
+                (addr) -> new AuthenticationFailureStatus(maxAttempts, banDuration));
+
+        status.notifyFailed();
+        return status;
+
+    }
+
+    /**
+     * Reports that an authentication request has been received, as well as
+     * whether that request is known to have failed. If the associated address
+     * is currently being blocked, an exception will be thrown.
+     *
+     * @param credentials
+     *     The credentials associated with the authentication request.
+     *
+     * @param failed
+     *     Whether the request is known to have failed. If the status of the
+     *     request is not yet known, this should be false.
+     *
+     * @throws GuacamoleException
+     *     If the authentication request is being blocked due to brute force
+     *     prevention rules.
+     */
+    private void notifyAuthenticationStatus(Credentials credentials,
+            boolean failed) throws GuacamoleException {
+
+        // Ignore requests that do not contain explicit parameters of any kind
+        if (credentials.isEmpty())
+            return;
+
+        // Determine originating address of the authentication request
+        String address = credentials.getRemoteAddress();
+        if (address == null)
+            throw new GuacamoleServerException("Source address cannot be determined.");
+
+        // Get current failure status for the address associated with the
+        // authentication request, adding/updating that status if the request
+        // was itself a failure
+        AuthenticationFailureStatus status;
+        if (failed) {
+            status = getAuthenticationFailure(address);
+            logger.info("Authentication has failed for address \"{}\" (current total failures: {}/{}).",
+                    address, status.getFailures(), maxAttempts);
+        }
+        else
+            status = failures.getIfPresent(address);
+
+        if (status != null) {
+
+            // Explicitly block further processing of authentication/authorization
+            // if too many failures have occurred
+            if (status.isBlocked()) {
+                logger.warn("Blocking authentication attempt from address \"{}\" due to number of authentication failures.", address);
+                throw new TranslatableGuacamoleClientTooManyException("Too "
+                        + "many failed authentication attempts.",
+                        "LOGIN.ERROR_TOO_MANY_ATTEMPTS");
+            }
+
+            // Clean up tracking of failures if the address is no longer
+            // relevant (all failures are sufficiently old)
+            else if (!status.isValid()) {
+                logger.debug("Removing address \"{}\" from tracking as there are no recent authentication failures.", address);
+                failures.invalidate(address);
+            }
+
+        }
+
+    }
+
+    @Override
+    public void notifyAuthenticationRequestReceived(Credentials credentials)
+            throws GuacamoleException {
+        notifyAuthenticationStatus(credentials, false);
+    }
+
+    @Override
+    public void notifyAuthenticationSuccess(Credentials credentials)
+            throws GuacamoleException {
+        notifyAuthenticationStatus(credentials, false);
+    }
+
+    @Override
+    public void notifyAuthenticationFailed(Credentials credentials)
+            throws GuacamoleException {
+        notifyAuthenticationStatus(credentials, true);
+    }
+
+}
diff --git a/extensions/guacamole-auth-ban/src/main/java/org/apache/guacamole/auth/ban/status/NullAuthenticationFailureTracker.java b/extensions/guacamole-auth-ban/src/main/java/org/apache/guacamole/auth/ban/status/NullAuthenticationFailureTracker.java
new file mode 100644
index 0000000..9b50a30
--- /dev/null
+++ b/extensions/guacamole-auth-ban/src/main/java/org/apache/guacamole/auth/ban/status/NullAuthenticationFailureTracker.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.ban.status;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.Credentials;
+
+/**
+ * AuthenticationFailureTracker implementation that does nothing. All requests
+ * are ignored, regardless of status, and no tracking is performed.
+ */
+public class NullAuthenticationFailureTracker implements AuthenticationFailureTracker {
+
+    @Override
+    public void notifyAuthenticationRequestReceived(Credentials credentials)
+            throws GuacamoleException {
+        // Do nothing
+    }
+
+    @Override
+    public void notifyAuthenticationSuccess(Credentials credentials)
+            throws GuacamoleException {
+        // Do nothing
+    }
+
+    @Override
+    public void notifyAuthenticationFailed(Credentials credentials)
+            throws GuacamoleException {
+        // Do nothing
+    }
+
+}
diff --git a/extensions/guacamole-auth-ban/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-ban/src/main/resources/guac-manifest.json
new file mode 100644
index 0000000..d61454f
--- /dev/null
+++ b/extensions/guacamole-auth-ban/src/main/resources/guac-manifest.json
@@ -0,0 +1,17 @@
+{
+
+    "guacamoleVersion" : "1.5.2",
+
+    "name"      : "Brute-force Authentication Detection/Prevention",
+    "namespace" : "ban",
+
+    "listeners" : [
+        "org.apache.guacamole.auth.ban.BanningAuthenticationListener"
+    ],
+
+    "translations" : [
+        "translations/en.json",
+        "translations/pl.json"
+    ]
+
+}
diff --git a/extensions/guacamole-auth-ban/src/main/resources/translations/en.json b/extensions/guacamole-auth-ban/src/main/resources/translations/en.json
new file mode 100644
index 0000000..2ef8a37
--- /dev/null
+++ b/extensions/guacamole-auth-ban/src/main/resources/translations/en.json
@@ -0,0 +1,5 @@
+{
+    "LOGIN": {
+        "ERROR_TOO_MANY_ATTEMPTS" : "Too many failed authentication attempts. Please try again later."
+    }
+}
diff --git a/extensions/guacamole-auth-ban/src/main/resources/translations/pl.json b/extensions/guacamole-auth-ban/src/main/resources/translations/pl.json
new file mode 100644
index 0000000..0ba1f80
--- /dev/null
+++ b/extensions/guacamole-auth-ban/src/main/resources/translations/pl.json
@@ -0,0 +1,5 @@
+{
+    "LOGIN": {
+        "ERROR_TOO_MANY_ATTEMPTS" : "Zbyt wiele nieudanych prób logowania. Spróbuj ponownie później."
+    }
+}
diff --git a/extensions/guacamole-auth-duo/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-duo/src/main/resources/guac-manifest.json
index 3605f23..13f55cc 100644
--- a/extensions/guacamole-auth-duo/src/main/resources/guac-manifest.json
+++ b/extensions/guacamole-auth-duo/src/main/resources/guac-manifest.json
@@ -16,6 +16,7 @@
         "translations/fr.json",
         "translations/ja.json",
         "translations/ko.json",
+        "translations/pl.json",
         "translations/pt.json",
         "translations/ru.json",
         "translations/zh.json"
diff --git a/extensions/guacamole-auth-duo/src/main/resources/translations/pl.json b/extensions/guacamole-auth-duo/src/main/resources/translations/pl.json
new file mode 100644
index 0000000..dd4d3b8
--- /dev/null
+++ b/extensions/guacamole-auth-duo/src/main/resources/translations/pl.json
@@ -0,0 +1,13 @@
+{
+
+    "DATA_SOURCE_DUO" : {
+        "NAME" : "Duo TFA Backend"
+    },
+
+    "LOGIN" : {
+        "FIELD_HEADER_GUAC_DUO_SIGNED_RESPONSE" : "",
+        "INFO_DUO_VALIDATION_CODE_INCORRECT"    : "Nieprawidłowy kod weryfikacyjny Duo.",
+        "INFO_DUO_AUTH_REQUIRED"                : "Aby kontynuować uwierzytelnij się w Duo."
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/AuthenticationProviderService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/AuthenticationProviderService.java
index afdaf33..5fedd23 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/AuthenticationProviderService.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/AuthenticationProviderService.java
@@ -110,4 +110,34 @@
             UserContext context, AuthenticatedUser authenticatedUser,
             Credentials credentials) throws GuacamoleException;
 
+    /**
+     * Decorates a UserContext instance for the given already-authenticated user.
+     * If no decoration is required, the original UserContext will be returned.
+     *
+     * @param authenticationProvider
+     *     The AuthenticationProvider on behalf of which the UserContext is
+     *     being decorated.
+     *
+     * @param context
+     *     The UserContext to decorate.
+     *
+     * @param authenticatedUser
+     *     The AuthenticatedUser associated with the UserContext being decorated.
+     *
+     * @param credentials
+     *     The credentials most recently submitted by the user. These
+     *     credentials are not guaranteed to be the same as the credentials
+     *     already associated with the AuthenticatedUser.
+     *
+     * @return
+     *     A decorated UserContext instance for the user identified by the given
+     *     credentials, or the original user context if no decoration is required.
+     *
+     * @throws GuacamoleException
+     *     If the an error occurs during decoration of the UserContext.
+     */
+    public UserContext decorateUserContext(AuthenticationProvider authenticationProvider,
+            UserContext context, AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException;
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingConnection.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingConnection.java
new file mode 100644
index 0000000..f25620b
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingConnection.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.jdbc;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.jdbc.connection.ConnectionRecordMapper;
+import org.apache.guacamole.auth.jdbc.connection.ConnectionRecordModel;
+import org.apache.guacamole.auth.jdbc.connection.ModeledConnectionRecord;
+import org.apache.guacamole.net.GuacamoleTunnel;
+import org.apache.guacamole.net.auth.Connection;
+import org.apache.guacamole.net.auth.DelegatingConnection;
+import org.apache.guacamole.net.auth.User;
+import org.apache.guacamole.protocol.GuacamoleClientInformation;
+
+/**
+ * Connection implementation that creates a history record when the connection
+ * is established, and returns a HistoryTrackingTunnel to automatically set the
+ * end date when the connection is closed.
+ */
+public class HistoryTrackingConnection extends DelegatingConnection {
+
+    /**
+     * The current Guacamole user.
+     */
+    private final User currentUser;
+
+    /**
+     * The remote host that the user connected from.
+     */
+    private final String remoteHost;
+
+    /**
+     * The connection record mapper to use when writing history entries for
+     * established connections.
+     */
+    private final ConnectionRecordMapper connectionRecordMapper;
+
+    /**
+     * Creates a new HistoryConnection that wraps the given connection,
+     * automatically creating a history record when the connection is
+     * established, and returning a HistoryTrackingTunnel to set the end
+     * date on the history entry when the connection is closed.
+     *
+     * @param currentUser
+     *     The current Guacamole user.
+     *
+     * @param remoteHost
+     *     The remote host that the user connected from.
+     *
+     * @param connection
+     *     The connection to wrap.
+     *
+     * @param connectionRecordMapper
+     *     The connection record mapper that will be used to write the connection history records.
+     */
+    public HistoryTrackingConnection(User currentUser, String remoteHost, Connection connection, ConnectionRecordMapper connectionRecordMapper) {
+        super(connection);
+
+        this.currentUser = currentUser;
+        this.remoteHost = remoteHost;
+        this.connectionRecordMapper = connectionRecordMapper;
+    }
+
+    @Override
+    public GuacamoleTunnel connect(GuacamoleClientInformation info,
+            Map<String, String> tokens) throws GuacamoleException {
+
+        // Create a connection record model, starting at the current date/time
+        ConnectionRecordModel connectionRecordModel = new ConnectionRecordModel();
+        connectionRecordModel.setStartDate(new Date());
+
+        // Set the user information
+        connectionRecordModel.setUsername(this.currentUser.getIdentifier());
+        connectionRecordModel.setRemoteHost(this.remoteHost);
+
+        // Set the connection information
+        connectionRecordModel.setConnectionName(this.getDelegateConnection().getName());
+
+        // Insert the connection history record to mark the start of this connection
+        connectionRecordMapper.insert(connectionRecordModel);
+
+        // Include history record UUID as token
+        ModeledConnectionRecord modeledRecord = new ModeledConnectionRecord(connectionRecordModel);
+        Map<String, String> updatedTokens = new HashMap<>(tokens);
+        updatedTokens.put("HISTORY_UUID", modeledRecord.getUUID().toString());
+
+        // Connect, and wrap the tunnel for return
+        GuacamoleTunnel tunnel = super.connect(info, updatedTokens);
+        return new HistoryTrackingTunnel(
+            tunnel, this.connectionRecordMapper, connectionRecordModel);
+    }
+
+    /**
+     * Get the Connection wrapped by this HistoryTrackingConnection.
+     *
+     * @return
+     *     The wrapped Connection.
+     */
+    public Connection getWrappedConnection() {
+        return getDelegateConnection();
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingConnectionDirectory.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingConnectionDirectory.java
new file mode 100644
index 0000000..ddc3afb
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingConnectionDirectory.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.jdbc;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.jdbc.connection.ConnectionRecordMapper;
+import org.apache.guacamole.net.auth.Connection;
+import org.apache.guacamole.net.auth.DecoratingDirectory;
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.User;
+
+/**
+ * A connection directory that returns HistoryTrackingConnection-wrapped connections
+ * when queried.
+ */
+public class HistoryTrackingConnectionDirectory extends DecoratingDirectory<Connection> {
+
+    /**
+     * The connection record mapper to use when writing history entries for
+     * established connections.
+     */
+    private final ConnectionRecordMapper connectionRecordMapper;
+
+    /**
+     * The user that directory operations are being performed for.
+     */
+    private final User user;
+
+    /**
+     * The remote host that the user connected from.
+     */
+    private final String remoteHost;
+
+    /**
+     * Create a new history tracking connection directory. Any connection retrieved from this
+     * directory will be wrapped in a HistoryTrackingConnection, enabling connection history
+     * records to be written with the provided connection record mapper.
+     *
+     * @param directory
+     *     The connection directory to wrap.
+     *
+     * @param user
+     *     The user associated with the connection directory.
+     *
+     * @param remoteHost
+     *     The remote host that the user connected from.
+     *
+     * @param connectionRecordMapper
+     *     The connection record mapper that will be used to write the connection history records.
+     */
+    public HistoryTrackingConnectionDirectory(Directory<Connection> directory, User user, String remoteHost, ConnectionRecordMapper connectionRecordMapper) {
+        super(directory);
+
+        this.user = user;
+        this.remoteHost = remoteHost;
+        this.connectionRecordMapper = connectionRecordMapper;
+    }
+
+    @Override
+    protected Connection decorate(Connection connection) throws GuacamoleException {
+
+         // Wrap the connection in a history-tracking layer
+         return new HistoryTrackingConnection(
+            this.user, this.remoteHost, connection, this.connectionRecordMapper);
+    }
+
+    @Override
+    protected Connection undecorate(Connection connection) throws GuacamoleException {
+
+        // If the connection was wrapped, unwrap it
+        if (connection instanceof HistoryTrackingConnection) {
+            return ((HistoryTrackingConnection) connection).getWrappedConnection();
+        }
+
+        // Otherwise, return the unwrapped connection directly
+        return connection;
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingTunnel.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingTunnel.java
new file mode 100644
index 0000000..aeccc3a
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingTunnel.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.jdbc;
+
+import java.util.Date;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.jdbc.connection.ConnectionRecordMapper;
+import org.apache.guacamole.auth.jdbc.connection.ConnectionRecordModel;
+import org.apache.guacamole.net.DelegatingGuacamoleTunnel;
+import org.apache.guacamole.net.GuacamoleTunnel;
+
+/**
+ * Tunnel implementation which automatically writes an end date for the
+ * provided connection history record model using the provided connection
+ * history mapper, when the tunnel is closed.
+ */
+public class HistoryTrackingTunnel extends DelegatingGuacamoleTunnel {
+
+    /**
+     * The connection for which this tunnel was established.
+     */
+    private final ConnectionRecordMapper connectionRecordMapper;
+
+    /**
+     * The user for which this tunnel was established.
+     */
+    private final ConnectionRecordModel connectionRecordModel;
+
+    /**
+     * Creates a new HistoryTrackingTunnel that wraps the given tunnel,
+     * automatically setting the end date for the provided connection history records,
+     * using the provided connection history record mapper.
+     *
+     * @param tunnel
+     *     The tunnel to wrap.
+     *
+     * @param connectionRecordMapper
+     *     The mapper to use when writing connection history records.
+     *
+     * @param connectionRecordModel
+     *     The connection history record model representing the in-progress connection.
+     */
+    public HistoryTrackingTunnel(GuacamoleTunnel tunnel,
+            ConnectionRecordMapper connectionRecordMapper, ConnectionRecordModel connectionRecordModel) {
+
+        super(tunnel);
+
+        // Store the connection record mapper and model for history tracking
+        this.connectionRecordMapper = connectionRecordMapper;
+        this.connectionRecordModel = connectionRecordModel;
+    }
+
+    @Override
+    public void close() throws GuacamoleException {
+
+        // Set the end date to complete the connection history record
+        this.connectionRecordModel.setEndDate(new Date());
+        this.connectionRecordMapper.updateEndDate(this.connectionRecordModel);
+
+        super.close();
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingUserContext.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingUserContext.java
new file mode 100644
index 0000000..2c13a7e
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/HistoryTrackingUserContext.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.jdbc;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.jdbc.connection.ConnectionRecordMapper;
+import org.apache.guacamole.net.auth.Connection;
+import org.apache.guacamole.net.auth.DelegatingUserContext;
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.UserContext;
+
+/**
+ * DelegatingUserContext implementation which writes connection history records
+ * when connections are established and closed.
+ */
+public class HistoryTrackingUserContext extends DelegatingUserContext {
+
+    /**
+     * The remote host that the user associated with the user context
+     * connected from.
+     */
+    private final String remoteHost;
+
+    /**
+     * The connection record mapper to use when writing history entries for
+     * established connections.
+     */
+    private final ConnectionRecordMapper connectionRecordMapper;
+
+    /**
+     * Creates a new HistoryTrackingUserContext which wraps the given
+     * UserContext, allowing for tracking of connection history external to
+     * this authentication provider.
+     *
+     * @param userContext
+     *     The UserContext to wrap.
+     *
+     * @param remoteHost
+     *     The host that the user associated with the given user context connected from.
+     *
+     * @param connectionRecordMapper
+     *     The mapper to use when writing connection history entries to the DB.
+     */
+    public HistoryTrackingUserContext(UserContext userContext, String remoteHost, ConnectionRecordMapper connectionRecordMapper) {
+        super(userContext);
+
+        this.remoteHost = remoteHost;
+        this.connectionRecordMapper = connectionRecordMapper;
+    }
+
+    @Override
+    public Directory<Connection> getConnectionDirectory() throws GuacamoleException {
+        return new HistoryTrackingConnectionDirectory(
+                super.getConnectionDirectory(), self(),
+                this.remoteHost, this.connectionRecordMapper);
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/InjectedAuthenticationProvider.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/InjectedAuthenticationProvider.java
index fddb204..8ae2eea 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/InjectedAuthenticationProvider.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/InjectedAuthenticationProvider.java
@@ -90,4 +90,12 @@
                 authenticatedUser, credentials);
     }
 
+    @Override
+    public UserContext decorate(UserContext context,
+            AuthenticatedUser authenticatedUser, Credentials credentials)
+            throws GuacamoleException {
+        return authProviderService.decorateUserContext(this, context,
+                authenticatedUser, credentials);
+    }
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderModule.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderModule.java
index 5ae0ea5..1b361e5 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderModule.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderModule.java
@@ -45,6 +45,7 @@
 import org.apache.guacamole.auth.jdbc.security.SecureRandomSaltService;
 import org.apache.guacamole.auth.jdbc.permission.SystemPermissionService;
 import org.apache.guacamole.auth.jdbc.user.UserService;
+import org.apache.ibatis.session.ExecutorType;
 import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
 import org.apache.guacamole.auth.jdbc.permission.ConnectionGroupPermissionMapper;
 import org.apache.guacamole.auth.jdbc.permission.ConnectionGroupPermissionService;
@@ -126,6 +127,12 @@
         // Transaction factory
         bindTransactionFactoryType(JdbcTransactionFactory.class);
         
+        // Set the JDBC Auth provider to use batch execution if enabled
+        if (environment.shouldUseBatchExecutor())
+            bindConfigurationSetting(configuration -> {
+                configuration.setDefaultExecutorType(ExecutorType.BATCH);
+            });
+
         // Add MyBatis mappers
         addMapperClass(ConnectionMapper.class);
         addMapperClass(ConnectionGroupMapper.class);
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderService.java
index 2f38ebe..2b130b6 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderService.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCAuthenticationProviderService.java
@@ -22,6 +22,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.jdbc.connection.ConnectionRecordMapper;
 import org.apache.guacamole.auth.jdbc.security.PasswordPolicyService;
 import org.apache.guacamole.auth.jdbc.sharing.user.SharedAuthenticatedUser;
 import org.apache.guacamole.auth.jdbc.user.ModeledAuthenticatedUser;
@@ -68,6 +69,12 @@
     @Inject
     private Provider<ModeledUserContext> userContextProvider;
 
+    /**
+     * Mapper for writing connection history.
+     */
+    @Inject
+    private ConnectionRecordMapper connectionRecordMapper;
+
     @Override
     public AuthenticatedUser authenticateUser(AuthenticationProvider authenticationProvider,
             Credentials credentials) throws GuacamoleException {
@@ -99,7 +106,7 @@
         ModeledUser user = userService.retrieveUser(authenticationProvider, authenticatedUser);
         ModeledUserContext context = userContextProvider.get();
         if (user != null && !user.isDisabled()) {
-            
+
             // Enforce applicable account restrictions
             if (databaseRestrictionsApplicable) {
 
@@ -125,18 +132,18 @@
             }
 
         }
-        
+
         // If no user account is found, and database-specific account
         // restrictions do not apply, get a skeleton user.
         else if (!databaseRestrictionsApplicable) {
             user = userService.retrieveSkeletonUser(authenticationProvider, authenticatedUser);
-            
+
             // If auto account creation is enabled, add user to DB.
             if (environment.autoCreateAbsentAccounts()) {
                 ModeledUser createdUser = userService.createObject(new PrivilegedModeledAuthenticatedUser(user.getCurrentUser()), user);
                 user.setModel(createdUser.getModel());
             }
-            
+
         }
 
         // Veto authentication result only if database-specific account
@@ -144,7 +151,7 @@
         else
             throw new GuacamoleInvalidCredentialsException("Invalid login",
                     CredentialsInfo.USERNAME_PASSWORD);
-        
+
         // Initialize the UserContext with the user account and return it.
         context.init(user.getCurrentUser());
         context.recordUserLogin();
@@ -157,7 +164,21 @@
             UserContext context, AuthenticatedUser authenticatedUser,
             Credentials credentials) throws GuacamoleException {
 
-        // No need to update the context
+        // Refresh the user context
+        return getUserContext(authenticationProvider, authenticatedUser);
+
+    }
+
+    @Override
+    public UserContext decorateUserContext(AuthenticationProvider authenticationProvider,
+            UserContext context, AuthenticatedUser authenticatedUser,
+            Credentials credentials) throws GuacamoleException {
+
+        // Track connection history only for external connections, and only if enabled in the config
+        if (environment.trackExternalConnectionHistory() && context.getAuthenticationProvider() != authenticationProvider) {
+            return new HistoryTrackingUserContext(context, credentials.getRemoteHostname(), connectionRecordMapper);
+        }
+
         return context;
 
     }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCEnvironment.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCEnvironment.java
index 18f5b05..27f3d05 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCEnvironment.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/JDBCEnvironment.java
@@ -30,7 +30,7 @@
  * intended for use within JDBC based authentication providers.
  */
 public abstract class JDBCEnvironment extends DelegatingEnvironment {
-    
+
     /**
      * Constructs a new JDBCEnvironment using an underlying LocalEnviroment to
      * read properties from the file system.
@@ -81,12 +81,12 @@
     public abstract int getBatchSize() throws GuacamoleException;
 
     /**
-     * Returns the default maximum number of concurrent connections to allow to 
-     * any one connection, unless specified differently on an individual 
+     * Returns the default maximum number of concurrent connections to allow to
+     * any one connection, unless specified differently on an individual
      * connection. Zero denotes unlimited.
-     * 
+     *
      * @return
-     *     The default maximum allowable number of concurrent connections 
+     *     The default maximum allowable number of concurrent connections
      *     to any connection.
      *
      * @throws GuacamoleException
@@ -95,10 +95,10 @@
     public abstract int getDefaultMaxConnections() throws GuacamoleException;
 
     /**
-     * Returns the default maximum number of concurrent connections to allow to 
-     * any one connection group, unless specified differently on an individual 
+     * Returns the default maximum number of concurrent connections to allow to
+     * any one connection group, unless specified differently on an individual
      * connection group. Zero denotes unlimited.
-     * 
+     *
      * @return
      *     The default maximum allowable number of concurrent connections
      *     to any connection group.
@@ -108,12 +108,12 @@
      */
     public abstract int getDefaultMaxGroupConnections()
             throws GuacamoleException;
-    
+
     /**
-     * Returns the default maximum number of concurrent connections to allow to 
+     * Returns the default maximum number of concurrent connections to allow to
      * any one connection by an individual user, unless specified differently on
      * an individual connection. Zero denotes unlimited.
-     * 
+     *
      * @return
      *     The default maximum allowable number of concurrent connections to
      *     any connection by an individual user.
@@ -123,12 +123,12 @@
      */
     public abstract int getDefaultMaxConnectionsPerUser()
             throws GuacamoleException;
-    
+
     /**
-     * Returns the default maximum number of concurrent connections to allow to 
-     * any one connection group by an individual user, unless specified 
+     * Returns the default maximum number of concurrent connections to allow to
+     * any one connection group by an individual user, unless specified
      * differently on an individual connection group. Zero denotes unlimited.
-     * 
+     *
      * @return
      *     The default maximum allowable number of concurrent connections to
      *     any connection group by an individual user.
@@ -162,19 +162,19 @@
      *     true if the database supports recursive queries, false otherwise.
      */
     public abstract boolean isRecursiveQuerySupported(SqlSession session);
-    
+
     /**
      * Returns a boolean value representing whether or not the JDBC module
      * should automatically create accounts within the database for users that
      * are successfully authenticated via other extensions. Returns true if
      * accounts should be auto-created, otherwise returns false.
-     * 
+     *
      * @return
      *     true if user accounts should be automatically created within the
      *     database when authentication succeeds from another extension;
      *     otherwise false.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If guacamole.properties cannot be parsed.
      */
     public abstract boolean autoCreateAbsentAccounts() throws GuacamoleException;
@@ -225,4 +225,51 @@
         }
     }
 
+    /**
+     * Returns a boolean value representing whether or not the JDBC module
+     * should automatically track connection history for external connections,
+     * i.e. connections not originated from within the JDBC auth provider
+     * itself.
+     *
+     * @return
+     *     true if connection history should be tracked for connections that
+     *     do not originate from within this JDBC auth provider, false otherwise.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public abstract boolean trackExternalConnectionHistory() throws GuacamoleException;
+
+    /**
+     * Returns a boolean value representing whether access time windows should
+     * be enforced for active connections - i.e. whether a currently-connected
+     * user should be disconnected upon the closure of an access window.
+     *
+     * @return
+     *     true if a connected user should be disconnected upon an access time
+     *     window closing, false otherwise.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public abstract boolean enforceAccessWindowsForActiveSessions() throws GuacamoleException;
+
+    /**
+     * Returns true if the JDBC batch executor should be used by default, false
+     * otherwise. The batch executor allows repeated updates to be batched
+     * together for improved performance. 
+     * See https://mybatis.org/mybatis-3/java-api.html#sqlSessions
+     *
+     * @return
+     *     true if the batch executor should be used by default, false otherwise.
+     */
+    public boolean shouldUseBatchExecutor() {
+
+        // Unless otherwise overwritten due to implementation-specific problems,
+        // all JDBC extensions should use the batch executor if possible to
+        // ensure the best performance for repetitive queries
+        return true;
+
+    }
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/activeconnection/ActiveConnectionDirectory.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/activeconnection/ActiveConnectionDirectory.java
index b0d6324..28510b7 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/activeconnection/ActiveConnectionDirectory.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/activeconnection/ActiveConnectionDirectory.java
@@ -25,16 +25,14 @@
 import java.util.Collections;
 import java.util.Set;
 import org.apache.guacamole.GuacamoleException;
-import org.apache.guacamole.auth.jdbc.base.RestrictedObject;
+import org.apache.guacamole.auth.jdbc.base.JDBCDirectory;
 import org.apache.guacamole.net.auth.ActiveConnection;
-import org.apache.guacamole.net.auth.Directory;
 
 /**
  * Implementation of a Directory which contains all currently-active
  * connections.
  */
-public class ActiveConnectionDirectory extends RestrictedObject
-    implements Directory<ActiveConnection> {
+public class ActiveConnectionDirectory extends JDBCDirectory<ActiveConnection> {
 
     /**
      * Service for retrieving and manipulating active connections.
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/JDBCDirectory.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/JDBCDirectory.java
new file mode 100644
index 0000000..3ed6d8a
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/base/JDBCDirectory.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.jdbc.base;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.AtomicDirectoryOperation;
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.Identifiable;
+import org.mybatis.guice.transactional.Transactional;
+
+/**
+ * An implementation of Directory that uses database transactions to guarantee
+ * atomicity for any operations supplied to tryAtomically().
+ */
+public abstract class JDBCDirectory<ObjectType extends Identifiable>
+        extends RestrictedObject implements Directory<ObjectType>  {
+
+    @Override
+    @Transactional
+    public void tryAtomically(AtomicDirectoryOperation<ObjectType> operation)
+            throws GuacamoleException {
+
+        // Execute the operation atomically - the @Transactional annotation
+        // specifies that the entire operation will be performed in a transaction
+        operation.executeOperation(true, this);
+
+    }
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionDirectory.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionDirectory.java
index 52a127d..3e364f5 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionDirectory.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connection/ConnectionDirectory.java
@@ -25,17 +25,15 @@
 import java.util.Collections;
 import java.util.Set;
 import org.apache.guacamole.GuacamoleException;
-import org.apache.guacamole.auth.jdbc.base.RestrictedObject;
+import org.apache.guacamole.auth.jdbc.base.JDBCDirectory;
 import org.apache.guacamole.net.auth.Connection;
-import org.apache.guacamole.net.auth.Directory;
 import org.mybatis.guice.transactional.Transactional;
 
 /**
  * Implementation of the Connection Directory which is driven by an underlying,
  * arbitrary database.
  */
-public class ConnectionDirectory extends RestrictedObject
-    implements Directory<Connection> {
+public class ConnectionDirectory extends JDBCDirectory<Connection> {
 
     /**
      * Service for managing connection objects.
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connectiongroup/ConnectionGroupDirectory.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connectiongroup/ConnectionGroupDirectory.java
index 9f39305..2e21dc2 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connectiongroup/ConnectionGroupDirectory.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/connectiongroup/ConnectionGroupDirectory.java
@@ -25,17 +25,15 @@
 import java.util.Collections;
 import java.util.Set;
 import org.apache.guacamole.GuacamoleException;
-import org.apache.guacamole.auth.jdbc.base.RestrictedObject;
+import org.apache.guacamole.auth.jdbc.base.JDBCDirectory;
 import org.apache.guacamole.net.auth.ConnectionGroup;
-import org.apache.guacamole.net.auth.Directory;
 import org.mybatis.guice.transactional.Transactional;
 
 /**
  * Implementation of the ConnectionGroup Directory which is driven by an
  * underlying, arbitrary database.
  */
-public class ConnectionGroupDirectory extends RestrictedObject
-    implements Directory<ConnectionGroup> {
+public class ConnectionGroupDirectory extends JDBCDirectory<ConnectionGroup> {
 
     /**
      * Service for managing connection group objects.
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/sharing/SharedAuthenticationProviderService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/sharing/SharedAuthenticationProviderService.java
index 40df1e3..8cd6edc 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/sharing/SharedAuthenticationProviderService.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/sharing/SharedAuthenticationProviderService.java
@@ -109,4 +109,13 @@
 
     }
 
+    @Override
+    public UserContext decorateUserContext(AuthenticationProvider authenticationProvider,
+            UserContext context, AuthenticatedUser authenticatedUser,
+            Credentials credentials) {
+
+        // There's no need to decorate the user context here
+        return context;
+    }
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/sharingprofile/SharingProfileDirectory.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/sharingprofile/SharingProfileDirectory.java
index 6325570..4035ff0 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/sharingprofile/SharingProfileDirectory.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/sharingprofile/SharingProfileDirectory.java
@@ -24,8 +24,7 @@
 import java.util.Collections;
 import java.util.Set;
 import org.apache.guacamole.GuacamoleException;
-import org.apache.guacamole.auth.jdbc.base.RestrictedObject;
-import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.auth.jdbc.base.JDBCDirectory;
 import org.apache.guacamole.net.auth.SharingProfile;
 import org.mybatis.guice.transactional.Transactional;
 
@@ -33,8 +32,7 @@
  * Implementation of the SharingProfile Directory which is driven by an
  * underlying, arbitrary database.
  */
-public class SharingProfileDirectory extends RestrictedObject
-    implements Directory<SharingProfile> {
+public class SharingProfileDirectory extends JDBCDirectory<SharingProfile> {
 
     /**
      * Service for managing sharing profile objects.
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/ModeledUserContext.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/ModeledUserContext.java
index 1416b0b..e5ad5d2 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/ModeledUserContext.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/ModeledUserContext.java
@@ -29,6 +29,7 @@
 import java.util.Date;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.auth.jdbc.base.RestrictedObject;
+import org.apache.guacamole.auth.jdbc.JDBCEnvironment;
 import org.apache.guacamole.auth.jdbc.activeconnection.ActiveConnectionDirectory;
 import org.apache.guacamole.auth.jdbc.base.ActivityRecordModel;
 import org.apache.guacamole.auth.jdbc.connection.ConnectionRecordSet;
@@ -50,6 +51,8 @@
 import org.apache.guacamole.net.auth.User;
 import org.apache.guacamole.net.auth.UserContext;
 import org.apache.guacamole.net.auth.UserGroup;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * UserContext implementation which is driven by an arbitrary, underlying
@@ -59,6 +62,11 @@
     implements org.apache.guacamole.net.auth.UserContext {
 
     /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(ModeledUserContext.class);
+
+    /**
      * User directory restricted by the permissions of the user associated
      * with this context.
      */
@@ -131,6 +139,12 @@
     private UserRecordMapper userRecordMapper;
 
     /**
+     * The environment of the Guacamole server.
+     */
+    @Inject
+    private JDBCEnvironment environment;
+
+    /**
      * The activity record associated with this user's Guacamole session. If
      * this user's session will not have an associated activity record, such as
      * a temporary privileged session created via getPrivileged(), this will be
@@ -296,4 +310,31 @@
 
     }
 
+    @Override
+    public boolean isValid() {
+
+        try {
+            // If access window enforcement is disabled for active sessions,
+            // skip validity checks entirely
+            if (!environment.enforceAccessWindowsForActiveSessions())
+                return true;
+        }
+
+        catch (GuacamoleException e) {
+
+            logger.warn(
+                    "Unable to determine if access window enforcement is"
+                    + " enabled for active sessions; enforcing by default: {}"
+                    , e.getMessage());
+            logger.debug("Unable to determine access window enforcement policy.", e);
+
+        }
+
+        // A user context is valid if the associated user's account is valid
+        // for the current date, and the user is within an access time window
+        ModeledUser user = getCurrentUser().getUser();
+        return user.isAccountValid() && user.isAccountAccessible();
+
+    }
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserDirectory.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserDirectory.java
index dffd8e2..72d5a4f 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserDirectory.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserDirectory.java
@@ -25,8 +25,7 @@
 import java.util.Collections;
 import java.util.Set;
 import org.apache.guacamole.GuacamoleException;
-import org.apache.guacamole.auth.jdbc.base.RestrictedObject;
-import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.auth.jdbc.base.JDBCDirectory;
 import org.apache.guacamole.net.auth.User;
 import org.mybatis.guice.transactional.Transactional;
 
@@ -34,8 +33,7 @@
  * Implementation of the User Directory which is driven by an underlying,
  * arbitrary database.
  */
-public class UserDirectory extends RestrictedObject
-    implements Directory<User> {
+public class UserDirectory extends JDBCDirectory<User> {
 
     /**
      * Service for managing user objects.
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserService.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserService.java
index c43aa1b..161976c 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserService.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/user/UserService.java
@@ -415,10 +415,6 @@
     public ModeledUser retrieveUser(AuthenticationProvider authenticationProvider,
             AuthenticatedUser authenticatedUser) throws GuacamoleException {
 
-        // If we already queried this user, return that rather than querying again
-        if (authenticatedUser instanceof ModeledAuthenticatedUser)
-            return ((ModeledAuthenticatedUser) authenticatedUser).getUser();
-
         // Retrieve corresponding user model, if such a user exists
         UserModel userModel = userMapper.selectOne(authenticatedUser.getIdentifier());
         if (userModel == null)
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/usergroup/UserGroupDirectory.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/usergroup/UserGroupDirectory.java
index 911b852..c6bb895 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/usergroup/UserGroupDirectory.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/java/org/apache/guacamole/auth/jdbc/usergroup/UserGroupDirectory.java
@@ -24,8 +24,7 @@
 import java.util.Collections;
 import java.util.Set;
 import org.apache.guacamole.GuacamoleException;
-import org.apache.guacamole.auth.jdbc.base.RestrictedObject;
-import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.auth.jdbc.base.JDBCDirectory;
 import org.apache.guacamole.net.auth.UserGroup;
 import org.mybatis.guice.transactional.Transactional;
 
@@ -33,8 +32,7 @@
  * Implementation of the UserGroup Directory which is driven by an underlying,
  * arbitrary database.
  */
-public class UserGroupDirectory extends RestrictedObject
-    implements Directory<UserGroup> {
+public class UserGroupDirectory extends JDBCDirectory<UserGroup> {
 
     /**
      * Service for managing user group objects.
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/resources/translations/pl.json b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/resources/translations/pl.json
new file mode 100644
index 0000000..8c31932
--- /dev/null
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-base/src/main/resources/translations/pl.json
@@ -0,0 +1,113 @@
+{
+
+    "LOGIN" : {
+
+        "ERROR_PASSWORD_BLANK"    : "@:APP.ERROR_PASSWORD_BLANK",
+        "ERROR_PASSWORD_SAME"     : "Nowe hasło musi być inne od tego, które wygasło.",
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+        "ERROR_NOT_VALID"         : "To konto użytkownika nie jest poprawne.",
+        "ERROR_NOT_ACCESSIBLE"    : "Dostęp do tego konta jest obecnie zablokowany. Spróbuj ponownie później.",
+
+        "INFO_PASSWORD_EXPIRED" : "Twoje hasło wygasło i należy je zresetować. Wprowadź nowe hasło, aby kontynuować.",
+
+        "FIELD_HEADER_NEW_PASSWORD"         : "Nowe hasło",
+        "FIELD_HEADER_CONFIRM_NEW_PASSWORD" : "Potwierdź nowe hasło"
+
+    },
+
+    "CONNECTION_ATTRIBUTES" : {
+
+        "FIELD_HEADER_MAX_CONNECTIONS"          : "Maksymalna liczba połączeń:",
+        "FIELD_HEADER_MAX_CONNECTIONS_PER_USER" : "Maksymalna liczba połączeń dla jednego użytkownika:",
+
+        "FIELD_HEADER_FAILOVER_ONLY"            : "Używaj tylko do przełączania awaryjnego:",
+        "FIELD_HEADER_WEIGHT"                   : "Waga połączenia:",
+
+        "FIELD_HEADER_GUACD_HOSTNAME"   : "Nazwa Hosta:",
+        "FIELD_HEADER_GUACD_ENCRYPTION" : "Szyfrowanie:",
+        "FIELD_HEADER_GUACD_PORT"       : "Port:",
+
+        "FIELD_OPTION_GUACD_ENCRYPTION_EMPTY" : "",
+        "FIELD_OPTION_GUACD_ENCRYPTION_NONE"  : "Brak (nieszyfrowane)",
+        "FIELD_OPTION_GUACD_ENCRYPTION_SSL"   : "SSL / TLS",
+
+        "SECTION_HEADER_CONCURRENCY"    : "Limity Jednoczesnych Połączeń",
+        "SECTION_HEADER_LOAD_BALANCING" : "Równoważenie Obciążenia",
+        "SECTION_HEADER_GUACD"          : "Parametry Guacamole Proxy (guacd)"
+
+    },
+
+    "CONNECTION_GROUP_ATTRIBUTES" : {
+
+        "FIELD_HEADER_ENABLE_SESSION_AFFINITY"  : "Włącz powiązanie sesji:",
+        "FIELD_HEADER_MAX_CONNECTIONS"          : "Maksymalna liczba połączeń:",
+        "FIELD_HEADER_MAX_CONNECTIONS_PER_USER" : "Maksymalna liczba połączeń dla jednego użytkownika:",
+
+        "SECTION_HEADER_CONCURRENCY" : "Limity Jednoczesnych Połączeń (Grupy Równoważenia Obciążenia)"
+
+    },
+
+    "DATA_SOURCE_MYSQL" : {
+        "NAME" : "MySQL"
+    },
+
+    "DATA_SOURCE_MYSQL_SHARED" : {
+        "NAME" : "Współdzielone połączenia (MySQL)"
+    },
+
+    "DATA_SOURCE_POSTGRESQL" : {
+        "NAME" : "PostgreSQL"
+    },
+
+    "DATA_SOURCE_POSTGRESQL_SHARED" : {
+        "NAME" : "Współdzielone połączenia (PostgreSQL)"
+    },
+
+    "DATA_SOURCE_SQLSERVER" : {
+        "NAME" : "SQL Server"
+    },
+
+    "DATA_SOURCE_SQLSERVER_SHARED" : {
+        "NAME" : "Współdzielone połączenia (SQL Server)"
+    },
+
+    "HOME" : {
+        "INFO_SHARED_BY" : "Współdzielone przez {USERNAME}"
+    },
+
+    "PASSWORD_POLICY" : {
+
+        "ERROR_CONTAINS_USERNAME"      : "Hasła nie mogą zawierać nazwy użytkownika.",
+        "ERROR_REQUIRES_DIGIT"         : "Hasła muszą zawierać przynajmniej jedną cyfrę.",
+        "ERROR_REQUIRES_MULTIPLE_CASE" : "Hasła muszą zawierać zarówno wielkie, jak i małe litery.",
+        "ERROR_REQUIRES_NON_ALNUM"     : "Hasła muszą zawierać co najmniej jeden znak specjalny.",
+        "ERROR_REUSED"                 : "To hasło zostało już użyte. Nie można ponownie użyć żadnego z poprzednich {HISTORY_SIZE} {HISTORY_SIZE, plural, one{hasła} other{haseł}}.",
+        "ERROR_TOO_SHORT"              : "Hasło musi mieć co najmniej {LENGTH} {LENGTH, plural, one{znak} other{znaków}} długości.",
+        "ERROR_TOO_YOUNG"              : "Hasło do tego konta zostało już zresetowane. Poczekać przynajmniej {WAIT} {WAIT, plural, one{dzień} other{dni}} dłużej przed ponowną zmianą hasła."
+
+    },
+
+    "USER_ATTRIBUTES" : {
+
+        "FIELD_HEADER_DISABLED"            : "Logowanie zablokowane:",
+        "FIELD_HEADER_EXPIRED"             : "Hasło wygasło:",
+        "FIELD_HEADER_ACCESS_WINDOW_END"   : "Nie zezwalaj na dostęp po:",
+        "FIELD_HEADER_ACCESS_WINDOW_START" : "Zezwalaj na dostęp od:",
+        "FIELD_HEADER_TIMEZONE"            : "Strefa czasowa użytkownika:",
+        "FIELD_HEADER_VALID_FROM"          : "Włącz konto od:",
+        "FIELD_HEADER_VALID_UNTIL"         : "Wyłącz konto po:",
+
+        "SECTION_HEADER_RESTRICTIONS" : "Ograniczenia Konta",
+        "SECTION_HEADER_PROFILE"      : "Profil"
+
+    },
+
+    "USER_GROUP_ATTRIBUTES" : {
+
+        "FIELD_HEADER_DISABLED" : "Wyłączona:",
+
+        "SECTION_HEADER_RESTRICTIONS" : "Ograniczenia Grupy"
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/conf/MySQLEnvironment.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/conf/MySQLEnvironment.java
index a18e634..d5931e5 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/conf/MySQLEnvironment.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/conf/MySQLEnvironment.java
@@ -99,7 +99,7 @@
      * allowed to any one connection group.
      */
     private final int DEFAULT_MAX_GROUP_CONNECTIONS = 0;
-    
+
     /**
      * The default SSL mode for connecting to MySQL servers.
      */
@@ -122,8 +122,8 @@
     /**
      * Constructs a new MySQLEnvironment, providing access to MySQL-specific
      * configuration options.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If an error occurs while setting up the underlying JDBCEnvironment
      *     or while parsing legacy MySQL configuration options.
      */
@@ -198,12 +198,12 @@
      * database server hosting the Guacamole database. If unspecified, the
      * installed MySQL driver will be automatically detected by inspecting the
      * classes available in the classpath.
-     * 
+     *
      * @return
      *     The MySQL driver that will be used to communicate with the MySQL-
      *     compatible server.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If guacamole.properties cannot be parsed, or if no MySQL-compatible
      *     JDBC driver is present.
      */
@@ -231,15 +231,15 @@
         throw new GuacamoleServerException("No JDBC driver for MySQL/MariaDB is installed.");
 
     }
-    
+
     /**
      * Returns the hostname of the MySQL server hosting the Guacamole
      * authentication tables. If unspecified, this will be "localhost".
-     * 
+     *
      * @return
      *     The URL of the MySQL server.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value.
      */
     public String getMySQLHostname() throws GuacamoleException {
@@ -248,30 +248,30 @@
             DEFAULT_HOSTNAME
         );
     }
-    
+
     /**
      * Returns the port number of the MySQL server hosting the Guacamole
      * authentication tables. If unspecified, this will be the default MySQL
      * port of 3306.
-     * 
+     *
      * @return
      *     The port number of the MySQL server.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value.
      */
     public int getMySQLPort() throws GuacamoleException {
         return getProperty(MySQLGuacamoleProperties.MYSQL_PORT, DEFAULT_PORT);
     }
-    
+
     /**
-     * Returns the name of the MySQL database containing the Guacamole 
+     * Returns the name of the MySQL database containing the Guacamole
      * authentication tables.
-     * 
+     *
      * @return
      *     The name of the MySQL database.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value, or if the
      *     value was not set, as this property is required.
      */
@@ -283,7 +283,7 @@
     public String getUsername() throws GuacamoleException {
         return getRequiredProperty(MySQLGuacamoleProperties.MYSQL_USERNAME);
     }
-    
+
     @Override
     public String getPassword() throws GuacamoleException {
         return getRequiredProperty(MySQLGuacamoleProperties.MYSQL_PASSWORD);
@@ -324,15 +324,15 @@
         }
 
     }
-    
+
     /**
      * Return the MySQL SSL mode as configured in guacamole.properties, or the
      * default value of PREFERRED if not configured.
-     * 
+     *
      * @return
      *     The SSL mode to use when connecting to the MySQL server.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If an error occurs retrieving the property value.
      */
     public MySQLSSLMode getMySQLSSLMode() throws GuacamoleException {
@@ -340,71 +340,71 @@
                 MySQLGuacamoleProperties.MYSQL_SSL_MODE,
                 DEFAULT_SSL_MODE);
     }
-    
+
     /**
      * Returns the File where the trusted certificate store is located as
      * configured in guacamole.properties, or null if no value has been
      * configured.  The trusted certificate store is used to validate server
      * certificates when making SSL connections to MySQL servers.
-     * 
+     *
      * @return
      *     The File where the trusted certificate store is located, or null
      *     if the value has not been configured.
-     * 
+     *
      * @throws GuacamoleException
      *     If guacamole.properties cannot be parsed.
      */
     public File getMySQLSSLTrustStore() throws GuacamoleException {
         return getProperty(MySQLGuacamoleProperties.MYSQL_SSL_TRUST_STORE);
     }
-    
+
     /**
      * Returns the password used to access the trusted certificate store as
      * configured in guacamole.properties, or null if no password has been
      * specified.
-     * 
+     *
      * @return
      *     The password used to access the trusted certificate store.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If guacamole.properties cannot be parsed.
      */
     public String getMySQLSSLTrustPassword() throws GuacamoleException {
         return getProperty(MySQLGuacamoleProperties.MYSQL_SSL_TRUST_PASSWORD);
     }
-    
+
     /**
      * Returns the File used to store the client SSL certificate as configured
      * in guacamole.properties, or null if no value has been specified.  This
      * file will be used to load the client certificate used for SSL connections
      * to MySQL servers, if the SSL connection is so configured to require
      * client certificate authentication.
-     * 
+     *
      * @return
      *     The File where the client SSL certificate is stored.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If guacamole.properties cannot be parsed.
      */
     public File getMySQLSSLClientStore() throws GuacamoleException {
         return getProperty(MySQLGuacamoleProperties.MYSQL_SSL_CLIENT_STORE);
     }
-    
+
     /**
      * Returns the password used to access the client certificate store as
      * configured in guacamole.properties, or null if no value has been
      * specified.
-     * 
+     *
      * @return
      *     The password used to access the client SSL certificate store.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If guacamole.properties cannot be parsed.
      */
     public String getMYSQLSSLClientPassword() throws GuacamoleException {
         return getProperty(MySQLGuacamoleProperties.MYSQL_SSL_CLIENT_PASSWORD);
     }
-    
+
     @Override
     public boolean autoCreateAbsentAccounts() throws GuacamoleException {
         return getProperty(MySQLGuacamoleProperties.MYSQL_AUTO_CREATE_ACCOUNTS,
@@ -414,15 +414,32 @@
     /**
      * Return the server timezone if configured in guacamole.properties, or
      * null if the configuration option is not present.
-     * 
+     *
      * @return
      *     The server timezone as configured in guacamole.properties.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If an error occurs retrieving the configuration value.
      */
     public TimeZone getServerTimeZone() throws GuacamoleException {
         return getProperty(MySQLGuacamoleProperties.SERVER_TIMEZONE);
     }
 
+    @Override
+    public boolean trackExternalConnectionHistory() throws GuacamoleException {
+
+        // Track external connection history unless explicitly disabled
+        return getProperty(MySQLGuacamoleProperties.MYSQL_TRACK_EXTERNAL_CONNECTION_HISTORY,
+                true);
+    }
+
+    @Override
+    public boolean enforceAccessWindowsForActiveSessions() throws GuacamoleException {
+
+        // Enforce access window restrictions for active sessions unless explicitly disabled
+        return getProperty(
+                MySQLGuacamoleProperties.MYSQL_ENFORCE_ACCESS_WINDOWS_FOR_ACTIVE_SESSIONS,
+                true);
+    }
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/conf/MySQLGuacamoleProperties.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/conf/MySQLGuacamoleProperties.java
index 85d9377..5e20b52 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/conf/MySQLGuacamoleProperties.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/java/org/apache/guacamole/auth/mysql/conf/MySQLGuacamoleProperties.java
@@ -35,7 +35,7 @@
      * This class should not be instantiated.
      */
     private MySQLGuacamoleProperties() {}
-    
+
     /**
      * The JDBC driver that should be used to talk to MySQL-compatible servers.
      */
@@ -48,7 +48,7 @@
     };
 
     /**
-     * The hostname of the MySQL server hosting the Guacamole authentication 
+     * The hostname of the MySQL server hosting the Guacamole authentication
      * tables.
      */
     public static final StringGuacamoleProperty MYSQL_HOSTNAME = new StringGuacamoleProperty() {
@@ -59,7 +59,7 @@
     };
 
     /**
-     * The port number of the MySQL server hosting the Guacamole authentication 
+     * The port number of the MySQL server hosting the Guacamole authentication
      * tables.
      */
     public static final IntegerGuacamoleProperty MYSQL_PORT = new IntegerGuacamoleProperty() {
@@ -70,7 +70,7 @@
     };
 
     /**
-     * The name of the MySQL database containing the Guacamole authentication 
+     * The name of the MySQL database containing the Guacamole authentication
      * tables.
      */
     public static final StringGuacamoleProperty MYSQL_DATABASE = new StringGuacamoleProperty() {
@@ -179,19 +179,19 @@
         public String getName() { return "mysql-default-max-group-connections-per-user"; }
 
     };
-    
+
     /**
      * The SSL mode used to connect to the MySQL Server.  By default the driver
      * will attempt SSL connections and fall back to plain-text if SSL fails.
      */
     public static final EnumGuacamoleProperty<MySQLSSLMode> MYSQL_SSL_MODE =
             new EnumGuacamoleProperty<MySQLSSLMode>(MySQLSSLMode.class) {
-        
+
         @Override
         public String getName() { return "mysql-ssl-mode" ; }
-        
+
     };
-    
+
     /**
      * The File where trusted SSL certificate authorities and server certificates
      * are stored.  By default no file is specified, and the default Java
@@ -199,24 +199,24 @@
      */
     public static final FileGuacamoleProperty MYSQL_SSL_TRUST_STORE =
             new FileGuacamoleProperty() {
-        
+
         @Override
         public String getName() { return "mysql-ssl-trust-store"; }
-        
+
     };
-    
+
     /**
      * The password to use to access the mysql-ssl-trust-store, if required.  By
      * default no password will be used to attempt to access the store.
      */
     public static final StringGuacamoleProperty MYSQL_SSL_TRUST_PASSWORD =
             new StringGuacamoleProperty() {
-        
+
         @Override
         public String getName() { return "mysql-ssl-trust-password"; }
-        
+
     };
-    
+
     /**
      * The File used to store the client certificate for configurations where
      * a client certificate is required for authentication.  By default no
@@ -224,24 +224,24 @@
      */
     public static final FileGuacamoleProperty MYSQL_SSL_CLIENT_STORE =
             new FileGuacamoleProperty() {
-        
+
         @Override
         public String getName() { return "mysql-ssl-client-store"; }
-        
+
     };
-    
+
     /**
      * The password to use to access the mysql-ssl-client-store file.  By
      * default no password will be used to attempt to access the file.
      */
     public static final StringGuacamoleProperty MYSQL_SSL_CLIENT_PASSWORD =
             new StringGuacamoleProperty() {
-        
+
         @Override
         public String getName() { return "mysql-ssl-client-password"; }
-        
+
     };
-    
+
     /**
      * Whether or not to automatically create accounts in the MySQL database for
      * users who successfully authenticate through another extension. By default
@@ -249,7 +249,7 @@
      */
     public static final BooleanGuacamoleProperty MYSQL_AUTO_CREATE_ACCOUNTS =
             new BooleanGuacamoleProperty() {
-    
+
         @Override
         public String getName() { return "mysql-auto-create-accounts"; }
     };
@@ -259,10 +259,36 @@
      */
     public static final TimeZoneGuacamoleProperty SERVER_TIMEZONE =
             new TimeZoneGuacamoleProperty() {
-                
+
         @Override
         public String getName() { return "mysql-server-timezone"; }
-                
+
+    };
+
+    /**
+     * Whether or not to track connection history for connections that do not originate
+     * from within the MySQL database. By default, external connection history will be
+     * tracked.
+     */
+    public static final BooleanGuacamoleProperty MYSQL_TRACK_EXTERNAL_CONNECTION_HISTORY =
+            new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "mysql-track-external-connection-history"; }
+
+    };
+
+    /**
+     * Whether or not user-specific access time windows should be enforced for active sessions,
+     * i.e. whether users with active sessions should be logged out immediately when an access
+     * window closes.
+     */
+    public static final BooleanGuacamoleProperty MYSQL_ENFORCE_ACCESS_WINDOWS_FOR_ACTIVE_SESSIONS =
+            new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "mysql-enforce-access-windows-for-active-sessions"; }
+
     };
 
     /**
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/guac-manifest.json
index 344e902..4003b6b 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/guac-manifest.json
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-mysql/src/main/resources/guac-manifest.json
@@ -26,6 +26,7 @@
         "translations/fr.json",
         "translations/ja.json",
         "translations/ko.json",
+        "translations/pl.json",
         "translations/pt.json",
         "translations/ru.json",
         "translations/zh.json"
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/conf/PostgreSQLEnvironment.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/conf/PostgreSQLEnvironment.java
index fc615cd..459282f 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/conf/PostgreSQLEnvironment.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/conf/PostgreSQLEnvironment.java
@@ -110,7 +110,7 @@
      * the values that should be used in the absence of the correct properties.
      */
     private final int DEFAULT_MAX_GROUP_CONNECTIONS = 0;
-    
+
     /**
      * The default value to use for SSL mode if none is explicitly configured.
      */
@@ -131,8 +131,8 @@
     /**
      * Constructs a new PostgreSQLEnvironment, providing access to PostgreSQL-specific
      * configuration options.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If an error occurs while setting up the underlying JDBCEnvironment
      *     or while parsing legacy PostgreSQL configuration options.
      */
@@ -205,11 +205,11 @@
     /**
      * Returns the hostname of the PostgreSQL server hosting the Guacamole
      * authentication tables. If unspecified, this will be "localhost".
-     * 
+     *
      * @return
      *     The URL of the PostgreSQL server.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value.
      */
     public String getPostgreSQLHostname() throws GuacamoleException {
@@ -218,16 +218,16 @@
             DEFAULT_HOSTNAME
         );
     }
-    
+
     /**
      * Returns the port number of the PostgreSQL server hosting the Guacamole
      * authentication tables. If unspecified, this will be the default
      * PostgreSQL port of 5432.
-     * 
+     *
      * @return
      *     The port number of the PostgreSQL server.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value.
      */
     public int getPostgreSQLPort() throws GuacamoleException {
@@ -236,15 +236,15 @@
             DEFAULT_PORT
         );
     }
-    
+
     /**
      * Returns the name of the PostgreSQL database containing the Guacamole
      * authentication tables.
-     * 
+     *
      * @return
      *     The name of the PostgreSQL database.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value, or if the
      *     value was not set, as this property is required.
      */
@@ -261,16 +261,16 @@
     public String getPassword() throws GuacamoleException {
         return getRequiredProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_PASSWORD);
     }
-    
+
     /**
      * Returns the defaultStatementTimeout set for PostgreSQL connections.
      * If unspecified, this will default to 0,
      * and should not be passed through to the backend.
-     * 
+     *
      * @return
      *     The statement timeout (in seconds)
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value.
      */
     public int getPostgreSQLDefaultStatementTimeout() throws GuacamoleException {
@@ -279,15 +279,15 @@
             DEFAULT_STATEMENT_TIMEOUT
         );
     }
-    
+
     /**
      * Returns the socketTimeout property to set on PostgreSQL connections.
      * If unspecified, this will default to 0 (no timeout)
-     * 
+     *
      * @return
      *     The socketTimeout to use when waiting on read operations (in seconds)
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value.
      */
     public int getPostgreSQLSocketTimeout() throws GuacamoleException {
@@ -301,85 +301,102 @@
     public boolean isRecursiveQuerySupported(SqlSession session) {
         return true; // All versions of PostgreSQL support recursive queries through CTEs
     }
-    
+
     /**
      * Get the SSL mode to use to make the JDBC connection to the PostgreSQL
      * server.  If unspecified this will default to PREFER, attempting SSL
      * and falling back to plain-text if SSL fails.
-     * 
+     *
      * @return
      *     The enum value of the SSL mode to use to make the JDBC connection
      *     to the server.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If an error occurs retrieving the value from guacamole.properties.
      */
     public PostgreSQLSSLMode getPostgreSQLSSLMode() throws GuacamoleException {
         return getProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_SSL_MODE,
                 DEFAULT_SSL_MODE);
     }
-    
+
     /**
      * Return the SSL client certificate file to use to make the connection
      * to the PostgreSQL server.
-     * 
+     *
      * @return
      *     The SSL client certificate file to use for the PostgreSQL connection.
-     * 
+     *
      * @throws GuacamoleException
      *     If an error occurs retrieving the value from guacamole.properties.
      */
     public File getPostgreSQLSSLClientCertFile() throws GuacamoleException {
         return getProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_SSL_CERT_FILE);
     }
-    
+
     /**
      * Return the SSL client private key file to use to make the connection to the
      * PostgreSQL server.
-     * 
+     *
      * @return
      *     The SSL client private key file to use for the PostgreSQL connection.
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs retrieving the value from guacamole.properties.
      */
     public File getPostgreSQLSSLClientKeyFile() throws GuacamoleException {
         return getProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_SSL_KEY_FILE);
     }
-    
+
     /**
      * Return the SSL client root certificate file to use to make the connection
      * to the PostgreSQL server.
-     * 
+     *
      * @return
      *     The SSL client root certificate file to use to make the connection
      *     to the PostgreSQL server.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If an error occurs retrieving the value from guacamole.properties.
      */
     public File getPostgreSQLSSLClientRootCertFile() throws GuacamoleException {
         return getProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_SSL_ROOT_CERT_FILE);
     }
-    
+
     /**
      * Return the password to use to decrypt the private SSL key file when making
      * the connection to the PostgreSQL server.
-     * 
+     *
      * @return
      *     The password to use to decrypt the private SSL key file when making
      *     the connection to the PostgreSQL server.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If an error occurs retrieving the value from guacamole.properties.
      */
     public String getPostgreSQLSSLClientKeyPassword() throws GuacamoleException {
         return getProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_SSL_KEY_PASSWORD);
     }
-    
+
     @Override
     public boolean autoCreateAbsentAccounts() throws GuacamoleException {
         return getProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_AUTO_CREATE_ACCOUNTS,
                 false);
     }
-    
+
+    @Override
+    public boolean trackExternalConnectionHistory() throws GuacamoleException {
+
+        // Track external connection history unless explicitly disabled
+        return getProperty(PostgreSQLGuacamoleProperties.POSTGRESQL_TRACK_EXTERNAL_CONNECTION_HISTORY,
+                true);
+    }
+
+    @Override
+    public boolean enforceAccessWindowsForActiveSessions() throws GuacamoleException {
+
+        // Enforce access window restrictions for active sessions unless explicitly disabled
+        return getProperty(
+                PostgreSQLGuacamoleProperties.POSTGRESQL_ENFORCE_ACCESS_WINDOWS_FOR_ACTIVE_SESSIONS,
+                true);
+    }
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/conf/PostgreSQLGuacamoleProperties.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/conf/PostgreSQLGuacamoleProperties.java
index cd6912e..707b83c 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/conf/PostgreSQLGuacamoleProperties.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/java/org/apache/guacamole/auth/postgresql/conf/PostgreSQLGuacamoleProperties.java
@@ -111,7 +111,7 @@
      * The number of seconds to wait for socket read operations.
      * If reading from the server takes longer than this value, the
      * connection will be closed. This can be used to handle network problems
-     * such as a dropped connection to the database. Similar to 
+     * such as a dropped connection to the database. Similar to
      * postgresql-default-statement-timeout, it will have the effect of
      * aborting queries that take too long.
      * A value of 0 (the default) means the timeout is disabled.
@@ -202,7 +202,7 @@
         public String getName() { return "postgresql-default-max-group-connections-per-user"; }
 
     };
-    
+
     /**
      * The SSL mode that should be used by the JDBC driver when making
      * connections to the remote server.  By default SSL will be attempted but
@@ -210,60 +210,60 @@
      */
     public static final EnumGuacamoleProperty<PostgreSQLSSLMode> POSTGRESQL_SSL_MODE =
             new EnumGuacamoleProperty<PostgreSQLSSLMode>(PostgreSQLSSLMode.class) {
-        
+
         @Override
         public String getName() { return "postgresql-ssl-mode"; }
-        
+
     };
-    
+
     /**
      * The client SSL certificate file used by the JDBC driver to make the
      * SSL connection.
      */
     public static final FileGuacamoleProperty POSTGRESQL_SSL_CERT_FILE =
             new FileGuacamoleProperty() {
-             
+
         @Override
         public String getName() { return "postgresql-ssl-cert-file"; }
-                
+
     };
-    
+
     /**
      * The client SSL private key file used by the JDBC driver to make the
      * SSL connection.
      */
     public static final FileGuacamoleProperty POSTGRESQL_SSL_KEY_FILE =
             new FileGuacamoleProperty() {
-    
+
         @Override
         public String getName() { return "postgresql-ssl-key-file"; }
-        
+
     };
-    
+
     /**
      * The client SSL root certificate file used by the JDBC driver to validate
      * certificates when making the SSL connection.
      */
     public static final FileGuacamoleProperty POSTGRESQL_SSL_ROOT_CERT_FILE =
             new FileGuacamoleProperty() {
-        
+
         @Override
         public String getName() { return "postgresql-ssl-root-cert-file"; }
-        
+
     };
-    
+
     /**
      * The password of the SSL private key used by the JDBC driver to make
      * the SSL connection to the PostgreSQL server.
      */
     public static final StringGuacamoleProperty POSTGRESQL_SSL_KEY_PASSWORD =
             new StringGuacamoleProperty() {
-        
+
         @Override
         public String getName() { return "postgresql-ssl-key-password"; }
-        
+
     };
-    
+
     /**
      * Whether or not to automatically create accounts in the PostgreSQL
      * database for users who successfully authenticate through another
@@ -271,10 +271,36 @@
      */
     public static final BooleanGuacamoleProperty POSTGRESQL_AUTO_CREATE_ACCOUNTS =
             new BooleanGuacamoleProperty() {
-                
+
         @Override
         public String getName() { return "postgresql-auto-create-accounts"; }
-                
+
+    };
+
+    /**
+     * Whether or not to track connection history for connections that do not originate
+     * from within the Postgres database. By default, external connection history will be
+     * tracked.
+     */
+    public static final BooleanGuacamoleProperty POSTGRESQL_TRACK_EXTERNAL_CONNECTION_HISTORY =
+            new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "postgresql-track-external-connection-history"; }
+
+    };
+
+    /**
+     * Whether or not user-specific access time windows should be enforced for active sessions,
+     * i.e. whether users with active sessions should be logged out immediately when an access
+     * window closes.
+     */
+    public static final BooleanGuacamoleProperty POSTGRESQL_ENFORCE_ACCESS_WINDOWS_FOR_ACTIVE_SESSIONS =
+            new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "postgresql-enforce-access-windows-for-active-sessions"; }
+
     };
     
     /**
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/guac-manifest.json
index bfa0921..fa3c67f 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/guac-manifest.json
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-postgresql/src/main/resources/guac-manifest.json
@@ -26,6 +26,7 @@
         "translations/fr.json",
         "translations/ja.json",
         "translations/ko.json",
+        "translations/pl.json",
         "translations/pt.json",
         "translations/ru.json",
         "translations/zh.json"
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/SQLServerAuthenticationProviderModule.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/SQLServerAuthenticationProviderModule.java
index 74d3c95..5a3d400 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/SQLServerAuthenticationProviderModule.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/SQLServerAuthenticationProviderModule.java
@@ -76,6 +76,10 @@
 
         // Use UTF-8 in database
         driverProperties.setProperty("characterEncoding", "UTF-8");
+
+        // Trust unknown server certificates if configured to do so
+        if (environment.trustAllServerCertificates())
+            driverProperties.setProperty("trustServerCertificate", "true");
         
         // Retrieve instance name and set it
         String instance = environment.getSQLServerInstance();
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/conf/SQLServerEnvironment.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/conf/SQLServerEnvironment.java
index ba38313..3f31337 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/conf/SQLServerEnvironment.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/conf/SQLServerEnvironment.java
@@ -102,8 +102,8 @@
     /**
      * Constructs a new SQLServerEnvironment, providing access to SQLServer-specific
      * configuration options.
-     * 
-     * @throws GuacamoleException 
+     *
+     * @throws GuacamoleException
      *     If an error occurs while setting up the underlying JDBCEnvironment
      *     or while parsing legacy SQLServer configuration options.
      */
@@ -176,11 +176,11 @@
     /**
      * Returns the hostname of the SQLServer server hosting the Guacamole
      * authentication tables. If unspecified, this will be "localhost".
-     * 
+     *
      * @return
      *     The URL of the SQLServer server.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value.
      */
     public String getSQLServerHostname() throws GuacamoleException {
@@ -189,15 +189,15 @@
             DEFAULT_HOSTNAME
         );
     }
-    
+
     /**
      * Returns the instance name of the SQL Server installation hosting the
      * Guacamole database, if any.  If unspecified it will be null.
-     * 
+     *
      * @return
      *     The instance name of the SQL Server install hosting the Guacamole
      *     database, or null if undefined.
-     * 
+     *
      * @throws GuacamoleException
      *     If an error occurs reading guacamole.properties.
      */
@@ -206,16 +206,16 @@
             SQLServerGuacamoleProperties.SQLSERVER_INSTANCE
         );
     }
-    
+
     /**
      * Returns the port number of the SQLServer server hosting the Guacamole
      * authentication tables. If unspecified, this will be the default
      * SQLServer port of 5432.
-     * 
+     *
      * @return
      *     The port number of the SQLServer server.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value.
      */
     public int getSQLServerPort() throws GuacamoleException {
@@ -224,15 +224,15 @@
             DEFAULT_PORT
         );
     }
-    
+
     /**
      * Returns the name of the SQLServer database containing the Guacamole
      * authentication tables.
-     * 
+     *
      * @return
      *     The name of the SQLServer database.
      *
-     * @throws GuacamoleException 
+     * @throws GuacamoleException
      *     If an error occurs while retrieving the property value, or if the
      *     value was not set, as this property is required.
      */
@@ -244,7 +244,7 @@
     public String getUsername() throws GuacamoleException {
         return getRequiredProperty(SQLServerGuacamoleProperties.SQLSERVER_USERNAME);
     }
-    
+
     @Override
     public String getPassword() throws GuacamoleException {
         return getRequiredProperty(SQLServerGuacamoleProperties.SQLSERVER_PASSWORD);
@@ -271,11 +271,60 @@
     public boolean isRecursiveQuerySupported(SqlSession session) {
         return true; // All versions of SQL Server support recursive queries through CTEs
     }
-    
+
     @Override
     public boolean autoCreateAbsentAccounts() throws GuacamoleException {
         return getProperty(SQLServerGuacamoleProperties.SQLSERVER_AUTO_CREATE_ACCOUNTS,
                 false);
     }
 
+    @Override
+    public boolean trackExternalConnectionHistory() throws GuacamoleException {
+
+        // Track external connection history unless explicitly disabled
+        return getProperty(SQLServerGuacamoleProperties.SQLSERVER_TRACK_EXTERNAL_CONNECTION_HISTORY,
+                true);
+    }
+
+    @Override
+    public boolean enforceAccessWindowsForActiveSessions() throws GuacamoleException {
+
+        // Enforce access window restrictions for active sessions unless explicitly disabled
+        return getProperty(
+                SQLServerGuacamoleProperties.SQLSERVER_ENFORCE_ACCESS_WINDOWS_FOR_ACTIVE_SESSIONS,
+                true);
+    }
+
+    @Override
+    public boolean shouldUseBatchExecutor() {
+
+        // The SQL Server driver does not work when batch execution is enabled.
+        // Specifically, inserts fail with com.microsoft.sqlserver.jdbc.SQLServerException:
+        // The statement must be executed before any results can be obtained.
+        // See https://github.com/microsoft/mssql-jdbc/issues/358 for more.
+        logger.warn(
+                "JDBC batch executor is disabled for SQL Server Connections. "
+                + "Large batched updates may run slower."
+        );
+        return false;
+        
+    }
+
+    /**
+     * Returns true if all server certificates should be trusted, including
+     * those signed by an unknown certificate authority, such as self-signed
+     * certificates, or false otherwise.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the property value, or if the
+     *     value was not set, as this property is required.
+     */
+    public boolean trustAllServerCertificates() throws GuacamoleException {
+
+        // Do not trust unknown certificates unless explicitly enabled
+        return getProperty(
+                SQLServerGuacamoleProperties.SQLSERVER_TRUST_ALL_SERVER_CERTIFICATES,
+                false);
+    }
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/conf/SQLServerGuacamoleProperties.java b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/conf/SQLServerGuacamoleProperties.java
index 10c8075..c4df813 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/conf/SQLServerGuacamoleProperties.java
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/java/org/apache/guacamole/auth/sqlserver/conf/SQLServerGuacamoleProperties.java
@@ -44,7 +44,7 @@
         public String getName() { return "sqlserver-hostname"; }
 
     };
-    
+
     /**
      * The instance name of the SQL Server where the Guacamole database is running.
      */
@@ -53,7 +53,7 @@
 
         @Override
         public String getName() { return "sqlserver-instance"; }
-                
+
     };
 
     /**
@@ -193,7 +193,7 @@
         public String getName() { return "sqlserver-driver"; }
 
     };
-    
+
     /**
      * Whether or not to automatically create accounts in the SQL Server
      * database for users who successfully authenticate through another
@@ -201,10 +201,36 @@
      */
     public static final BooleanGuacamoleProperty SQLSERVER_AUTO_CREATE_ACCOUNTS =
             new BooleanGuacamoleProperty() {
-        
+
         @Override
         public String getName() { return "sqlserver-auto-create-accounts"; }
-        
+
+    };
+
+    /**
+     * Whether or not to track connection history for connections that do not originate
+     * from within the SQL Server database. By default, external connection history will be
+     * tracked.
+     */
+    public static final BooleanGuacamoleProperty SQLSERVER_TRACK_EXTERNAL_CONNECTION_HISTORY =
+            new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "sqlserver-track-external-connection-history"; }
+
+    };
+
+    /**
+     * Whether or not user-specific access time windows should be enforced for active sessions,
+     * i.e. whether users with active sessions should be logged out immediately when an access
+     * window closes.
+     */
+    public static final BooleanGuacamoleProperty SQLSERVER_ENFORCE_ACCESS_WINDOWS_FOR_ACTIVE_SESSIONS =
+            new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "sqlserver-enforce-access-windows-for-active-sessions"; }
+
     };
     
     /**
@@ -219,4 +245,17 @@
 
     };
 
+    /**
+     * Whether or not all server certificates should be trusted, including those
+     * signed by an unknown certificate authority, such as self-signed
+     * certificates.
+     */
+    public static final BooleanGuacamoleProperty SQLSERVER_TRUST_ALL_SERVER_CERTIFICATES =
+            new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "sqlserver-trust-all-server-certificates"; }
+
+    };
+
 }
diff --git a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/guac-manifest.json
index 8daf28f..543061b 100644
--- a/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/guac-manifest.json
+++ b/extensions/guacamole-auth-jdbc/modules/guacamole-auth-jdbc-sqlserver/src/main/resources/guac-manifest.json
@@ -26,6 +26,7 @@
         "translations/fr.json",
         "translations/ja.json",
         "translations/ko.json",
+        "translations/pl.json",
         "translations/pt.json",
         "translations/ru.json",
         "translations/zh.json"
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/ConnectedLDAPConfiguration.java b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/ConnectedLDAPConfiguration.java
index 5617bb7..493cafc 100644
--- a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/ConnectedLDAPConfiguration.java
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/ConnectedLDAPConfiguration.java
@@ -27,6 +27,7 @@
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.auth.ldap.conf.EncryptionMethod;
 import org.apache.guacamole.auth.ldap.conf.LDAPConfiguration;
+import org.apache.guacamole.auth.ldap.conf.LDAPSSLProtocol;
 import org.apache.guacamole.auth.ldap.conf.MemberAttributeType;
 
 /**
@@ -161,6 +162,11 @@
     public EncryptionMethod getEncryptionMethod() throws GuacamoleException {
         return config.getEncryptionMethod();
     }
+    
+    @Override
+    public LDAPSSLProtocol getSslProtocol() throws GuacamoleException {
+        return config.getSslProtocol();
+    }
 
     @Override
     public int getMaxResults() throws GuacamoleException {
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/LDAPConnectionService.java b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/LDAPConnectionService.java
index b8f5d30..06f83b5 100644
--- a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/LDAPConnectionService.java
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/LDAPConnectionService.java
@@ -35,6 +35,7 @@
 import org.apache.guacamole.GuacamoleUnsupportedException;
 import org.apache.guacamole.auth.ldap.conf.EncryptionMethod;
 import org.apache.guacamole.auth.ldap.conf.LDAPConfiguration;
+import org.apache.guacamole.auth.ldap.conf.LDAPSSLProtocol;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -51,7 +52,8 @@
     /**
      * Creates a new instance of LdapNetworkConnection, configured as required
      * to use the given encryption method to communicate with the LDAP server
-     * at the given hostname and port. The returned LdapNetworkConnection is
+     * at the given hostname and port, with the specified encryption method,
+     * SSL protocol version, and timeout. The returned LdapNetworkConnection is
      * configured for use but is not yet connected nor bound to the LDAP
      * server. It will not be bound until a bind operation is explicitly
      * requested, and will not be connected until it is used in an LDAP
@@ -66,6 +68,85 @@
      * @param encryptionMethod
      *     The encryption method that should be used to communicate with the
      *     LDAP server.
+     * 
+     * @param sslProtocol
+     *     The SSL protocol version to use to make a secure LDAP configuration,
+     *     if SSL or STARTTLS is used.
+     *
+     * @param timeout
+     *     The maximum number of milliseconds to wait for a response from the
+     *     LDAP server.
+     *
+     * @return
+     *     A new instance of LdapNetworkConnection which uses the given
+     *     encryption method to communicate with the LDAP server at the given
+     *     hostname and port.
+     *
+     * @throws GuacamoleException
+     *     If the requested encryption method is actually not implemented (a
+     *     bug).
+     */
+    private LdapNetworkConnection createLDAPConnection(String host, int port,
+            EncryptionMethod encryptionMethod, LDAPSSLProtocol sslProtocol,
+            int timeout)
+            throws GuacamoleException {
+
+        LdapConnectionConfig config = new LdapConnectionConfig();
+        config.setLdapHost(host);
+        config.setLdapPort(port);
+        config.setTimeout(timeout);
+
+        // Map encryption method to proper connection and socket factory
+        switch (encryptionMethod) {
+
+            // Unencrypted LDAP connection
+            case NONE:
+                logger.debug("Connection to LDAP server without encryption.");
+                break;
+
+            // LDAP over SSL (LDAPS)
+            case SSL:
+                logger.debug("Connecting to LDAP server using SSL/TLS.");
+                config.setUseSsl(true);
+                config.setSslProtocol(sslProtocol.toString());
+                break;
+
+            // LDAP + STARTTLS
+            case STARTTLS:
+                logger.debug("Connecting to LDAP server using STARTTLS.");
+                config.setUseTls(true);
+                config.setSslProtocol(sslProtocol.toString());
+                break;
+
+            // The encryption method, though known, is not actually
+            // implemented. If encountered, this would be a bug.
+            default:
+                throw new GuacamoleUnsupportedException("Unimplemented encryption method: " + encryptionMethod);
+
+        }
+
+        return new LdapNetworkConnection(config);
+
+    }
+    
+    /**
+     * Creates a new instance of LdapNetworkConnection, configured as required
+     * to use the given encryption method to communicate with the LDAP server
+     * at the given hostname and port with the encryption method and timeout
+     * specified, as well. The returned LdapNetworkConnection is configured
+     * for use but is not yet connected nor bound to the LDAP server. It will
+     * not be bound until a bind operation is explicitly requested, and will
+     * not be connected until it is used in an LDAP operation (such as a bind).
+     *
+     * @param host
+     *     The hostname or IP address of the LDAP server.
+     *
+     * @param port
+     *     The TCP port that the LDAP server is listening on.
+     *
+     * @param encryptionMethod
+     *     The encryption method that should be used to communicate with the
+     *     LDAP server.
      *
      * @param timeout
      *     The maximum number of milliseconds to wait for a response from the
@@ -83,41 +164,8 @@
     private LdapNetworkConnection createLDAPConnection(String host, int port,
             EncryptionMethod encryptionMethod, int timeout)
             throws GuacamoleException {
-
-        LdapConnectionConfig config = new LdapConnectionConfig();
-        config.setLdapHost(host);
-        config.setLdapPort(port);
-        config.setTimeout(timeout);
-
-        // Map encryption method to proper connection and socket factory
-        switch (encryptionMethod) {
-
-            // Unencrypted LDAP connection
-            case NONE:
-                logger.debug("Connection to LDAP server without encryption.");
-                break;
-
-            // LDAP over SSL (LDAPS)
-            case SSL:
-                logger.debug("Connecting to LDAP server using SSL/TLS.");
-                config.setUseSsl(true);
-                break;
-
-            // LDAP + STARTTLS
-            case STARTTLS:
-                logger.debug("Connecting to LDAP server using STARTTLS.");
-                config.setUseTls(true);
-                break;
-
-            // The encryption method, though known, is not actually
-            // implemented. If encountered, this would be a bug.
-            default:
-                throw new GuacamoleUnsupportedException("Unimplemented encryption method: " + encryptionMethod);
-
-        }
-
-        return new LdapNetworkConnection(config);
-
+        return createLDAPConnection(host, port, encryptionMethod,
+                LDAPSSLProtocol.TLSv1_3, timeout);
     }
 
     /**
@@ -147,6 +195,7 @@
                 config.getServerHostname(),
                 config.getServerPort(),
                 config.getEncryptionMethod(),
+                config.getSslProtocol(),
                 config.getNetworkTimeout());
     }
 
@@ -217,7 +266,7 @@
             port = encryptionMethod.DEFAULT_PORT;
 
         return createLDAPConnection(host, port, encryptionMethod,
-                config.getNetworkTimeout());
+                config.getSslProtocol(), config.getNetworkTimeout());
 
     }
 
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/DefaultLDAPConfiguration.java b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/DefaultLDAPConfiguration.java
index 28ab8ed..cb2db9b 100644
--- a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/DefaultLDAPConfiguration.java
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/DefaultLDAPConfiguration.java
@@ -89,6 +89,11 @@
     public EncryptionMethod getEncryptionMethod() {
         return EncryptionMethod.NONE;
     }
+    
+    @Override
+    public LDAPSSLProtocol getSslProtocol() {
+        return LDAPSSLProtocol.TLSv1_3;
+    }
 
     @Override
     public int getMaxResults() {
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/EnvironmentLDAPConfiguration.java b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/EnvironmentLDAPConfiguration.java
index ae2d3cf..9fb44a1 100644
--- a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/EnvironmentLDAPConfiguration.java
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/EnvironmentLDAPConfiguration.java
@@ -136,6 +136,14 @@
             DEFAULT.getEncryptionMethod()
         );
     }
+    
+    @Override
+    public LDAPSSLProtocol getSslProtocol() throws GuacamoleException {
+        return environment.getProperty(
+            LDAPGuacamoleProperties.LDAP_SSL_PROTOCOL,
+            DEFAULT.getSslProtocol()
+        );
+    }
 
     @Override
     public int getMaxResults() throws GuacamoleException {
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/JacksonLDAPConfiguration.java b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/JacksonLDAPConfiguration.java
index f888908..01d58a3 100644
--- a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/JacksonLDAPConfiguration.java
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/JacksonLDAPConfiguration.java
@@ -116,6 +116,14 @@
      */
     @JsonProperty("encryption-method")
     private String encryptionMethod;
+    
+    /**
+     * The raw YAML value of {@link LDAPGuacamoleProperties#LDAP_SSL_PROTOCOL}. If
+     * not set within the YAML, this will be null, and will default to the value
+     * specified by the LDAP API library.
+     */
+    @JsonProperty("ssl-protocol")
+    private String sslProtocol;
 
     /**
      * The raw YAML value of {@link LDAPGuacamoleProperties#LDAP_MAX_SEARCH_RESULTS}.
@@ -365,6 +373,12 @@
         return withDefault(LDAPGuacamoleProperties.LDAP_ENCRYPTION_METHOD,
                 encryptionMethod, defaultConfig::getEncryptionMethod);
     }
+    
+    @Override
+    public LDAPSSLProtocol getSslProtocol() throws GuacamoleException {
+        return withDefault(LDAPGuacamoleProperties.LDAP_SSL_PROTOCOL,
+                sslProtocol, defaultConfig::getSslProtocol);
+    }
 
     @Override
     public int getMaxResults() throws GuacamoleException {
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/LDAPConfiguration.java b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/LDAPConfiguration.java
index 77eb315..975631d 100644
--- a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/LDAPConfiguration.java
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/LDAPConfiguration.java
@@ -183,6 +183,20 @@
      *     If the encryption method cannot be retrieved.
      */
     EncryptionMethod getEncryptionMethod() throws GuacamoleException;
+    
+    /**
+     * Returns the SSL protocol that should be used when making a secure
+     * connection to the LDAP server. By default the latest available TLS
+     * version will be used.
+     * 
+     * @return
+     *     The SSL protocol that should be used when making a secure connection
+     *     to the LDAP server.
+     * 
+     * @throws GuacamoleException 
+     *     If the SSL protocol cannot be retrieved.
+     */
+    LDAPSSLProtocol getSslProtocol() throws GuacamoleException;
 
     /**
      * Returns maximum number of results a LDAP query can return.
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/LDAPGuacamoleProperties.java b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/LDAPGuacamoleProperties.java
index 1db4f72..cd0b724 100644
--- a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/LDAPGuacamoleProperties.java
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/LDAPGuacamoleProperties.java
@@ -170,6 +170,14 @@
         public String getName() { return "ldap-encryption-method"; }
 
     };
+    
+    public static final EnumGuacamoleProperty<LDAPSSLProtocol> LDAP_SSL_PROTOCOL =
+            new EnumGuacamoleProperty<LDAPSSLProtocol>(LDAPSSLProtocol.class) {
+    
+        @Override
+        public String getName() { return "ldap-ssl-protocol"; }
+                
+    };
 
     /**
      * The maximum number of results a LDAP query can return.
diff --git a/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/LDAPSSLProtocol.java b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/LDAPSSLProtocol.java
new file mode 100644
index 0000000..c5b1ca8
--- /dev/null
+++ b/extensions/guacamole-auth-ldap/src/main/java/org/apache/guacamole/auth/ldap/conf/LDAPSSLProtocol.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.ldap.conf;
+
+import org.apache.guacamole.properties.EnumGuacamoleProperty.PropertyValue;
+
+/**
+ * All possible SSL protocols which may be used for secure LDAP connections.
+ */
+public enum LDAPSSLProtocol {
+
+    /**
+     * Use SSLv3 for secure LDAP connection.
+     */
+    @PropertyValue("SSLv3")
+    SSLv3("SSLv3"),
+    
+    /**
+     * Use original TLS for secure LDAP connection.
+     */
+    @PropertyValue("TLS")
+    TLS("TLS"),
+    
+    /**
+     * Use TLSv1 for secure LDAP connection.
+     */
+    @PropertyValue("TLSv1")
+    TLSv1("TLSv1"),
+    
+    /**
+     * Use TLSv1.1 for secure LDAP connection.
+     */
+    @PropertyValue("TLSv1.1")
+    TLSv1_1("TLSv1.1"),
+    
+    /**
+     * Use TLSv1.2 for secure LDAP connection.
+     */
+    @PropertyValue("TLSv1.2")
+    TLSv1_2("TLSv1.2"),
+    
+    /**
+     * Use TLSv1.3 for secure LDAP connection.
+     */
+    @PropertyValue("TLSv1.3")
+    TLSv1_3("TLSv1.3");
+
+    /**
+     * The string value of the option to use which is ultimately what the LDAP
+     * API consumes to set the SSL protocol.
+     */
+    public final String STRING_VALUE;
+
+    /**
+     * Initializes this SSL protocol such that it is associated with the
+     * given string value.
+     *
+     * @param value
+     *     The string value that will be associated with the enum value.
+     */
+    private LDAPSSLProtocol(String value) {
+        this.STRING_VALUE = value;
+    }
+    
+    @Override
+    public String toString() {
+        return STRING_VALUE;
+    }
+
+}
diff --git a/extensions/guacamole-auth-quickconnect/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-quickconnect/src/main/resources/guac-manifest.json
index 654f428..152c870 100644
--- a/extensions/guacamole-auth-quickconnect/src/main/resources/guac-manifest.json
+++ b/extensions/guacamole-auth-quickconnect/src/main/resources/guac-manifest.json
@@ -27,6 +27,7 @@
         "translations/fr.json",
         "translations/ja.json",
         "translations/ko.json",
+        "translations/pl.json",
         "translations/pt.json",
         "translations/ru.json",
         "translations/zh.json"
diff --git a/extensions/guacamole-auth-quickconnect/src/main/resources/translations/pl.json b/extensions/guacamole-auth-quickconnect/src/main/resources/translations/pl.json
new file mode 100644
index 0000000..e213f94
--- /dev/null
+++ b/extensions/guacamole-auth-quickconnect/src/main/resources/translations/pl.json
@@ -0,0 +1,18 @@
+{
+
+    "DATA_SOURCE_QUICKCONNECT" : {
+        "NAME" : "QuickConnect"
+    },
+
+    "QUICKCONNECT" : {
+        "ACTION_CONNECT"        : "Połącz",
+        
+        "ERROR_INVALID_URI"      : "Podano nieprawidłowy URI",
+        "ERROR_NO_HOST"          : "Nie podano hosta",
+        "ERROR_NO_PROTOCOL"      : "Nie podano protokołu",
+        "ERROR_NOT_ABSOLUTE_URI" : "Adres URI nie jest absolutny",
+        
+        "FIELD_PLACEHOLDER_URI" : "Podaj URI Połączenia"
+    }
+
+}
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/java/org/apache/guacamole/auth/sso/AuthenticationSession.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/java/org/apache/guacamole/auth/sso/AuthenticationSession.java
new file mode 100644
index 0000000..89d75df
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/java/org/apache/guacamole/auth/sso/AuthenticationSession.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.sso;
+
+/**
+ * Representation of an in-progress authentication attempt.
+ */
+public class AuthenticationSession {
+
+    /**
+     * The absolute point in time after which this authentication session is
+     * invalid. This value is a UNIX epoch timestamp, as may be returned by
+     * {@link System#currentTimeMillis()}.
+     */
+    private final long expirationTimestamp;
+
+    /**
+     * Creates a new AuthenticationSession representing an in-progress
+     * authentication attempt.
+     *
+     * @param expires
+     *     The number of milliseconds that may elapse before this session must
+     *     be considered invalid.
+     */
+    public AuthenticationSession(long expires) {
+        this.expirationTimestamp = System.currentTimeMillis() + expires;
+    }
+
+    /**
+     * Returns whether this authentication session is still valid (has not yet
+     * expired).
+     *
+     * @return
+     *     true if this authentication session is still valid, false if it has
+     *     expired.
+     */
+    public boolean isValid() {
+        return System.currentTimeMillis() < expirationTimestamp;
+    }
+
+}
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AuthenticationSessionManager.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/java/org/apache/guacamole/auth/sso/AuthenticationSessionManager.java
similarity index 65%
rename from extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AuthenticationSessionManager.java
rename to extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/java/org/apache/guacamole/auth/sso/AuthenticationSessionManager.java
index 198347c..3261228 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AuthenticationSessionManager.java
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/java/org/apache/guacamole/auth/sso/AuthenticationSessionManager.java
@@ -17,11 +17,10 @@
  * under the License.
  */
 
-package org.apache.guacamole.auth.saml.acs;
+package org.apache.guacamole.auth.sso;
 
 import com.google.common.base.Predicates;
 import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.Executors;
@@ -29,14 +28,16 @@
 import java.util.concurrent.TimeUnit;
 
 /**
- * Manager service that temporarily stores SAML authentication attempts while
+ * Manager service that temporarily stores a user's authentication status while
  * the authentication flow is underway. Authentication attempts are represented
  * as temporary authentication sessions, allowing authentication attempts to
- * span multiple requests and redirects. Invalid or stale authentication
+ * span multiple requests, redirects, etc. Invalid or stale authentication
  * sessions are automatically purged from storage.
+ *
+ * @param <T>
+ *     The type of sessions managed by this session manager.
  */
-@Singleton
-public class AuthenticationSessionManager {
+public class AuthenticationSessionManager<T extends AuthenticationSession> {
 
     /**
      * Generator of arbitrary, unique, unpredictable identifiers.
@@ -48,8 +49,7 @@
      * Map of authentication session identifiers to their associated
      * {@link AuthenticationSession}.
      */
-    private final ConcurrentMap<String, AuthenticationSession> sessions =
-        new ConcurrentHashMap<>();
+    private final ConcurrentMap<String, T> sessions = new ConcurrentHashMap<>();
 
     /**
      * Executor service which runs the periodic cleanup task
@@ -59,7 +59,7 @@
 
     /**
      * Creates a new AuthenticationSessionManager that manages in-progress
-     * SAML authentication attempts. Invalid, stale sessions are automatically
+     * authentication attempts. Invalid, stale sessions are automatically
      * cleaned up.
      */
     public AuthenticationSessionManager() {
@@ -69,6 +69,20 @@
     }
 
     /**
+     * Generates a cryptographically-secure value identical in form to the
+     * session tokens generated by {@link #defer(org.apache.guacamole.auth.sso.AuthenticationSession)}
+     * but invalid. The returned value is indistinguishable from a valid token,
+     * but is not a valid token.
+     *
+     * @return
+     *     An invalid token value that is indistinguishable from a valid
+     *     token.
+     */
+    public String generateInvalid() {
+        return idGenerator.generateIdentifier();
+    }
+
+    /**
      * Resumes the Guacamole side of the authentication process that was
      * previously deferred through a call to defer(). Once invoked, the
      * provided value ceases to be valid for future calls to resume().
@@ -82,10 +96,10 @@
      *     was invoked, or null if the session is no longer valid or no such
      *     value was returned by defer().
      */
-    public AuthenticationSession resume(String identifier) {
+    public T resume(String identifier) {
 
         if (identifier != null) {
-            AuthenticationSession session = sessions.remove(identifier);
+            T session = sessions.remove(identifier);
             if (session != null && session.isValid())
                 return session;
         }
@@ -95,54 +109,51 @@
     }
 
     /**
-     * Returns the identity finally asserted by the SAML IdP at the end of the
-     * authentication process represented by the authentication session with
-     * the given identifier. If there is no such authentication session, or no
-     * valid identity has been asserted by the SAML IdP for that session, null
-     * is returned.
-     *
-     * @param identifier
-     *     The unique string returned by the call to defer(). For convenience,
-     *     this value may safely be null.
-     *
-     * @return
-     *     The identity finally asserted by the SAML IdP at the end of the
-     *     authentication process represented by the authentication session
-     *     with the given identifier, or null if there is no such identity.
-     */
-    public AssertedIdentity getIdentity(String identifier) {
-
-        AuthenticationSession session = resume(identifier);
-        if (session != null)
-            return session.getIdentity();
-
-        return null;
-
-    }
-
-    /**
      * Defers the Guacamole side of authentication for the user having the
      * given authentication session such that it may be later resumed through a
      * call to resume(). If authentication is never resumed, the session will
      * automatically be cleaned up after it ceases to be valid.
      *
+     * This method will automatically generate a new identifier.
+     *
      * @param session
-     *     The {@link AuthenticationSession} representing the in-progress SAML
+     *     The {@link AuthenticationSession} representing the in-progress
      *     authentication attempt.
      *
      * @return
      *     A unique and unpredictable string that may be used to represent the
      *     given session when calling resume().
      */
-    public String defer(AuthenticationSession session) {
+    public String defer(T session) {
         String identifier = idGenerator.generateIdentifier();
         sessions.put(identifier, session);
         return identifier;
     }
 
     /**
+     * Defers the Guacamole side of authentication for the user having the
+     * given authentication session such that it may be later resumed through a
+     * call to resume(). If authentication is never resumed, the session will
+     * automatically be cleaned up after it ceases to be valid.
+     *
+     * This method accepts an externally generated ID, which should be a UUID
+     * or similar unique identifier.
+     *
+     * @param session
+     *     The {@link AuthenticationSession} representing the in-progress
+     *     authentication attempt.
+     *
+     * @param identifier
+     *     A unique and unpredictable string that may be used to represent the
+     *     given session when calling resume().
+     */
+    public void defer(T session, String identifier) {
+        sessions.put(identifier, session);
+    }
+
+    /**
      * Shuts down the executor service that periodically removes all invalid
-     * authentication sessions. This must be invoked when the SAML extension is
+     * authentication sessions. This must be invoked when the auth extension is
      * shut down in order to avoid resource leaks.
      */
     public void shutdown() {
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/java/org/apache/guacamole/auth/sso/IdentifierGenerator.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/java/org/apache/guacamole/auth/sso/IdentifierGenerator.java
new file mode 100644
index 0000000..82538c6
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/java/org/apache/guacamole/auth/sso/IdentifierGenerator.java
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.sso;
+
+import com.google.common.io.BaseEncoding;
+import com.google.inject.Singleton;
+import java.math.BigInteger;
+import java.security.SecureRandom;
+
+/**
+ * Generator of unique and unpredictable identifiers. Each generated identifier
+ * is an arbitrary, random string produced using a cryptographically-secure
+ * random number generator.
+ */
+@Singleton
+public class IdentifierGenerator {
+
+    /**
+     * Cryptographically-secure random number generator for generating unique
+     * identifiers.
+     */
+    private final SecureRandom secureRandom = new SecureRandom();
+
+    /**
+     * Generates a unique and unpredictable identifier. Each identifier is at
+     * least 256-bit and produced using a cryptographically-secure random
+     * number generator. The identifier may contain characters that differ only
+     * in case.
+     *
+     * @return
+     *     A unique and unpredictable identifier with at least 256 bits of
+     *     entropy.
+     */
+    public String generateIdentifier() {
+        return generateIdentifier(256);
+    }
+
+    /**
+     * Generates a unique and unpredictable identifier having at least the
+     * given number of bits of entropy. The resulting identifier may have more
+     * than the number of bits required. The identifier may contain characters
+     * that differ only in case.
+     *
+     * @param minBits
+     *     The number of bits of entropy that the identifier should contain.
+     *
+     * @return
+     *     A unique and unpredictable identifier with at least the given number
+     *     of bits of entropy.
+     */
+    public String generateIdentifier(int minBits) {
+        return generateIdentifier(minBits, true);
+    }
+
+    /**
+     * Generates a unique and unpredictable identifier having at least the
+     * given number of bits of entropy. The resulting identifier may have more
+     * than the number of bits required. The identifier may contain characters
+     * that differ only in case.
+     *
+     * @param minBits
+     *     The number of bits of entropy that the identifier should contain.
+     *
+     * @param caseSensitive
+     *     Whether identifiers are permitted to contain characters that vary
+     *     by case. If false, all characters that may vary by case will be
+     *     lowercase, and the generated identifier will be longer.
+     *
+     * @return
+     *     A unique and unpredictable identifier with at least the given number
+     *     of bits of entropy.
+     */
+    public String generateIdentifier(int minBits, boolean caseSensitive) {
+
+        // Generate a base64 identifier if we're allowed to vary by case
+        if (caseSensitive) {
+            int minBytes = (minBits + 23) / 24 * 3; // Round up to nearest multiple of 3 bytes, as base64 encodes blocks of 3 bytes at a time
+            byte[] bytes = new byte[minBytes];
+            secureRandom.nextBytes(bytes);
+            return BaseEncoding.base64().encode(bytes);
+        }
+
+        // Generate base32 identifiers if we cannot vary by case
+        minBits = (minBits + 4) / 5 * 5; // Round up to nearest multiple of 5 bits, as base32 encodes 5 bits at a time
+        return new BigInteger(minBits, secureRandom).toString(32);
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/token/NonceService.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/java/org/apache/guacamole/auth/sso/NonceService.java
similarity index 80%
rename from extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/token/NonceService.java
rename to extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/java/org/apache/guacamole/auth/sso/NonceService.java
index 778112a..5717794 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/token/NonceService.java
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/java/org/apache/guacamole/auth/sso/NonceService.java
@@ -17,33 +17,32 @@
  * under the License.
  */
 
-package org.apache.guacamole.auth.openid.token;
+package org.apache.guacamole.auth.sso;
 
-import com.google.inject.Singleton;
-import java.math.BigInteger;
-import java.security.SecureRandom;
+import com.google.inject.Inject;
 import java.util.Iterator;
+import java.util.Locale;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 
 /**
  * Service for generating and validating single-use random tokens (nonces).
+ * Each generated nonce is at least 128 bits and case-insensitive.
  */
-@Singleton
 public class NonceService {
 
     /**
-     * Cryptographically-secure random number generator for generating the
-     * required nonce.
+     * Generator of arbitrary, unique, unpredictable identifiers.
      */
-    private final SecureRandom random = new SecureRandom();
+    @Inject
+    private IdentifierGenerator idGenerator;
 
     /**
      * Map of all generated nonces to their corresponding expiration timestamps.
      * This Map must be periodically swept of expired nonces to avoid growing
      * without bound.
      */
-    private final Map<String, Long> nonces = new ConcurrentHashMap<String, Long>();
+    private final Map<String, Long> nonces = new ConcurrentHashMap<>();
 
     /**
      * The timestamp of the last expired nonce sweep.
@@ -51,6 +50,11 @@
     private long lastSweep = System.currentTimeMillis();
 
     /**
+     * The minimum number of bits of entropy to include in each nonce.
+     */
+    private static final int NONCE_BITS = 128;
+
+    /**
      * The minimum amount of time to wait between sweeping expired nonces from
      * the Map.
      */
@@ -94,7 +98,8 @@
      *     valid, in milliseconds.
      *
      * @return
-     *     A cryptographically-secure nonce value.
+     *     A cryptographically-secure nonce value. Generated nonces are at
+     *     least 128-bit and are case-insensitive.
      */
     public String generate(long maxAge) {
 
@@ -102,7 +107,7 @@
         sweepExpiredNonces();
 
         // Generate and store nonce, along with expiration timestamp
-        String nonce = new BigInteger(130, random).toString(32);
+        String nonce = idGenerator.generateIdentifier(NONCE_BITS, false);
         nonces.put(nonce, System.currentTimeMillis() + maxAge);
         return nonce;
 
@@ -115,15 +120,20 @@
      * invalidates that nonce.
      *
      * @param nonce
-     *     The nonce value to test.
+     *     The nonce value to test. This value may be null, which will be
+     *     considered an invalid nonce. Comparisons are case-insensitive.
      *
      * @return
      *     true if the provided nonce is valid, false otherwise.
      */
     public boolean isValid(String nonce) {
 
+        // All null nonces are invalid.
+        if (nonce == null)
+            return false;
+
         // Remove nonce, verifying whether it was present at all
-        Long expires = nonces.remove(nonce);
+        Long expires = nonces.remove(nonce.toLowerCase(Locale.US));
         if (expires == null)
             return false;
 
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/resources/html/sso-providers.html b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/resources/html/sso-providers.html
index d089ea2..309d1bb 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/resources/html/sso-providers.html
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/resources/html/sso-providers.html
@@ -1,4 +1,4 @@
-<meta name="after" content=".login-ui .login-dialog-middle">
+<meta name="after" content=".login-ui .login-dialog-middle:not(:has(~ .sso-providers))">
 <div class="sso-providers">
     {{ 'LOGIN.SECTION_HEADER_SSO_OPTIONS' | translate }}
     <ul class="sso-provider-list"></ul>
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/resources/styles/sso-providers.css b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/resources/styles/sso-providers.css
index 0a97446..5f8b49a 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/resources/styles/sso-providers.css
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/resources/styles/sso-providers.css
@@ -24,12 +24,6 @@
     bottom: 0;
     left: 0;
 
-    display: none;
-
-}
-
-.login-ui .sso-providers:last-child {
-    display: block;
 }
 
 .sso-providers ul {
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/resources/translations/en.json b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/resources/translations/en.json
index 8593015..085414b 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/resources/translations/en.json
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/resources/translations/en.json
@@ -12,14 +12,20 @@
         "NAME" : "SAML SSO Backend"
     },
 
+    "DATA_SOURCE_SSL" : {
+        "NAME" : "SSL/TLS SSO Backend"
+    },
+
     "LOGIN" : {
         "FIELD_HEADER_ID_TOKEN"      : "",
         "FIELD_HEADER_STATE"         : "",
         "FIELD_HEADER_TICKET"        : "",
         "INFO_IDP_REDIRECT_PENDING"  : "Please wait, redirecting to identity provider...",
+        "INFO_REDIRECT_PENDING"      : "Please wait while you are redirected...",
         "NAME_IDP_CAS"               : "CAS",
         "NAME_IDP_OPENID"            : "OpenID",
         "NAME_IDP_SAML"              : "SAML",
+        "NAME_IDP_SSL"               : "Certificate / Smart Card",
         "SECTION_HEADER_SSO_OPTIONS" : "Sign in with:"
     }
 
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/resources/translations/pl.json b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/resources/translations/pl.json
new file mode 100644
index 0000000..9e391ec
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-base/src/main/resources/translations/pl.json
@@ -0,0 +1,26 @@
+{
+
+    "DATA_SOURCE_CAS" : {
+        "NAME" : "CAS SSO Backend"
+    },
+
+    "DATA_SOURCE_OPENID" : {
+        "NAME" : "OpenID SSO Backend"
+    },
+
+    "DATA_SOURCE_SAML" : {
+        "NAME" : "SAML SSO Backend"
+    },
+
+    "LOGIN" : {
+        "FIELD_HEADER_ID_TOKEN"      : "",
+        "FIELD_HEADER_STATE"         : "",
+        "FIELD_HEADER_TICKET"        : "",
+        "INFO_IDP_REDIRECT_PENDING"  : "Proszę czekać, przekierowanie do dostawcy tożsamości...",
+        "NAME_IDP_CAS"               : "CAS",
+        "NAME_IDP_OPENID"            : "OpenID",
+        "NAME_IDP_SAML"              : "SAML",
+        "SECTION_HEADER_SSO_OPTIONS" : "Zaloguj z:"
+    }
+
+}
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-cas/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-cas/src/main/resources/guac-manifest.json
index e014649..7ede8bf 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-cas/src/main/resources/guac-manifest.json
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-cas/src/main/resources/guac-manifest.json
@@ -25,6 +25,7 @@
         "translations/fr.json",
         "translations/ja.json",
         "translations/ko.json",
+        "translations/pl.json",
         "translations/pt.json",
         "translations/ru.json",
         "translations/zh.json"
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-cas/src/main/resources/html/sso-provider-cas.html b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-cas/src/main/resources/html/sso-provider-cas.html
index 348da10..6fcf14e 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-cas/src/main/resources/html/sso-provider-cas.html
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-cas/src/main/resources/html/sso-provider-cas.html
@@ -1,4 +1,4 @@
-<meta name="after-children" content=".login-ui .sso-provider-list:last-child">
+<meta name="after-children" content=".login-ui .sso-provider-list">
 <li class="sso-provider sso-provider-cas"><a href="api/ext/cas/login">{{
     'LOGIN.NAME_IDP_CAS' | translate
 }}</a></li>
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-dist/pom.xml b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-dist/pom.xml
index 1e10fb5..be3b4af 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-dist/pom.xml
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-dist/pom.xml
@@ -59,6 +59,13 @@
             <version>1.5.2</version>
         </dependency>
 
+        <!-- SSL Authentication Extension -->
+        <dependency>
+            <groupId>org.apache.guacamole</groupId>
+            <artifactId>guacamole-auth-sso-ssl</artifactId>
+            <version>1.5.2</version>
+        </dependency>
+
     </dependencies>
 
     <build>
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-dist/src/main/assembly/dist.xml b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-dist/src/main/assembly/dist.xml
index f122c8d..066da29 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-dist/src/main/assembly/dist.xml
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-dist/src/main/assembly/dist.xml
@@ -60,6 +60,15 @@
             </includes>
         </dependencySet>
 
+        <!-- SSL extension .jar -->
+        <dependencySet>
+            <outputDirectory>ssl</outputDirectory>
+            <useProjectArtifact>false</useProjectArtifact>
+            <includes>
+                <include>org.apache.guacamole:guacamole-auth-sso-ssl</include>
+            </includes>
+        </dependencySet>
+
     </dependencySets>
 
     <!-- Include extension licenses -->
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/AuthenticationProviderService.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/AuthenticationProviderService.java
index 23ac815..e83ca09 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/AuthenticationProviderService.java
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/AuthenticationProviderService.java
@@ -29,9 +29,9 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.ws.rs.core.UriBuilder;
 import org.apache.guacamole.auth.openid.conf.ConfigurationService;
-import org.apache.guacamole.auth.openid.token.NonceService;
 import org.apache.guacamole.auth.openid.token.TokenValidationService;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.sso.NonceService;
 import org.apache.guacamole.auth.sso.SSOAuthenticationProviderService;
 import org.apache.guacamole.auth.sso.user.SSOAuthenticatedUser;
 import org.apache.guacamole.form.Field;
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java
index dde4ef2..2fce2a7 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/OpenIDAuthenticationProviderModule.java
@@ -20,8 +20,9 @@
 package org.apache.guacamole.auth.openid;
 
 import com.google.inject.AbstractModule;
+import com.google.inject.Scopes;
 import org.apache.guacamole.auth.openid.conf.ConfigurationService;
-import org.apache.guacamole.auth.openid.token.NonceService;
+import org.apache.guacamole.auth.sso.NonceService;
 import org.apache.guacamole.auth.openid.token.TokenValidationService;
 
 /**
@@ -32,7 +33,7 @@
     @Override
     protected void configure() {
         bind(ConfigurationService.class);
-        bind(NonceService.class);
+        bind(NonceService.class).in(Scopes.SINGLETON);
         bind(TokenValidationService.class);
     }
 
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/token/TokenValidationService.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/token/TokenValidationService.java
index 72200df..b9c2add 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/token/TokenValidationService.java
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/java/org/apache/guacamole/auth/openid/token/TokenValidationService.java
@@ -26,6 +26,7 @@
 import java.util.Set;
 import org.apache.guacamole.auth.openid.conf.ConfigurationService;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.sso.NonceService;
 import org.jose4j.jwk.HttpsJwks;
 import org.jose4j.jwt.JwtClaims;
 import org.jose4j.jwt.MalformedClaimException;
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/resources/guac-manifest.json
index 19e8401..cb174f6 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/resources/guac-manifest.json
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/resources/guac-manifest.json
@@ -25,6 +25,7 @@
         "translations/fr.json",
         "translations/ja.json",
         "translations/ko.json",
+        "translations/pl.json",
         "translations/pt.json",
         "translations/ru.json",
         "translations/zh.json"
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/resources/html/sso-provider-openid.html b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/resources/html/sso-provider-openid.html
index 0da260f..5325e63 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/resources/html/sso-provider-openid.html
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-openid/src/main/resources/html/sso-provider-openid.html
@@ -1,4 +1,4 @@
-<meta name="after-children" content=".login-ui .sso-provider-list:last-child">
+<meta name="after-children" content=".login-ui .sso-provider-list">
 <li class="sso-provider sso-provider-openid"><a href="api/ext/openid/login">{{
     'LOGIN.NAME_IDP_OPENID' | translate
 }}</a></li>
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/AuthenticationProviderService.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/AuthenticationProviderService.java
index cdd53de..982028f 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/AuthenticationProviderService.java
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/AuthenticationProviderService.java
@@ -28,7 +28,7 @@
 import org.apache.guacamole.auth.saml.user.SAMLAuthenticatedUser;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.auth.saml.acs.AssertedIdentity;
-import org.apache.guacamole.auth.saml.acs.AuthenticationSessionManager;
+import org.apache.guacamole.auth.saml.acs.SAMLAuthenticationSessionManager;
 import org.apache.guacamole.auth.saml.acs.SAMLService;
 import org.apache.guacamole.auth.sso.SSOAuthenticationProviderService;
 import org.apache.guacamole.form.Field;
@@ -61,7 +61,7 @@
      * Manager of active SAML authentication attempts.
      */
     @Inject
-    private AuthenticationSessionManager sessionManager;
+    private SAMLAuthenticationSessionManager sessionManager;
 
     /**
      * Service for processing SAML requests/responses.
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLAuthenticationProviderModule.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLAuthenticationProviderModule.java
index 3c7300b..7c7dd49 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLAuthenticationProviderModule.java
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/SAMLAuthenticationProviderModule.java
@@ -22,8 +22,7 @@
 import com.google.inject.AbstractModule;
 import org.apache.guacamole.auth.saml.conf.ConfigurationService;
 import org.apache.guacamole.auth.saml.acs.AssertionConsumerServiceResource;
-import org.apache.guacamole.auth.saml.acs.AuthenticationSessionManager;
-import org.apache.guacamole.auth.saml.acs.IdentifierGenerator;
+import org.apache.guacamole.auth.saml.acs.SAMLAuthenticationSessionManager;
 import org.apache.guacamole.auth.saml.acs.SAMLService;
 
 /**
@@ -34,9 +33,8 @@
     @Override
     protected void configure() {
         bind(AssertionConsumerServiceResource.class);
-        bind(AuthenticationSessionManager.class);
         bind(ConfigurationService.class);
-        bind(IdentifierGenerator.class);
+        bind(SAMLAuthenticationSessionManager.class);
         bind(SAMLService.class);
     }
 
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AssertionConsumerServiceResource.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AssertionConsumerServiceResource.java
index e316b38..7aadc73 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AssertionConsumerServiceResource.java
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AssertionConsumerServiceResource.java
@@ -56,7 +56,7 @@
      * Manager of active SAML authentication attempts.
      */
     @Inject
-    private AuthenticationSessionManager sessionManager;
+    private SAMLAuthenticationSessionManager sessionManager;
 
     /**
      * Service for processing SAML requests/responses.
@@ -107,7 +107,7 @@
         try {
 
             // Validate and parse identity asserted by SAML IdP
-            AuthenticationSession session = saml.processResponse(
+            SAMLAuthenticationSession session = saml.processResponse(
                     consumedRequest.getRequestURL().toString(),
                     relayState, samlResponse);
 
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/IdentifierGenerator.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/IdentifierGenerator.java
deleted file mode 100644
index a2a3aae..0000000
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/IdentifierGenerator.java
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package org.apache.guacamole.auth.saml.acs;
-
-import com.google.common.io.BaseEncoding;
-import com.google.inject.Singleton;
-import java.security.SecureRandom;
-
-/**
- * Generator of unique and unpredictable identifiers. Each generated identifier
- * is an arbitrary, random string produced using a cryptographically-secure
- * random number generator and consists of at least 256 bits.
- */
-@Singleton
-public class IdentifierGenerator {
-
-    /**
-     * Cryptographically-secure random number generator for generating unique
-     * identifiers.
-     */
-    private final SecureRandom secureRandom = new SecureRandom();
-
-    /**
-     * Generates a unique and unpredictable identifier. Each identifier is at
-     * least 256-bit and produced using a cryptographically-secure random
-     * number generator.
-     *
-     * @return
-     *     A unique and unpredictable identifier.
-     */
-    public String generateIdentifier() {
-        byte[] bytes = new byte[33];
-        secureRandom.nextBytes(bytes);
-        return BaseEncoding.base64().encode(bytes);
-    }
-
-}
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AuthenticationSession.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/SAMLAuthenticationSession.java
similarity index 77%
rename from extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AuthenticationSession.java
rename to extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/SAMLAuthenticationSession.java
index b73bc7a..bbd74e2 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/AuthenticationSession.java
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/SAMLAuthenticationSession.java
@@ -19,17 +19,12 @@
 
 package org.apache.guacamole.auth.saml.acs;
 
+import org.apache.guacamole.auth.sso.AuthenticationSession;
+
 /**
  * Representation of an in-progress SAML authentication attempt.
  */
-public class AuthenticationSession {
-
-    /**
-     * The absolute point in time after which this authentication session is
-     * invalid. This value is a UNIX epoch timestamp, as may be returned by
-     * {@link System#currentTimeMillis()}.
-     */
-    private final long expirationTimestamp;
+public class SAMLAuthenticationSession extends AuthenticationSession {
 
     /**
      * The request ID of the SAML request associated with the authentication
@@ -55,24 +50,21 @@
      *     The number of milliseconds that may elapse before this session must
      *     be considered invalid.
      */
-    public AuthenticationSession(String requestId, long expires) {
-        this.expirationTimestamp = System.currentTimeMillis() + expires;
+    public SAMLAuthenticationSession(String requestId, long expires) {
+        super(expires);
         this.requestId = requestId;
     }
 
     /**
-     * Returns whether this authentication session is still valid (has not yet
-     * expired). If an identity has been asserted by the SAML IdP, this
+     * {@inheritDoc}
+     *
+     * <p>If an identity has been asserted by the SAML IdP, this
      * considers also whether the SAML response asserting that identity has
      * expired.
-     *
-     * @return
-     *     true if this authentication session is still valid, false if it has
-     *     expired.
      */
+    @Override
     public boolean isValid() {
-        return System.currentTimeMillis() < expirationTimestamp
-                && (identity == null || identity.isValid());
+        return super.isValid() && (identity == null || identity.isValid());
     }
 
     /**
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/SAMLAuthenticationSessionManager.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/SAMLAuthenticationSessionManager.java
new file mode 100644
index 0000000..4adf82f
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/SAMLAuthenticationSessionManager.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.saml.acs;
+
+import com.google.inject.Singleton;
+import org.apache.guacamole.auth.sso.AuthenticationSessionManager;
+
+/**
+ * Manager service that temporarily stores SAML authentication attempts while
+ * the authentication flow is underway.
+ */
+@Singleton
+public class SAMLAuthenticationSessionManager
+        extends AuthenticationSessionManager<SAMLAuthenticationSession> {
+
+    /**
+     * Returns the identity finally asserted by the SAML IdP at the end of the
+     * authentication process represented by the authentication session with
+     * the given identifier. If there is no such authentication session, or no
+     * valid identity has been asserted by the SAML IdP for that session, null
+     * is returned.
+     *
+     * @param identifier
+     *     The unique string returned by the call to defer(). For convenience,
+     *     this value may safely be null.
+     *
+     * @return
+     *     The identity finally asserted by the SAML IdP at the end of the
+     *     authentication process represented by the authentication session
+     *     with the given identifier, or null if there is no such identity.
+     */
+    public AssertedIdentity getIdentity(String identifier) {
+
+        SAMLAuthenticationSession session = resume(identifier);
+        if (session != null)
+            return session.getIdentity();
+
+        return null;
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/SAMLService.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/SAMLService.java
index bd94520..2fe6da4 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/SAMLService.java
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/acs/SAMLService.java
@@ -21,7 +21,8 @@
 
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import com.onelogin.saml2.authn.AuthnRequest;
+import com.onelogin.saml2.Auth;
+import com.onelogin.saml2.authn.AuthnRequestParams;
 import com.onelogin.saml2.authn.SamlResponse;
 import com.onelogin.saml2.exception.SettingsException;
 import com.onelogin.saml2.exception.ValidationError;
@@ -29,13 +30,13 @@
 import java.io.IOException;
 import java.net.URI;
 import java.net.URISyntaxException;
-import javax.ws.rs.core.UriBuilder;
 import javax.xml.parsers.ParserConfigurationException;
 import javax.xml.xpath.XPathExpressionException;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleSecurityException;
 import org.apache.guacamole.GuacamoleServerException;
 import org.apache.guacamole.auth.saml.conf.ConfigurationService;
+import org.apache.guacamole.auth.sso.IdentifierGenerator;
 import org.xml.sax.SAXException;
 
 /**
@@ -55,7 +56,13 @@
      * Manager of active SAML authentication attempts.
      */
     @Inject
-    private AuthenticationSessionManager sessionManager;
+    private SAMLAuthenticationSessionManager sessionManager;
+
+    /**
+     * Generator of arbitrary, unique, unpredictable identifiers.
+     */
+    @Inject
+    private IdentifierGenerator idGenerator;
 
     /**
      * Creates a new SAML request, beginning the overall authentication flow
@@ -75,20 +82,31 @@
     public URI createRequest() throws GuacamoleException {
 
         Saml2Settings samlSettings = confService.getSamlSettings();
-        AuthnRequest samlReq = new AuthnRequest(samlSettings);
-
-        // Create a new authentication session to represent this attempt while
-        // it is in progress
-        AuthenticationSession session = new AuthenticationSession(samlReq.getId(),
-                confService.getAuthenticationTimeout() * 60000L);
 
         // Produce redirect for continuing the authentication process with
         // the SAML IdP
         try {
-            return UriBuilder.fromUri(samlSettings.getIdpSingleSignOnServiceUrl().toURI())
-                    .queryParam("SAMLRequest", samlReq.getEncodedAuthnRequest())
-                    .queryParam("RelayState", sessionManager.defer(session))
-                    .build();
+            Auth auth = new Auth(samlSettings, null, null);
+
+            // Generate a unique ID to use for the relay state
+            String identifier = idGenerator.generateIdentifier();
+
+            // Create the request URL for the SAML IdP
+            String requestUrl = auth.login(
+                    identifier,
+                    new AuthnRequestParams(false, false, true),
+                    true);
+
+            // Create a new authentication session to represent this attempt while
+            // it is in progress, using the request ID that was just issued
+            SAMLAuthenticationSession session = new SAMLAuthenticationSession(
+                    auth.getLastRequestId(),
+                    confService.getAuthenticationTimeout() * 60000L);
+
+            // Save the session with the unique relay state ID
+            sessionManager.defer(session, identifier);
+
+            return new URI(requestUrl);
         }
         catch (IOException e) {
             throw new GuacamoleServerException("SAML authentication request "
@@ -99,12 +117,17 @@
                     + "be generated due to an error in the URI syntax: "
                     + e.getMessage());
         }
+        catch (SettingsException e) {
+            throw new GuacamoleServerException("Error while attempting to sign "
+                    + "request using provided private key / certificate: "
+                    + e.getMessage(), e);
+        }
 
     }
 
     /**
      * Processes the given SAML response, as received by the SAML ACS endpoint
-     * at the given URL, producing an {@link AuthenticationSession} that now
+     * at the given URL, producing an {@link SAMLAuthenticationSession} that now
      * includes a valid assertion of the user's identity. If the SAML response
      * is invalid in any way, an exception is thrown.
      *
@@ -125,7 +148,7 @@
      *     given URL.
      *
      * @return
-     *     The {@link AuthenticationSession} associated with the in-progress
+     *     The {@link SAMLAuthenticationSession} associated with the in-progress
      *     authentication attempt, now associated with the {@link AssertedIdentity}
      *     representing the identity of the user asserted by the SAML IdP.
      *
@@ -134,14 +157,14 @@
      *     information required to validate or decrypt the response cannot be
      *     read.
      */
-    public AuthenticationSession processResponse(String url, String relayState,
+    public SAMLAuthenticationSession processResponse(String url, String relayState,
             String encodedResponse) throws GuacamoleException {
 
         if (relayState == null)
             throw new GuacamoleSecurityException("\"RelayState\" value "
                     + "is missing from SAML response.");
 
-        AuthenticationSession session = sessionManager.resume(relayState);
+        SAMLAuthenticationSession session = sessionManager.resume(relayState);
         if (session == null)
             throw new GuacamoleSecurityException("\"RelayState\" value "
                     + "included with SAML response is not valid.");
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/conf/ConfigurationService.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/conf/ConfigurationService.java
index 0487737..d2a73c4 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/conf/ConfigurationService.java
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/java/org/apache/guacamole/auth/saml/conf/ConfigurationService.java
@@ -24,7 +24,12 @@
 import com.onelogin.saml2.settings.Saml2Settings;
 import com.onelogin.saml2.settings.SettingsBuilder;
 import com.onelogin.saml2.util.Constants;
+
+import java.io.File;
+import java.io.IOException;
 import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
 import java.util.HashMap;
 import java.util.Map;
 import javax.ws.rs.core.UriBuilder;
@@ -32,6 +37,7 @@
 import org.apache.guacamole.GuacamoleServerException;
 import org.apache.guacamole.environment.Environment;
 import org.apache.guacamole.properties.BooleanGuacamoleProperty;
+import org.apache.guacamole.properties.FileGuacamoleProperty;
 import org.apache.guacamole.properties.IntegerGuacamoleProperty;
 import org.apache.guacamole.properties.StringGuacamoleProperty;
 import org.apache.guacamole.properties.URIGuacamoleProperty;
@@ -162,6 +168,30 @@
     };
 
     /**
+     * The file containing the X.509 cert to use when signing or encrypting
+     * requests to the SAML IdP.
+     */
+    private static final FileGuacamoleProperty SAML_X509_CERT_PATH =
+            new FileGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "saml-x509-cert-path"; }
+
+    };
+
+    /**
+     * The file containing the private key to use when signing or encrypting
+     * requests to the SAML IdP.
+     */
+    private static final FileGuacamoleProperty SAML_PRIVATE_KEY_PATH =
+            new FileGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "saml-private-key-path"; }
+
+    };
+
+    /**
      * The Guacamole server environment.
      */
     @Inject
@@ -330,6 +360,70 @@
     }
 
     /**
+     * Returns the file containing the X.509 certificate to use when signing
+     * requests to the SAML IdP. If the property is not set, null will be
+     * returned.
+     *
+     * @return
+     *     The file containing the X.509 certificate to use when signing
+     *     requests to the SAML IdP, or null if not defined.
+     *
+     * @throws GuacamoleException
+     *     If the X.509 certificate cannot be parsed.
+     */
+    public File getCertificateFile() throws GuacamoleException {
+        return environment.getProperty(SAML_X509_CERT_PATH);
+    }
+
+    /**
+     * Returns the file containing the private key to use when signing
+     * requests to the SAML IdP. If the property is not set, null will be
+     * returned.
+     *
+     * @return
+     *     The file containing the private key to use when signing
+     *     requests to the SAML IdP, or null if not defined.
+     *
+     * @throws GuacamoleException
+     *     If the private key file cannot be parsed.
+     */
+    public File getPrivateKeyFile() throws GuacamoleException {
+        return environment.getProperty(SAML_PRIVATE_KEY_PATH);
+    }
+
+    /**
+     * Returns the contents of a small file, such as a private key or certificate into
+     * a String. If the file does not exist, or cannot be read for any reason, an exception
+     * will be thrown with the details of the failure.
+     *
+     * @param file
+     *     The file to read into a string.
+     *
+     * @param name
+     *     A human-readable name for the file, to be used when formatting log messages.
+     *
+     * @return
+     *     The contents of the file having the given path.
+     *
+     * @throws GuacamoleException
+     *     If the provided file does not exist, or cannot be read for any reason.
+     */
+    private String readFileContentsIntoString(File file, String name) throws GuacamoleException {
+
+        // Attempt to read the file directly into a String
+        try {
+            return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
+        }
+
+        // If the file cannot be read, log a warning and treat it as if it does not exist
+        catch (IOException e) {
+            throw new GuacamoleServerException(
+                    name + " at \"" + file.getAbsolutePath() + "\" could not be read.", e);
+        }
+
+    }
+
+    /**
      * Returns the collection of SAML settings used to initialize the client.
      *
      * @return
@@ -380,13 +474,33 @@
                     UriBuilder.fromUri(getCallbackUrl()).path("api/ext/saml/callback").build().toString());
         }
 
+        // If a private key file is set, load the value into the builder now
+        File privateKeyFile = getPrivateKeyFile();
+        if (privateKeyFile != null)
+            samlMap.put(SettingsBuilder.SP_PRIVATEKEY_PROPERTY_KEY,
+                    readFileContentsIntoString(privateKeyFile, "Private Key"));
+
+        // If a certificate file is set, load the value into the builder now
+        File certificateFile = getCertificateFile();
+        if (certificateFile != null)
+            samlMap.put(SettingsBuilder.SP_X509CERT_PROPERTY_KEY,
+                    readFileContentsIntoString(certificateFile, "X.509 Certificate"));
+
         SettingsBuilder samlBuilder = new SettingsBuilder();
         Saml2Settings samlSettings = samlBuilder.fromValues(samlMap).build();
         samlSettings.setStrict(getStrict());
         samlSettings.setDebug(getDebug());
         samlSettings.setCompressRequest(getCompressRequest());
         samlSettings.setCompressResponse(getCompressResponse());
-    
+
+        // Request that the SAML library sign everything that it can, if
+        // both private key and certificate are specified
+        if (privateKeyFile != null && certificateFile != null) {
+            samlSettings.setAuthnRequestsSigned(true);
+            samlSettings.setLogoutRequestSigned(true);
+            samlSettings.setLogoutResponseSigned(true);
+        }
+
         return samlSettings;
     }
 
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/resources/guac-manifest.json
index 25ba26d..d867537 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/resources/guac-manifest.json
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/resources/guac-manifest.json
@@ -25,6 +25,7 @@
         "translations/fr.json",
         "translations/ja.json",
         "translations/ko.json",
+        "translations/pl.json",
         "translations/pt.json",
         "translations/ru.json",
         "translations/zh.json"
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/resources/html/sso-provider-saml.html b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/resources/html/sso-provider-saml.html
index 93a7704..734f7f4 100644
--- a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/resources/html/sso-provider-saml.html
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-saml/src/main/resources/html/sso-provider-saml.html
@@ -1,4 +1,4 @@
-<meta name="after-children" content=".login-ui .sso-provider-list:last-child">
+<meta name="after-children" content=".login-ui .sso-provider-list">
 <li class="sso-provider sso-provider-saml"><a href="api/ext/saml/login">{{
     'LOGIN.NAME_IDP_SAML' | translate
 }}</a></li>
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/.gitignore b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/.gitignore
new file mode 100644
index 0000000..30eb487
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/.gitignore
@@ -0,0 +1,3 @@
+*~
+target/
+src/main/resources/generated/
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/.ratignore b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/.ratignore
new file mode 100644
index 0000000..da318d1
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/.ratignore
@@ -0,0 +1 @@
+src/main/resources/html/*.html
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/pom.xml b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/pom.xml
new file mode 100644
index 0000000..2255d37
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/pom.xml
@@ -0,0 +1,131 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you under the Apache License, Version 2.0 (the
+    "License"); you may not use this file except in compliance
+    with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing,
+    software distributed under the License is distributed on an
+    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+    KIND, either express or implied.  See the License for the
+    specific language governing permissions and limitations
+    under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
+                        http://maven.apache.org/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>org.apache.guacamole</groupId>
+    <artifactId>guacamole-auth-sso-ssl</artifactId>
+    <packaging>jar</packaging>
+    <version>1.5.2</version>
+    <name>guacamole-auth-sso-ssl</name>
+    <url>http://guacamole.apache.org/</url>
+
+    <parent>
+        <groupId>org.apache.guacamole</groupId>
+        <artifactId>guacamole-auth-sso</artifactId>
+        <version>1.5.2</version>
+        <relativePath>../../</relativePath>
+    </parent>
+
+    <build>
+        <plugins>
+
+            <!-- JS/CSS Minification Plugin -->
+            <plugin>
+                <groupId>com.github.buckelieg</groupId>
+                <artifactId>minify-maven-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <id>default-cli</id>
+                        <configuration>
+                            <charset>UTF-8</charset>
+
+                            <webappSourceDir>${basedir}/src/main/resources</webappSourceDir>
+                            <webappTargetDir>${project.build.directory}/classes</webappTargetDir>
+
+                            <jsSourceDir>/</jsSourceDir>
+                            <jsTargetDir>/</jsTargetDir>
+                            <jsFinalFile>ssl.js</jsFinalFile>
+
+                            <jsSourceFiles>
+                                <jsSourceFile>license.txt</jsSourceFile>
+                            </jsSourceFiles>
+
+                            <jsSourceIncludes>
+                                <jsSourceInclude>**/*.js</jsSourceInclude>
+                            </jsSourceIncludes>
+
+                            <!-- Do not minify and include tests -->
+                            <jsSourceExcludes>
+                                <jsSourceExclude>**/*.test.js</jsSourceExclude>
+                            </jsSourceExcludes>
+                            <jsEngine>CLOSURE</jsEngine>
+
+                            <!-- Disable warnings for JSDoc annotations -->
+                            <closureWarningLevels>
+                                <misplacedTypeAnnotation>OFF</misplacedTypeAnnotation>
+                                <nonStandardJsDocs>OFF</nonStandardJsDocs>
+                            </closureWarningLevels>
+
+                        </configuration>
+                        <goals>
+                            <goal>minify</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+
+        </plugins>
+    </build>
+    <dependencies>
+
+        <!-- Guacamole Extension API -->
+        <dependency>
+            <groupId>org.apache.guacamole</groupId>
+            <artifactId>guacamole-ext</artifactId>
+        </dependency>
+
+        <!-- Core SSO support -->
+        <dependency>
+            <groupId>org.apache.guacamole</groupId>
+            <artifactId>guacamole-auth-sso-base</artifactId>
+        </dependency>
+
+        <!-- Guice -->
+        <dependency>
+            <groupId>com.google.inject</groupId>
+            <artifactId>guice</artifactId>
+        </dependency>
+
+        <!-- Java servlet API -->
+        <dependency>
+            <groupId>javax.servlet</groupId>
+            <artifactId>servlet-api</artifactId>
+        </dependency>
+
+        <!-- JAX-RS Annotations -->
+        <dependency>
+            <groupId>javax.ws.rs</groupId>
+            <artifactId>jsr311-api</artifactId>
+        </dependency>
+
+        <!-- Use FIPS variant of Bouncy Castle crypto library -->
+        <dependency>
+            <groupId>org.bouncycastle</groupId>
+            <artifactId>bcpkix-fips</artifactId>
+            <version>1.0.7</version>
+        </dependency>
+
+    </dependencies>
+
+</project>
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/AuthenticationProviderService.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/AuthenticationProviderService.java
new file mode 100644
index 0000000..807df0c
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/AuthenticationProviderService.java
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.ssl;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Collections;
+import javax.servlet.http.HttpServletRequest;
+import org.apache.guacamole.auth.ssl.conf.ConfigurationService;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.GuacamoleResourceNotFoundException;
+import org.apache.guacamole.auth.sso.SSOAuthenticationProviderService;
+import org.apache.guacamole.auth.sso.user.SSOAuthenticatedUser;
+import org.apache.guacamole.form.Field;
+import org.apache.guacamole.form.RedirectField;
+import org.apache.guacamole.language.TranslatableMessage;
+import org.apache.guacamole.net.auth.Credentials;
+import org.apache.guacamole.net.auth.credentials.CredentialsInfo;
+import org.apache.guacamole.net.auth.credentials.GuacamoleInvalidCredentialsException;
+
+/**
+ * Service that authenticates Guacamole users using SSL/TLS authentication
+ * provided by an external SSL termination service.
+ */
+@Singleton
+public class AuthenticationProviderService implements SSOAuthenticationProviderService {
+
+    /**
+     * Service for retrieving configuration information.
+     */
+    @Inject
+    private ConfigurationService confService;
+
+    /**
+     * Session manager for generating and maintaining unique tokens to
+     * represent the authentication flow of a user who has only partially
+     * authenticated. Here, these tokens represent a user that has been
+     * validated by SSL termination and allow the Guacamole instance that
+     * doesn't require SSL/TLS authentication to retrieve the user's identity
+     * and complete the authentication process.
+     */
+    @Inject
+    private SSLAuthenticationSessionManager sessionManager;
+
+    /**
+     * Provider for AuthenticatedUser objects.
+     */
+    @Inject
+    private Provider<SSOAuthenticatedUser> authenticatedUserProvider;
+
+    /**
+     * The name of the query parameter containing the temporary session token
+     * representing the current state of an in-progress authentication attempt.
+     */
+    private static final String AUTH_SESSION_PARAMETER_NAME = "state";
+
+    /**
+     * Processes the given HTTP request, returning the identity represented by
+     * the auth session token present in that request. If no such token is
+     * present, or the token does not represent a valid identity, null is
+     * returned.
+     *
+     * @param request
+     *     The HTTP request to process.
+     *
+     * @return
+     *     The identity represented by the auth session token in the request,
+     *     or null if there is no such token or the token does not represent a
+     *     valid identity.
+     */
+    private SSOAuthenticatedUser processIdentity(Credentials credentials, HttpServletRequest request) {
+
+        String state = request.getParameter(AUTH_SESSION_PARAMETER_NAME);
+        String username = sessionManager.getIdentity(state);
+        if (username == null)
+            return null;
+
+        SSOAuthenticatedUser authenticatedUser = authenticatedUserProvider.get();
+        authenticatedUser.init(username, credentials,
+                Collections.emptySet(), Collections.emptyMap());
+        return authenticatedUser;
+
+    }
+
+    @Override
+    public SSOAuthenticatedUser authenticateUser(Credentials credentials)
+            throws GuacamoleException {
+
+        //
+        // Overall flow:
+        //
+        // 1) An unauthenticated user makes a GET request to
+        //    ".../api/ext/ssl/identity". After a series of redirects
+        //    intended to prevent that identity from being inadvertently
+        //    cached and inherited by future authentication attempts on the
+        //    same client machine, an external SSL termination service requests
+        //    and validates the user's certificate, those details are passed
+        //    back to Guacamole via HTTP headers, and Guacamole produces a JSON
+        //    response containing an opaque state value.
+        //
+        // 2) The user (still unauthenticated) resubmits the opaque state
+        //    value from the received JSON as the "state" parameter of a
+        //    standard Guacamole authentication request (".../api/tokens").
+        //
+        // 3) If the certificate received was valid, the user is authenticated
+        //    according to the identity asserted by that certificate. If not,
+        //    authentication is refused.
+        //
+        // NOTE: All SSL termination endpoints in front of Guacamole MUST
+        // be configured to drop these headers from any inbound requests
+        // or users may be able to assert arbitrary identities, since this
+        // extension does not validate anything but the certificate timestamps.
+        // It relies purely on SSL termination to validate that the certificate
+        // was signed by the expected CA.
+        //
+
+        // We can't authenticate using SSL/TLS client auth unless there's an
+        // associated HTTP request
+        HttpServletRequest request = credentials.getRequest();
+        if (request == null)
+            return null;
+
+        // We MUST have the domain associated with the request to ensure we
+        // always get fresh SSL sessions when validating client certificates
+        String host = request.getHeader("Host");
+        if (host == null)
+            return null;
+
+        //
+        // Handle only auth session tokens at the primary URI, using the
+        // pre-verified information from those tokens to determine user
+        // identity.
+        //
+
+        if (confService.isPrimaryHostname(host))
+            return processIdentity(credentials, request);
+
+        // All other requests are not allowed - redirect to proper hostname
+        throw new GuacamoleInvalidCredentialsException("Authentication is "
+                + "only allowed against the primary URL of this Guacamole "
+                + "instance.",
+            new CredentialsInfo(Arrays.asList(new Field[] {
+                new RedirectField("primaryURI", confService.getPrimaryURI(),
+                        new TranslatableMessage("LOGIN.INFO_REDIRECT_PENDING"))
+            }))
+        );
+
+    }
+
+    @Override
+    public URI getLoginURI() throws GuacamoleException {
+        throw new GuacamoleResourceNotFoundException("No such resource.");
+    }
+
+    @Override
+    public void shutdown() {
+        sessionManager.shutdown();
+    }
+
+}
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/OpaqueAuthenticationResult.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/OpaqueAuthenticationResult.java
new file mode 100644
index 0000000..66020f2
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/OpaqueAuthenticationResult.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.guacamole.auth.ssl;
+
+/**
+ * REST API response that reports the result of attempting to authenticate the
+ * user using SSL/TLS client authentication. The information within this
+ * result is intentionally opaque and must be resubmitted in a separate
+ * authentication request for authentication to finally succeed or fail.
+ */
+public class OpaqueAuthenticationResult {
+
+    /**
+     * An arbitrary value representing the result of authenticating the
+     * current user.
+     */
+    private final String state;
+
+    /**
+     * Creates a new OpaqueAuthenticationResult containing the given opaque
+     * state value. Successful authentication results must be indistinguishable
+     * from unsuccessful results with respect to this value. Only using this
+     * value within ANOTHER authentication attempt can determine whether
+     * authentication is successful.
+     *
+     * @param state
+     *     An arbitrary value representing the result of authenticating the
+     *     current user.
+     */
+    public OpaqueAuthenticationResult(String state) {
+        this.state = state;
+    }
+
+    /**
+     * Returns an arbitrary value representing the result of authenticating the
+     * current user. This value may be resubmitted as the "state" parameter of
+     * an authentication request beneath the primary URI of the web application
+     * to finalize the authentication procedure and determine whether the
+     * operation has succeeded or failed.
+     *
+     * @return
+     *     An arbitrary value representing the result of authenticating the
+     *     current user.
+     */
+    public String getState() {
+        return state;
+    }
+
+}
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLAuthenticationProvider.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLAuthenticationProvider.java
new file mode 100644
index 0000000..2458c1a
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLAuthenticationProvider.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.ssl;
+
+import org.apache.guacamole.auth.sso.SSOAuthenticationProvider;
+
+/**
+ * Guacamole authentication backend which authenticates users using SSL/TLS
+ * client authentication provided by some external SSL termination system. This
+ * SSL termination system must be configured to provide access to this same
+ * instance of Guacamole and must have both a wildcard certificate and wildcard
+ * DNS. No storage for connections is provided - only authentication. Storage
+ * must be provided by some other extension.
+ */
+public class SSLAuthenticationProvider extends SSOAuthenticationProvider {
+
+    /**
+     * Creates a new SSLAuthenticationProvider that authenticates users against
+     * an external SSL termination system using SSL/TLS client authentication.
+     */
+    public SSLAuthenticationProvider() {
+        super(AuthenticationProviderService.class, SSLClientAuthenticationResource.class,
+                new SSLAuthenticationProviderModule());
+    }
+
+    @Override
+    public String getIdentifier() {
+        return "ssl";
+    }
+
+}
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLAuthenticationProviderModule.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLAuthenticationProviderModule.java
new file mode 100644
index 0000000..46eeaa9
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLAuthenticationProviderModule.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.ssl;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Scopes;
+import org.apache.guacamole.auth.ssl.conf.ConfigurationService;
+import org.apache.guacamole.auth.sso.NonceService;
+
+/**
+ * Guice module which configures injections specific to SSO using SSL/TLS
+ * client authentication.
+ */
+public class SSLAuthenticationProviderModule extends AbstractModule {
+
+    @Override
+    protected void configure() {
+        bind(ConfigurationService.class);
+        bind(NonceService.class).in(Scopes.SINGLETON);
+        bind(SSLAuthenticationSessionManager.class);
+    }
+
+}
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLAuthenticationSession.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLAuthenticationSession.java
new file mode 100644
index 0000000..4a4c9ce
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLAuthenticationSession.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.ssl;
+
+import org.apache.guacamole.auth.sso.AuthenticationSession;
+
+/**
+ * Representation of an in-progress SSL/TLS authentication attempt.
+ */
+public class SSLAuthenticationSession extends AuthenticationSession {
+
+    /**
+     * The identity asserted by the external SSL termination service.
+     */
+    private final String identity;
+
+    /**
+     * Creates a new AuthenticationSession representing an in-progress SSL/TLS
+     * authentication attempt.
+     *
+     * @param identity
+     *     The identity asserted by the external SSL termination service. This
+     *     MAY NOT be null.
+     *
+     * @param expires
+     *     The number of milliseconds that may elapse before this session must
+     *     be considered invalid.
+     */
+    public SSLAuthenticationSession(String identity, long expires) {
+        super(expires);
+        this.identity = identity;
+    }
+
+    /**
+     * Returns the identity asserted by the external SSL termination service.
+     * As authentication will have completed with respect to the SSL
+     * termination service by the time this session is created, this will
+     * always be non-null.
+     *
+     * @return
+     *     The identity asserted by the external SSL termination service.
+     */
+    public String getIdentity() {
+        return identity;
+    }
+
+}
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLAuthenticationSessionManager.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLAuthenticationSessionManager.java
new file mode 100644
index 0000000..fc1b084
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLAuthenticationSessionManager.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.ssl;
+
+import com.google.inject.Singleton;
+import org.apache.guacamole.auth.sso.AuthenticationSessionManager;
+
+/**
+ * Manager service that temporarily stores SSL/TLS authentication attempts
+ * while the authentication flow is underway.
+ */
+@Singleton
+public class SSLAuthenticationSessionManager
+        extends AuthenticationSessionManager<SSLAuthenticationSession> {
+
+    /**
+     * Returns the identity asserted by the external SSL termination service at
+     * the end of the authentication process represented by the authentication
+     * session with the given identifier. If there is no such authentication
+     * session, or no valid identity has been asserted for that session, null
+     * is returned.
+     *
+     * @param identifier
+     *     The unique string returned by the call to defer(). For convenience,
+     *     this value may safely be null.
+     *
+     * @return
+     *     The identity asserted by the external SSL termination service at the
+     *     end of the authentication process represented by the authentication
+     *     session with the given identifier, or null if there is no such
+     *     identity.
+     */
+    public String getIdentity(String identifier) {
+
+        SSLAuthenticationSession session = resume(identifier);
+        if (session != null)
+            return session.getIdentity();
+
+        return null;
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLClientAuthenticationResource.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLClientAuthenticationResource.java
new file mode 100644
index 0000000..984a68f
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/SSLClientAuthenticationResource.java
@@ -0,0 +1,425 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.guacamole.auth.ssl;
+
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StringReader;
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import javax.naming.InvalidNameException;
+import javax.naming.ldap.LdapName;
+import javax.naming.ldap.Rdn;
+import javax.ws.rs.GET;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.HeaderParam;
+import javax.ws.rs.Path;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.UriBuilder;
+import org.apache.guacamole.GuacamoleClientException;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.GuacamoleResourceNotFoundException;
+import org.apache.guacamole.GuacamoleServerException;
+import org.apache.guacamole.auth.ssl.conf.ConfigurationService;
+import org.apache.guacamole.auth.sso.NonceService;
+import org.apache.guacamole.auth.sso.SSOResource;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x500.style.RFC4519Style;
+import org.bouncycastle.cert.X509CertificateHolder;
+import org.bouncycastle.openssl.PEMParser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * REST API resource that allows the user to retrieve an opaque state value
+ * representing their identity as determined by SSL/TLS client authentication.
+ * The opaque value may represent a valid identity or an authentication
+ * failure, and must be resubmitted within a normal Guacamole authentication
+ * request to finalize the authentication process.
+ */
+public class SSLClientAuthenticationResource extends SSOResource {
+
+    /**
+     * The string value that the SSL termination service uses for its client
+     * verification header to represent that the client certificate has been
+     * verified.
+     */
+    private static final String CLIENT_VERIFIED_HEADER_SUCCESS_VALUE = "SUCCESS";
+
+    /**
+     * The string value that the SSL termination service uses for its client
+     * verification header to represent that the client certificate is absent.
+     */
+    private static final String CLIENT_VERIFIED_HEADER_NONE_VALUE = "NONE";
+
+    /**
+     * The string prefix that the SSL termination service uses for its client
+     * verification header to represent that the client certificate has failed
+     * validation. The error message describing the nature of the failure is
+     * provided by the SSL termination service after this prefix.
+     */
+    private static final String CLIENT_VERIFIED_HEADER_FAILED_PREFIX = "FAILED:";
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(SSLClientAuthenticationResource.class);
+
+    /**
+     * Service for retrieving configuration information.
+     */
+    @Inject
+    private ConfigurationService confService;
+
+    /**
+     * Session manager for generating and maintaining unique tokens to
+     * represent the authentication flow of a user who has only partially
+     * authenticated. Here, these tokens represent a user that has been
+     * validated by SSL termination and allow the Guacamole instance that
+     * doesn't require SSL/TLS authentication to retrieve the user's identity
+     * and complete the authentication process.
+     */
+    @Inject
+    private SSLAuthenticationSessionManager sessionManager;
+
+    /**
+     * Service for validating and generating unique nonce values. Here, these
+     * nonces are used specifically for generating unique domains.
+     */
+    @Inject
+    private NonceService subdomainNonceService;
+
+    /**
+     * Retrieves a single value from the HTTP header having the given name. If
+     * there are multiple HTTP headers present with this name, the first
+     * matching header in the request is used. If there are no such headers in
+     * the request, null is returned.
+     *
+     * @param headers
+     *     The HTTP headers present in the request.
+     *
+     * @param name
+     *     The name of the header to retrieve.
+     *
+     * @return
+     *     The first value of the HTTP header having the given name, or null if
+     *     there is no such header.
+     */
+    private String getHeader(HttpHeaders headers, String name) {
+
+        List<String> values = headers.getRequestHeader(name);
+        if (values.isEmpty())
+            return null;
+
+        return values.get(0);
+
+    }
+
+    /**
+     * Decodes the provided URL-encoded string as UTF-8, returning the result.
+     *
+     * @param value
+     *     The URL-encoded string to decode.
+     *
+     * @return
+     *     The decoded string.
+     *
+     * @throws GuacamoleException
+     *     If the provided value is not a valid URL-encoded string.
+     */
+    private byte[] decode(String value) throws GuacamoleException {
+        try {
+            return URLDecoder.decode(value, StandardCharsets.UTF_8.name())
+                    .getBytes(StandardCharsets.UTF_8);
+        }
+        catch (IllegalArgumentException e) {
+            throw new GuacamoleClientException("Invalid URL-encoded value.", e);
+        }
+        catch (UnsupportedEncodingException e) {
+            // This should never happen, as UTF-8 is a standard charset that
+            // the JVM is required to support
+            throw new UnsupportedOperationException("Unexpected lack of UTF-8 support.", e);
+        }
+    }
+
+    /**
+     * Extracts a user's username from the X.509 subject name, which should be
+     * in LDAP DN format. If specific username attributes are configured, only
+     * those username attributes are used to determine the name. If a specific
+     * base DN is configured, only subject names that are formatted as LDAP DNs
+     * within that base DN will be accepted.
+     *
+     * @param name
+     *     The subject name to extract the username from.
+     *
+     * @return
+     *     The username of the user represented by the given subject name.
+     *
+     * @throws GuacamoleException
+     *     If any configuration parameters related to retrieving certificates
+     *     from HTTP request cannot be parsed, or if the provided subject name
+     *     cannot be parsed or is not acceptable (wrong base DN or wrong
+     *     username attribute).
+     */
+    public String getUsername(String name) throws GuacamoleException {
+
+        // Extract user's DN from their X.509 certificate
+        LdapName dn;
+        try {
+            dn = new LdapName(name);
+        }
+        catch (InvalidNameException e) {
+            throw new GuacamoleClientException("Subject \"" + name + "\" is "
+                    + "not a valid DN: " + e.getMessage(), e);
+        }
+
+        // Verify DN actually contains components
+        int numComponents = dn.size();
+        if (numComponents < 1)
+            throw new GuacamoleClientException("Subject DN is empty.");
+
+        // Verify DN is within configured base DN (if any)
+        LdapName baseDN = confService.getSubjectBaseDN();
+        if (baseDN != null && !(numComponents > baseDN.size() && dn.startsWith(baseDN)))
+            throw new GuacamoleClientException("Subject DN \"" + dn + "\" is "
+                    + "not within the configured base DN.");
+
+        // Retrieve the least significant attribute from the parsed DN - this
+        // will be the username
+        Rdn nameRdn = dn.getRdn(numComponents - 1);
+
+        // Verify that the username is specified with one of the allowed
+        // attributes
+        List<String> usernameAttributes = confService.getSubjectUsernameAttributes();
+        if (usernameAttributes != null && !usernameAttributes.stream().anyMatch(nameRdn.getType()::equalsIgnoreCase))
+            throw new GuacamoleClientException("Subject DN \"" + dn + "\" "
+                    + "does not contain an acceptable username attribute.");
+
+        // The DN is valid - extract the username from the least significant
+        // component
+        String username = nameRdn.getValue().toString();
+        logger.debug("Username \"{}\" extracted from subject DN \"{}\".", username, dn);
+        return username;
+
+    }
+
+    /**
+     * Authenticates a user using HTTP headers containing that user's verified
+     * X.509 certificate. It is assumed that this certificate is being passed
+     * to Guacamole from an SSL termination service that has already verified
+     * that this certificate is valid and authorized for access to that
+     * Guacamole instance.
+     *
+     * @param certificate
+     *     The raw bytes of the X.509 certificate retrieved from the request.
+     *
+     * @return
+     *     The username of the user asserted by the SSL termination service via
+     *     that user's X.509 certificate.
+     *
+     * @throws GuacamoleException
+     *     If any configuration parameters related to retrieving certificates
+     *     from HTTP request cannot be parsed, or if the certificate is not
+     *     valid/present.
+     */
+    public String getUsername(byte[] certificate) throws GuacamoleException {
+
+        // Parse and re-verify certificate is valid with respect to timestamps
+        X509CertificateHolder cert;
+        try (Reader reader = new StringReader(new String(certificate, StandardCharsets.UTF_8))) {
+
+            PEMParser parser = new PEMParser(reader);
+            Object object = parser.readObject();
+
+            // Verify received data is indeed an X.509 certificate
+            if (object == null || !(object instanceof X509CertificateHolder))
+                throw new GuacamoleClientException("Certificate did not "
+                        + "contain an X.509 certificate.");
+
+            // Verify sanity of received certificate (there should be only
+            // one object here)
+            if (parser.readObject() != null)
+                throw new GuacamoleClientException("Certificate contains "
+                        + "more than a single X.509 certificate.");
+
+            cert = (X509CertificateHolder) object;
+
+            // Verify certificate is valid (it should be given pre-validation
+            // from SSL termination, but it's worth rechecking for sanity)
+            if (!cert.isValidOn(new Date()))
+                throw new GuacamoleClientException("Certificate has expired.");
+
+        }
+        catch (IOException e) {
+            throw new GuacamoleServerException("Certificate could not be read: " + e.getMessage(), e);
+        }
+
+        // Extract user's DN from their X.509 certificate in LDAP (RFC 4919) format
+        X500Name subject = X500Name.getInstance(RFC4519Style.INSTANCE, cert.getSubject());
+        return getUsername(subject.toString());
+
+    }
+
+    /**
+     * Processes the X.509 certificate in the given set of HTTP request
+     * headers, returning an authentication session token representing the
+     * identity in that certificate. If the certificate is invalid or not
+     * present, an invalid session token is returned.
+     *
+     * @param headers
+     *     The headers of the HTTP request to process.
+     *
+     * @return
+     *     An authentication session token representing the identity in the
+     *     certificate in the given HTTP request, or an invalid session token
+     *     if no valid identity was asserted.
+     */
+    private String processCertificate(HttpHeaders headers) {
+
+        //
+        // NOTE: A result with an associated state is ALWAYS returned by
+        // processCertificate(), even if the request does not actually contain
+        // a valid certificate. This is by design and ensures that the nature
+        // of a certificate (valid vs. invalid) cannot be determined except
+        // via Guacamole's authentication endpoint, thus allowing auth failure
+        // hooks to consider attempts to use invalid certificates as auth
+        // failures.
+        //
+
+        try {
+
+            // Verify that SSL termination has already verified the certificate
+            String verified = getHeader(headers, confService.getClientVerifiedHeader());
+            if (verified != null && verified.startsWith(CLIENT_VERIFIED_HEADER_FAILED_PREFIX)) {
+                String message = verified.substring(CLIENT_VERIFIED_HEADER_FAILED_PREFIX.length());
+                throw new GuacamoleClientException("Client certificate did "
+                        + "not pass validation. SSL termination reports the "
+                        + "following failure: \"" + message + "\"");
+            }
+            else if (CLIENT_VERIFIED_HEADER_NONE_VALUE.equals(verified)) {
+                throw new GuacamoleClientException("No client certificate was presented.");
+            }
+            else if (!CLIENT_VERIFIED_HEADER_SUCCESS_VALUE.equals(verified)) {
+                throw new GuacamoleClientException("Client certificate did not pass validation.");
+            }
+
+            String certificate = getHeader(headers, confService.getClientCertificateHeader());
+            if (certificate == null)
+                throw new GuacamoleClientException("Client certificate missing from request.");
+
+            String username = getUsername(decode(certificate));
+            long validityDuration = TimeUnit.MINUTES.toMillis(confService.getMaxTokenValidity());
+            return sessionManager.defer(new SSLAuthenticationSession(username, validityDuration));
+
+        }
+        catch (GuacamoleClientException e) {
+            logger.warn("SSL/TLS client authentication attempt rejected: {}", e.getMessage());
+            logger.debug("SSL/TLS client authentication failed.", e);
+        }
+        catch (GuacamoleException e) {
+            logger.error("SSL/TLS client authentication attempt could not be processed: {}", e.getMessage());
+            logger.debug("SSL/TLS client authentication failed.", e);
+        }
+        catch (RuntimeException | Error e) {
+            logger.error("SSL/TLS client authentication attempt failed internally: {}", e.getMessage());
+            logger.debug("Internal failure processing SSL/TLS client authentication attempt.", e);
+        }
+
+        return sessionManager.generateInvalid();
+
+    }
+
+    /**
+     * Attempts to authenticate the current user using SSL/TLS client
+     * authentication, returning an opaque value that represents their
+     * authenticated status. If necessary, the user is first redirected to a
+     * unique endpoint that supports SSL/TLS client authentication.
+     *
+     * @param headers
+     *     All HTTP headers submitted in the user's authentication request.
+     *
+     * @param host
+     *     The hostname that the user specified in their HTTP request.
+     *
+     * @return
+     *     A Response containing an opaque value representing the user's
+     *     authenticated status, or a Response redirecting the user to a
+     *     unique endpoint that can provide this.
+     *
+     * @throws GuacamoleException
+     *     If any required configuration information is missing or cannot be
+     *     parsed, or if the request was not received at a valid subdomain.
+     */
+    @GET
+    @Path("identity")
+    public Response authenticateClient(@Context HttpHeaders headers,
+            @HeaderParam("Host") String host) throws GuacamoleException {
+
+        // Redirect any requests to the domain that does NOT require SSL/TLS
+        // client authentication to the same endpoint at a domain that does
+        // require SSL/TLS authentication
+        String subdomain = confService.getClientAuthenticationSubdomain(host);
+        if (subdomain == null) {
+
+            long validityDuration = TimeUnit.MINUTES.toMillis(confService.getMaxDomainValidity());
+            String uniqueSubdomain = subdomainNonceService.generate(validityDuration);
+
+            URI clientAuthURI = UriBuilder.fromUri(confService.getClientAuthenticationURI(uniqueSubdomain))
+                    .path("api/ext/ssl/identity")
+                    .build();
+
+            return Response.seeOther(clientAuthURI).build();
+
+        }
+
+        //
+        // Process certificates only at valid single-use subdomains dedicated
+        // to client authentication, redirecting back to the main redirect URI
+        // for final authentication if that processing is successful.
+        //
+        // NOTE: This is CRITICAL. If unique subdomains are not generated and
+        // tied to strictly one authentication attempt, then those subdomains
+        // could be reused by a user on a shared machine to assume the cached
+        // credentials of another user that used that machine earlier. The
+        // browser and/or OS may cache the certificate so that it can be reused
+        // for future SSL sessions to that same domain. Here, we ensure each
+        // generated domain is unique and only valid for certificate processing
+        // ONCE. The domain may still be valid with DNS, but will no longer be
+        // usable for certificate authentication.
+        //
+
+        if (subdomainNonceService.isValid(subdomain))
+            return Response.ok(new OpaqueAuthenticationResult(processCertificate(headers)))
+                    .header("Access-Control-Allow-Origin", confService.getPrimaryOrigin().toString())
+                    .type(MediaType.APPLICATION_JSON)
+                    .build();
+
+        throw new GuacamoleResourceNotFoundException("No such resource.");
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/conf/ConfigurationService.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/conf/ConfigurationService.java
new file mode 100644
index 0000000..48fc86b
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/conf/ConfigurationService.java
@@ -0,0 +1,440 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.ssl.conf;
+
+import com.google.inject.Inject;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.List;
+import javax.naming.ldap.LdapName;
+import javax.ws.rs.core.UriBuilder;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.GuacamoleServerException;
+import org.apache.guacamole.environment.Environment;
+import org.apache.guacamole.properties.IntegerGuacamoleProperty;
+import org.apache.guacamole.properties.StringGuacamoleProperty;
+import org.apache.guacamole.properties.StringListProperty;
+import org.apache.guacamole.properties.URIGuacamoleProperty;
+
+/**
+ * Service for retrieving configuration information regarding SSO using SSL/TLS
+ * authentication.
+ */
+public class ConfigurationService {
+
+    /**
+     * The default name of the header to use to retrieve the URL-encoded client
+     * certificate from an HTTP request received from an SSL termination
+     * service providing SSL/TLS client authentication.
+     */
+    private static String DEFAULT_CLIENT_CERTIFICATE_HEADER = "X-Client-Certificate";
+
+    /**
+     * The default name of the header to use to retrieve the verification
+     * status of the certificate an HTTP request received from an SSL
+     * termination service providing SSL/TLS client authentication.
+     */
+    private static String DEFAULT_CLIENT_VERIFIED_HEADER = "X-Client-Verified";
+
+    /**
+     * The default amount of time that a temporary authentication token for
+     * SSL/TLS authentication may remain valid, in minutes.
+     */
+    private static int DEFAULT_MAX_TOKEN_VALIDITY = 5;
+
+    /**
+     * The default amount of time that the temporary, unique subdomain
+     * generated for SSL/TLS authentication may remain valid, in minutes.
+     */
+    private static int DEFAULT_MAX_DOMAIN_VALIDITY = 5;
+
+    /**
+     * The property representing the URI that should be used to authenticate
+     * users with SSL/TLS client authentication. This must be a URI that points
+     * to THIS instance of Guacamole, but behind SSL termination that requires
+     * SSL/TLS client authentication.
+     */
+    private static final WildcardURIGuacamoleProperty SSL_CLIENT_AUTH_URI =
+            new WildcardURIGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "ssl-client-auth-uri"; }
+
+    };
+
+    /**
+     * The property representing the URI of this instance without SSL/TLS
+     * client authentication required. This must be a URI that points
+     * to THIS instance of Guacamole, but behind SSL termination that DOES NOT
+     * require or request SSL/TLS client authentication.
+     */
+    private static final URIGuacamoleProperty SSL_PRIMARY_URI =
+            new URIGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "ssl-primary-uri"; }
+
+    };
+
+    /**
+     * The property representing the name of the header to use to retrieve the
+     * URL-encoded client certificate from an HTTP request received from an
+     * SSL termination service providing SSL/TLS client authentication.
+     */
+    private static final StringGuacamoleProperty SSL_CLIENT_CERTIFICATE_HEADER =
+            new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "ssl-client-certificate-header"; }
+
+    };
+
+    /**
+     * The property representing the name of the header to use to retrieve the
+     * verification status of the certificate an HTTP request received from an
+     * SSL termination service providing SSL/TLS client authentication. This
+     * value of this header must be "SUCCESS" (all uppercase) if the
+     * certificate was successfully verified.
+     */
+    private static final StringGuacamoleProperty SSL_CLIENT_VERIFIED_HEADER =
+            new StringGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "ssl-client-verified-header"; }
+
+    };
+
+    /**
+     * The property representing the amount of time that a temporary
+     * authentication token for SSL/TLS authentication may remain valid, in
+     * minutes. This token is used to represent the user's asserted identity
+     * after it has been verified by the SSL termination service. This interval
+     * must be long enough to allow for network delays in receiving the token,
+     * but short enough that unused tokens do not consume unnecessary server
+     * resources and cannot potentially be guessed while the token is still
+     * valid. These tokens are 256-bit secure random values.
+     */
+    private static final IntegerGuacamoleProperty SSL_MAX_TOKEN_VALIDITY =
+            new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "ssl-max-token-validity"; }
+
+    };
+
+    /**
+     * The property defining the LDAP attribute or attributes that may be used
+     * to represent a username within the subject DN of a user's X.509
+     * certificate. If the least-significant attribute of the subject DN is not
+     * one of these attributes, the certificate will be rejected. By default,
+     * any attribute is accepted.
+     */
+    private static final StringListProperty SSL_SUBJECT_USERNAME_ATTRIBUTE =
+            new StringListProperty () {
+
+        @Override
+        public String getName() { return "ssl-subject-username-attribute"; }
+
+    };
+
+    /**
+     * The property defining the base DN containing all valid subject DNs. If
+     * specified, only certificates asserting subject DNs beneath this base DN
+     * will be accepted. By default, all DNs are accepted.
+     */
+    private static final LdapNameGuacamoleProperty SSL_SUBJECT_BASE_DN =
+            new LdapNameGuacamoleProperty () {
+
+        @Override
+        public String getName() { return "ssl-subject-base-dn"; }
+
+    };
+
+    /**
+     * The property representing the amount of time that the temporary, unique
+     * subdomain generated for SSL/TLS authentication may remain valid, in
+     * minutes. This subdomain is used to ensure each SSL/TLS authentication
+     * attempt is fresh and does not potentially reuse a previous
+     * authentication attempt that was cached by the browser or OS. This
+     * interval must be long enough to allow for network delays in
+     * authenticating the user with the SSL termination service that enforces
+     * SSL/TLS client authentication, but short enough that an unused domain
+     * does not consume unnecessary server resources and cannot potentially be
+     * guessed while that subdomain is still valid. These subdomains are
+     * 128-bit secure random values.
+     */
+    private static final IntegerGuacamoleProperty SSL_MAX_DOMAIN_VALIDITY =
+            new IntegerGuacamoleProperty() {
+
+        @Override
+        public String getName() { return "ssl-max-domain-validity"; }
+
+    };
+
+    /**
+     * The Guacamole server environment.
+     */
+    @Inject
+    private Environment environment;
+
+    /**
+     * Returns a URI that should be used to authenticate users with SSL/TLS
+     * client authentication. The returned URI will consist of the configured
+     * client authentication URI with the wildcard portion ("*.") replaced with
+     * the given subdomain.
+     *
+     * @param subdomain
+     *     The subdomain that should replace the wildcard portion of the
+     *     configured client authentication URI.
+     *
+     * @return
+     *     A URI that should be used to authenticate users with SSL/TLS
+     *     client authentication.
+     *
+     * @throws GuacamoleException
+     *     If the required property for configuring the client authentication
+     *     URI is missing or cannot be parsed.
+     */
+    public URI getClientAuthenticationURI(String subdomain) throws GuacamoleException {
+
+        URI authURI = environment.getRequiredProperty(SSL_CLIENT_AUTH_URI);
+        String baseHostname = authURI.getHost();
+
+        // Add provided subdomain to auth URI
+        return UriBuilder.fromUri(authURI)
+                .host(subdomain + "." + baseHostname)
+                .build();
+
+    }
+
+    /**
+     * Given a hostname that was used by a user for SSL/TLS client
+     * authentication, returns the subdomain at the beginning of that hostname.
+     * If the hostname does not match the pattern of hosts represented by the
+     * configured client authentication URI, null is returned.
+     *
+     * @param hostname
+     *     The hostname to extract the subdomain from.
+     *
+     * @return
+     *     The subdomain at the beginning of the provided hostname, if that
+     *     hostname matches the pattern of hosts represented by the
+     *     configured client authentication URI, or null otherwise.
+     *
+     * @throws GuacamoleException
+     *     If the required property for configuring the client authentication
+     *     URI is missing or cannot be parsed.
+     */
+    public String getClientAuthenticationSubdomain(String hostname) throws GuacamoleException {
+
+        // Any hostname that matches the explicitly-specific primary URI is not
+        // a client auth subdomain
+        if (isPrimaryHostname(hostname))
+            return null;
+
+        URI authURI = environment.getRequiredProperty(SSL_CLIENT_AUTH_URI);
+        String baseHostname = authURI.getHost();
+
+        // Verify the first domain component is at least one character in
+        // length
+        int firstPeriod = hostname.indexOf('.');
+        if (firstPeriod <= 0)
+            return null;
+
+        // Verify domain matches the configured auth URI except for the leading
+        // subdomain
+        if (!hostname.regionMatches(true, firstPeriod + 1, baseHostname, 0, baseHostname.length()))
+            return null;
+
+        // Extract subdomain
+        return hostname.substring(0, firstPeriod);
+
+    }
+
+    /**
+     * Returns the URI of this instance without SSL/TLS client authentication
+     * required.
+     *
+     * @return
+     *     The URI of this instance without SSL/TLS client authentication
+     *     required.
+     *
+     * @throws GuacamoleException
+     *     If the required property for configuring the primary URI is missing
+     *     or cannot be parsed.
+     */
+    public URI getPrimaryURI() throws GuacamoleException {
+        return environment.getRequiredProperty(SSL_PRIMARY_URI);
+    }
+
+    /**
+     * Returns the HTTP request origin for requests originating from this
+     * instance via the primary URI (as returned by {@link #getPrimaryURI()}.
+     * This value is essentially the same as the primary URI but with only the
+     * scheme, host, and port present.
+     *
+     * @return
+     *     The HTTP request origin for requests originating from this instance
+     *     via the primary URI.
+     *
+     * @throws GuacamoleException
+     *     If the required property for configuring the primary URI is missing
+     *     or cannot be parsed.
+     */
+    public URI getPrimaryOrigin() throws GuacamoleException {
+        URI primaryURI = getPrimaryURI();
+        try {
+            return new URI(primaryURI.getScheme(), null, primaryURI.getHost(), primaryURI.getPort(), null, null, null);
+        }
+        catch (URISyntaxException e) {
+            throw new GuacamoleServerException("Request origin could not be "
+                    + "derived from the configured primary URI.", e);
+        }
+    }
+
+    /**
+     * Returns whether the given hostname is the same as the hostname in the
+     * primary URI (as returned by {@link #getPrimaryURI()}. Hostnames are
+     * case-insensitive.
+     *
+     * @param hostname
+     *     The hostname to test.
+     *
+     * @return
+     *     true if the hostname is the same as the hostname in the primary URI,
+     *     false otherwise.
+     *
+     * @throws GuacamoleException
+     *     If the required property for configuring the primary URI is missing
+     *     or cannot be parsed.
+     */
+    public boolean isPrimaryHostname(String hostname) throws GuacamoleException {
+        URI primaryURI = getPrimaryURI();
+        return hostname.equalsIgnoreCase(primaryURI.getHost());
+    }
+
+    /**
+     * Returns the name of the header to use to retrieve the URL-encoded client
+     * certificate from an HTTP request received from an SSL termination
+     * service providing SSL/TLS client authentication.
+     *
+     * @return
+     *     The name of the header to use to retrieve the URL-encoded client
+     *     certificate from an HTTP request received from an SSL termination
+     *     service providing SSL/TLS client authentication.
+     *
+     * @throws GuacamoleException
+     *     If the property for configuring the client certificate header cannot
+     *     be parsed.
+     */
+    public String getClientCertificateHeader() throws GuacamoleException {
+        return environment.getProperty(SSL_CLIENT_CERTIFICATE_HEADER, DEFAULT_CLIENT_CERTIFICATE_HEADER);
+    }
+
+    /**
+     * Returns the name of the header to use to retrieve the verification
+     * status of the certificate an HTTP request received from an SSL
+     * termination service providing SSL/TLS client authentication.
+     *
+     * @return
+     *     The name of the header to use to retrieve the verification
+     *     status of the certificate an HTTP request received from an SSL
+     *     termination service providing SSL/TLS client authentication.
+     *
+     * @throws GuacamoleException
+     *     If the property for configuring the client verification header
+     *     cannot be parsed.
+     */
+    public String getClientVerifiedHeader() throws GuacamoleException {
+        return environment.getProperty(SSL_CLIENT_VERIFIED_HEADER, DEFAULT_CLIENT_VERIFIED_HEADER);
+    }
+
+    /**
+     * Returns the maximum amount of time that the token generated by the
+     * Guacamole server representing current SSL authentication state should
+     * remain valid, in minutes. This imposes an upper limit on the amount of
+     * time any particular authentication request can result in successful
+     * authentication within Guacamole when SSL/TLS client authentication is
+     * configured. By default, this will be 5.
+     *
+     * @return
+     *     The maximum amount of time that an SSL authentication token
+     *     generated by the Guacamole server should remain valid, in minutes.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public int getMaxTokenValidity() throws GuacamoleException {
+        return environment.getProperty(SSL_MAX_TOKEN_VALIDITY, DEFAULT_MAX_TOKEN_VALIDITY);
+    }
+
+    /**
+     * Returns the maximum amount of time that a unique client authentication
+     * subdomain generated by the Guacamole server should remain valid, in
+     * minutes. This imposes an upper limit on the amount of time any
+     * particular authentication request can result in successful
+     * authentication within Guacamole when SSL/TLS client authentication is
+     * configured. By default, this will be 5.
+     *
+     * @return
+     *     The maximum amount of time that a unique client authentication
+     *     subdomain generated by the Guacamole server should remain valid, in
+     *     minutes.
+     *
+     * @throws GuacamoleException
+     *     If guacamole.properties cannot be parsed.
+     */
+    public int getMaxDomainValidity() throws GuacamoleException {
+        return environment.getProperty(SSL_MAX_DOMAIN_VALIDITY, DEFAULT_MAX_DOMAIN_VALIDITY);
+    }
+
+    /**
+     * Returns the base DN that contains all valid subject DNs. If there is no
+     * such base DN (and all subject DNs are valid), null is returned.
+     *
+     * @return
+     *     The base DN that contains all valid subject DNs, or null if all
+     *     subject DNs are valid.
+     *
+     * @throws GuacamoleException
+     *     If the configured base DN cannot be read or is not a valid LDAP DN.
+     */
+    public LdapName getSubjectBaseDN() throws GuacamoleException {
+        return environment.getProperty(SSL_SUBJECT_BASE_DN);
+    }
+
+    /**
+     * Returns a list of all attributes that may be used to represent a user's
+     * username within their subject DN. If all attributes may be accepted,
+     * null is returned.
+     *
+     * @return
+     *     A list of all attributes that may be used to represent a user's
+     *     username within their subject DN, or null if any attribute may be
+     *     used.
+     *
+     * @throws GuacamoleException
+     *     If the configured set of username attributes cannot be read.
+     */
+    public List<String> getSubjectUsernameAttributes() throws GuacamoleException {
+        return environment.getProperty(SSL_SUBJECT_USERNAME_ATTRIBUTE);
+    }
+
+}
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/conf/LdapNameGuacamoleProperty.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/conf/LdapNameGuacamoleProperty.java
new file mode 100644
index 0000000..0299d01
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/conf/LdapNameGuacamoleProperty.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.ssl.conf;
+
+import javax.naming.InvalidNameException;
+import javax.naming.ldap.LdapName;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.GuacamoleServerException;
+import org.apache.guacamole.properties.GuacamoleProperty;
+
+/**
+ * A GuacamoleProperty whose value is an LDAP name, such as a distinguished
+ * name.
+ */
+public abstract class LdapNameGuacamoleProperty implements GuacamoleProperty<LdapName> {
+
+    @Override
+    public LdapName parseValue(String value) throws GuacamoleException {
+
+        if (value == null)
+            return null;
+
+        try {
+            return new LdapName(value);
+        }
+        catch (InvalidNameException e) {
+            throw new GuacamoleServerException("Value \"" + value
+                + "\" is not a valid LDAP name.", e);
+        }
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/conf/WildcardURIGuacamoleProperty.java b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/conf/WildcardURIGuacamoleProperty.java
new file mode 100644
index 0000000..ab08ee3
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/java/org/apache/guacamole/auth/ssl/conf/WildcardURIGuacamoleProperty.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.auth.ssl.conf;
+
+import java.net.URI;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.GuacamoleServerException;
+import org.apache.guacamole.properties.URIGuacamoleProperty;
+
+/**
+ * A GuacamoleProperty whose value is a wildcard URI. The behavior of this
+ * property is identical to URIGuacamoleProperty except that it verifies a
+ * wildcard hostname prefix ("*.") is present and strips that prefix from the
+ * parsed URI.
+ */
+public abstract class WildcardURIGuacamoleProperty extends URIGuacamoleProperty {
+
+    /**
+     * Regular expression that broadly matches URIs that contain wildcards in
+     * their hostname. This regular expression is NOT strict and will match
+     * invalid URIs. It is only strict enough to recognize a wildcard hostname
+     * prefix.
+     */
+    private static final Pattern WILDCARD_URI_PATTERN = Pattern.compile("([^:]+://(?:[^@]+@)?)\\*\\.(.*)");
+
+    @Override
+    public URI parseValue(String value) throws GuacamoleException {
+
+        if (value == null)
+            return null;
+
+        // Verify wildcard prefix is present
+        Matcher matcher = WILDCARD_URI_PATTERN.matcher(value);
+        if (matcher.matches()) {
+
+            // Strip wildcard prefix from URI and verify a valid hostname is
+            // still present
+            URI uri = super.parseValue(matcher.group(1) + matcher.group(2));
+            if (uri.getHost() != null)
+                return uri;
+
+        }
+
+        // All other values are not valid wildcard URIs
+        throw new GuacamoleServerException("Value \"" + value
+            + "\" is not a valid wildcard URI.");
+
+    }
+
+}
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/resources/directives/guacSslAuth.js b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/resources/directives/guacSslAuth.js
new file mode 100644
index 0000000..4934712
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/resources/directives/guacSslAuth.js
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * A directive which automatically attempts to log the current user in using
+ * SSL/TLS client authentication when the associated element is clicked.
+ */
+angular.module('element').directive('guacSslAuth', ['$injector', function guacSslAuth($injector) {
+
+    // Required services
+    var clientAuthService = $injector.get('clientAuthService');
+
+    var directive = {
+        restrict: 'A'
+    };
+
+    directive.link = function linkGuacSslAuth($scope, $element) {
+
+        /**
+         * The element which will register the click.
+         *
+         * @type Element
+         */
+        const element = $element[0];
+
+        // Attempt SSL/TLS client authentication upon click
+        element.addEventListener('click', function elementClicked() {
+            clientAuthService.authenticate();
+        });
+
+    };
+
+    return directive;
+
+}]);
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/resources/guac-manifest.json
new file mode 100644
index 0000000..032db7a
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/resources/guac-manifest.json
@@ -0,0 +1,35 @@
+{
+
+    "guacamoleVersion" : "1.5.2",
+
+    "name"      : "SSL Authentication Extension",
+    "namespace" : "ssl",
+
+    "authProviders" : [
+        "org.apache.guacamole.auth.ssl.SSLAuthenticationProvider"
+    ],
+
+    "css" : [
+        "styles/sso-providers.css"
+    ],
+
+    "js" : [ "ssl.min.js" ],
+
+    "html" : [
+        "html/sso-providers.html",
+        "html/sso-provider-ssl.html"
+    ],
+
+    "translations" : [
+        "translations/ca.json",
+        "translations/de.json",
+        "translations/en.json",
+        "translations/fr.json",
+        "translations/ja.json",
+        "translations/ko.json",
+        "translations/pt.json",
+        "translations/ru.json",
+        "translations/zh.json"
+    ]
+
+}
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/resources/html/sso-provider-ssl.html b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/resources/html/sso-provider-ssl.html
new file mode 100644
index 0000000..d9a3db8
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/resources/html/sso-provider-ssl.html
@@ -0,0 +1,4 @@
+<meta name="after-children" content=".login-ui .sso-provider-list">
+<li class="sso-provider sso-provider-ssl"><a guac-ssl-auth href="">{{
+    'LOGIN.NAME_IDP_SSL' | translate
+}}</a></li>
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/resources/license.txt b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/resources/license.txt
new file mode 100644
index 0000000..042f3ce
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/resources/license.txt
@@ -0,0 +1,18 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/resources/services/clientAuthService.js b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/resources/services/clientAuthService.js
new file mode 100644
index 0000000..3bf9d9e
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/resources/services/clientAuthService.js
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Service for authenticating a user using SSL/TLS client authentication.
+ */
+angular.module('guacSsoSsl').factory('clientAuthService', ['$injector',
+    function clientAuthServiceProvider($injector) {
+
+    // Required services
+    var requestService        = $injector.get('requestService');
+    var authenticationService = $injector.get('authenticationService');
+
+    var service = {};
+
+    /**
+     * Attempt to authenticate using a unique token obtained through SSL/TLS
+     * client authentication.
+     */
+    service.authenticate = function authenticate() {
+
+        // Transform SSL/TLS identity into an opaque "state" value and
+        // attempt authentication using that value
+        authenticationService.authenticate(
+            requestService({
+                method: 'GET',
+                headers : {
+                    'Cache-Control' : undefined, // Avoid sending headers that would result in a pre-flight OPTIONS request for CORS
+                    'Pragma'        : undefined
+                },
+                url: 'api/ext/ssl/identity'
+            })
+            .then(function identityRetrieved(data) {
+                return { 'state' : data.state || '' };
+            })
+        )['catch'](requestService.IGNORE);
+
+    };
+
+    return service;
+
+}]);
diff --git a/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/resources/sslModule.js b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/resources/sslModule.js
new file mode 100644
index 0000000..2e3f844
--- /dev/null
+++ b/extensions/guacamole-auth-sso/modules/guacamole-auth-sso-ssl/src/main/resources/sslModule.js
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * The module for code implementing SSO using SSL/TLS client authentication.
+ */
+angular.module('guacSsoSsl', [
+    'auth',
+    'rest'
+]);
+
+// Ensure the guacSsoSsl module is loaded along with the rest of the app
+angular.module('index').requires.push('guacSsoSsl');
diff --git a/extensions/guacamole-auth-sso/pom.xml b/extensions/guacamole-auth-sso/pom.xml
index e15b240..edb202f 100644
--- a/extensions/guacamole-auth-sso/pom.xml
+++ b/extensions/guacamole-auth-sso/pom.xml
@@ -49,6 +49,7 @@
         <module>modules/guacamole-auth-sso-cas</module>
         <module>modules/guacamole-auth-sso-openid</module>
         <module>modules/guacamole-auth-sso-saml</module>
+        <module>modules/guacamole-auth-sso-ssl</module>
 
     </modules>
 
diff --git a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUser.java b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUser.java
index d50e3a6..e84b754 100644
--- a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUser.java
+++ b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUser.java
@@ -34,6 +34,12 @@
 public class TOTPUser extends DelegatingUser {
 
     /**
+     * The name of the user attribute which disables the TOTP requirement
+     * for that specific user.
+     */
+    public static final String TOTP_KEY_DISABLED_ATTRIBUTE_NAME = "guac-totp-disabled";
+    
+    /**
      * The name of the user attribute which stores the TOTP key.
      */
     public static final String TOTP_KEY_SECRET_ATTRIBUTE_NAME = "guac-totp-key-secret";
@@ -45,17 +51,27 @@
     public static final String TOTP_KEY_CONFIRMED_ATTRIBUTE_NAME = "guac-totp-key-confirmed";
     
     /**
-     * The name of the field used to trigger a reset of the TOTP data.
+     * The name of the user attribute defines whether the TOTP key has been
+     * generated for the user, regardless of whether that key has been
+     * confirmed. This attribute is not stored, but is instead exposed
+     * dynamically in lieu of exposing the actual TOTP key.
      */
-    public static final String TOTP_KEY_SECRET_RESET_FIELD = "guac-totp-reset";
+    public static final String TOTP_KEY_SECRET_GENERATED_ATTRIBUTE_NAME = "guac-totp-key-generated";
+
+    /**
+     * The string value used by TOTP user attributes to represent the boolean
+     * value "true".
+     */
+    public static final String TRUTH_VALUE = "true";
 
     /**
      * The form which contains all configurable properties for this user.
      */
-    public static final Form TOTP_CONFIG_FORM = new Form("totp-config-form",
+    public static final Form TOTP_ENROLLMENT_STATUS = new Form("totp-enrollment-status",
             Arrays.asList(
-                    new BooleanField(TOTP_KEY_SECRET_RESET_FIELD, "true"),
-                    new BooleanField(TOTP_KEY_CONFIRMED_ATTRIBUTE_NAME, "true")
+                    new BooleanField(TOTP_KEY_DISABLED_ATTRIBUTE_NAME, TRUTH_VALUE),
+                    new BooleanField(TOTP_KEY_SECRET_GENERATED_ATTRIBUTE_NAME, TRUTH_VALUE),
+                    new BooleanField(TOTP_KEY_CONFIRMED_ATTRIBUTE_NAME, TRUTH_VALUE)
             )
     );
     
@@ -87,16 +103,14 @@
         // Create independent, mutable copy of attributes
         Map<String, String> attributes = new HashMap<>(super.getAttributes());
         
-        // Protect the secret value by removing it
+        if (!attributes.containsKey(TOTP_KEY_DISABLED_ATTRIBUTE_NAME))
+            attributes.put(TOTP_KEY_DISABLED_ATTRIBUTE_NAME, null);
+
+        // Replace secret key with simple boolean attribute representing
+        // whether a key has been generated at all
         String secret = attributes.remove(TOTP_KEY_SECRET_ATTRIBUTE_NAME);
-        
-        // If secret is null or empty, mark the reset as true.
-        if (secret == null || secret.isEmpty())
-            attributes.put(TOTP_KEY_SECRET_RESET_FIELD, "true");
-            
-        // If secret has a value, mark the reset as false.
-        else
-            attributes.put(TOTP_KEY_SECRET_RESET_FIELD, "false");
+        if (secret != null && !secret.isEmpty())
+            attributes.put(TOTP_KEY_SECRET_GENERATED_ATTRIBUTE_NAME, TRUTH_VALUE);
 
         return attributes;
 
@@ -107,15 +121,13 @@
 
         // Create independent, mutable copy of attributes
         attributes = new HashMap<>(attributes);
-        
-        // Do not expose any TOTP secret attribute outside this extension
+
+        // Do not allow TOTP secret to be directly manipulated
         attributes.remove(TOTP_KEY_SECRET_ATTRIBUTE_NAME);
-        
-        // Pull off the boolean reset field
-        String reset = attributes.remove(TOTP_KEY_SECRET_RESET_FIELD);
-        
-        // If reset has been set to true, clear the secret.
-        if (reset != null && reset.equals("true")) {
+
+        // Reset TOTP status entirely if requested
+        String generated = attributes.remove(TOTP_KEY_SECRET_GENERATED_ATTRIBUTE_NAME);
+        if (generated != null && !generated.equals(TRUTH_VALUE)) {
             attributes.put(TOTP_KEY_SECRET_ATTRIBUTE_NAME, null);
             attributes.put(TOTP_KEY_CONFIRMED_ATTRIBUTE_NAME, null);
         }
diff --git a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUserContext.java b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUserContext.java
index f478519..d3269e7 100644
--- a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUserContext.java
+++ b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/TOTPUserContext.java
@@ -23,12 +23,14 @@
 import java.util.Collections;
 import java.util.HashSet;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.auth.totp.usergroup.TOTPUserGroup;
 import org.apache.guacamole.form.Form;
 import org.apache.guacamole.net.auth.DecoratingDirectory;
 import org.apache.guacamole.net.auth.DelegatingUserContext;
 import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.User;
 import org.apache.guacamole.net.auth.UserContext;
+import org.apache.guacamole.net.auth.UserGroup;
 
 /**
  * TOTP-specific UserContext implementation which wraps the UserContext of
@@ -66,10 +68,35 @@
     }
     
     @Override
+    public Directory<UserGroup> getUserGroupDirectory() throws GuacamoleException {
+        return new DecoratingDirectory<UserGroup>(super.getUserGroupDirectory()) {
+           
+            @Override
+            protected UserGroup decorate(UserGroup object) {
+                return new TOTPUserGroup(object);
+            }
+            
+            @Override
+            protected UserGroup undecorate(UserGroup object) {
+                assert(object instanceof TOTPUserGroup);
+                return ((TOTPUserGroup) object).getUndecorated();
+            }
+            
+        };
+    }
+    
+    @Override
     public Collection<Form> getUserAttributes() {
         Collection<Form> userAttrs = new HashSet<>(super.getUserAttributes());
-        userAttrs.add(TOTPUser.TOTP_CONFIG_FORM);
+        userAttrs.add(TOTPUser.TOTP_ENROLLMENT_STATUS);
         return Collections.unmodifiableCollection(userAttrs);
     }
+    
+    @Override
+    public Collection<Form> getUserGroupAttributes() {
+        Collection<Form> userGroupAttrs = new HashSet<>(super.getUserGroupAttributes());
+        userGroupAttrs.add(TOTPUserGroup.TOTP_USER_GROUP_CONFIG);
+        return Collections.unmodifiableCollection(userGroupAttrs);
+    }
 
 }
diff --git a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/UserVerificationService.java b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/UserVerificationService.java
index 2fca742..027a228 100644
--- a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/UserVerificationService.java
+++ b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/user/UserVerificationService.java
@@ -26,19 +26,23 @@
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
 import javax.servlet.http.HttpServletRequest;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleSecurityException;
 import org.apache.guacamole.GuacamoleUnsupportedException;
 import org.apache.guacamole.auth.totp.conf.ConfigurationService;
 import org.apache.guacamole.auth.totp.form.AuthenticationCodeField;
+import org.apache.guacamole.auth.totp.usergroup.TOTPUserGroup;
 import org.apache.guacamole.form.Field;
 import org.apache.guacamole.language.TranslatableGuacamoleClientException;
 import org.apache.guacamole.language.TranslatableGuacamoleInsufficientCredentialsException;
 import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.Credentials;
+import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.User;
 import org.apache.guacamole.net.auth.UserContext;
+import org.apache.guacamole.net.auth.UserGroup;
 import org.apache.guacamole.net.auth.credentials.CredentialsInfo;
 import org.apache.guacamole.totp.TOTPGenerator;
 import org.slf4j.Logger;
@@ -203,6 +207,65 @@
         return true;
 
     }
+    
+    /**
+     * Checks the user in question, via both UserContext and AuthenticatedUser,
+     * to see if TOTP has been disabled for this user, either directly or via
+     * membership in a group that has had TOTP marked as disabled.
+     * 
+     * @param context
+     *     The UserContext for the user being verified.
+     * 
+     * @param authenticatedUser
+     *     The AuthenticatedUser for the user being verified.
+     * 
+     * @return
+     *     True if TOTP access has been disabled for the user, otherwise
+     *     false.
+     * 
+     * @throws GuacamoleException
+     *     If the extension handling storage fails internally while attempting
+     *     to update the user.
+     */
+    private boolean totpDisabled(UserContext context,
+            AuthenticatedUser authenticatedUser)
+            throws GuacamoleException {
+        
+        // If TOTP is disabled for this user, return, allowing login to continue
+        Map<String, String> myAttributes = context.self().getAttributes();
+        if (myAttributes != null
+                && TOTPUser.TRUTH_VALUE.equals(myAttributes.get(TOTPUser.TOTP_KEY_DISABLED_ATTRIBUTE_NAME))) {
+
+            logger.warn("TOTP validation has been disabled for user \"{}\"",
+                    context.self().getIdentifier());
+            return true;
+
+        }
+        
+        // Check if any effective user groups have TOTP marked as disabled
+        Set<String> userGroups = authenticatedUser.getEffectiveUserGroups();
+        Directory<UserGroup> directoryGroups = context.getPrivileged().getUserGroupDirectory();
+        for (String userGroup : userGroups) {
+            UserGroup thisGroup = directoryGroups.get(userGroup);
+            if (thisGroup == null)
+                continue;
+            
+            Map<String, String> grpAttributes = thisGroup.getAttributes();
+            if (grpAttributes != null 
+                    && TOTPUserGroup.TRUTH_VALUE.equals(grpAttributes.get(TOTPUserGroup.TOTP_KEY_DISABLED_ATTRIBUTE_NAME))) {
+
+                logger.warn("TOTP validation will be bypassed for user \"{}\""
+                            + " because it has been disabled for group \"{}\"",
+                            context.self().getIdentifier(), userGroup);
+                return true;
+
+            }
+        }
+        
+        // TOTP has not been disabled
+        return false;
+        
+    }
 
     /**
      * Verifies the identity of the given user using TOTP. If a authentication
@@ -230,6 +293,10 @@
         if (username.equals(AuthenticatedUser.ANONYMOUS_IDENTIFIER))
             return;
 
+        // Check if TOTP has been disabled for this user
+        if (totpDisabled(context, authenticatedUser))
+            return;
+        
         // Ignore users which do not have an associated key
         UserTOTPKey key = getKey(context, username);
         if (key == null)
diff --git a/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/usergroup/TOTPUserGroup.java b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/usergroup/TOTPUserGroup.java
new file mode 100644
index 0000000..5b6921b
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/java/org/apache/guacamole/auth/totp/usergroup/TOTPUserGroup.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.guacamole.auth.totp.usergroup;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.guacamole.form.BooleanField;
+import org.apache.guacamole.form.Form;
+import org.apache.guacamole.net.auth.DelegatingUserGroup;
+import org.apache.guacamole.net.auth.UserGroup;
+
+/**
+ * A UserGroup that wraps another UserGroup implementation, decorating it with
+ * attributes that control TOTP configuration for users that are members of that
+ * group.
+ */
+public class TOTPUserGroup extends DelegatingUserGroup {
+    
+    /**
+     * The attribute associated with a group that disables the TOTP requirement
+     * for any users that are a member of that group, or are members of any
+     * groups that are members of this group.
+     */
+    public static final String TOTP_KEY_DISABLED_ATTRIBUTE_NAME = "guac-totp-disabled";
+    
+    /**
+     * The string value used by TOTP user attributes to represent the boolean
+     * value "true".
+     */
+    public static final String TRUTH_VALUE = "true";
+    
+    /**
+     * The form that contains fields for configuring TOTP for members of this
+     * group.
+     */
+    public static final Form TOTP_USER_GROUP_CONFIG = new Form("totp-user-group-config",
+            Arrays.asList(
+                    new BooleanField(TOTP_KEY_DISABLED_ATTRIBUTE_NAME, TRUTH_VALUE)
+            )
+    );
+    
+    /**
+     * Create a new instance of this user group implementation, wrapping the
+     * provided UserGroup.
+     * 
+     * @param userGroup 
+     *     The UserGroup to be wrapped.
+     */
+    public TOTPUserGroup(UserGroup userGroup) {
+        super(userGroup);
+    }
+    
+    /**
+     * Return the original UserGroup that this implementation is wrapping.
+     * 
+     * @return 
+     *     The original UserGroup that this implementation wraps.
+     */
+    public UserGroup getUndecorated() {
+        return getDelegateUserGroupGroup();
+    }
+    
+    /**
+     * Returns whether or not TOTP has been disabled for members of this group.
+     * 
+     * @return 
+     *     True if TOTP has been disabled for members of this group, otherwise
+     *     false.
+     */
+    public boolean totpDisabled() {
+        return (TRUTH_VALUE.equals(getAttributes().get(TOTP_KEY_DISABLED_ATTRIBUTE_NAME)));
+    }
+    
+    @Override
+    public Map<String, String> getAttributes() {
+        
+        // Create a mutable copy of the attributes
+        Map<String, String> attributes = new HashMap<>(super.getAttributes());
+        
+        if (!attributes.containsKey(TOTP_KEY_DISABLED_ATTRIBUTE_NAME))
+            attributes.put(TOTP_KEY_DISABLED_ATTRIBUTE_NAME, null);
+        
+        return attributes;
+        
+    }
+    
+}
diff --git a/extensions/guacamole-auth-totp/src/main/resources/guac-manifest.json b/extensions/guacamole-auth-totp/src/main/resources/guac-manifest.json
index 309661b..646a651 100644
--- a/extensions/guacamole-auth-totp/src/main/resources/guac-manifest.json
+++ b/extensions/guacamole-auth-totp/src/main/resources/guac-manifest.json
@@ -16,6 +16,7 @@
         "translations/fr.json",
         "translations/ja.json",
         "translations/ko.json",
+        "translations/pl.json",
         "translations/pt.json",
         "translations/ru.json",
         "translations/zh.json"
diff --git a/extensions/guacamole-auth-totp/src/main/resources/translations/en.json b/extensions/guacamole-auth-totp/src/main/resources/translations/en.json
index 55bd69a..e9f4b71 100644
--- a/extensions/guacamole-auth-totp/src/main/resources/translations/en.json
+++ b/extensions/guacamole-auth-totp/src/main/resources/translations/en.json
@@ -33,10 +33,19 @@
     
     "USER_ATTRIBUTES" : {
         
-        "FIELD_HEADER_GUAC_TOTP_RESET"         : "Clear TOTP secret:",
-        "FIELD_HEADER_GUAC_TOTP_KEY_CONFIRMED" : "TOTP key confirmed:",
+        "FIELD_HEADER_GUAC_TOTP_DISABLED"      : "Disable TOTP:",
+        "FIELD_HEADER_GUAC_TOTP_KEY_GENERATED" : "Secret key generated:",
+        "FIELD_HEADER_GUAC_TOTP_KEY_CONFIRMED" : "Authentication device confirmed:",
         
-        "SECTION_HEADER_TOTP_CONFIG_FORM" : "Configure TOTP"
+        "SECTION_HEADER_TOTP_ENROLLMENT_STATUS" : "TOTP Enrollment Status"
+        
+    },
+    
+    "USER_GROUP_ATTRIBUTES" : {
+        
+        "FIELD_HEADER_GUAC_TOTP_DISABLED" : "Disable TOTP:",
+        
+        "SECTION_HEADER_TOTP_USER_GROUP_CONFIG" : "TOTP Configuration"
         
     }
 
diff --git a/extensions/guacamole-auth-totp/src/main/resources/translations/pl.json b/extensions/guacamole-auth-totp/src/main/resources/translations/pl.json
new file mode 100644
index 0000000..208a1e8
--- /dev/null
+++ b/extensions/guacamole-auth-totp/src/main/resources/translations/pl.json
@@ -0,0 +1,43 @@
+{
+
+    "DATA_SOURCE_TOTP" : {
+        "NAME" : "TOTP TFA Backend"
+    },
+
+    "LOGIN" : {
+        "FIELD_HEADER_GUAC_TOTP" : ""
+    },
+
+    "TOTP" : {
+
+        "ACTION_HIDE_DETAILS" : "Ukryj",
+        "ACTION_SHOW_DETAILS" : "Pokaż",
+
+        "FIELD_HEADER_ALGORITHM"  : "Algorytm:",
+        "FIELD_HEADER_DIGITS"     : "Cyfry:",
+        "FIELD_HEADER_INTERVAL"   : "Interwał:",
+        "FIELD_HEADER_SECRET_KEY" : "Sekretny klucz:",
+
+        "FIELD_PLACEHOLDER_CODE" : "Kod weryfikacyjny",
+
+        "INFO_CODE_REQUIRED"       : "Podaj kod weryfikacyjny, aby potwierdzić swoją tożsamość.",
+        "INFO_ENROLL_REQUIRED"     : "Uwierzytelnianie wieloskładnikowe zostało włączone na Twoim koncie.",
+        "INFO_VERIFICATION_FAILED" : "Weryfikacja nie powiodła się. Spróbuj ponownie.",
+
+        "HELP_ENROLL_BARCODE" : "Aby zakończyć proces rejestracji, zeskanuj poniższy kod kreskowy za pomocą aplikacji do uwierzytelniania dwuskładnikowego na swoim telefonie lub urządzeniu.",
+        "HELP_ENROLL_VERIFY"  : "Po zeskanowaniu kodu kreskowego, wprowadź {DIGITS}-cyfrowy kod weryfikacyjny aby potwierdzić, że proces rejestracji przebiegł pomyślnie.",
+
+        "SECTION_HEADER_DETAILS" : "Szczegóły:"
+
+    },
+    
+    "USER_ATTRIBUTES" : {
+        
+        "FIELD_HEADER_GUAC_TOTP_KEY_GENERATED" : "Wygenerowano klucz:",
+        "FIELD_HEADER_GUAC_TOTP_KEY_CONFIRMED" : "Potwierdzono urządzenie uwierzytelniające:",
+        
+        "SECTION_HEADER_TOTP_ENROLLMENT_STATUS" : "Status Rejestracji TOTP"
+        
+    }
+
+}
diff --git a/extensions/guacamole-auth-totp/src/main/resources/translations/zh.json b/extensions/guacamole-auth-totp/src/main/resources/translations/zh.json
index 9d27667..edb01e5 100644
--- a/extensions/guacamole-auth-totp/src/main/resources/translations/zh.json
+++ b/extensions/guacamole-auth-totp/src/main/resources/translations/zh.json
@@ -1,7 +1,7 @@
 {
 
     "DATA_SOURCE_TOTP" : {
-        "NAME" : "TOTP TFA后端"
+        "NAME" : "TOTP TFA 后端"
     },
 
     "LOGIN" : {
@@ -13,22 +13,40 @@
         "ACTION_HIDE_DETAILS" : "隐藏",
         "ACTION_SHOW_DETAILS" : "显示",
 
-        "FIELD_HEADER_ALGORITHM"  : "算法:",
-        "FIELD_HEADER_DIGITS"     : "位数:",
-        "FIELD_HEADER_INTERVAL"   : "间隔:",
-        "FIELD_HEADER_SECRET_KEY" : "密钥:",
+        "FIELD_HEADER_ALGORITHM"  : "算法:",
+        "FIELD_HEADER_DIGITS"     : "数字位数:",
+        "FIELD_HEADER_INTERVAL"   : "时间间隔:",
+        "FIELD_HEADER_SECRET_KEY" : "密钥:",
 
-        "FIELD_PLACEHOLDER_CODE" : "授权码",
+        "FIELD_PLACEHOLDER_CODE" : "认证码",
 
-        "INFO_CODE_REQUIRED"       : "请输入您的授权码以验证您的身份。",
-        "INFO_ENROLL_REQUIRED"     : "您的帐户已启用多因素身份验证。",
-        "INFO_VERIFICATION_FAILED" : "验证失败, 请重试。",
+        "INFO_CODE_REQUIRED"       : "请输入您的认证码以验证您的身份。",
+        "INFO_ENROLL_REQUIRED"     : "您的账户已启用多因素身份验证。",
+        "INFO_VERIFICATION_FAILED" : "验证失败,请重试。",
 
-        "HELP_ENROLL_BARCODE" : "要完成注册过程,请使用手机或设备上的two-factor验证程序扫描下面的条形码。",
-        "HELP_ENROLL_VERIFY"  : "扫描条形码后,输入显示的{DIGITS}-数字授权码以验证注册是否成功。",
+        "HELP_ENROLL_BARCODE" : "为了完成注册流程,请使用您的手机或设备上的双因素身份验证应用程序扫描下面的条形码。",
+        "HELP_ENROLL_VERIFY"  : "扫描条形码后,输入显示的 {DIGITS} 位认证码以验证注册是否成功。",
 
-        "SECTION_HEADER_DETAILS" : "详情:"
+        "SECTION_HEADER_DETAILS" : "详细信息:"
 
+    },
+
+    "USER_ATTRIBUTES" : {
+        
+        "FIELD_HEADER_GUAC_TOTP_DISABLED"      : "禁用 TOTP:",
+        "FIELD_HEADER_GUAC_TOTP_KEY_GENERATED" : "已生成密钥:",
+        "FIELD_HEADER_GUAC_TOTP_KEY_CONFIRMED" : "已确认身份验证设备:",
+        
+        "SECTION_HEADER_TOTP_ENROLLMENT_STATUS" : "TOTP 注册状态"
+        
+    },
+
+    "USER_GROUP_ATTRIBUTES" : {
+        
+        "FIELD_HEADER_GUAC_TOTP_DISABLED" : "禁用 TOTP:",
+        
+        "SECTION_HEADER_TOTP_USER_GROUP_CONFIG" : "TOTP 配置"
+        
     }
 
 }
diff --git a/extensions/guacamole-display-statistics/.gitignore b/extensions/guacamole-display-statistics/.gitignore
new file mode 100644
index 0000000..e55f47f
--- /dev/null
+++ b/extensions/guacamole-display-statistics/.gitignore
@@ -0,0 +1 @@
+src/main/resources/generated/
diff --git a/extensions/guacamole-display-statistics/.ratignore b/extensions/guacamole-display-statistics/.ratignore
new file mode 100644
index 0000000..da318d1
--- /dev/null
+++ b/extensions/guacamole-display-statistics/.ratignore
@@ -0,0 +1 @@
+src/main/resources/html/*.html
diff --git a/extensions/guacamole-display-statistics/pom.xml b/extensions/guacamole-display-statistics/pom.xml
new file mode 100644
index 0000000..68aea45
--- /dev/null
+++ b/extensions/guacamole-display-statistics/pom.xml
@@ -0,0 +1,124 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you under the Apache License, Version 2.0 (the
+    "License"); you may not use this file except in compliance
+    with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing,
+    software distributed under the License is distributed on an
+    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+    KIND, either express or implied.  See the License for the
+    specific language governing permissions and limitations
+    under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
+                        http://maven.apache.org/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>org.apache.guacamole</groupId>
+    <artifactId>guacamole-display-statistics</artifactId>
+    <packaging>jar</packaging>
+    <version>1.5.2</version>
+    <name>guacamole-display-statistics</name>
+    <url>http://guacamole.apache.org/</url>
+
+    <parent>
+        <groupId>org.apache.guacamole</groupId>
+        <artifactId>extensions</artifactId>
+        <version>1.5.2</version>
+        <relativePath>../</relativePath>
+    </parent>
+
+    <build>
+        <plugins>
+
+            <!-- Pre-cache Angular templates with maven-angular-plugin -->
+            <plugin>
+                <groupId>com.keithbranton.mojo</groupId>
+                <artifactId>angular-maven-plugin</artifactId>
+                <version>0.3.4</version>
+                <executions>
+                    <execution>
+                        <phase>generate-resources</phase>
+                        <goals>
+                            <goal>html2js</goal>
+                        </goals>
+                    </execution>
+                </executions>
+                <configuration>
+                    <sourceDir>${basedir}/src/main/resources</sourceDir>
+                    <include>**/*.html</include>
+                    <target>${basedir}/src/main/resources/generated/templates-main/templates.js</target>
+                    <prefix>app/ext/display-stats</prefix>
+                </configuration>
+            </plugin>
+
+            <!-- JS/CSS Minification Plugin -->
+            <plugin>
+                <groupId>com.github.buckelieg</groupId>
+                <artifactId>minify-maven-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <id>default-cli</id>
+                        <configuration>
+                            <charset>UTF-8</charset>
+
+                            <webappSourceDir>${basedir}/src/main/resources</webappSourceDir>
+                            <webappTargetDir>${project.build.directory}/classes</webappTargetDir>
+
+                            <cssSourceDir>/</cssSourceDir>
+                            <cssTargetDir>/</cssTargetDir>
+                            <cssFinalFile>display-stats.css</cssFinalFile>
+
+                            <cssSourceFiles>
+                                <cssSourceFile>license.txt</cssSourceFile>
+                            </cssSourceFiles>
+
+                            <cssSourceIncludes>
+                                <cssSourceInclude>**/*.css</cssSourceInclude>
+                            </cssSourceIncludes>
+
+                            <jsSourceDir>/</jsSourceDir>
+                            <jsTargetDir>/</jsTargetDir>
+                            <jsFinalFile>display-stats.js</jsFinalFile>
+
+                            <jsSourceFiles>
+                                <jsSourceFile>license.txt</jsSourceFile>
+                            </jsSourceFiles>
+
+                            <jsSourceIncludes>
+                                <jsSourceInclude>**/*.js</jsSourceInclude>
+                            </jsSourceIncludes>
+
+                            <!-- Do not minify and include tests -->
+                            <jsSourceExcludes>
+                                <jsSourceExclude>**/*.test.js</jsSourceExclude>
+                            </jsSourceExcludes>
+                            <jsEngine>CLOSURE</jsEngine>
+
+                            <!-- Disable warnings for JSDoc annotations -->
+                            <closureWarningLevels>
+                                <misplacedTypeAnnotation>OFF</misplacedTypeAnnotation>
+                                <nonStandardJsDocs>OFF</nonStandardJsDocs>
+                            </closureWarningLevels>
+
+                        </configuration>
+                        <goals>
+                            <goal>minify</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+
+        </plugins>
+    </build>
+
+</project>
diff --git a/extensions/guacamole-display-statistics/src/main/assembly/dist.xml b/extensions/guacamole-display-statistics/src/main/assembly/dist.xml
new file mode 100644
index 0000000..0b16a71
--- /dev/null
+++ b/extensions/guacamole-display-statistics/src/main/assembly/dist.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you under the Apache License, Version 2.0 (the
+    "License"); you may not use this file except in compliance
+    with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing,
+    software distributed under the License is distributed on an
+    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+    KIND, either express or implied.  See the License for the
+    specific language governing permissions and limitations
+    under the License.
+-->
+<assembly
+    xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0 http://maven.apache.org/xsd/assembly-1.1.0.xsd">
+    
+    <id>dist</id>
+    <baseDirectory>${project.artifactId}-${project.version}</baseDirectory>
+
+    <!-- Output tar.gz -->
+    <formats>
+        <format>tar.gz</format>
+    </formats>
+
+    <!-- Include licenses and extension .jar -->
+    <fileSets>
+
+        <!-- Include licenses -->
+        <fileSet>
+            <outputDirectory></outputDirectory>
+            <directory>target/licenses</directory>
+        </fileSet>
+
+        <!-- Include extension .jar -->
+        <fileSet>
+            <directory>target</directory>
+            <outputDirectory></outputDirectory>
+            <includes>
+                <include>*.jar</include>
+            </includes>
+        </fileSet>
+
+    </fileSets>
+
+</assembly>
diff --git a/extensions/guacamole-display-statistics/src/main/resources/directives/guacClientStatistics.js b/extensions/guacamole-display-statistics/src/main/resources/directives/guacClientStatistics.js
new file mode 100644
index 0000000..4dc6b8a
--- /dev/null
+++ b/extensions/guacamole-display-statistics/src/main/resources/directives/guacClientStatistics.js
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * A directive which displays frame rendering performance statistics for a
+ * Guacamole client.
+ */
+angular.module('client').directive('guacClientStatistics', [function guacClientStatistics() {
+
+    const directive = {
+        restrict: 'E',
+        templateUrl: 'app/ext/display-stats/templates/guacClientStatistics.html',
+    };
+
+    directive.scope = {
+
+        /**
+         * The Guacamole client to display frame rendering statistics for.
+         *
+         * @type ManagedClient
+         */
+        client : '='
+
+    };
+
+    directive.controller = ['$scope', function guacClientStatisticsController($scope) {
+
+        /**
+         * Updates the displayed frame rendering statistics to the values
+         * within the given statistics object.
+         *
+         * @param {!Guacamole.Display.Statistics} stats
+         *     An object containing general rendering performance statistics for
+         *     the remote desktop, Guacamole server, and Guacamole client.
+         */
+        var updateStatistics = function updateStatistics(stats) {
+            $scope.$apply(function statisticsChanged() {
+                $scope.statistics = stats;
+            });
+        };
+
+        /**
+         * Returns whether the given value is a defined value that should be
+         * rendered within the statistics toolbar.
+         *
+         * @param {number} value
+         *     The value to test.
+         *
+         * @returns {!boolean}
+         *     true if the given value should be rendered within the statistics
+         *     toolbar, false otherwise.
+         */
+        $scope.hasValue = function hasValue(value) {
+            return value || value === 0;
+        };
+
+        /**
+         * Rounds the given numeric value to the nearest hundredth (two decimal places).
+         *
+         * @param {!number} value
+         *     The value to round.
+         *
+         * @param {!number}
+         *     The given value, rounded to the nearest hundredth.
+         */
+        $scope.round = function round(value) {
+            return Math.round(value * 100) / 100;
+        };
+
+        // Assign/remove onstatistics handlers to track the statistics of the
+        // current client
+        $scope.$watch('client', function clientChanged(client, oldClient) {
+
+            if (oldClient)
+                oldClient.managedDisplay.display.onstatistics = null;
+
+            client.managedDisplay.display.statisticWindow = 1000;
+            client.managedDisplay.display.onstatistics = updateStatistics;
+
+        });
+
+        // Clear onstatistics handler when directive is being unloaded
+        $scope.$on('$destroy', function scopeDestroyed() {
+            if ($scope.client)
+                $scope.client.managedDisplay.display.onstatistics = null;
+        });
+
+    }];
+
+    return directive;
+
+}]);
diff --git a/extensions/guacamole-display-statistics/src/main/resources/guac-manifest.json b/extensions/guacamole-display-statistics/src/main/resources/guac-manifest.json
new file mode 100644
index 0000000..3766d06
--- /dev/null
+++ b/extensions/guacamole-display-statistics/src/main/resources/guac-manifest.json
@@ -0,0 +1,28 @@
+{
+
+    "guacamoleVersion" : "1.5.2",
+
+    "name"      : "Display Statistic Toolbar",
+    "namespace" : "display-stats",
+
+    "translations" : [
+        "translations/en.json"
+    ],
+
+    "js" : [
+        "display-stats.min.js"
+    ],
+
+    "css" : [
+        "display-stats.min.css"
+    ],
+
+    "html" : [
+        "html/add-statistics.html"
+    ],
+
+    "resources" : {
+        "templates/guacClientStatistics.html" : "text/html"
+    }
+
+}
diff --git a/extensions/guacamole-display-statistics/src/main/resources/html/add-statistics.html b/extensions/guacamole-display-statistics/src/main/resources/html/add-statistics.html
new file mode 100644
index 0000000..5978f7f
--- /dev/null
+++ b/extensions/guacamole-display-statistics/src/main/resources/html/add-statistics.html
@@ -0,0 +1,4 @@
+<meta name="after" content=".client-tile guac-client">
+
+<!-- Guacamole display statistics -->
+<guac-client-statistics client="client"></guac-client-statistics>
diff --git a/extensions/guacamole-display-statistics/src/main/resources/license.txt b/extensions/guacamole-display-statistics/src/main/resources/license.txt
new file mode 100644
index 0000000..042f3ce
--- /dev/null
+++ b/extensions/guacamole-display-statistics/src/main/resources/license.txt
@@ -0,0 +1,18 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
diff --git a/extensions/guacamole-display-statistics/src/main/resources/styles/clientStatistics.css b/extensions/guacamole-display-statistics/src/main/resources/styles/clientStatistics.css
new file mode 100644
index 0000000..ba46614
--- /dev/null
+++ b/extensions/guacamole-display-statistics/src/main/resources/styles/clientStatistics.css
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+guac-client-statistics {
+    font-size: 13px;
+    color: white;
+    background: #111;
+}
+
+guac-client-statistics dl.client-statistics {
+    display: table;
+    margin: 0;
+    padding: 0.25em;
+}
+
+guac-client-statistics dl.client-statistics dt,
+guac-client-statistics dl.client-statistics dd {
+    display: table-cell;
+    padding: 0.25em;
+}
+
+guac-client-statistics dl.client-statistics dt {
+    padding-right: 0.5em;
+    padding-left: 1em;
+}
+
+guac-client-statistics dl.client-statistics dt:first-child {
+    padding-left: 0.5em;
+}
+
+guac-client-statistics dl.client-statistics dd {
+    min-width: 6em;
+    border: 1px solid rgba(255, 255, 255, 0.125);
+    border-radius: 3px;
+    background: black;
+}
+
+guac-client-statistics dl.client-statistics dd.no-value::before {
+    color: #888;
+    content: '-';
+}
diff --git a/extensions/guacamole-display-statistics/src/main/resources/templates/guacClientStatistics.html b/extensions/guacamole-display-statistics/src/main/resources/templates/guacClientStatistics.html
new file mode 100644
index 0000000..ae7537c
--- /dev/null
+++ b/extensions/guacamole-display-statistics/src/main/resources/templates/guacClientStatistics.html
@@ -0,0 +1,39 @@
+<dl class="client-statistics">
+
+    <dt class="client-statistic desktop-fps">
+        {{ 'CLIENT.FIELD_HEADER_DESKTOP_FRAMERATE' | translate }}
+    </dt>
+    <dd ng-class="{ 'no-value' : !hasValue(statistics.desktopFps) }">
+        <span ng-show="hasValue(statistics.desktopFps)"
+            translate="CLIENT.INFO_FRAMERATE"
+            translate-values="{ VALUE : round(statistics.desktopFps) }"></span>
+    </dd>
+
+    <dt class="client-statistic server-fps">
+        {{ 'CLIENT.FIELD_HEADER_SERVER_FRAMERATE' | translate }}
+    </dt>
+    <dd ng-class="{ 'no-value' : !hasValue(statistics.serverFps) }">
+        <span ng-show="hasValue(statistics.serverFps)"
+            translate="CLIENT.INFO_FRAMERATE"
+            translate-values="{ VALUE : round(statistics.serverFps) }"></span>
+    </dd>
+
+    <dt class="client-statistic client-fps">
+        {{ 'CLIENT.FIELD_HEADER_CLIENT_FRAMERATE' | translate }}
+    </dt>
+    <dd ng-class="{ 'no-value' : !hasValue(statistics.clientFps) }">
+        <span ng-show="hasValue(statistics.clientFps)"
+            translate="CLIENT.INFO_FRAMERATE"
+            translate-values="{ VALUE : round(statistics.clientFps) }"></span>
+    </dd>
+
+    <dt class="client-statistic drop-rate">
+        {{ 'CLIENT.FIELD_HEADER_DROP_FRAMERATE' | translate }}
+    </dt>
+    <dd ng-class="{ 'no-value' : !hasValue(statistics.dropRate) }">
+        <span ng-show="hasValue(statistics.dropRate)"
+            translate="CLIENT.INFO_FRAMERATE"
+            translate-values="{ VALUE : round(statistics.dropRate) }"></span>
+    </dd>
+
+</dl>
\ No newline at end of file
diff --git a/extensions/guacamole-display-statistics/src/main/resources/translations/en.json b/extensions/guacamole-display-statistics/src/main/resources/translations/en.json
new file mode 100644
index 0000000..ef42908
--- /dev/null
+++ b/extensions/guacamole-display-statistics/src/main/resources/translations/en.json
@@ -0,0 +1,12 @@
+{
+    "CLIENT" : {
+
+        "FIELD_HEADER_CLIENT_FRAMERATE"  : "Guacamole (Client):",
+        "FIELD_HEADER_DESKTOP_FRAMERATE" : "Remote Desktop:",
+        "FIELD_HEADER_DROP_FRAMERATE"    : "Drop:",
+        "FIELD_HEADER_SERVER_FRAMERATE"  : "Guacamole (Server):",
+
+        "INFO_FRAMERATE" : "{VALUE} fps"
+
+    }
+}
diff --git a/extensions/guacamole-history-recording-storage/src/main/resources/guac-manifest.json b/extensions/guacamole-history-recording-storage/src/main/resources/guac-manifest.json
index 3ab944d..7e4e3f8 100644
--- a/extensions/guacamole-history-recording-storage/src/main/resources/guac-manifest.json
+++ b/extensions/guacamole-history-recording-storage/src/main/resources/guac-manifest.json
@@ -10,7 +10,8 @@
     ],
 
     "translations" : [
-        "translations/en.json"
+        "translations/en.json",
+        "translations/pl.json"
     ]
 
 }
diff --git a/extensions/guacamole-history-recording-storage/src/main/resources/translations/pl.json b/extensions/guacamole-history-recording-storage/src/main/resources/translations/pl.json
new file mode 100644
index 0000000..fc8f63f
--- /dev/null
+++ b/extensions/guacamole-history-recording-storage/src/main/resources/translations/pl.json
@@ -0,0 +1,14 @@
+{
+
+    "DATA_SOURCE_RECORDING_STORAGE" : {
+        "NAME" : "Session Recording Storage"
+    },
+
+    "RECORDING_STORAGE" : {
+        "INFO_GUACAMOLE_SESSION_RECORDING" :  "Graficzne nagranie sesji zdalnego pulpitu",
+        "INFO_SERVER_LOG" :  "Serwer/system log",
+        "INFO_TYPESCRIPT" :  "Tesktowe nagranie sesji",
+        "INFO_TYPESCRIPT_TIMING" :  "Informacje o czasie dla tesktowego nagrania sesji"
+    }
+
+}
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/pom.xml b/extensions/guacamole-vault/modules/guacamole-vault-base/pom.xml
index d0ac467..9821065 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-base/pom.xml
+++ b/extensions/guacamole-vault/modules/guacamole-vault-base/pom.xml
@@ -59,6 +59,13 @@
             <artifactId>jackson-dataformat-yaml</artifactId>
         </dependency>
 
+        <!-- JUnit -->
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <scope>test</scope>
+        </dependency>
+
         <!-- Guice -->
         <dependency>
             <groupId>com.google.inject</groupId>
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultAttributeService.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultAttributeService.java
new file mode 100644
index 0000000..bccf08e
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultAttributeService.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.vault.conf;
+
+import java.util.Collection;
+
+import org.apache.guacamole.form.Form;
+
+/**
+ * A service that exposes attributes for the admin UI, specific to the vault
+ * implementation. Any vault implementation will need to expose the attributes
+ * necessary for that implementation.
+ */
+public interface VaultAttributeService {
+
+    /**
+     * Return all custom connection attributes to be exposed through the
+     * admin UI for the current vault implementation.
+     *
+     * @return
+     *     All custom connection attributes to be exposed through the
+     *     admin UI for the current vault implementation.
+     */
+    public Collection<Form> getConnectionAttributes();
+
+    /**
+     * Return all custom connection group attributes to be exposed through the
+     * admin UI for the current vault implementation.
+     *
+     * @return
+     *     All custom connection group attributes to be exposed through the
+     *     admin UI for the current vault implementation.
+     */
+    public Collection<Form> getConnectionGroupAttributes();
+
+    /**
+     * Return all custom user attributes to be exposed through the admin UI for
+     * the current vault implementation.
+     *
+     * @return
+     *     All custom user attributes to be exposed through the admin UI for
+     *     the current vault implementation.
+     */
+    public Collection<Form> getUserAttributes();
+
+    /**
+     * Return all user preference attributes to be exposed through the user
+     * preferences UI for the current vault implementation.
+     *
+     * @return
+     *     All user preference attributes to be exposed through the user
+     *     preferences UI for the current vault implementation.
+     */
+    public Collection<Form> getUserPreferenceAttributes();
+}
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultConfigurationService.java
index a666a7b..3a3189b 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultConfigurationService.java
+++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultConfigurationService.java
@@ -55,7 +55,7 @@
 
     @Inject
     private VaultSecretService secretService;
-    
+
     /**
      * ObjectMapper for deserializing YAML.
      */
@@ -127,7 +127,7 @@
                 return Collections.emptyMap();
 
             return mapping;
-            
+
         }
 
         // Fail if YAML is invalid/unreadable
@@ -169,7 +169,7 @@
                     String secretName = super.getProperty(name);
                     if (secretName == null)
                         return null;
-                    
+
                     return secretService.getValue(secretName).get();
 
                 }
@@ -177,7 +177,7 @@
 
                     if (e.getCause() instanceof GuacamoleException)
                         throw (GuacamoleException) e;
-                    
+
                     throw new GuacamoleServerException(String.format("Property "
                             + "\"%s\" could not be retrieved from the vault.", name), e);
                 }
@@ -187,4 +187,40 @@
 
     }
 
+    /**
+     * Return whether Windows domains should be split out from usernames when
+     * fetched from the vault.
+     *
+     * For example: "DOMAIN\\user" or "user@DOMAIN" should both
+     * be split into seperate username and domain tokens if this configuration
+     * is true. If false, no domain token should be created and the above values
+     * should be stored directly in the username token.
+     *
+     * @return
+     *     true if windows domains should be split out from usernames, false
+     *     otherwise.
+     *
+     * @throws GuacamoleException
+     *     If the value specified within guacamole.properties cannot be
+     *     parsed.
+     */
+    public abstract boolean getSplitWindowsUsernames() throws GuacamoleException;
+
+    /**
+     * Return whether domains should be considered when matching user records
+     * that are fetched from the vault.
+     *
+     * If set to true, the username and domain must both match when matching
+     * records from the vault. If false, only the username will be considered.
+     *
+     * @return
+     *     true if both the username and domain should be considered when
+     *     matching user records from the vault.
+     *
+     * @throws GuacamoleException
+     *     If the value specified within guacamole.properties cannot be
+     *     parsed.
+     */
+    public abstract boolean getMatchUserRecordsByDomain() throws GuacamoleException;
+
 }
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/VaultSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/VaultSecretService.java
index 76349ba..81204be 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/VaultSecretService.java
+++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/VaultSecretService.java
@@ -22,6 +22,8 @@
 import java.util.Map;
 import java.util.concurrent.Future;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.Connectable;
+import org.apache.guacamole.net.auth.UserContext;
 import org.apache.guacamole.protocol.GuacamoleConfiguration;
 import org.apache.guacamole.token.TokenFilter;
 
@@ -55,7 +57,9 @@
     /**
      * Returns a Future which eventually completes with the value of the secret
      * having the given name. If no such secret exists, the Future will be
-     * completed with null.
+     * completed with null. The secrets retrieved from this method are independent
+     * of the context of the particular connection being established, or any
+     * associated user context.
      *
      * @param name
      *     The name of the secret to retrieve.
@@ -73,6 +77,35 @@
     Future<String> getValue(String name) throws GuacamoleException;
 
     /**
+     * Returns a Future which eventually completes with the value of the secret
+     * having the given name. If no such secret exists, the Future will be
+     * completed with null. The connection or connection group, as well as the
+     * user context associated with the request are provided for additional context.
+     *
+     * @param userContext
+     *     The user context associated with the connection or connection group for
+     *     which the secret is being retrieved.
+     *
+     * @param connectable
+     *     The connection or connection group for which the secret is being retrieved.
+     *
+     * @param name
+     *     The name of the secret to retrieve.
+     *
+     * @return
+     *     A Future which completes with value of the secret having the given
+     *     name. If no such secret exists, the Future will be completed with
+     *     null. If an error occurs asynchronously which prevents retrieval of
+     *     the secret, that error will be exposed through an ExecutionException
+     *     when an attempt is made to retrieve the value from the Future.
+     *
+     * @throws GuacamoleException
+     *     If the secret cannot be retrieved due to an error.
+     */
+    Future<String> getValue(UserContext userContext, Connectable connectable,
+            String name) throws GuacamoleException;
+
+    /**
      * Returns a map of token names to corresponding Futures which eventually
      * complete with the value of that token, where each token is dynamically
      * defined based on connection parameters. If a vault implementation allows
@@ -80,6 +113,12 @@
      * function should be implemented to provide automatic tokens for those
      * secrets and remove the need for manual mapping via YAML.
      *
+     * @param userContext
+     *     The user context from which the connectable originated.
+     *
+     * @param connectable
+     *     The connection or connection group for which the tokens are being replaced.
+     *
      * @param config
      *     The configuration of the Guacamole connection for which tokens are
      *     being generated. This configuration may be empty or partial,
@@ -99,7 +138,7 @@
      *     If an error occurs producing the tokens and values required for the
      *     given configuration.
      */
-    Map<String, Future<String>> getTokens(GuacamoleConfiguration config,
-            TokenFilter filter) throws GuacamoleException;
+    Map<String, Future<String>> getTokens(UserContext userContext, Connectable connectable,
+            GuacamoleConfiguration config, TokenFilter filter) throws GuacamoleException;
 
 }
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/WindowsUsername.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/WindowsUsername.java
new file mode 100644
index 0000000..36d8a1b
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/secret/WindowsUsername.java
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.vault.secret;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.annotation.Nonnull;
+
+/**
+ * A class representing a Windows username, which may optionally also include
+ * a domain. This class can be used to parse the username and domain out of a
+ * username from a vault.
+ */
+public class WindowsUsername {
+
+    /**
+     * A pattern for matching a down-level logon name containing a Windows
+     * domain and username - e.g. domain\\user. For more information, see
+     * https://docs.microsoft.com/en-us/windows/win32/secauthn/user-name-formats#down-level-logon-name
+     */
+    private static final Pattern DOWN_LEVEL_LOGON_NAME_PATTERN = Pattern.compile(
+            "(?<domain>[^@\\\\]+)\\\\(?<username>[^@\\\\]+)");
+
+    /**
+     * A pattern for matching a user principal name containing a Windows
+     * domain and username - e.g. user@domain. For more information, see
+     * https://docs.microsoft.com/en-us/windows/win32/secauthn/user-name-formats#user-principal-name
+     */
+    private static final  Pattern USER_PRINCIPAL_NAME_PATTERN = Pattern.compile(
+            "(?<username>[^@\\\\]+)@(?<domain>[^@\\\\]+)");
+
+    /**
+     * The username associated with the potential Windows domain/username
+     * value. If no domain is found, the username field will contain the
+     * entire value as read from the vault.
+     */
+    private final String username;
+
+    /**
+     * The dinaun associated with the potential Windows domain/username
+     * value. If no domain is found, this will be null.
+     */
+    private final String domain;
+
+    /**
+     * Create a WindowsUsername record with no associated domain.
+     *
+     * @param username
+     *     The username, which should be the entire value as extracted
+     *     from the vault.
+     */
+    private WindowsUsername(@Nonnull String username) {
+        this.username = username;
+        this.domain = null;
+    }
+
+    /**
+     * Create a WindowsUsername record with a username and a domain.
+     *
+     * @param username
+     *     The username portion of the field value from the vault.
+     *
+     * @param domain
+     *     The domain portion of the field value from the vault.
+     */
+    private WindowsUsername(
+            @Nonnull String username, @Nonnull String domain) {
+        this.username = username;
+        this.domain = domain;
+    }
+
+    /**
+     * Return the value of the username as extracted from the vault field.
+     * If the domain is null, this will be the entire field value.
+     *
+     * @return
+     *     The username value as extracted from the vault field.
+     */
+    public String getUsername() {
+        return username;
+    }
+
+    /**
+     * Return the value of the domain as extracted from the vault field.
+     * If this is null, it means that no domain was found in the vault field.
+     *
+     * @return
+     *     The domain value as extracted from the vault field.
+     */
+    public String getDomain() {
+        return domain;
+    }
+
+    /**
+     * Return true if a domain was found in the vault field, false otherwise.
+     *
+     * @return
+     *     true if a domain was found in the vault field, false otherwise.
+     */
+    public boolean hasDomain() {
+        return this.domain != null;
+    }
+
+    /**
+     * Strip off a Windows domain from the provided username, if one is
+     * present. For example: "DOMAIN\\user" or "user@DOMAIN" will both
+     * be stripped to just "user". Note: neither the '@' or '\\' characters
+     * are valid in Windows usernames.
+     *
+     * @param vaultField
+     *     The raw field value as retrieved from the vault. This might contain
+     *     a Windows domain.
+     *
+     * @return
+     *     The provided username with the Windows domain stripped off, if one
+     *     is present.
+     */
+    public static WindowsUsername splitWindowsUsernameFromDomain(String vaultField) {
+
+        // If it's the down-level logon format, return the extracted username and domain
+        Matcher downLevelLogonMatcher = DOWN_LEVEL_LOGON_NAME_PATTERN.matcher(vaultField);
+        if (downLevelLogonMatcher.matches())
+            return new WindowsUsername(
+                    downLevelLogonMatcher.group("username"),
+                    downLevelLogonMatcher.group("domain"));
+
+        // If it's the user principal format, return the extracted username and domain
+        Matcher userPrincipalMatcher = USER_PRINCIPAL_NAME_PATTERN.matcher(vaultField);
+        if (userPrincipalMatcher.matches())
+            return new WindowsUsername(
+                    userPrincipalMatcher.group("username"),
+                    userPrincipalMatcher.group("domain"));
+
+        // If none of the expected formats matched, return the username with do domain
+        return new WindowsUsername(vaultField);
+
+    }
+
+}
\ No newline at end of file
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultDirectoryService.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultDirectoryService.java
new file mode 100644
index 0000000..700d9d3
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultDirectoryService.java
@@ -0,0 +1,140 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.vault.user;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.ActiveConnection;
+import org.apache.guacamole.net.auth.Connection;
+import org.apache.guacamole.net.auth.ConnectionGroup;
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.SharingProfile;
+import org.apache.guacamole.net.auth.User;
+import org.apache.guacamole.net.auth.UserGroup;
+
+/**
+ * A service that allows a vault implementation to override the directory
+ * for any entity that a user context may return.
+ */
+public abstract class VaultDirectoryService {
+
+    /**
+     * Given an existing User Directory, return a new Directory for
+     * this vault implementation.
+     *
+     * @return
+     *     A new User Directory based on the provided Directory.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the Directory.
+     */
+    public Directory<User> getUserDirectory(
+            Directory<User> underlyingDirectory) throws GuacamoleException {
+
+        // By default, the provided directly will be returned unchanged
+        return underlyingDirectory;
+    }
+
+    /**
+     * Given an existing UserGroup Directory, return a new Directory for
+     * this vault implementation.
+     *
+     * @return
+     *     A new UserGroup Directory based on the provided Directory.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the Directory.
+     */
+    public Directory<UserGroup> getUserGroupDirectory(
+            Directory<UserGroup> underlyingDirectory) throws GuacamoleException {
+
+        // Unless overriden in the vault implementation, the underlying directory
+        // will be returned directly
+        return underlyingDirectory;
+    }
+
+    /**
+     * Given an existing Connection Directory, return a new Directory for
+     * this vault implementation.
+     *
+     * @return
+     *     A new Connection Directory based on the provided Directory.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the Directory.
+     */
+    public Directory<Connection> getConnectionDirectory(
+            Directory<Connection> underlyingDirectory) throws GuacamoleException {
+
+        // By default, the provided directly will be returned unchanged
+        return underlyingDirectory;
+    }
+
+    /**
+     * Given an existing ConnectionGroup Directory, return a new Directory for
+     * this vault implementation.
+     *
+     * @return
+     *     A new ConnectionGroup Directory based on the provided Directory.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the Directory.
+     */
+    public Directory<ConnectionGroup> getConnectionGroupDirectory(
+            Directory<ConnectionGroup> underlyingDirectory) throws GuacamoleException {
+
+        // By default, the provided directly will be returned unchanged
+        return underlyingDirectory;
+    }
+
+    /**
+     * Given an existing ActiveConnection Directory, return a new Directory for
+     * this vault implementation.
+     *
+     * @return
+     *     A new ActiveConnection Directory based on the provided Directory.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the Directory.
+     */
+    public Directory<ActiveConnection> getActiveConnectionDirectory(
+            Directory<ActiveConnection> underlyingDirectory) throws GuacamoleException {
+
+        // By default, the provided directly will be returned unchanged
+        return underlyingDirectory;
+    }
+
+    /**
+     * Given an existing SharingProfile Directory, return a new Directory for
+     * this vault implementation.
+     *
+     * @return
+     *     A new SharingProfile Directory based on the provided Directory.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the Directory.
+     */
+    public Directory<SharingProfile> getSharingProfileDirectory(
+            Directory<SharingProfile> underlyingDirectory) throws GuacamoleException {
+
+        // By default, the provided directly will be returned unchanged
+        return underlyingDirectory;
+    }
+
+}
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java
index 5390148..eeddbe8 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java
+++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java
@@ -22,19 +22,33 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
+
+import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleServerException;
+import org.apache.guacamole.form.Form;
+import org.apache.guacamole.net.auth.ActiveConnection;
+import org.apache.guacamole.net.auth.Connectable;
 import org.apache.guacamole.net.auth.Connection;
 import org.apache.guacamole.net.auth.ConnectionGroup;
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.SharingProfile;
 import org.apache.guacamole.net.auth.TokenInjectingUserContext;
+import org.apache.guacamole.net.auth.User;
 import org.apache.guacamole.net.auth.UserContext;
+import org.apache.guacamole.net.auth.UserGroup;
 import org.apache.guacamole.protocol.GuacamoleConfiguration;
 import org.apache.guacamole.token.GuacamoleTokenUndefinedException;
 import org.apache.guacamole.token.TokenFilter;
+import org.apache.guacamole.vault.conf.VaultAttributeService;
 import org.apache.guacamole.vault.conf.VaultConfigurationService;
 import org.apache.guacamole.vault.secret.VaultSecretService;
 import org.slf4j.Logger;
@@ -122,6 +136,20 @@
     private VaultSecretService secretService;
 
     /**
+     * Service for retrieving any custom attributes defined for the
+     * current vault implementation.
+     */
+    @Inject
+    private VaultAttributeService attributeService;
+
+    /**
+     * Service for modifying any underlying directories for the current
+     * vault implementation.
+     */
+    @Inject
+    private VaultDirectoryService directoryService;
+
+    /**
      * Creates a new VaultUserContext which automatically injects tokens
      * containing values of secrets retrieved from a vault. The given
      * UserContext is decorated such that connections and connection groups
@@ -182,6 +210,10 @@
      * corresponding values from the vault, using the given TokenFilter to
      * filter tokens within the secret names prior to retrieving those secrets.
      *
+     * @param connectable
+     *     The connection or connection group to which the connection is being
+     *     established.
+     *
      * @param tokenMapping
      *     The mapping dictating the name of the secret which maps to each
      *     parameter token, where the key is the name of the parameter token
@@ -211,7 +243,8 @@
      *     If the value for any applicable secret cannot be retrieved from the
      *     vault due to an error.
      */
-    private Map<String, Future<String>> getTokens(Map<String, String> tokenMapping,
+    private Map<String, Future<String>> getTokens(
+            Connectable connectable, Map<String, String> tokenMapping,
             TokenFilter secretNameFilter, GuacamoleConfiguration config,
             TokenFilter configFilter) throws GuacamoleException {
 
@@ -236,14 +269,16 @@
 
             // Initiate asynchronous retrieval of the token value
             String tokenName = entry.getKey();
-            Future<String> secret = secretService.getValue(secretName);
+            Future<String> secret = secretService.getValue(
+                    this, connectable, secretName);
             pendingTokens.put(tokenName, secret);
 
         }
 
         // Additionally include any dynamic, parameter-based tokens
-        pendingTokens.putAll(secretService.getTokens(config, configFilter));
-        
+        pendingTokens.putAll(secretService.getTokens(
+                this, connectable, config, configFilter));
+
         return pendingTokens;
 
     }
@@ -318,7 +353,8 @@
 
         // Substitute tokens producing secret names, retrieving and storing
         // those secrets as parameter tokens
-        tokens.putAll(resolve(getTokens(confService.getTokenMapping(), filter,
+        tokens.putAll(resolve(getTokens(
+                connectionGroup, confService.getTokenMapping(), filter,
                 null, new TokenFilter(tokens))));
 
     }
@@ -371,7 +407,6 @@
         TokenFilter filter = createFilter();
         filter.setToken(CONNECTION_NAME_TOKEN, connection.getName());
         filter.setToken(CONNECTION_IDENTIFIER_TOKEN, identifier);
-
         // Add hostname and username tokens if available (implementations are
         // not required to expose connection configuration details)
 
@@ -398,8 +433,95 @@
 
         // Substitute tokens producing secret names, retrieving and storing
         // those secrets as parameter tokens
-        tokens.putAll(resolve(getTokens(confService.getTokenMapping(), filter,
-                config, new TokenFilter(tokens))));
+        tokens.putAll(resolve(getTokens(connection, confService.getTokenMapping(),
+                filter, config, new TokenFilter(tokens))));
+
+    }
+
+    @Override
+    public Directory<User> getUserDirectory() throws GuacamoleException {
+
+        // Defer to the vault-specific directory service
+        return directoryService.getUserDirectory(super.getUserDirectory());
+    }
+
+    @Override
+    public Directory<UserGroup> getUserGroupDirectory() throws GuacamoleException {
+
+        // Defer to the vault-specific directory service
+        return directoryService.getUserGroupDirectory(super.getUserGroupDirectory());
+    }
+
+    @Override
+    public Directory<Connection> getConnectionDirectory() throws GuacamoleException {
+
+        // Defer to the vault-specific directory service
+        return directoryService.getConnectionDirectory(super.getConnectionDirectory());
+    }
+
+    @Override
+    public Directory<ConnectionGroup> getConnectionGroupDirectory() throws GuacamoleException {
+
+        // Defer to the vault-specific directory service
+        return directoryService.getConnectionGroupDirectory(super.getConnectionGroupDirectory());
+    }
+
+    @Override
+    public Directory<ActiveConnection> getActiveConnectionDirectory() throws GuacamoleException {
+
+        // Defer to the vault-specific directory service
+        return directoryService.getActiveConnectionDirectory(super.getActiveConnectionDirectory());
+    }
+
+    @Override
+    public Directory<SharingProfile> getSharingProfileDirectory() throws GuacamoleException {
+
+        // Defer to the vault-specific directory service
+        return directoryService.getSharingProfileDirectory(super.getSharingProfileDirectory());
+
+    }
+
+    @Override
+    public Collection<Form> getUserAttributes() {
+
+        // Add any custom attributes to any previously defined attributes
+        return Collections.unmodifiableCollection(Stream.concat(
+                super.getUserAttributes().stream(),
+                attributeService.getUserAttributes().stream()
+        ).collect(Collectors.toList()));
+
+    }
+
+    @Override
+    public Collection<Form> getUserPreferenceAttributes() {
+
+        // Add any custom preference attributes to any previously defined attributes
+        return Collections.unmodifiableCollection(Stream.concat(
+                super.getUserPreferenceAttributes().stream(),
+                attributeService.getUserPreferenceAttributes().stream()
+        ).collect(Collectors.toList()));
+
+    }
+
+    @Override
+    public Collection<Form> getConnectionAttributes() {
+
+        // Add any custom attributes to any previously defined attributes
+        return Collections.unmodifiableCollection(Stream.concat(
+                super.getConnectionAttributes().stream(),
+                attributeService.getConnectionAttributes().stream()
+        ).collect(Collectors.toList()));
+
+    }
+
+    @Override
+    public Collection<Form> getConnectionGroupAttributes() {
+
+        // Add any custom attributes to any previously defined attributes
+        return Collections.unmodifiableCollection(Stream.concat(
+                super.getConnectionGroupAttributes().stream(),
+                attributeService.getConnectionGroupAttributes().stream()
+        ).collect(Collectors.toList()));
 
     }
 
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/test/java/org/apache/guacamole/vault/secret/WindowsUsernameTest.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/test/java/org/apache/guacamole/vault/secret/WindowsUsernameTest.java
new file mode 100644
index 0000000..61f4546
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/test/java/org/apache/guacamole/vault/secret/WindowsUsernameTest.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.vault.secret;
+
+import org.junit.Test;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Class to test the parsing functionality of the WindowsUsername class.
+ */
+public class WindowsUsernameTest {
+
+    /**
+     * Verify that the splitWindowsUsernameFromDomain() method correctly strips Windows
+     * domains from provided usernames that include them, and does not modify
+     * usernames that do not have Windows domains.
+     */
+    @Test
+    public void testSplitWindowsUsernameFromDomain() {
+
+        WindowsUsername usernameAndDomain;
+
+        // If no Windows domain is present in the provided field, the username should
+        // contain the entire field, and no domain should be returned
+        usernameAndDomain = WindowsUsername.splitWindowsUsernameFromDomain("bob");
+        assertEquals(usernameAndDomain.getUsername(), "bob");
+        assertFalse(usernameAndDomain.hasDomain());
+
+        // It should parse down-level logon name style domains
+        usernameAndDomain = WindowsUsername.splitWindowsUsernameFromDomain("localhost\\bob");
+        assertEquals("bob", usernameAndDomain.getUsername(), "bob");
+        assertTrue(usernameAndDomain.hasDomain());
+        assertEquals("localhost", usernameAndDomain.getDomain());
+
+        // It should parse user principal name style domains
+        usernameAndDomain = WindowsUsername.splitWindowsUsernameFromDomain("bob@localhost");
+        assertEquals("bob", usernameAndDomain.getUsername(), "bob");
+        assertTrue(usernameAndDomain.hasDomain());
+        assertEquals("localhost", usernameAndDomain.getDomain());
+
+        // It should not match if there are an invalid number of separators
+        List<String> invalidSeparators = Arrays.asList(
+                "bob@local@host", "local\\host\\bob",
+                "bob\\local@host", "local@host\\bob");
+        invalidSeparators.stream().forEach(
+            invalidSeparator -> {
+
+                // An invalid number of separators means that the parse failed -
+                // there should be no detected domain, and the entire field value
+                // should be returned as the username
+                WindowsUsername parseOutput =
+                        WindowsUsername.splitWindowsUsernameFromDomain(invalidSeparator);
+                assertFalse(parseOutput.hasDomain());
+                assertEquals(invalidSeparator, parseOutput.getUsername());
+
+            });
+
+    }
+
+}
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/GuacamoleExceptionSupplier.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/GuacamoleExceptionSupplier.java
new file mode 100644
index 0000000..c996137
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/GuacamoleExceptionSupplier.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.vault.ksm;
+
+import org.apache.guacamole.GuacamoleException;
+
+/**
+ * A class that is basically equivalent to the standard Supplier class in
+ * Java, except that the get() function can throw GuacamoleException, which
+ * is impossible with any of the standard Java lambda type classes, since
+ * none of them can handle checked exceptions
+ *
+ * @param <T>
+ *     The type of object which will be returned as a result of calling
+ *     get().
+ */
+public interface GuacamoleExceptionSupplier<T> {
+
+    /**
+     * Returns a value of the declared type.
+     *
+     * @return
+     *    A value of the declared type.
+     *
+     * @throws GuacamoleException
+     *    If an error occurs while attemping to calculate the return value.
+     */
+    public T get() throws GuacamoleException;
+
+}
\ No newline at end of file
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java
index bcc5a78..b9d38da 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java
@@ -21,12 +21,22 @@
 
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.vault.VaultAuthenticationProviderModule;
+import org.apache.guacamole.vault.ksm.conf.KsmAttributeService;
 import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService;
 import org.apache.guacamole.vault.ksm.secret.KsmSecretService;
+import org.apache.guacamole.vault.ksm.user.KsmConnectionGroup;
+import org.apache.guacamole.vault.ksm.user.KsmDirectoryService;
+import org.apache.guacamole.vault.ksm.user.KsmUserFactory;
+import org.apache.guacamole.vault.ksm.user.KsmUser;
+import org.apache.guacamole.vault.conf.VaultAttributeService;
 import org.apache.guacamole.vault.conf.VaultConfigurationService;
 import org.apache.guacamole.vault.ksm.secret.KsmClient;
+import org.apache.guacamole.vault.ksm.secret.KsmClientFactory;
 import org.apache.guacamole.vault.ksm.secret.KsmRecordService;
 import org.apache.guacamole.vault.secret.VaultSecretService;
+import org.apache.guacamole.vault.user.VaultDirectoryService;
+
+import com.google.inject.assistedinject.FactoryModuleBuilder;
 
 /**
  * Guice module which configures injections specific to Keeper Secrets
@@ -49,10 +59,22 @@
     protected void configureVault() {
 
         // Bind services specific to Keeper Secrets Manager
-        bind(KsmClient.class);
         bind(KsmRecordService.class);
+        bind(KsmAttributeService.class);
+        bind(VaultAttributeService.class).to(KsmAttributeService.class);
         bind(VaultConfigurationService.class).to(KsmConfigurationService.class);
         bind(VaultSecretService.class).to(KsmSecretService.class);
+        bind(VaultDirectoryService.class).to(KsmDirectoryService.class);
+
+        // Bind factory for creating KSM Clients
+        install(new FactoryModuleBuilder()
+                .implement(KsmClient.class, KsmClient.class)
+                .build(KsmClientFactory.class));
+
+        // Bind factory for creating KsmUsers
+        install(new FactoryModuleBuilder()
+                .implement(KsmUser.class, KsmUser.class)
+                .build(KsmUserFactory.class));
     }
 
 }
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmAttributeService.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmAttributeService.java
new file mode 100644
index 0000000..8b3c5d9
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmAttributeService.java
@@ -0,0 +1,356 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.vault.ksm.conf;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.form.BooleanField;
+import org.apache.guacamole.form.Form;
+import org.apache.guacamole.form.TextField;
+import org.apache.guacamole.language.TranslatableGuacamoleClientException;
+import org.apache.guacamole.vault.conf.VaultAttributeService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.keepersecurity.secretsManager.core.InMemoryStorage;
+import com.keepersecurity.secretsManager.core.SecretsManager;
+import com.keepersecurity.secretsManager.core.SecretsManagerOptions;
+
+/**
+ * A service that exposes KSM-specific attributes, allowing setting KSM
+ * configuration through the admin interface.
+ */
+@Singleton
+public class KsmAttributeService implements VaultAttributeService {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(KsmAttributeService.class);
+
+    /**
+     * Service for retrieving KSM configuration details.
+     */
+    @Inject
+    private KsmConfigurationService configurationService;
+
+    /**
+     * A singleton ObjectMapper for converting a Map to a JSON string when
+     * generating a base64-encoded JSON KSM config blob.
+     */
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * All expected fields in the KSM configuration JSON blob.
+     */
+    private static final List<String> EXPECTED_KSM_FIELDS = (
+            Collections.unmodifiableList(Arrays.asList(
+                    SecretsManager.KEY_HOSTNAME,
+                    SecretsManager.KEY_CLIENT_ID,
+                    SecretsManager.KEY_PRIVATE_KEY,
+                    SecretsManager.KEY_CLIENT_KEY,
+                    SecretsManager.KEY_APP_KEY,
+                    SecretsManager.KEY_OWNER_PUBLIC_KEY,
+                    SecretsManager.KEY_SERVER_PUBIC_KEY_ID
+    )));
+
+    /**
+     * The name of the attribute which can contain a KSM configuration blob
+     * associated with either a connection group or user.
+     */
+    public static final String KSM_CONFIGURATION_ATTRIBUTE = "ksm-config";
+
+    /**
+     * The KSM configuration attribute contains sensitive information, so it
+     * should not be exposed through the directory. Instead, if a value is
+     * set on the attributes of an object, the following value will be exposed
+     * in its place, and correspondingly the underlying value will not be
+     * changed if this value is provided to an update call.
+     */
+    public static final String KSM_ATTRIBUTE_PLACEHOLDER_VALUE = "**********";
+
+    /**
+     * All attributes related to configuring the KSM vault on a
+     * per-connection-group or per-user basis.
+     */
+    public static final Form KSM_CONFIGURATION_FORM = new Form("ksm-config",
+            Arrays.asList(new TextField(KSM_CONFIGURATION_ATTRIBUTE)));
+
+    /**
+     * All KSM-specific attributes for users, connections, or connection groups, organized by form.
+     */
+    public static final Collection<Form> KSM_ATTRIBUTES =
+            Collections.unmodifiableCollection(Arrays.asList(KSM_CONFIGURATION_FORM));
+
+    /**
+     * The name of the attribute which can controls whether a KSM user configuration
+     * is enabled on a connection-by-connection basis.
+     */
+    public static final String KSM_USER_CONFIG_ENABLED_ATTRIBUTE = "ksm-user-config-enabled";
+
+    /**
+     * The string value used by KSM attributes to represent the boolean value "true".
+     */
+    public static final String TRUTH_VALUE = "true";
+
+    /**
+     * All attributes related to configuring the KSM vault on a per-connection basis.
+     */
+    public static final Form KSM_CONNECTION_FORM = new Form("ksm-config",
+            Arrays.asList(new BooleanField(KSM_USER_CONFIG_ENABLED_ATTRIBUTE, TRUTH_VALUE)));
+
+    /**
+     * All KSM-specific attributes for connections, organized by form.
+     */
+    public static final Collection<Form> KSM_CONNECTION_ATTRIBUTES =
+            Collections.unmodifiableCollection(Arrays.asList(KSM_CONNECTION_FORM));
+
+    @Override
+    public Collection<Form> getConnectionAttributes() {
+        return KSM_CONNECTION_ATTRIBUTES;
+    }
+
+    @Override
+    public Collection<Form> getConnectionGroupAttributes() {
+        return KSM_ATTRIBUTES;
+    }
+
+    @Override
+    public Collection<Form> getUserAttributes() {
+
+        try {
+
+            // Expose the user attributes IFF user-level KSM configuration is enabled
+            return configurationService.getAllowUserConfig() ? KSM_ATTRIBUTES : Collections.emptyList();
+
+        }
+
+        catch (GuacamoleException e) {
+
+            logger.warn(
+                    "Unable to determine if KSM user attributes "
+                    + "should be exposed due to config parsing error: {}.", e.getMessage());
+            logger.debug(
+                    "Config parsing error prevented checking user attribute configuration",
+                    e);
+
+            // If the configuration can't be parsed, default to not exposing the attributes
+            return Collections.emptyList();
+        }
+
+    }
+
+    @Override
+    public Collection<Form> getUserPreferenceAttributes() {
+
+        // KSM-specific user preference attributes have the same semantics as
+        // user attributes
+        return getUserAttributes();
+    }
+
+    /**
+     * Sanitize the value of the provided KSM config attribute. If the provided
+     * config value is non-empty, it will be replaced with the placeholder
+     * value to avoid leaking sensitive information. If the value is empty, it
+     * will be replaced by `null`.
+     *
+     * @param ksmAttributeValue
+     *    The KSM configuration attribute value to sanitize.
+     *
+     * @return
+     *    The sanitized KSM configuration attribute value, stripped of any
+     *    sensitive information.
+     */
+    public static String sanitizeKsmAttributeValue(String ksmAttributeValue) {
+
+        // Any non-empty values may contain sensitive information, and should
+        // be replaced by the safe placeholder value
+        if (ksmAttributeValue != null && !ksmAttributeValue.trim().isEmpty())
+            return KSM_ATTRIBUTE_PLACEHOLDER_VALUE;
+
+        // If the configuration value is empty, expose a null value
+        else
+            return null;
+
+    }
+
+    /**
+     * Return true if the provided input is a valid base64-encoded string,
+     * false otherwise.
+     *
+     * @param input
+     *     The string to check if base-64 encoded.
+     *
+     * @return
+     *     true if the provided input is a valid base64-encoded string,
+     *     false otherwise.
+     */
+    private static boolean isBase64(String input) {
+
+        try {
+            Base64.getDecoder().decode(input);
+            return true;
+        } catch (IllegalArgumentException e) {
+            return false;
+        }
+    }
+
+    /**
+     * Given a map of attribute values, check for the presence of the
+     * KSM_CONFIGURATION_ATTRIBUTE attribute. If it's set, check if it's a valid
+     * KSM one-time token. If so, attempt to translate it to a base-64-encoded
+     * json KSM config blob. If it's already a KSM config blob, validate it as
+     * config blob. If either validation fails, a GuacamoleException will be thrown.
+     * The processed attribute values will be returned.
+     *
+     * @param attributes
+     *     The attributes for which the KSM configuration attribute
+     *     parsing/validation should be performed.
+     *
+     * @throws GuacamoleException
+     *     If the KSM_CONFIGURATION_ATTRIBUTE is set, but fails to validate as
+     *     either a KSM one-time-token, or a KSM base64-encoded JSON config blob.
+     */
+    public Map<String, String> processAttributes(
+            Map<String, String> attributes) throws GuacamoleException {
+
+        // Get the value of the KSM config attribute in the provided map
+        String ksmConfigValue = attributes.get(
+                KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE);
+
+        // If the placeholder value was provided, do not update the attribute
+        if (KsmAttributeService.KSM_ATTRIBUTE_PLACEHOLDER_VALUE.equals(ksmConfigValue)) {
+
+            // Remove the attribute from the map so it won't be updated
+            attributes = new HashMap<>(attributes);
+            attributes.remove(KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE);
+
+        }
+
+        // Check if the attribute is set to a non-empty value
+        else if (ksmConfigValue != null && !ksmConfigValue.trim().isEmpty()) {
+
+            // If it's already base64-encoded, it's a KSM configuration blob,
+            // so validate it immediately
+            if (isBase64(ksmConfigValue)) {
+
+                // Attempt to validate the config as a base64-econded KSM config blob
+                try {
+                    KsmConfig.parseKsmConfig(ksmConfigValue);
+
+                    // If it validates, the entity can be left alone - it's already valid
+                    return attributes;
+                }
+
+                catch (GuacamoleException exception) {
+
+                    // If the parsing attempt fails, throw a translatable error for display
+                    // on the frontend
+                    throw new TranslatableGuacamoleClientException(
+                            "Invalid base64-encoded JSON KSM config provided for "
+                            + KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE + " attribute",
+                            "CONNECTION_GROUP_ATTRIBUTES.ERROR_INVALID_KSM_CONFIG_BLOB",
+                            exception);
+                }
+            }
+
+            // It wasn't a valid base64-encoded string, it should be a one-time token, so
+            // attempt to validat it as such, and if valid, update the attribute to the
+            // base64 config blob generated by the token
+            try {
+
+                // Create an initially empty storage to be populated using the one-time token
+                InMemoryStorage storage = new InMemoryStorage();
+
+                // Populate the in-memory storage using the one-time-token
+                SecretsManager.initializeStorage(storage, ksmConfigValue, null);
+
+                // Create an options object using the values we extracted from the one-time token
+                SecretsManagerOptions options = new SecretsManagerOptions(
+                    storage, null,
+                    configurationService.getAllowUnverifiedCertificate());
+
+                // Attempt to fetch secrets using the options we created. This will both validate
+                // that the configuration works, and potentially populate missing fields that the
+                // initializeStorage() call did not set.
+                SecretsManager.getSecrets(options);
+
+                // Create a map to store the extracted values from the KSM storage
+                Map<String, String> configMap = new HashMap<>();
+
+                // Go through all the expected fields, extract from the KSM storage,
+                // and write to the newly created map
+                EXPECTED_KSM_FIELDS.forEach(configKey -> {
+
+                    // Only write the value into the new map if non-null
+                    String value = storage.getString(configKey);
+                    if (value != null)
+                        configMap.put(configKey, value);
+
+                });
+
+                // JSON-encode the value, and then base64 encode that to get the format
+                // that KSM would expect
+                String jsonString = objectMapper.writeValueAsString(configMap);
+                String base64EncodedJson = Base64.getEncoder().encodeToString(
+                        jsonString.getBytes(StandardCharsets.UTF_8));
+
+                // Finally, try to parse the newly generated token as a KSM config. If this
+                // works, the config should be fully functional
+                KsmConfig.parseKsmConfig(base64EncodedJson);
+
+                // Make a copy of the existing attributes, modifying just the value for
+                // KSM_CONFIGURATION_ATTRIBUTE
+                attributes = new HashMap<>(attributes);
+                attributes.put(
+                        KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE, base64EncodedJson);
+
+            }
+
+            // The KSM SDK only throws raw Exceptions, so we can't be more specific
+            catch (Exception exception) {
+
+                // If the parsing attempt fails, throw a translatable error for display
+                // on the frontend
+                throw new TranslatableGuacamoleClientException(
+                        "Invalid one-time KSM token provided for "
+                        + KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE + " attribute",
+                        "CONNECTION_GROUP_ATTRIBUTES.ERROR_INVALID_KSM_ONE_TIME_TOKEN",
+                        exception);
+            }
+        }
+
+        return attributes;
+
+    }
+
+
+}
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigProperty.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfig.java
similarity index 65%
rename from extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigProperty.java
rename to extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfig.java
index aaddb0d..54aaec7 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigProperty.java
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfig.java
@@ -23,21 +23,28 @@
 import com.keepersecurity.secretsManager.core.KeyValueStorage;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleServerException;
-import org.apache.guacamole.properties.GuacamoleProperty;
 
 /**
- * A GuacamoleProperty whose value is Keeper Secrets Manager {@link KeyValueStorage}
- * object. The value of this property must be base64-encoded JSON, as output by
- * the Keeper Commander CLI tool via the "sm client add" command.
+ * A utility for parsing base64-encoded JSON, as output by the Keeper Commander
+ * CLI tool via the "sm client add" command into a Keeper Secrets Manager
+ * {@link KeyValueStorage} object.
  */
-public abstract class KsmConfigProperty implements GuacamoleProperty<KeyValueStorage> {
+public class KsmConfig {
 
-    @Override
-    public KeyValueStorage parseValue(String value) throws GuacamoleException {
-
-        // If no property provided, return null.
-        if (value == null)
-            return null;
+    /**
+     * Given a base64-encoded JSON KSM configuration, parse and return a
+     * KeyValueStorage object.
+     *
+     * @param value
+     *     The base64-encoded JSON KSM configuration to parse.
+     *
+     * @return
+     *     The KeyValueStorage that is a result of the parsing operation
+     *
+     * @throws GuacamoleException
+     *     If the provided value is not valid base-64 encoded JSON KSM configuration.
+     */
+    public static KeyValueStorage parseKsmConfig(String value) throws GuacamoleException {
 
         // Parse base64 value as KSM config storage
         try {
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigurationService.java
index 38bcaae..197b979 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigurationService.java
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigurationService.java
@@ -21,10 +21,19 @@
 
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+
+import javax.annotation.Nonnull;
+
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.GuacamoleServerException;
 import org.apache.guacamole.environment.Environment;
 import org.apache.guacamole.properties.BooleanGuacamoleProperty;
+import org.apache.guacamole.properties.LongGuacamoleProperty;
+import org.apache.guacamole.properties.StringGuacamoleProperty;
 import org.apache.guacamole.vault.conf.VaultConfigurationService;
+
+import com.keepersecurity.secretsManager.core.InMemoryStorage;
+import com.keepersecurity.secretsManager.core.KeyValueStorage;
 import com.keepersecurity.secretsManager.core.SecretsManagerOptions;
 
 /**
@@ -57,7 +66,7 @@
      * The base64-encoded configuration information generated by the Keeper
      * Commander CLI tool.
      */
-    private static final KsmConfigProperty KSM_CONFIG = new KsmConfigProperty() {
+    private static final StringGuacamoleProperty KSM_CONFIG = new StringGuacamoleProperty() {
 
         @Override
         public String getName() {
@@ -77,6 +86,53 @@
     };
 
     /**
+     * Whether users should be able to supply their own KSM configurations.
+     */
+    private static final BooleanGuacamoleProperty ALLOW_USER_CONFIG = new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() {
+            return "ksm-allow-user-config";
+        }
+    };
+
+    /**
+     * Whether windows domains should be stripped off from usernames that are
+     * read from the KSM vault.
+     */
+    private static final BooleanGuacamoleProperty STRIP_WINDOWS_DOMAINS = new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() {
+            return "ksm-strip-windows-domains";
+        }
+    };
+
+    /**
+     * Whether domains should be considered when matching login records in the KSM vault.
+     * If true, both the domain and username must match for a record to match when using
+     * tokens like "KEEPER_USER_*". If false, only the username must match.
+     */
+    private static final BooleanGuacamoleProperty MATCH_USER_DOMAINS = new BooleanGuacamoleProperty() {
+
+        @Override
+        public String getName() {
+            return "ksm-match-domains-for-users";
+        }
+    };
+
+    /**
+     * The minimum number of milliseconds between KSM API calls.
+     */
+    private static final LongGuacamoleProperty KSM_API_CALL_INTERVAL = new LongGuacamoleProperty() {
+
+        @Override
+        public String getName() {
+            return "ksm-api-call-interval";
+        }
+    };
+
+    /**
      * Creates a new KsmConfigurationService which reads the configuration
      * from "ksm-token-mapping.yml" and properties from
      * "guacamole.properties.ksm". The token mapping is a YAML file which lists
@@ -106,21 +162,114 @@
     }
 
     /**
+     * Return whether user-level KSM configs should be enabled. If this
+     * flag is set to true, users can edit their own KSM configs, as can
+     * admins. If set to false, no existing user-specific KSM configuration
+     * will be exposed through the UI or used when looking up secrets.
+     *
+     * @return
+     *     true if user-specific KSM configuration is enabled, false otherwise.
+     *
+     * @throws GuacamoleException
+     *     If the value specified within guacamole.properties cannot be
+     *     parsed.
+     */
+    public boolean getAllowUserConfig() throws GuacamoleException {
+        return environment.getProperty(ALLOW_USER_CONFIG, false);
+    }
+
+    @Override
+    public boolean getSplitWindowsUsernames() throws GuacamoleException {
+        return environment.getProperty(STRIP_WINDOWS_DOMAINS, false);
+    }
+
+    @Override
+    public boolean getMatchUserRecordsByDomain() throws GuacamoleException {
+        return environment.getProperty(MATCH_USER_DOMAINS, false);
+    }
+
+    /**
+     * Return the minimum number of milliseconds between KSM API calls. If not
+     * otherwise configured, this value will be 10 seconds.
+     *
+     * @return
+     *     The minimum number of milliseconds between KSM API calls.
+     *
+     * @throws GuacamoleException
+     *     If the value specified within guacamole.properties cannot be
+     *     parsed or does not exist.
+     */
+    public long getKsmApiInterval() throws GuacamoleException {
+        return environment.getProperty(KSM_API_CALL_INTERVAL, 10000L);
+    }
+
+    /**
+     * Return the globally-defined base-64-encoded JSON KSM configuration blob
+     * as a string.
+     *
+     * @return
+     *     The globally-defined base-64-encoded JSON KSM configuration blob
+     *     as a string.
+     *
+     * @throws GuacamoleException
+     *     If the value specified within guacamole.properties cannot be
+     *     parsed or does not exist.
+     */
+    @Nonnull
+    @SuppressWarnings("null")
+    public String getKsmConfig() throws GuacamoleException {
+
+        // This will always return a non-null value; an exception would be
+        // thrown if the required value is not set
+        return environment.getRequiredProperty(KSM_CONFIG);
+    }
+
+    /**
+     * Given a base64-encoded JSON KSM configuration, parse and return a
+     * KeyValueStorage object.
+     *
+     * @param value
+     *     The base64-encoded JSON KSM configuration to parse.
+     *
+     * @return
+     *     The KeyValueStorage that is a result of the parsing operation
+     *
+     * @throws GuacamoleException
+     *     If the provided value is not valid base-64 encoded JSON KSM configuration.
+     */
+    private static KeyValueStorage parseKsmConfig(String value) throws GuacamoleException {
+
+        // Parse base64 value as KSM config storage
+        try {
+            return new InMemoryStorage(value);
+        }
+        catch (IllegalArgumentException e) {
+            throw new GuacamoleServerException("Invalid base64 configuration "
+                    + "for Keeper Secrets Manager.", e);
+        }
+
+    }
+
+    /**
      * Returns the options required to authenticate with Keeper Secrets Manager
      * when retrieving secrets. These options are read from the contents of
      * base64-encoded JSON configuration data generated by the Keeper Commander
-     * CLI tool.
+     * CLI tool. This configuration data must be passed directly as an argument.
+     *
+     * @param ksmConfig
+     *     The KSM configuration blob to parse.
      *
      * @return
      *     The options that should be used when connecting to Keeper Secrets
      *     Manager when retrieving secrets.
      *
      * @throws GuacamoleException
-     *     If required properties are not specified within
-     *     guacamole.properties or cannot be parsed.
+     *     If an invalid ksmConfig parameter is provided.
      */
-    public SecretsManagerOptions getSecretsManagerOptions() throws GuacamoleException {
-        return new SecretsManagerOptions(environment.getRequiredProperty(KSM_CONFIG), null,
-                getAllowUnverifiedCertificate());
+    @Nonnull
+    public SecretsManagerOptions getSecretsManagerOptions(@Nonnull String ksmConfig) throws GuacamoleException {
+
+        return new SecretsManagerOptions(
+                parseKsmConfig(ksmConfig), null, getAllowUnverifiedCertificate());
     }
 }
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmClient.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmClient.java
index 2372dcb..1dbcd73 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmClient.java
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmClient.java
@@ -20,14 +20,16 @@
 package org.apache.guacamole.vault.ksm.secret;
 
 import com.google.inject.Inject;
-import com.google.inject.Singleton;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import com.keepersecurity.secretsManager.core.Hosts;
-import com.keepersecurity.secretsManager.core.KeeperFile;
 import com.keepersecurity.secretsManager.core.KeeperRecord;
 import com.keepersecurity.secretsManager.core.KeeperSecrets;
 import com.keepersecurity.secretsManager.core.Login;
 import com.keepersecurity.secretsManager.core.Notation;
 import com.keepersecurity.secretsManager.core.SecretsManager;
+import com.keepersecurity.secretsManager.core.SecretsManagerOptions;
+
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
@@ -41,8 +43,13 @@
 import java.util.concurrent.locks.ReentrantReadWriteLock;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+
+import javax.annotation.Nullable;
+
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService;
+import org.apache.guacamole.vault.secret.WindowsUsername;
+import org.apache.guacamole.vault.ksm.GuacamoleExceptionSupplier;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -54,7 +61,6 @@
  * information), it's not possible for the server to perform a search of
  * content on the client's behalf. The client has to perform its own search.
  */
-@Singleton
 public class KsmClient {
 
     /**
@@ -90,10 +96,9 @@
     private static final Pattern KEEPER_FILE_NOTATION = Pattern.compile("^(keeper://)?[^/]*/file/.+");
 
     /**
-     * The maximum amount of time that an entry will be stored in the cache
-     * before being refreshed, in milliseconds.
+     * The KSM configuration associated with this client instance.
      */
-    private static final long CACHE_INTERVAL = 5000;
+    private final SecretsManagerOptions ksmConfig;
 
     /**
      * Read/write lock which guards access to all cached data, including the
@@ -105,6 +110,13 @@
     private final ReadWriteLock cacheLock = new ReentrantReadWriteLock();
 
     /**
+     * The maximum amount of time that an entry will be stored in the cache
+     * before being refreshed, in milliseconds. This is also the shortest
+     * possible interval between API calls to KSM.
+     */
+    private final long cacheInterval;
+
+    /**
      * The timestamp that the cache was last refreshed, in milliseconds, as
      * returned by System.currentTimeMillis(). This value is automatically
      * updated if {@link #validateCache()} refreshes the cache. This value must
@@ -119,7 +131,7 @@
      * {@link #cacheLock} acquired appropriately.
      */
     private KeeperSecrets cachedSecrets = null;
-    
+
     /**
      * All records retrieved from Keeper Secrets Manager, where each key is the
      * UID of the corresponding record. The contents of this Map are
@@ -157,31 +169,74 @@
 
     /**
      * All records retrieved from Keeper Secrets Manager, where each key is the
-     * username of the corresponding record. The username of a record is
+     * username/domain of the corresponding record. The username of a record is
+     * determined by {@link Login} and "domain" fields, thus a record may be
+     * associated with multiple users. If a record is associated with multiple
+     * users, there will be multiple references to that record within this Map.
+     * The contents  of this Map are automatically updated if
+     * {@link #validateCache()} refreshes the cache. This Map must not be accessed
+     * without {@link #cacheLock} acquired appropriately. Before using a value from
+     * this Map, {@link #cachedAmbiguousUsers} must first be checked to
+     * verify that there is indeed only one record associated with that user.
+     */
+    private final Map<UserLogin, KeeperRecord> cachedRecordsByUser = new HashMap<>();
+
+    /**
+     * The set of all username/domain combos that are associated with multiple
+     * records, and thus cannot uniquely identify a record. The contents of
+     * this Set are automatically updated if {@link #validateCache()} refreshes
+     * the cache. This Set must not be accessed without {@link #cacheLock}
+     * acquired appropriately. This Set must be checked before using a value
+     * retrieved from {@link #cachedRecordsByUser}.
+     */
+    private final Set<UserLogin> cachedAmbiguousUsers = new HashSet<>();
+
+    /**
+     * All records retrieved from Keeper Secrets Manager, where each key is the
+     * domain of the corresponding record. The domain of a record is
      * determined by {@link Login} fields, thus a record may be associated with
-     * multiple users. If a record is associated with multiple users, there
+     * multiple domains. If a record is associated with multiple domains, there
      * will be multiple references to that record within this Map. The contents
      * of this Map are automatically updated if {@link #validateCache()}
      * refreshes the cache. This Map must not be accessed without
      * {@link #cacheLock} acquired appropriately. Before using a value from
-     * this Map, {@link #cachedAmbiguousUsernames} must first be checked to
-     * verify that there is indeed only one record associated with that user.
+     * this Map, {@link #cachedAmbiguousDomains} must first be checked to
+     * verify that there is indeed only one record associated with that domain.
      */
-    private final Map<String, KeeperRecord> cachedRecordsByUsername = new HashMap<>();
+    private final Map<String, KeeperRecord> cachedRecordsByDomain = new HashMap<>();
 
     /**
-     * The set of all usernames that are associated with multiple records, and
+     * The set of all domains that are associated with multiple records, and
      * thus cannot uniquely identify a record. The contents of this Set are
      * automatically updated if {@link #validateCache()} refreshes the cache.
      * This Set must not be accessed without {@link #cacheLock} acquired
-     * appropriately.This Set must be checked before using a value retrieved
-     * from {@link #cachedRecordsByUsername}.
+     * appropriately. This Set must be checked before using a value retrieved
+     * from {@link #cachedRecordsByDomain}.
      */
-    private final Set<String> cachedAmbiguousUsernames = new HashSet<>();
+    private final Set<String> cachedAmbiguousDomains = new HashSet<>();
+
+    /**
+     * Create a new KSM client based around the provided KSM configuration and
+     * API timeout setting.
+     *
+     * @param ksmConfig
+     *     The KSM configuration to use when retrieving properties from KSM.
+     *
+     * @param apiInterval
+     *     The minimum number of milliseconds that must elapse between KSM API
+     *     calls.
+     */
+    @AssistedInject
+    public KsmClient(
+            @Assisted SecretsManagerOptions ksmConfig,
+            @Assisted long apiInterval) {
+        this.ksmConfig = ksmConfig;
+        this.cacheInterval = apiInterval;
+    }
 
     /**
      * Validates that all cached data is current with respect to
-     * {@link #CACHE_INTERVAL}, refreshing data from the server as needed.
+     * {@link #cacheInterval}, refreshing data from the server as needed.
      *
      * @throws GuacamoleException
      *     If an error occurs preventing the cached data from being refreshed.
@@ -194,7 +249,7 @@
         // continuing
         cacheLock.readLock().lock();
         try {
-            if (currentTime - cacheTimestamp < CACHE_INTERVAL)
+            if (currentTime - cacheTimestamp < cacheInterval)
                 return;
         }
         finally {
@@ -206,17 +261,17 @@
 
             // Cache may have been updated since the read-only check. Re-verify
             // that the cache has expired before continuing with a full refresh
-            if (currentTime - cacheTimestamp < CACHE_INTERVAL)
+            if (currentTime - cacheTimestamp < cacheInterval)
                 return;
 
             // Attempt to pull all records first, allowing that operation to
             // succeed/fail BEFORE we clear out the last cached success
-            KeeperSecrets secrets = SecretsManager.getSecrets(confService.getSecretsManagerOptions());
+            KeeperSecrets secrets = SecretsManager.getSecrets(ksmConfig);
             List<KeeperRecord> records = secrets.getRecords();
 
             // Store all secrets within cache
             cachedSecrets = secrets;
-            
+
             // Clear unambiguous cache of all records by UID
             cachedRecordsByUid.clear();
 
@@ -225,11 +280,19 @@
             cachedRecordsByHost.clear();
 
             // Clear cache of login-based records
-            cachedAmbiguousUsernames.clear();
-            cachedRecordsByUsername.clear();
+            cachedAmbiguousUsers.clear();
+            cachedRecordsByUser.clear();
 
-            // Store all records, sorting each into host-based and login-based
-            // buckets
+            // Clear cache of domain-based records
+            cachedAmbiguousDomains.clear();
+            cachedRecordsByDomain.clear();
+
+            // Parse configuration
+            final boolean shouldSplitUsernames = confService.getSplitWindowsUsernames();
+            final boolean shouldMatchByDomain = confService.getMatchUserRecordsByDomain();
+
+            // Store all records, sorting each into host-based, login-based,
+            // and domain-based buckets
             records.forEach(record -> {
 
                 // Store based on UID ...
@@ -239,11 +302,40 @@
                 String hostname = recordService.getHostname(record);
                 addRecordForHost(record, hostname);
 
-                // Store based on username ONLY if no hostname (will otherwise
+                // ... and domain
+                String domain = recordService.getDomain(record);
+                addRecordForDomain(record, domain);
+
+                // Get the username off of the record
+                String username = recordService.getUsername(record);
+
+                // If we have a username, and there isn't already a domain explicitly defined
+                if (username != null && domain == null && shouldSplitUsernames) {
+
+                    // Attempt to split out the domain of the username
+                    WindowsUsername usernameAndDomain = (
+                            WindowsUsername.splitWindowsUsernameFromDomain(username));
+
+                    // Use the username-split domain if available
+                    if (usernameAndDomain.hasDomain()) {
+                        domain = usernameAndDomain.getDomain();
+                        username = usernameAndDomain.getUsername();
+                        addRecordForDomain(record, domain);
+                    }
+
+                }
+
+                // If domain matching is not enabled for user records,
+                // explicitly set all domains to null to allow matching
+                // on username only
+                if (!shouldMatchByDomain)
+                    domain = null;
+
+                // Store based on login ONLY if no hostname (will otherwise
                 // result in ambiguous entries for servers tied to identical
                 // accounts)
                 if (hostname == null)
-                    addRecordForLogin(record, recordService.getUsername(record));
+                    addRecordForLogin(record, username, domain);
 
             });
 
@@ -258,6 +350,30 @@
     }
 
     /**
+     * Associates the given record with the given domain. The domain may be
+     * null. Both {@link #cachedRecordsByDomain} and {@link #cachedAmbiguousDomains}
+     * are updated appropriately. The write lock of {@link #cacheLock} must
+     * already be acquired before invoking this function.
+     *
+     * @param record
+     *     The record to associate with the domains in the given field.
+     *
+     * @param domain
+     *     The domain that the given record should be associated with.
+     *     This may be null.
+     */
+    private void addRecordForDomain(KeeperRecord record, String domain) {
+
+        if (domain == null)
+            return;
+
+        KeeperRecord existing = cachedRecordsByDomain.putIfAbsent(domain, record);
+        if (existing != null && record != existing)
+            cachedAmbiguousDomains.add(domain);
+
+    }
+
+    /**
      * Associates the given record with the given hostname. The hostname may be
      * null. Both {@link #cachedRecordsByHost} and {@link #cachedAmbiguousHosts}
      * are updated appropriately. The write lock of {@link #cacheLock} must
@@ -282,27 +398,34 @@
     }
 
     /**
-     * Associates the given record with the given username. The given username
-     * may be null. Both {@link #cachedRecordsByUsername} and
-     * {@link #cachedAmbiguousUsernames} are updated appropriately. The write
+     * Associates the given record with the given user, and optional domain.
+     * The given username or domain may be null. Both {@link #cachedRecordsByUser}
+     * and {@link #cachedAmbiguousUsers} are updated appropriately. The write
      * lock of {@link #cacheLock} must already be acquired before invoking this
      * function.
      *
      * @param record
-     *     The record to associate with the given username.
+     *     The record to associate with the given user.
      *
      * @param username
      *     The username that the given record should be associated with. This
      *     may be null.
+     *
+     * @param domain
+     *     The domain that the given record should be associated with. This
+     *     may be null.
      */
-    private void addRecordForLogin(KeeperRecord record, String username) {
+    private void addRecordForLogin(
+            KeeperRecord record, String username, String domain) {
 
         if (username == null)
             return;
 
-        KeeperRecord existing = cachedRecordsByUsername.putIfAbsent(username, record);
+        UserLogin userDomain = new UserLogin(username, domain);
+        KeeperRecord existing = cachedRecordsByUser.putIfAbsent(
+                userDomain, record);
         if (existing != null && record != existing)
-            cachedAmbiguousUsernames.add(username);
+            cachedAmbiguousUsers.add(userDomain);
 
     }
 
@@ -388,32 +511,75 @@
     }
 
     /**
-     * Returns the record associated with the given username. If no such record
-     * exists, or there are multiple such records, null is returned.
+     * Returns the record associated with the given username and domain. If no
+     * such record exists, or there are multiple such records, null is returned.
      *
      * @param username
      *     The username of the record to return.
      *
-     * @return
-     *     The record associated with the given username, or null if there is
-     *     no such record or multiple such records.
+     * @param domain
+     *     The domain of the record to return, or null if no domain exists.
      *
-     * @throws GuacamoleException 
+     * @return
+     *     The record associated with the given username and domain, or null
+     *     if there is no such record or multiple such records.
+     *
+     * @throws GuacamoleException
      *     If an error occurs that prevents the record from being retrieved.
      */
-    public KeeperRecord getRecordByLogin(String username) throws GuacamoleException {
+    public KeeperRecord getRecordByLogin(
+            String username, String domain) throws GuacamoleException {
+
+        validateCache();
+        cacheLock.readLock().lock();
+
+        UserLogin userDomain = new UserLogin(username, domain);
+
+        try {
+
+            if (cachedAmbiguousUsers.contains(userDomain)) {
+                logger.debug("The username \"{}\" with domain \"{}\" is "
+                        + "referenced by multiple Keeper records and "
+                        + "cannot be used to locate individual secrets.",
+                        username, domain);
+                return null;
+            }
+
+            return cachedRecordsByUser.get(userDomain);
+
+        }
+        finally {
+            cacheLock.readLock().unlock();
+        }
+    }
+
+    /**
+     * Returns the record associated with the given domain. If no such record
+     * exists, or there are multiple such records, null is returned.
+     *
+     * @param domain
+     *     The domain of the record to return.
+     *
+     * @return
+     *     The record associated with the given domain, or null if there is
+     *     no such record or multiple such records.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs that prevents the record from being retrieved.
+     */
+    public KeeperRecord getRecordByDomain(String domain) throws GuacamoleException {
         validateCache();
         cacheLock.readLock().lock();
         try {
 
-            if (cachedAmbiguousUsernames.contains(username)) {
-                logger.debug("The username \"{}\" is referenced by multiple "
+            if (cachedAmbiguousDomains.contains(domain)) {
+                logger.debug("The domain \"{}\" is referenced by multiple "
                         + "Keeper records and cannot be used to locate "
-                        + "individual secrets.", username);
+                        + "individual secrets.", domain);
                 return null;
             }
 
-            return cachedRecordsByUsername.get(username);
+            return cachedRecordsByDomain.get(domain);
 
         }
         finally {
@@ -439,6 +605,38 @@
      *     is invalid.
      */
     public Future<String> getSecret(String notation) throws GuacamoleException {
+        return getSecret(notation, null);
+    }
+
+    /**
+     * Returns the value of the secret stored within Keeper Secrets Manager and
+     * represented by the given Keeper notation. Keeper notation locates the
+     * value of a specific field, custom field, or file associated with a
+     * specific record. See: https://docs.keeper.io/secrets-manager/secrets-manager/about/keeper-notation
+     * If a fallbackFunction is provided, it will be invoked to generate
+     * a return value in the case where no secret is found with the given
+     * keeper notation.
+     *
+     * @param notation
+     *     The Keeper notation of the secret to retrieve.
+     *
+     * @param fallbackFunction
+     *     A function to invoke in order to produce a Future for return,
+     *     if the requested secret is not found. If the provided Function
+     *     is null, it will not be run.
+     *
+     * @return
+     *     A Future which completes with the value of the secret represented by
+     *     the given Keeper notation, or null if there is no such secret.
+     *
+     * @throws GuacamoleException
+     *     If the requested secret cannot be retrieved or the Keeper notation
+     *     is invalid.
+     */
+    public Future<String> getSecret(
+            String notation,
+            @Nullable GuacamoleExceptionSupplier<Future<String>> fallbackFunction)
+            throws GuacamoleException {
         validateCache();
         cacheLock.readLock().lock();
         try {
@@ -458,6 +656,11 @@
         catch (Error e) {
             logger.warn("Record \"{}\" does not exist.", notation);
             logger.debug("Retrieval of record by Keeper notation failed.", e);
+
+            // If the secret is not found, invoke the fallback function
+            if (fallbackFunction != null)
+                return fallbackFunction.get();
+
             return CompletableFuture.completedFuture(null);
         }
 
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmClientFactory.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmClientFactory.java
new file mode 100644
index 0000000..d977118
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmClientFactory.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.vault.ksm.secret;
+
+import javax.annotation.Nonnull;
+
+import com.keepersecurity.secretsManager.core.SecretsManagerOptions;
+
+/**
+ * Factory for creating KsmClient instances.
+ */
+public interface KsmClientFactory {
+
+    /**
+     * Returns a new instance of a KsmClient instance associated with
+     * the provided KSM configuration options and API interval.
+     *
+     * @param ksmConfigOptions
+     *     The KSM config options to use when constructing the KsmClient
+     *     object.
+     *
+     * @param apiInterval
+     *     The minimum number of milliseconds that must elapse between KSM API
+     *     calls.
+     *
+     * @return
+     *     A new KsmClient instance associated with the provided KSM config
+     *     options.
+     */
+    KsmClient create(
+            @Nonnull SecretsManagerOptions ksmConfigOptions, long apiInterval);
+
+}
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmRecordService.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmRecordService.java
index 70e500e..da53487 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmRecordService.java
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmRecordService.java
@@ -41,6 +41,7 @@
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
+
 /**
  * Service for automatically parsing out secrets and data from Keeper records.
  */
@@ -49,6 +50,13 @@
 
     /**
      * Regular expression which matches the labels of custom fields containing
+     * domains.
+     */
+    private static final Pattern DOMAIN_LABEL_PATTERN =
+            Pattern.compile("domain", Pattern.CASE_INSENSITIVE);
+
+    /**
+     * Regular expression which matches the labels of custom fields containing
      * hostnames/addresses.
      */
     private static final Pattern HOSTNAME_LABEL_PATTERN =
@@ -143,13 +151,13 @@
      * empty or contains multiple values, null is returned. Note that null will
      * also be returned if the mapping transformation returns null for the
      * single value stored in the list.
-     * 
+     *
      * @param <T>
      *     The type of object stored in the list.
-     *     
+     *
      * @param <R>
      *     The type of object to return.
-     *     
+     *
      * @param values
      *     The list to retrieve a single value from.
      *
@@ -168,7 +176,7 @@
             return null;
 
         return mapper.apply(value);
-        
+
     }
 
     /**
@@ -277,7 +285,7 @@
      * multiple such fields, null is returned. Both standard and custom fields
      * are searched. As standard fields do not have labels, any given label
      * pattern is ignored for standard fields.
-     * 
+     *
      * @param <T>
      *     The type of field to return.
      *
@@ -345,7 +353,7 @@
                 return null;
 
             foundFile = file;
-            
+
         }
 
         return foundFile;
@@ -368,7 +376,7 @@
 
         if (file == null)
             return CompletableFuture.completedFuture(null);
-        
+
         return CompletableFuture.supplyAsync(() -> {
             return new String(SecretsManager.downloadFile(file), StandardCharsets.UTF_8);
         });
@@ -452,6 +460,38 @@
     }
 
     /**
+     * Returns the single domain associated with the given record. If the
+     * record has no associated domain, or multiple domains, null is
+     * returned. Domains are retrieved from "Text" and "Hidden" fields
+     * that have the label "domain" (case-insensitive).
+     *
+     * @param record
+     *     The record to retrieve the domain from.
+     *
+     * @return
+     *     The domain associated with the given record, or null if the record
+     *     has no associated domain or multiple domains.
+     */
+    public String getDomain(KeeperRecord record) {
+
+        KeeperRecordData data = record.getData();
+        List<KeeperRecordField> custom = data.getCustom();
+
+        // First check text "domain" custom field ...
+        Text textField = getField(custom, Text.class, DOMAIN_LABEL_PATTERN);
+        if (textField != null)
+            return getSingleStringValue(textField.getValue());
+
+        // ... or hidden "domain" custom field if that's not found
+        HiddenField hiddenField = getField(custom, HiddenField.class, DOMAIN_LABEL_PATTERN);
+        if (hiddenField != null)
+            return getSingleStringValue(hiddenField.getValue());
+
+        return null;
+
+    }
+
+    /**
      * Returns the password associated with the given record. Both standard and
      * custom fields are searched. Only "Password" and "Hidden" field types are
      * considered. Custom fields must additionally have the label "password"
@@ -561,7 +601,7 @@
         // a pair of custom hidden fields for the private key and passphrase:
         // the standard password field of the "Login" record refers to the
         // user's own password, if any, not the passphrase of their key)
-        
+
         // Use password "private key" custom field as fallback ...
         Password passwordField = getField(custom, Password.class, PASSPHRASE_LABEL_PATTERN);
         if (passwordField != null)
@@ -573,7 +613,7 @@
             return getSingleStringValue(hiddenField.getValue());
 
         return null;
-        
+
     }
 
 }
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmSecretService.java
index 824f9e5..717a125 100644
--- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmSecretService.java
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmSecretService.java
@@ -19,32 +19,60 @@
 
 package org.apache.guacamole.vault.ksm.secret;
 
+import com.google.common.base.Objects;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.keepersecurity.secretsManager.core.KeeperRecord;
+import com.keepersecurity.secretsManager.core.SecretsManagerOptions;
+
 import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
+import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.Future;
 
+import javax.annotation.Nonnull;
+
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.Attributes;
+import org.apache.guacamole.net.auth.Connectable;
+import org.apache.guacamole.net.auth.Connection;
+import org.apache.guacamole.net.auth.ConnectionGroup;
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.User;
+import org.apache.guacamole.net.auth.UserContext;
 import org.apache.guacamole.protocol.GuacamoleConfiguration;
 import org.apache.guacamole.token.TokenFilter;
+import org.apache.guacamole.vault.ksm.GuacamoleExceptionSupplier;
+import org.apache.guacamole.vault.ksm.conf.KsmAttributeService;
+import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService;
+import org.apache.guacamole.vault.ksm.user.KsmDirectory;
 import org.apache.guacamole.vault.secret.VaultSecretService;
+import org.apache.guacamole.vault.secret.WindowsUsername;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Service which retrieves secrets from Keeper Secrets Manager.
+ * The configuration used to connect to KSM can be set at a global
+ * level using guacamole.properties, or using a connection group
+ * attribute.
  */
 @Singleton
 public class KsmSecretService implements VaultSecretService {
 
     /**
-     * Client for retrieving records and secrets from Keeper Secrets Manager.
+     * Logger for this class.
      */
-    @Inject
-    private KsmClient ksm;
+    private static final Logger logger = LoggerFactory.getLogger(VaultSecretService.class);
 
     /**
      * Service for retrieving data from records.
@@ -52,6 +80,57 @@
     @Inject
     private KsmRecordService recordService;
 
+    /**
+     * Service for retrieving configuration information.
+     */
+    @Inject
+    private KsmConfigurationService confService;
+
+    /**
+     * Factory for creating KSM client instances.
+     */
+    @Inject
+    private KsmClientFactory ksmClientFactory;
+
+    /**
+     * A map of base-64 encoded JSON KSM config blobs to associated KSM client instances.
+     * A distinct KSM client will exist for every KSM config.
+     */
+    private final ConcurrentMap<String, KsmClient> ksmClientMap = new ConcurrentHashMap<>();
+
+    /**
+     * Create and return a KSM client for the provided KSM config if not already
+     * present in the client map, otherwise return the existing client entry.
+     *
+     * @param ksmConfig
+     *     The base-64 encoded JSON KSM config blob associated with the client entry.
+     *     If an associated entry does not already exist, it will be created using
+     *     this configuration.
+     *
+     * @return
+     *     A KSM client for the provided KSM config if not already present in the
+     *     client map, otherwise the existing client entry.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while creating the KSM client.
+     */
+    private KsmClient getClient(@Nonnull String ksmConfig)
+            throws GuacamoleException {
+
+        // If a client already exists for the provided config, use it
+        KsmClient ksmClient = ksmClientMap.get(ksmConfig);
+        if (ksmClient != null)
+            return ksmClient;
+
+        // Create and store a new KSM client instance for the provided KSM config blob
+        SecretsManagerOptions options = confService.getSecretsManagerOptions(ksmConfig);
+        ksmClient = ksmClientFactory.create(options, confService.getKsmApiInterval());
+        KsmClient prevClient = ksmClientMap.putIfAbsent(ksmConfig, ksmClient);
+
+        // If the client was already set before this thread got there, use the existing one
+        return prevClient != null ? prevClient : ksmClient;
+    }
+
     @Override
     public String canonicalize(String nameComponent) {
         try {
@@ -67,8 +146,37 @@
     }
 
     @Override
+    public Future<String> getValue(UserContext userContext, Connectable connectable,
+            String name) throws GuacamoleException {
+
+        // Attempt to find a KSM config for this connection or group
+        String ksmConfig = getConnectionGroupKsmConfig(userContext, connectable);
+
+        return getClient(ksmConfig).getSecret(name, new GuacamoleExceptionSupplier<Future<String>>() {
+
+            @Override
+            public Future<String> get() throws GuacamoleException {
+
+                // Get the user-supplied KSM config, if allowed by config and
+                // set by the user
+                String userKsmConfig = getUserKSMConfig(userContext, connectable);
+
+                // If the user config happens to be the same as admin-defined one,
+                // don't bother trying again
+                if (userKsmConfig != null && !Objects.equal(userKsmConfig, ksmConfig))
+                    return getClient(userKsmConfig).getSecret(name);
+
+                return CompletableFuture.completedFuture(null);
+            }
+
+        });
+    }
+
+    @Override
     public Future<String> getValue(String name) throws GuacamoleException {
-        return ksm.getSecret(name);
+
+        // Use the default KSM configuration from guacamole.properties
+        return getClient(confService.getKsmConfig()).getSecret(name);
     }
 
     /**
@@ -86,17 +194,48 @@
      * @param record
      *     The record to retrieve secrets from when generating tokens. This may
      *     be null.
+     *
+     * @throws GuacamoleException
+     *     If configuration details in guacamole.properties cannot be parsed.
      */
     private void addRecordTokens(Map<String, Future<String>> tokens, String prefix,
-            KeeperRecord record) {
+            KeeperRecord record) throws GuacamoleException {
 
         if (record == null)
             return;
 
+        // Domain of server-related record
+        String domain = recordService.getDomain(record);
+        if (domain != null)
+            tokens.put(prefix + "DOMAIN", CompletableFuture.completedFuture(domain));
+
         // Username of server-related record
         String username = recordService.getUsername(record);
-        if (username != null)
-            tokens.put(prefix + "USERNAME", CompletableFuture.completedFuture(username));
+        if (username != null) {
+
+            // If the record had no directly defined domain, but there is a
+            // username, and the configuration is enabled to split Windows
+            // domains out of usernames, attempt to split the domain out now
+            if (domain == null && confService.getSplitWindowsUsernames()) {
+                WindowsUsername usernameAndDomain =
+                        WindowsUsername.splitWindowsUsernameFromDomain(username);
+
+                // Always store the username token
+                tokens.put(prefix + "USERNAME", CompletableFuture.completedFuture(
+                        usernameAndDomain.getUsername()));
+
+                // Only store the domain if one is detected
+                if (usernameAndDomain.hasDomain())
+                    tokens.put(prefix + "DOMAIN", CompletableFuture.completedFuture(
+                        usernameAndDomain.getDomain()));
+
+            }
+
+            // If splitting is not enabled, store the whole value in the USERNAME token
+            else {
+                tokens.put(prefix + "USERNAME", CompletableFuture.completedFuture(username));
+            }
+        }
 
         // Password of server-related record
         String password = recordService.getPassword(record);
@@ -113,13 +252,186 @@
         tokens.put(prefix + "KEY", privateKey);
 
     }
-    
-    @Override
-    public Map<String, Future<String>> getTokens(GuacamoleConfiguration config,
-            TokenFilter filter) throws GuacamoleException {
 
-        Map<String, Future<String>> tokens = new HashMap<>();
-        Map<String, String> parameters = config.getParameters();
+    /**
+     * Search for a KSM configuration attribute, recursing up the connection group tree
+     * until a connection group with the appropriate attribute is found. If the KSM config
+     * is found, it will be returned. If not, the default value from the config file will
+     * be returned.
+     *
+     * @param userContext
+     *     The userContext associated with the connection or connection group.
+     *
+     * @param connectable
+     *     A connection or connection group for which the tokens are being replaced.
+     *
+     * @return
+     *     The value of the KSM configuration attribute if found in the tree, the default
+     *     KSM config blob defined in guacamole.properties otherwise.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while attempting to retrieve the KSM config attribute, or if
+     *     no KSM config is found in the connection group tree, and the value is also not
+     *     defined in the config file.
+     */
+    @Nonnull
+    private String getConnectionGroupKsmConfig(
+            UserContext userContext, Connectable connectable) throws GuacamoleException {
+
+        // Check to make sure it's a usable type before proceeding
+        if (
+                !(connectable instanceof Connection)
+                && !(connectable instanceof ConnectionGroup)) {
+            logger.warn(
+                    "Unsupported Connectable type: {}; skipping KSM config lookup.",
+                    connectable.getClass());
+
+            // Use the default value if searching is impossible
+            return confService.getKsmConfig();
+        }
+
+        // For connections, start searching the parent group for the KSM config
+        // For connection groups, start searching the group directly
+        String parentIdentifier = (connectable instanceof Connection)
+                ? ((Connection) connectable).getParentIdentifier()
+                : ((ConnectionGroup) connectable).getIdentifier();
+
+        // Keep track of all group identifiers seen while recursing up the tree
+        // in case there's a cycle - if the same identifier is ever seen twice,
+        // the search is over.
+        Set<String> observedIdentifiers = new HashSet<>();
+        observedIdentifiers.add(parentIdentifier);
+
+        // Use the unwrapped connection group directory to avoid KSM config
+        // value sanitization
+        Directory<ConnectionGroup> connectionGroupDirectory = (
+                (KsmDirectory<ConnectionGroup>) userContext.getConnectionGroupDirectory()
+                ).getUnderlyingDirectory();
+
+        while (true) {
+
+            // Fetch the parent group, if one exists
+            ConnectionGroup group = connectionGroupDirectory.get(parentIdentifier);
+            if (group == null)
+                break;
+
+            // If the current connection group has the KSM configuration attribute
+            // set to a non-empty value, return immediately
+            String ksmConfig = group.getAttributes().get(KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE);
+            if (ksmConfig != null && !ksmConfig.trim().isEmpty())
+                return ksmConfig;
+
+            // Otherwise, keep searching up the tree until an appropriate configuration is found
+            parentIdentifier = group.getParentIdentifier();
+
+            // If the parent is a group that's already been seen, this is a cycle, so there's no
+            // need to search any further
+            if (!observedIdentifiers.add(parentIdentifier))
+                break;
+        }
+
+        // If no KSM configuration was ever found, use the default value
+        return confService.getKsmConfig();
+
+    }
+
+    /**
+     * Returns true if user-level KSM configuration is enabled for the given
+     * Connectable, false otherwise.
+     *
+     * @param connectable
+     *     The connectable to check for whether user-level KSM configs are
+     *     enabled.
+     *
+     * @return
+     *     True if user-level KSM configuration is enabled for the given
+     *     Connectable, false otherwise.
+     */
+    private boolean isKsmUserConfigEnabled(Connectable connectable) {
+
+        // User-level config is enabled IFF the appropriate attribute is set to true
+        if (connectable instanceof Attributes)
+            return KsmAttributeService.TRUTH_VALUE.equals(((Attributes) connectable).getAttributes().get(
+                KsmAttributeService.KSM_USER_CONFIG_ENABLED_ATTRIBUTE));
+
+        // If there's no attributes to check, the user config cannot be enabled
+        return false;
+
+    }
+
+    /**
+     * Return the KSM config blob for the current user IFF user KSM configs
+     * are enabled globally, and are enabled for the given connectable. If no
+     * KSM config exists for the given user or KSM configs are not enabled,
+     * null will be returned.
+     *
+     * @param userContext
+     *    The user context from which the current user should be fetched.
+     *
+     * @param connectable
+     *    The connectable to which the connection is being established. This
+     *    is the conneciton which will be checked to see if user KSM configs
+     *    are enabled.
+     *
+     * @return
+     *    The base64 encoded KSM config blob for the current user if one
+     *    exists, and if user KSM configs are enabled globally and for the
+     *    provided connectable.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while attempting to fetch the KSM config.
+     */
+    private String getUserKSMConfig(
+            UserContext userContext, Connectable connectable) throws GuacamoleException {
+
+        // If user KSM configs are enabled globally, and for the given connectable,
+        // return the user-specific KSM config, if one exists
+        if (confService.getAllowUserConfig() && isKsmUserConfigEnabled(connectable)) {
+
+            // Get the underlying user, to avoid the KSM config sanitization
+            User self = (
+                    ((KsmDirectory<User>) userContext.getUserDirectory())
+                    .getUnderlyingDirectory().get(userContext.self().getIdentifier()));
+
+            return self.getAttributes().get(
+                    KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE);
+        }
+
+
+        // If user-specific KSM config is disabled globally or for the given
+        // connectable, return null to indicate that no user config exists
+        return null;
+    }
+
+    /**
+     * Use the provided KSM client to add parameter tokens tokens to the
+     * provided token map. The supplied filter will be used to replace
+     * existing tokens in the provided connection parameters before KSM
+     * record lookup. The supplied GuacamoleConfiguration instance will
+     * be used to check the protocol, in case RDP-specific behavior is
+     * needed.
+
+     * @param config
+     *    The GuacamoleConfiguration associated with the Connectable for which
+     *    tokens are being added.
+     *
+     * @param ksm
+     *     The KSM client to use when fetching records.
+     *
+     * @param tokens
+     *     The tokens to which any fetched KSM record values should be added.
+     *
+     * @param parameters
+     *     The connection parameters associated with the Connectable for which
+     *     tokens are being added.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while attempting to fetch KSM records or check
+     *     configuration settings.
+     */
+    private void addConnectableTokens(
+            GuacamoleConfiguration config, KsmClient ksm, Map<String, Future<String>> tokens,
+            Map<String, String> parameters, TokenFilter filter) throws GuacamoleException {
 
         // Retrieve and define server-specific tokens, if any
         String hostname = parameters.get("hostname");
@@ -127,29 +439,90 @@
             addRecordTokens(tokens, "KEEPER_SERVER_",
                     ksm.getRecordByHost(filter.filter(hostname)));
 
-        // Retrieve and define user-specific tokens, if any
-        String username = parameters.get("username");
-        if (username != null && !username.isEmpty())
-            addRecordTokens(tokens, "KEEPER_USER_",
-                    ksm.getRecordByLogin(filter.filter(username)));
-
         // Tokens specific to RDP
         if ("rdp".equals(config.getProtocol())) {
-        
+
             // Retrieve and define gateway server-specific tokens, if any
             String gatewayHostname = parameters.get("gateway-hostname");
             if (gatewayHostname != null && !gatewayHostname.isEmpty())
                 addRecordTokens(tokens, "KEEPER_GATEWAY_",
                         ksm.getRecordByHost(filter.filter(gatewayHostname)));
 
+            // Retrieve and define domain tokens, if any
+            String domain = parameters.get("domain");
+            String filteredDomain = null;
+            if (domain != null && !domain.isEmpty()) {
+                filteredDomain = filter.filter(domain);
+                addRecordTokens(tokens, "KEEPER_DOMAIN_",
+                        ksm.getRecordByDomain(filteredDomain));
+            }
+
+            // Retrieve and define gateway domain tokens, if any
+            String gatewayDomain = parameters.get("gateway-domain");
+            String filteredGatewayDomain = null;
+            if (gatewayDomain != null && !gatewayDomain.isEmpty()) {
+                filteredGatewayDomain = filter.filter(gatewayDomain);
+                addRecordTokens(tokens, "KEEPER_GATEWAY_DOMAIN_",
+                        ksm.getRecordByDomain(filteredGatewayDomain));
+            }
+
+            // If domain matching is disabled for user records,
+            // explicitly set the domains to null when storing
+            // user records to enable username-only matching
+            if (!confService.getMatchUserRecordsByDomain()) {
+                filteredDomain = null;
+                filteredGatewayDomain = null;
+            }
+
+            // Retrieve and define user-specific tokens, if any
+            String username = parameters.get("username");
+            if (username != null && !username.isEmpty())
+                addRecordTokens(tokens, "KEEPER_USER_",
+                        ksm.getRecordByLogin(filter.filter(username),
+                        filteredDomain));
+
             // Retrieve and define gateway user-specific tokens, if any
             String gatewayUsername = parameters.get("gateway-username");
             if (gatewayUsername != null && !gatewayUsername.isEmpty())
                 addRecordTokens(tokens, "KEEPER_GATEWAY_USER_",
-                        ksm.getRecordByLogin(filter.filter(gatewayUsername)));
-
+                        ksm.getRecordByLogin(
+                            filter.filter(gatewayUsername),
+                            filteredGatewayDomain));
         }
 
+        else {
+
+            // Retrieve and define user-specific tokens, if any
+            // NOTE that non-RDP connections do not have a domain
+            // field in the connection parameters, so the domain
+            // will always be null
+            String username = parameters.get("username");
+            if (username != null && !username.isEmpty())
+                addRecordTokens(tokens, "KEEPER_USER_",
+                        ksm.getRecordByLogin(filter.filter(username), null));
+        }
+    }
+
+    @Override
+    public Map<String, Future<String>> getTokens(UserContext userContext, Connectable connectable,
+            GuacamoleConfiguration config, TokenFilter filter) throws GuacamoleException {
+
+        Map<String, Future<String>> tokens = new HashMap<>();
+        Map<String, String> parameters = config.getParameters();
+
+        // Only use the user-specific KSM config if explicitly enabled in the global
+        // configuration, AND for the specific connectable being connected to
+        String userKsmConfig = getUserKSMConfig(userContext, connectable);
+        if (userKsmConfig != null && !userKsmConfig.trim().isEmpty())
+            addConnectableTokens(
+                    config, getClient(userKsmConfig), tokens, parameters, filter);
+
+        // Add connection group or globally defined tokens after the user-specific
+        // ones to ensure that the user config will be overriden on collision
+        String ksmConfig = getConnectionGroupKsmConfig(userContext, connectable);
+        addConnectableTokens(
+            config, getClient(ksmConfig), tokens, parameters, filter);
+
         return tokens;
 
     }
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/UserLogin.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/UserLogin.java
new file mode 100644
index 0000000..5cd50ee
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/UserLogin.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.vault.ksm.secret;
+
+import java.util.Objects;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * A class intended for use as a key in KSM user record client cache. This
+ * class contains both a username and a password. When identifying a KSM
+ * record using token syntax like "KEEPER_USER_*", the user record will
+ * actually be identified by both the user and domain, if the appropriate
+ * settings are enabled.
+ */
+class UserLogin {
+
+    /**
+     * The username associated with the user record.
+     * This field should never be null.
+     */
+    private final String username;
+
+    /**
+     * The domain associated with the user record.
+     * This field can be null.
+     */
+    private final String domain;
+
+    /**
+     * Create a new UserLogin instance with the provided username and
+     * domain. The domain may be null, but the username should never be.
+     *
+     * @param username
+     *    The username to create the UserLogin instance with. This should
+     *    never be null.
+     *
+     * @param domain
+     *    The domain to create the UserLogin instance with. This can be null.
+     */
+    UserLogin(@Nonnull String username, @Nullable String domain) {
+        this.username = username;
+        this.domain = domain;
+    }
+
+    @Override
+    public int hashCode() {
+
+        return Objects.hash(domain, username);
+
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+
+        // Check if the other object is this exact object
+        if (this == obj)
+            return true;
+
+        // Check if the other object is null
+        if (obj == null)
+            return false;
+
+        // Check if the other object is also a UserLogin
+        if (getClass() != obj.getClass())
+            return false;
+
+        // If the other object is also a UserLogin, it must
+        // have the same username and domain
+        UserLogin other = (UserLogin) obj;
+        return Objects.equals(username, other.username)
+                && Objects.equals(domain, other.domain);
+
+    }
+
+    /**
+     * Get the username associated with this UserLogin.
+     *
+     * @return
+     *     The username associated with this UserLogin.
+     */
+    public String getUsername() {
+        return username;
+    }
+
+
+    /**
+     * Get the domain associated with this UserLogin.
+     *
+     * @return
+     *     The domain associated with this UserLogin.
+     */
+    public String getDomain() {
+        return domain;
+    }
+
+}
\ No newline at end of file
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmConnection.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmConnection.java
new file mode 100644
index 0000000..3981486
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmConnection.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.vault.ksm.user;
+
+import java.util.Map;
+
+import org.apache.guacamole.net.auth.DelegatingConnection;
+import org.apache.guacamole.vault.ksm.conf.KsmAttributeService;
+import org.apache.guacamole.net.auth.Connection;
+
+import com.google.common.collect.Maps;
+
+/**
+ * A Connection that explicitly adds a blank entry for any defined KSM
+ * connection attributes. This ensures that any such field will always
+ * be displayed to the user when editing a connection through the UI.
+ */
+public class KsmConnection extends DelegatingConnection {
+
+    /**
+     * Create a new Vault connection wrapping the provided Connection record. Any
+     * attributes defined in the provided connection attribute forms will have empty
+     * values automatically populated when getAttributes() is called.
+     *
+     * @param connection
+     *     The connection record to wrap.
+     */
+    KsmConnection(Connection connection) {
+        super(connection);
+    }
+
+    /**
+     * Return the underlying wrapped connection record.
+     *
+     * @return
+     *     The wrapped connection record.
+     */
+    Connection getUnderlyingConnection() {
+        return getDelegateConnection();
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+
+        // Make a copy of the existing map
+        Map<String, String> attributes = Maps.newHashMap(super.getAttributes());
+
+        // Add the user-config-enabled configuration attribute
+        attributes.putIfAbsent(KsmAttributeService.KSM_USER_CONFIG_ENABLED_ATTRIBUTE, null);
+        return attributes;
+    }
+
+}
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmConnectionGroup.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmConnectionGroup.java
new file mode 100644
index 0000000..68d4457
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmConnectionGroup.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.vault.ksm.user;
+
+import java.util.Map;
+
+import org.apache.guacamole.net.auth.ConnectionGroup;
+import org.apache.guacamole.net.auth.DelegatingConnectionGroup;
+import org.apache.guacamole.vault.ksm.conf.KsmAttributeService;
+
+import com.google.common.collect.Maps;
+
+ /**
+  * A KSM-specific connection group implementation that always exposes
+  * the KSM_CONFIGURATION_ATTRIBUTE attribute, even when no value is set.
+  * The value of the attribute will be sanitized if non-empty. This ensures
+  * that the attribute will always show up in the UI, even for connection
+  * groups that don't already have it set, and that any sensitive information
+  * in the attribute value will not be exposed.
+  */
+ public class KsmConnectionGroup extends DelegatingConnectionGroup {
+
+    /**
+     * Create a new KsmConnectionGroup wrapping the provided ConnectionGroup record.
+     *
+     * @param connectionGroup
+     *     The ConnectionGroup record to wrap.
+     */
+    KsmConnectionGroup(ConnectionGroup connectionGroup) {
+        super(connectionGroup);
+    }
+
+    /**
+     * Return the underlying wrapped connection group record.
+     *
+     * @return
+     *     The wrapped connection group record.
+     */
+    ConnectionGroup getUnderlyingConnectionGroup() {
+        return getDelegateConnectionGroup();
+    }
+
+    /**
+     * Return the underlying ConnectionGroup that's wrapped by this KsmConnectionGroup.
+     *
+     * @return
+     *     The underlying ConnectionGroup that's wrapped by this KsmConnectionGroup.
+     */
+    ConnectionGroup getUnderlyConnectionGroup() {
+        return getDelegateConnectionGroup();
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+
+        // Make a copy of the existing map
+        Map<String, String> attributes = Maps.newHashMap(super.getAttributes());
+
+        // Sanitize the KSM configuration attribute, and ensure the attribute
+        // is always present
+        attributes.put(
+                KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE,
+                KsmAttributeService.sanitizeKsmAttributeValue(
+                    attributes.get(KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE)));
+
+        return attributes;
+    }
+
+ }
\ No newline at end of file
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmDirectory.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmDirectory.java
new file mode 100644
index 0000000..5cfcca9
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmDirectory.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.vault.ksm.user;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.DelegatingDirectory;
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.Identifiable;
+
+/**
+ * A KSM-specific version of DecoratingDirectory that exposes the underlying
+ * directory for when it's needed.
+ */
+public abstract class KsmDirectory<ObjectType extends Identifiable>
+        extends DelegatingDirectory<ObjectType> {
+
+    /**
+     * Create a new KsmDirectory, delegating to the provided directory.
+     *
+     * @param directory
+     *    The directory to delegate to.
+     */
+    public KsmDirectory(Directory<ObjectType> directory) {
+        super(directory);
+    }
+
+    /**
+     * Returns the underlying directory that this DecoratingDirectory is
+     * delegating to.
+     *
+     * @return
+     *    The underlying directory.
+     */
+    public Directory<ObjectType> getUnderlyingDirectory() {
+        return getDelegateDirectory();
+    }
+
+    /**
+     * Process and return a potentially-modified version of the object
+     * with the same identifier in the wrapped directory.
+     *
+     * @param object
+     *     The object from the underlying directory.
+     *
+     * @return
+     *     A potentially-modified version of the object with the same
+     *     identifier in the wrapped directory.
+     */
+    protected abstract ObjectType wrap(ObjectType object);
+
+    @Override
+    public ObjectType get(String identifier) throws GuacamoleException {
+
+        // Process and return the object from the wrapped directory
+        return wrap(super.get(identifier));
+
+    }
+
+    @Override
+    public Collection<ObjectType> getAll(Collection<String> identifiers)
+            throws GuacamoleException {
+
+        // Process and return each object from the wrapped directory
+        return super.getAll(identifiers).stream().map(
+                superObject -> wrap(superObject)
+                ).collect(Collectors.toList());
+
+    }
+
+}
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmDirectoryService.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmDirectoryService.java
new file mode 100644
index 0000000..18934c9
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmDirectoryService.java
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.vault.ksm.user;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.Connection;
+import org.apache.guacamole.net.auth.ConnectionGroup;
+import org.apache.guacamole.net.auth.DecoratingDirectory;
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.User;
+import org.apache.guacamole.vault.ksm.conf.KsmAttributeService;
+import org.apache.guacamole.vault.user.VaultDirectoryService;
+
+import com.google.inject.Inject;
+
+/**
+ * A KSM-specific vault directory service that wraps the connection group directory
+ * to enable automatic translation of KSM one-time tokens into base64-encoded JSON
+ * config bundles.
+ */
+public class KsmDirectoryService extends VaultDirectoryService {
+
+    /**
+     * A factory for constructing new KsmUser instances.
+     */
+    @Inject
+    private KsmUserFactory ksmUserFactory;
+
+    /**
+     * Service for retrieving any custom attributes defined for the
+     * current vault implementation and processing of said attributes.
+     */
+    @Inject
+    private KsmAttributeService attributeService;
+
+    @Override
+    public Directory<Connection> getConnectionDirectory(
+            Directory<Connection> underlyingDirectory) throws GuacamoleException {
+
+        // A Connection directory that will decorate all connections with a
+        // KsmConnection wrapper to ensure that all defined KSM fields will
+        // be exposed in the connection group attributes.
+        return new DecoratingDirectory<Connection>(underlyingDirectory) {
+
+            @Override
+            protected Connection decorate(Connection connection) throws GuacamoleException {
+
+                // Wrap in a KsmConnection class to ensure that all defined KSM fields will be
+                // present
+                return new KsmConnection(connection);
+            }
+
+            @Override
+            protected Connection undecorate(Connection connection) throws GuacamoleException {
+
+                // Unwrap the KsmConnection
+                return ((KsmConnection) connection).getUnderlyingConnection();
+            }
+
+        };
+    }
+
+    @Override
+    public Directory<ConnectionGroup> getConnectionGroupDirectory(
+            Directory<ConnectionGroup> underlyingDirectory) throws GuacamoleException {
+
+        // A ConnectionGroup directory that will intercept add and update calls to
+        // validate KSM configurations, and translate one-time-tokens, if possible,
+        // as well as ensuring that all ConnectionGroups returned include the
+        // KSM_CONFIGURATION_ATTRIBUTE attribute, so it will be available in the UI.
+        // The value of the KSM_CONFIGURATION_ATTRIBUTE will be sanitized if set.
+        return new KsmDirectory<ConnectionGroup>(underlyingDirectory) {
+
+            @Override
+            public void add(ConnectionGroup connectionGroup) throws GuacamoleException {
+
+                // Process attribute values before saving
+                connectionGroup.setAttributes(
+                    attributeService.processAttributes(
+                        connectionGroup.getAttributes()));
+
+                super.add(connectionGroup);
+            }
+
+            @Override
+            public void update(ConnectionGroup connectionGroup) throws GuacamoleException {
+
+                // Unwrap the existing ConnectionGroup
+                if (connectionGroup instanceof KsmConnectionGroup)
+                    connectionGroup = ((KsmConnectionGroup) connectionGroup).getUnderlyingConnectionGroup();
+
+                // Process attribute values before saving
+                connectionGroup.setAttributes(
+                    attributeService.processAttributes(
+                        connectionGroup.getAttributes()));
+
+                super.update(connectionGroup);
+
+            }
+
+            @Override
+            protected ConnectionGroup wrap(ConnectionGroup object) {
+
+                // Do not process the ConnectionGroup further if it does not exist
+                if (object == null)
+                    return null;
+
+                // Sanitize values when a ConnectionGroup is fetched from the directory
+                return new KsmConnectionGroup(object);
+
+            }
+
+        };
+    }
+
+    @Override
+    public Directory<User> getUserDirectory(
+            Directory<User> underlyingDirectory) throws GuacamoleException {
+
+        // A User directory that will intercept add and update calls to
+        // validate KSM configurations, and translate one-time-tokens, if possible
+        // Additionally, this directory will will decorate all users with a
+        // KsmUser wrapper to ensure that all defined KSM fields will be exposed
+        // in the user attributes.  The value of the KSM_CONFIGURATION_ATTRIBUTE
+        // will be sanitized if set.
+        return new KsmDirectory<User>(underlyingDirectory) {
+
+            @Override
+            public void add(User user) throws GuacamoleException {
+
+                // Process attribute values before saving
+                user.setAttributes(
+                    attributeService.processAttributes(
+                        user.getAttributes()));
+
+                super.add(user);
+            }
+
+            @Override
+            public void update(User user) throws GuacamoleException {
+
+                // Unwrap the existing user
+                if (user instanceof KsmUser)
+                    user = ((KsmUser) user).getUnderlyingUser();
+
+                // Process attribute values before saving
+                user.setAttributes(
+                    attributeService.processAttributes(
+                        user.getAttributes()));
+
+                super.update(user);
+
+            }
+
+            @Override
+            protected User wrap(User object) {
+
+                // Do not process the user further if it does not exist
+                if (object == null)
+                    return null;
+
+                // Sanitize values when a user is fetched from the directory
+                return ksmUserFactory.create(object);
+
+            }
+
+        };
+    }
+}
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmUser.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmUser.java
new file mode 100644
index 0000000..9c002eb
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmUser.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.vault.ksm.user;
+
+import java.util.Map;
+
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.User;
+import org.apache.guacamole.net.auth.DelegatingUser;
+import org.apache.guacamole.vault.ksm.conf.KsmAttributeService;
+import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.collect.Maps;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+ /**
+  * A KSM-specific user implementation that exposes the
+  * KSM_CONFIGURATION_ATTRIBUTE attribute even if no value is set. but only
+  * if user-specific KSM configuration is enabled. The value of the attribute
+  * will be sanitized if non-empty. This ensures that the attribute will always
+  * show up in the UI when the feature is enabled, even for users that don't
+  * already have it set, and that any sensitive information in the attribute
+  * value will not be exposed.
+  */
+ public class KsmUser extends DelegatingUser {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(KsmUser.class);
+
+    /**
+     * Service for retrieving KSM configuration details.
+     */
+    @Inject
+    private KsmConfigurationService configurationService;
+
+    /**
+     * Create a new Ksmuser wrapping the provided User record.
+     *
+     * @param user
+     *     The User record to wrap.
+     */
+    @AssistedInject
+    KsmUser(@Assisted User user) {
+        super(user);
+    }
+
+    /**
+     * Return the underlying wrapped user record.
+     *
+     * @return
+     *     The wrapped user record.
+     */
+    User getUnderlyingUser() {
+        return getDelegateUser();
+    }
+
+    @Override
+    public Map<String, String> getAttributes() {
+
+        // Make a copy of the existing map
+        Map<String, String> attributes = Maps.newHashMap(super.getAttributes());
+
+        // Figure out if user-level KSM config is enabled
+        boolean userKsmConfigEnabled = false;
+        try {
+            userKsmConfigEnabled = configurationService.getAllowUserConfig();
+        } catch (GuacamoleException e) {
+
+            logger.warn(
+                    "Disabling user KSM config due to exception: {}"
+                    , e.getMessage());
+            logger.debug("Error looking up if user KSM config is enabled.", e);
+
+        }
+
+        // If user-specific KSM configuration is not enabled, do not expose the
+        // attribute at all
+        if (!userKsmConfigEnabled)
+            attributes.remove(KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE);
+
+        else
+            // Sanitize the KSM configuration attribute, and ensure the attribute
+            // is always present
+            attributes.put(
+                    KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE,
+                    KsmAttributeService.sanitizeKsmAttributeValue(
+                        attributes.get(KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE)));
+
+        return attributes;
+    }
+
+ }
\ No newline at end of file
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmUserFactory.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmUserFactory.java
new file mode 100644
index 0000000..9aafbb6
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmUserFactory.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.vault.ksm.user;
+
+import org.apache.guacamole.net.auth.User;
+
+/**
+ * Factory for creating KSM-specific users, which wrap an underlying User.
+ */
+public interface KsmUserFactory {
+
+    /**
+     * Returns a new instance of a KsmUser, wrapping the provided underlying User.
+     *
+     * @param user
+     *     The underlying User that should be wrapped.
+     *
+     * @return
+     *     A new instance of a KsmUser, wrapping the provided underlying User.
+     */
+    KsmUser create(User user);
+
+}
diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/resources/translations/en.json b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/resources/translations/en.json
new file mode 100644
index 0000000..034f5f2
--- /dev/null
+++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/resources/translations/en.json
@@ -0,0 +1,25 @@
+{
+
+    "DATA_SOURCE_KEEPER_SECRETS_MANAGER" : {
+        "NAME" : "Keeper Secrets Manager"
+    },
+
+    "CONNECTION_ATTRIBUTES" : {
+        "SECTION_HEADER_KSM_CONFIG"            : "Keeper Secrets Manager",
+        "FIELD_HEADER_KSM_USER_CONFIG_ENABLED" : "Allow user-provided KSM configuration"
+    },
+
+    "CONNECTION_GROUP_ATTRIBUTES" : {
+        "SECTION_HEADER_KSM_CONFIG" : "Keeper Secrets Manager",
+        "FIELD_HEADER_KSM_CONFIG"   : "KSM Service Configuration ",
+
+        "ERROR_INVALID_KSM_CONFIG_BLOB"    : "The provided base64-encoded KSM configuration blob is not valid. Please ensure that you have copied the entire blob.",
+        "ERROR_INVALID_KSM_ONE_TIME_TOKEN" : "The provided configuration is not a valid KSM one-time token or base64-encoded configuration blob. Please ensure that you have copied the entire token value."
+    },
+
+    "USER_ATTRIBUTES" : {
+        "SECTION_HEADER_KSM_CONFIG" : "Keeper Secrets Manager",
+        "FIELD_HEADER_KSM_CONFIG"   : "KSM Service Configuration "
+    }
+
+}
diff --git a/extensions/pom.xml b/extensions/pom.xml
index 3f2fae2..e9b8a3e 100644
--- a/extensions/pom.xml
+++ b/extensions/pom.xml
@@ -40,6 +40,7 @@
     <modules>
 
         <!-- Authentication extensions -->
+        <module>guacamole-auth-ban</module>
         <module>guacamole-auth-duo</module>
         <module>guacamole-auth-header</module>
         <module>guacamole-auth-jdbc</module>
@@ -53,6 +54,9 @@
         <module>guacamole-history-recording-storage</module>
         <module>guacamole-vault</module>
 
+        <!-- Utility extensions -->
+        <module>guacamole-display-statistics</module>
+
     </modules>
 
     <build>
diff --git a/guacamole-common-js/src/main/webapp/modules/Client.js b/guacamole-common-js/src/main/webapp/modules/Client.js
index 56fe434..bd59d65 100644
--- a/guacamole-common-js/src/main/webapp/modules/Client.js
+++ b/guacamole-common-js/src/main/webapp/modules/Client.js
@@ -32,14 +32,7 @@
 
     var guac_client = this;
 
-    var STATE_IDLE          = 0;
-    var STATE_CONNECTING    = 1;
-    var STATE_WAITING       = 2;
-    var STATE_CONNECTED     = 3;
-    var STATE_DISCONNECTING = 4;
-    var STATE_DISCONNECTED  = 5;
-
-    var currentState = STATE_IDLE;
+    var currentState = Guacamole.Client.State.IDLE;
     
     var currentTimestamp = 0;
 
@@ -161,8 +154,8 @@
     }
 
     function isConnected() {
-        return currentState == STATE_CONNECTED
-            || currentState == STATE_WAITING;
+        return currentState == Guacamole.Client.State.CONNECTED
+            || currentState == Guacamole.Client.State.WAITING;
     }
 
     /**
@@ -927,6 +920,12 @@
      * @event
      * @param {!number} timestamp
      *     The timestamp associated with the sync instruction.
+     *
+     * @param {!number} frames
+     *     The number of frames that were considered or combined to produce the
+     *     frame associated with this sync instruction, or zero if this value
+     *     is not known or the remote desktop server provides no concept of
+     *     frames.
      */
     this.onsync = null;
 
@@ -1655,6 +1654,7 @@
         "sync": function(parameters) {
 
             var timestamp = parseInt(parameters[0]);
+            var frames = parameters[1] ? parseInt(parameters[1]) : 0;
 
             // Flush display, send sync when done
             display.flush(function displaySyncComplete() {
@@ -1672,15 +1672,15 @@
                     currentTimestamp = timestamp;
                 }
 
-            });
+            }, timestamp, frames);
 
             // If received first update, no longer waiting.
-            if (currentState === STATE_WAITING)
-                setState(STATE_CONNECTED);
+            if (currentState === Guacamole.Client.State.WAITING)
+                setState(Guacamole.Client.State.CONNECTED);
 
             // Call sync handler if defined
             if (guac_client.onsync)
-                guac_client.onsync(timestamp);
+                guac_client.onsync(timestamp, frames);
 
         },
 
@@ -1832,10 +1832,10 @@
     this.disconnect = function() {
 
         // Only attempt disconnection not disconnected.
-        if (currentState != STATE_DISCONNECTED
-                && currentState != STATE_DISCONNECTING) {
+        if (currentState != Guacamole.Client.State.DISCONNECTED
+                && currentState != Guacamole.Client.State.DISCONNECTING) {
 
-            setState(STATE_DISCONNECTING);
+            setState(Guacamole.Client.State.DISCONNECTING);
 
             // Stop sending keep-alive messages
             stopKeepAlive();
@@ -1843,7 +1843,7 @@
             // Send disconnect message and disconnect
             tunnel.sendMessage("disconnect");
             tunnel.disconnect();
-            setState(STATE_DISCONNECTED);
+            setState(Guacamole.Client.State.DISCONNECTED);
 
         }
 
@@ -1862,13 +1862,13 @@
      */
     this.connect = function(data) {
 
-        setState(STATE_CONNECTING);
+        setState(Guacamole.Client.State.CONNECTING);
 
         try {
             tunnel.connect(data);
         }
         catch (status) {
-            setState(STATE_IDLE);
+            setState(Guacamole.Client.State.IDLE);
             throw status;
         }
 
@@ -1876,12 +1876,64 @@
         // still here, even if not active
         scheduleKeepAlive();
 
-        setState(STATE_WAITING);
+        setState(Guacamole.Client.State.WAITING);
     };
 
 };
 
 /**
+ * All possible Guacamole Client states.
+ * 
+ * @type {!Object.<string, number>}
+ */
+Guacamole.Client.State = {
+    
+    /**
+     * The client is idle, with no active connection.
+     * 
+     * @type number
+     */
+    "IDLE" : 0,
+    
+    /**
+     * The client is in the process of establishing a connection.
+     * 
+     * @type {!number}
+     */
+    "CONNECTING" : 1,
+    
+    /**
+     * The client is waiting on further information or a remote server to
+     * establish the connection.
+     * 
+     * @type {!number}
+     */
+    "WAITING" : 2,
+    
+    /**
+     * The client is actively connected to a remote server.
+     * 
+     * @type {!number}
+     */
+    "CONNECTED" : 3,
+    
+    /**
+     * The client is in the process of disconnecting from the remote server.
+     * 
+     * @type {!number}
+     */
+    "DISCONNECTING" : 4,
+    
+    /**
+     * The client has completed the connection and is no longer connected.
+     * 
+     * @type {!number}
+     */
+    "DISCONNECTED" : 5
+    
+};
+
+/**
  * Map of all Guacamole binary raster operations to transfer functions.
  *
  * @private
diff --git a/guacamole-common-js/src/main/webapp/modules/Display.js b/guacamole-common-js/src/main/webapp/modules/Display.js
index 8baa6b7..374a762 100644
--- a/guacamole-common-js/src/main/webapp/modules/Display.js
+++ b/guacamole-common-js/src/main/webapp/modules/Display.js
@@ -113,6 +113,17 @@
     this.cursorY = 0;
 
     /**
+     * The number of milliseconds over which display rendering statistics
+     * should be gathered, dispatching {@link #onstatistics} events as those
+     * statistics are available. If set to zero, no statistics will be
+     * gathered.
+     *
+     * @default 0
+     * @type {!number}
+     */
+    this.statisticWindow = 0;
+
+    /**
      * Fired when the default layer (and thus the entire Guacamole display)
      * is resized.
      * 
@@ -143,6 +154,18 @@
     this.oncursor = null;
 
     /**
+     * Fired whenever performance statistics are available for recently-
+     * rendered frames. This event will fire only if {@link #statisticWindow}
+     * is non-zero.
+     *
+     * @event
+     * @param {!Guacamole.Display.Statistics} stats
+     *     An object containing general rendering performance statistics for
+     *     the remote desktop, Guacamole server, and Guacamole client.
+     */
+    this.onstatistics = null;
+
+    /**
      * The queue of all pending Tasks. Tasks will be run in order, with new
      * tasks added at the end of the queue and old tasks removed from the
      * front of the queue (FIFO). These tasks will eventually be grouped
@@ -163,11 +186,33 @@
     var frames = [];
 
     /**
-     * Flushes all pending frames.
+     * The ID of the animation frame request returned by the last call to
+     * requestAnimationFrame(). This value will only be set if the browser
+     * supports requestAnimationFrame(), if a frame render is currently
+     * pending, and if the current browser tab is currently focused (likely to
+     * handle requests for animation frames). In all other cases, this will be
+     * null.
+     *
+     * @private
+     * @type {number}
+     */
+    var inProgressFrame = null;
+
+    /**
+     * Flushes all pending frames synchronously. This function will block until
+     * all pending frames have rendered. If a frame is currently blocked by an
+     * asynchronous operation like an image load, this function will return
+     * after reaching that operation and the flush operation will
+     * automamtically resume after that operation completes.
+     *
      * @private
      */
-    function __flush_frames() {
+    var syncFlush = function syncFlush() {
 
+        var localTimestamp = 0;
+        var remoteTimestamp = 0;
+
+        var renderedLogicalFrames = 0;
         var rendered_frames = 0;
 
         // Draw all pending frames, if ready
@@ -178,6 +223,10 @@
                 break;
 
             frame.flush();
+
+            localTimestamp = frame.localTimestamp;
+            remoteTimestamp = frame.remoteTimestamp;
+            renderedLogicalFrames += frame.logicalFrames;
             rendered_frames++;
 
         } 
@@ -185,6 +234,172 @@
         // Remove rendered frames from array
         frames.splice(0, rendered_frames);
 
+        if (rendered_frames)
+            notifyFlushed(localTimestamp, remoteTimestamp, renderedLogicalFrames);
+
+    };
+
+    /**
+     * Flushes all pending frames asynchronously. This function returns
+     * immediately, relying on requestAnimationFrame() to dictate when each
+     * frame should be flushed.
+     *
+     * @private
+     */
+    var asyncFlush = function asyncFlush() {
+
+        var continueFlush = function continueFlush() {
+
+            // We're no longer waiting to render a frame
+            inProgressFrame = null;
+
+            // Nothing to do if there are no frames remaining
+            if (!frames.length)
+                return;
+
+            // Flush the next frame only if it is ready (not awaiting
+            // completion of some asynchronous operation like an image load)
+            if (frames[0].isReady()) {
+                var frame = frames.shift();
+                frame.flush();
+                notifyFlushed(frame.localTimestamp, frame.remoteTimestamp, frame.logicalFrames);
+            }
+
+            // Request yet another animation frame if frames remain to be
+            // flushed
+            if (frames.length)
+                inProgressFrame = window.requestAnimationFrame(continueFlush);
+
+        };
+
+        // Begin flushing frames if not already waiting to render a frame
+        if (!inProgressFrame)
+            inProgressFrame = window.requestAnimationFrame(continueFlush);
+
+    };
+
+    /**
+     * Recently-gathered display render statistics, as made available by calls
+     * to notifyFlushed(). The contents of this array will be trimmed to
+     * contain only up to {@link #statisticWindow} milliseconds of statistics.
+     *
+     * @private
+     * @type {Guacamole.Display.Statistics[]}
+     */
+    var statistics = [];
+
+    /**
+     * Notifies that one or more frames have been successfully rendered
+     * (flushed) to the display.
+     *
+     * @private
+     * @param {!number} localTimestamp
+     *     The local timestamp of the point in time at which the most recent,
+     *     flushed frame was received by the display, in milliseconds since the
+     *     Unix Epoch.
+     *
+     * @param {!number} remoteTimestamp
+     *     The remote timestamp of sync instruction associated with the most
+     *     recent, flushed frame received by the display. This timestamp is in
+     *     milliseconds, but is arbitrary, having meaning only relative to
+     *     other timestamps in the same connection.
+     *
+     * @param {!number} logicalFrames
+     *     The number of remote desktop frames that were flushed.
+     */
+    var notifyFlushed = function notifyFlushed(localTimestamp, remoteTimestamp, logicalFrames) {
+
+        // Ignore if statistics are not being gathered
+        if (!guac_display.statisticWindow)
+            return;
+
+        var current = new Date().getTime();
+
+        // Find the first statistic that is still within the configured time
+        // window
+        for (var first = 0; first < statistics.length; first++) {
+            if (current - statistics[first].timestamp <= guac_display.statisticWindow)
+                break;
+        }
+
+        // Remove all statistics except those within the time window
+        statistics.splice(0, first - 1);
+
+        // Record statistics for latest frame
+        statistics.push({
+            localTimestamp : localTimestamp,
+            remoteTimestamp : remoteTimestamp,
+            timestamp : current,
+            frames : logicalFrames
+        });
+
+        // Determine the actual time interval of the available statistics (this
+        // will not perfectly match the configured interval, which is an upper
+        // bound)
+        var statDuration = (statistics[statistics.length - 1].timestamp - statistics[0].timestamp) / 1000;
+
+        // Determine the amount of time that elapsed remotely (within the
+        // remote desktop)
+        var remoteDuration = (statistics[statistics.length - 1].remoteTimestamp - statistics[0].remoteTimestamp) / 1000;
+
+        // Calculate the number of frames that have been rendered locally
+        // within the configured time interval
+        var localFrames = statistics.length;
+
+        // Calculate the number of frames actually received from the remote
+        // desktop by the Guacamole server
+        var remoteFrames = statistics.reduce(function sumFrames(prev, stat) {
+            return prev + stat.frames;
+        }, 0);
+
+        // Calculate the number of frames that the Guacamole server had to
+        // drop or combine with other frames
+        var drops = statistics.reduce(function sumDrops(prev, stat) {
+            return prev + Math.max(0, stat.frames - 1);
+        }, 0);
+
+        // Produce lag and FPS statistics from above raw measurements
+        var stats = new Guacamole.Display.Statistics({
+            processingLag : current - localTimestamp,
+            desktopFps : (remoteDuration && remoteFrames) ? remoteFrames / remoteDuration : null,
+            clientFps : statDuration ? localFrames / statDuration : null,
+            serverFps : remoteDuration ? localFrames / remoteDuration : null,
+            dropRate : remoteDuration ? drops / remoteDuration : null
+        });
+
+        // Notify of availability of new statistics
+        if (guac_display.onstatistics)
+            guac_display.onstatistics(stats);
+
+    };
+
+    // Switch from asynchronous frame handling to synchronous frame handling if
+    // requestAnimationFrame() is unlikely to be usable (browsers may not
+    // invoke the animation frame callback if the relevant tab is not focused)
+    window.addEventListener('blur', function switchToSyncFlush() {
+        if (inProgressFrame && !document.hasFocus()) {
+
+            // Cancel pending asynchronous processing of frame ...
+            window.cancelAnimationFrame(inProgressFrame);
+            inProgressFrame = null;
+
+            // ... and instead process it synchronously
+            syncFlush();
+
+        }
+    }, true);
+
+    /**
+     * Flushes all pending frames.
+     * @private
+     */
+    function __flush_frames() {
+
+        if (window.requestAnimationFrame && document.hasFocus())
+            asyncFlush();
+        else
+            syncFlush();
+
     }
 
     /**
@@ -198,8 +413,43 @@
      *
      * @param {!Task[]} tasks
      *     The set of tasks which must be executed to render this frame.
+     *
+     * @param {number} [timestamp]
+     *     The remote timestamp of sync instruction associated with this frame.
+     *     This timestamp is in milliseconds, but is arbitrary, having meaning
+     *     only relative to other remote timestamps in the same connection. If
+     *     omitted, a compatible but local timestamp will be used instead.
+     *
+     * @param {number} [logicalFrames=0]
+     *     The number of remote desktop frames that were combined to produce
+     *     this frame, or zero if this value is unknown or inapplicable.
      */
-    function Frame(callback, tasks) {
+    var Frame = function Frame(callback, tasks, timestamp, logicalFrames) {
+
+        /**
+         * The local timestamp of the point in time at which this frame was
+         * received by the display, in milliseconds since the Unix Epoch.
+         *
+         * @type {!number}
+         */
+        this.localTimestamp = new Date().getTime();
+
+        /**
+         * The remote timestamp of sync instruction associated with this frame.
+         * This timestamp is in milliseconds, but is arbitrary, having meaning
+         * only relative to other remote timestamps in the same connection.
+         *
+         * @type {!number}
+         */
+        this.remoteTimestamp = timestamp || this.localTimestamp;
+
+        /**
+         * The number of remote desktop frames that were combined to produce
+         * this frame. If unknown or not applicable, this will be zero.
+         *
+         * @type {!number}
+         */
+        this.logicalFrames = logicalFrames || 0;
 
         /**
          * Cancels rendering of this frame and all associated tasks. The
@@ -254,7 +504,7 @@
 
         };
 
-    }
+    };
 
     /**
      * A container for an task handler. Each operation which must be ordered
@@ -431,11 +681,20 @@
      * @param {function} [callback]
      *     The function to call when this frame is flushed. This may happen
      *     immediately, or later when blocked tasks become unblocked.
+     *
+     * @param {number} timestamp
+     *     The remote timestamp of sync instruction associated with this frame.
+     *     This timestamp is in milliseconds, but is arbitrary, having meaning
+     *     only relative to other remote timestamps in the same connection.
+     *
+     * @param {number} logicalFrames
+     *     The number of remote desktop frames that were combined to produce
+     *     this frame.
      */
-    this.flush = function(callback) {
+    this.flush = function(callback, timestamp, logicalFrames) {
 
         // Add frame, reset tasks
-        frames.push(new Frame(callback, tasks));
+        frames.push(new Frame(callback, tasks, timestamp, logicalFrames));
         tasks = [];
 
         // Attempt flush
@@ -1855,3 +2114,79 @@
  * @type {!number}
  */
 Guacamole.Display.VisibleLayer.__next_id = 0;
+
+/**
+ * A set of Guacamole display performance statistics, describing the speed at
+ * which the remote desktop, Guacamole server, and Guacamole client are
+ * rendering frames.
+ *
+ * @constructor
+ * @param {Guacamole.Display.Statistics|Object} [template={}]
+ *     The object whose properties should be copied within the new
+ *     Guacamole.Display.Statistics.
+ */
+Guacamole.Display.Statistics = function Statistics(template) {
+
+    template = template || {};
+
+    /**
+     * The amount of time that the Guacamole client is taking to render
+     * individual frames, in milliseconds, if known. If this value is unknown,
+     * such as if the there are insufficient frame statistics recorded to
+     * calculate this value, this will be null.
+     *
+     * @type {?number}
+     */
+    this.processingLag = template.processingLag;
+
+    /**
+     * The framerate of the remote desktop currently being viewed within the
+     * relevant Gucamole.Display, independent of Guacamole, in frames per
+     * second. This represents the speed at which the remote desktop is
+     * producing frame data for the Guacamole server to consume. If this
+     * value is unknown, such as if the remote desktop server does not actually
+     * define frame boundaries, this will be null.
+     *
+     * @type {?number}
+     */
+    this.desktopFps = template.desktopFps;
+
+    /**
+     * The rate at which the Guacamole server is generating frames for the
+     * Guacamole client to consume, in frames per second. If the Guacamole
+     * server is correctly adjusting for variance in client/browser processing
+     * power, this rate should closely match the client rate, and should remain
+     * independent of any network latency. If this value is unknown, such as if
+     * the there are insufficient frame statistics recorded to calculate this
+     * value, this will be null.
+     *
+     * @type {?number}
+     */
+    this.serverFps = template.serverFps;
+
+    /**
+     * The rate at which the Guacamole client is consuming frames generated by
+     * the Guacamole server, in frames per second. If the Guacamole server is
+     * correctly adjusting for variance in client/browser processing power,
+     * this rate should closely match the server rate, regardless of any
+     * latency on the network between the server and client. If this value is
+     * unknown, such as if the there are insufficient frame statistics recorded
+     * to calculate this value, this will be null.
+     *
+     * @type {?number}
+     */
+    this.clientFps = template.clientFps;
+
+    /**
+     * The rate at which the Guacamole server is dropping or combining frames
+     * received from the remote desktop server to compensate for variance in
+     * client/browser processing power, in frames per second. This value may
+     * also be non-zero if the server is compensating for variances in its own
+     * processing power, or relative slowness in image compression vs. the rate
+     * that inbound frames are received. If this value is unknown, such as if
+     * the remote desktop server does not actually define frame boundaries,
+     * this will be null.
+     */
+    this.dropRate = template.dropRate;
+
+};
diff --git a/guacamole-docker/bin/build-guacamole.sh b/guacamole-docker/bin/build-guacamole.sh
index 0909241..b9e5bcd 100755
--- a/guacamole-docker/bin/build-guacamole.sh
+++ b/guacamole-docker/bin/build-guacamole.sh
@@ -187,3 +187,13 @@
     mkdir -p "$DESTINATION/json"
     cp extensions/guacamole-auth-json/target/guacamole-auth-json*.jar "$DESTINATION/json"
 fi
+
+#
+# Copy automatic brute-force banning auth extension if it was built
+#
+
+if [ -f extensions/guacamole-auth-ban/target/guacamole-auth-ban*.jar ]; then
+    mkdir -p "$DESTINATION/ban"
+    cp extensions/guacamole-auth-ban/target/guacamole-auth-ban*.jar "$DESTINATION/ban"
+fi
+
diff --git a/guacamole-docker/bin/start.sh b/guacamole-docker/bin/start.sh
index 5289305..a86cce0 100755
--- a/guacamole-docker/bin/start.sh
+++ b/guacamole-docker/bin/start.sh
@@ -687,19 +687,21 @@
     fi
 
     # Update config file
-    set_optional_property "radius-hostname"         "$RADIUS_HOSTNAME"
-    set_optional_property "radius-auth-port"        "$RADIUS_AUTH_PORT"
-    set_property          "radius-shared-secret"    "$RADIUS_SHARED_SECRET"
-    set_property          "radius-auth-protocol"    "$RADIUS_AUTH_PROTOCOL"
-    set_optional_property "radius-key-file"         "$RADIUS_KEY_FILE"
-    set_optional_property "radius-key-type"         "$RADIUS_KEY_TYPE"
-    set_optional_property "radius-key-password"     "$RADIUS_KEY_PASSWORD"
-    set_optional_property "radius-ca-file"          "$RADIUS_CA_FILE"
-    set_optional_property "radius-ca-type"          "$RADIUS_CA_TYPE"
-    set_optional_property "radius-ca-password"      "$RADIUS_CA_PASSWORD"
-    set_optional_property "radius-trust-all"        "$RADIUS_TRUST_ALL"
-    set_optional_property "radius-retries"          "$RADIUS_RETRIES"
-    set_optional_property "radius-timeout"          "$RADIUS_TIMEOUT"
+    set_optional_property "radius-hostname"                 "$RADIUS_HOSTNAME"
+    set_optional_property "radius-auth-port"                "$RADIUS_AUTH_PORT"
+    set_property          "radius-shared-secret"            "$RADIUS_SHARED_SECRET"
+    set_property          "radius-auth-protocol"            "$RADIUS_AUTH_PROTOCOL"
+    set_optional_property "radius-key-file"                 "$RADIUS_KEY_FILE"
+    set_optional_property "radius-key-type"                 "$RADIUS_KEY_TYPE"
+    set_optional_property "radius-key-password"             "$RADIUS_KEY_PASSWORD"
+    set_optional_property "radius-ca-file"                  "$RADIUS_CA_FILE"
+    set_optional_property "radius-ca-type"                  "$RADIUS_CA_TYPE"
+    set_optional_property "radius-ca-password"              "$RADIUS_CA_PASSWORD"
+    set_optional_property "radius-trust-all"                "$RADIUS_TRUST_ALL"
+    set_optional_property "radius-retries"                  "$RADIUS_RETRIES"
+    set_optional_property "radius-timeout"                  "$RADIUS_TIMEOUT"
+    set_optional_property "radius-eap-ttls-inner-protocol"  "$RADIUS_EAP_TTLS_INNER_PROTOCOL"
+    set_optional_property "radius-nas-ip"                   "$RADIUS_NAS_IP"
 
     set_optional_property \
        "radius-eap-ttls-inner-protocol" \
@@ -757,7 +759,10 @@
     set_property          "openid-redirect-uri"              "$OPENID_REDIRECT_URI"
     set_optional_property "openid-username-claim-type"       "$OPENID_USERNAME_CLAIM_TYPE"
     set_optional_property "openid-groups-claim-type"         "$OPENID_GROUPS_CLAIM_TYPE"
+    set_optional_property "openid-scope"                     "$OPENID_SCOPE"
+    set_optional_property "openid-allowed-clock-skew"        "$OPENID_ALLOWED_CLOCK_SKEW"
     set_optional_property "openid-max-token-validity"        "$OPENID_MAX_TOKEN_VALIDITY"
+    set_optional_property "openid-max-nonce-validity"        "$OPENID_MAX_NONCE_VALIDITY"
 
     # Add required .jar files to GUACAMOLE_EXT
     # "1-{}" make it sorted as a first provider (only authentication)
@@ -1087,6 +1092,11 @@
 set_property "guacd-hostname" "$GUACD_HOSTNAME"
 set_property "guacd-port"     "$GUACD_PORT"
 
+# A comma-separated list of the identifiers of authentication providers that
+# should be allowed to fail internally without aborting the authentication process
+set_optional_property "skip-if-unavailable"     "$SKIP_IF_UNAVAILABLE"
+
+
 #
 # Track which authentication backends are installed
 #
@@ -1187,6 +1197,31 @@
     associate_apisessiontimeout
 fi
 
+# Maximum number of bytes to accept within the entity body of any particular HTTP request
+set_optional_property "api-max-request-size" "$API_MAX_REQUEST_SIZE"
+
+# A comma-separated list of language keys to allow as display language 
+# choices within the Guacamole interface
+set_optional_property "allowed-languages" "$ALLOWED_LANGUAGES"
+
+# If set to “true”, Guacamole will first evaluate its environment to obtain the value
+# for any given configuration property, before using a value specified in 
+# guacamole.properties or falling back to a default value
+set_optional_property "enable-environment-properties" "$ENABLE_ENVIRONMENT_PROPERTIES"
+
+
+# Apply any overrides for default address ban behavior
+set_optional_property "ban-address-duration" "$BAN_ADDRESS_DURATION"
+set_optional_property "ban-max-addresses" "$BAN_MAX_ADDRESSES"
+set_optional_property "ban-max-invalid-attempts" "$BAN_MAX_INVALID_ATTEMPTS"
+
+# Always load guacamole-auth-ban extension (automatic banning can be disabled
+# through seting BAN_ADDRESS_DURATION to 0). As guacamole-auth-ban performs
+# its banning by handling a pre-authentication event, it is guaranteed to
+# perform its checks before all other auth processing and load order does not
+# matter.
+ln -s /opt/guacamole/ban/guacamole-auth-*.jar "$GUACAMOLE_EXT"
+
 # Set logback level if specified
 if [ -n "$LOGBACK_LEVEL" ]; then
     unzip -o -j /opt/guacamole/guacamole.war WEB-INF/classes/logback.xml -d $GUACAMOLE_HOME
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/AtomicDirectoryOperation.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/AtomicDirectoryOperation.java
new file mode 100644
index 0000000..0e081f0
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/AtomicDirectoryOperation.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.net.auth;
+
+import org.apache.guacamole.GuacamoleException;
+
+/**
+ * An operation that should be attempted atomically when passed to
+ * {@link Directory#tryAtomically}, if atomic operations are supported by
+ * the Directory.
+ */
+public interface AtomicDirectoryOperation<ObjectType extends Identifiable>  {
+
+    /**
+     * Attempt the operation atomically. If the Directory does not support
+     * atomic operations, the atomic flag will be set to false. If the atomic
+     * flag is set to true, the provided directory is guaranteed to perform
+     * the operations within this function atomically. Atomicity of the
+     * provided directory outside this function, or of the directory invoking
+     * this function are not guaranteed.
+     *
+     * <p>NOTE: If atomicity is required for this operation, a 
+     * GuacamoleException may be thrown by this function before any changes are
+     * made, ensuring the operation will only ever be performed atomically.
+     *
+     * @param atomic
+     *     True if the provided directory is guaranteed to perform the operation
+     *     atomically within the context of this function.
+     *
+     * @param directory
+     *     A directory that will perform the operation atomically if the atomic
+     *     flag is set to true. If the flag is false, the directory may still
+     *     be used, though atomicity is not guaranteed.
+     *
+     * @throws GuacamoleException
+     *     If an issue occurs during the operation.
+     */
+    void executeOperation(boolean atomic, Directory<ObjectType> directory)
+            throws GuacamoleException;
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Connection.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Connection.java
index 3d8e64d..24cf49c 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Connection.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Connection.java
@@ -34,20 +34,7 @@
  * backing GuacamoleConfiguration may be intentionally obfuscated or tokenized
  * to protect sensitive configuration information.
  */
-public interface Connection extends Identifiable, Connectable, Attributes {
-
-    /**
-     * Returns the name assigned to this Connection.
-     * @return The name assigned to this Connection.
-     */
-    public String getName();
-
-    /**
-     * Sets the name assigned to this Connection.
-     *
-     * @param name The name to assign.
-     */
-    public void setName(String name);
+public interface Connection extends Identifiable, Connectable, Attributes, Nameable {
 
     /**
      * Returns the unique identifier of the parent ConnectionGroup for
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/ConnectionGroup.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/ConnectionGroup.java
index 74412de..a080afc 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/ConnectionGroup.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/ConnectionGroup.java
@@ -26,7 +26,7 @@
  * Represents a connection group, which can contain both other connection groups
  * as well as connections.
  */
-public interface ConnectionGroup extends Identifiable, Connectable, Attributes {
+public interface ConnectionGroup extends Identifiable, Connectable, Attributes, Nameable {
   
     /**
      * All legal types of connection group.
@@ -52,19 +52,6 @@
     };
 
     /**
-     * Returns the name assigned to this ConnectionGroup.
-     * @return The name assigned to this ConnectionGroup.
-     */
-    public String getName();
-
-    /**
-     * Sets the name assigned to this ConnectionGroup.
-     *
-     * @param name The name to assign.
-     */
-    public void setName(String name);
-
-    /**
      * Returns the unique identifier of the parent ConnectionGroup for
      * this ConnectionGroup.
      * 
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Credentials.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Credentials.java
index 40f9912..6ad0e24 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Credentials.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Credentials.java
@@ -236,4 +236,36 @@
         this.remoteHostname = remoteHostname;
     }
 
+    /**
+     * Returns whether this Credentials object does not contain any specific
+     * authentication parameters, including HTTP parameters and the HTTP header
+     * used for the authentication token. An authentication request that
+     * contains no parameters whatsoever will tend to be the first, anonymous,
+     * credential-less authentication attempt that results in the initial login
+     * screen rendering.
+     *
+     * @return
+     *     true if this Credentials object contains no authentication
+     *     parameters whatsoever, false otherwise.
+     */
+    public boolean isEmpty() {
+
+        // An authentication request that contains an explicit username or
+        // password (even if blank) is non-empty, regardless of how the values
+        // were passed
+        if (getUsername() != null || getPassword() != null)
+            return false;
+
+        // All further tests depend on HTTP request details
+        HttpServletRequest httpRequest = getRequest();
+        if (httpRequest == null)
+            return true;
+
+        // An authentication request is non-empty if it contains any HTTP
+        // parameters at all or contains an authentication token
+        return !httpRequest.getParameterNames().hasMoreElements()
+                && httpRequest.getHeader("Guacamole-Token") == null;
+
+    }
+
 }
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingDirectory.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingDirectory.java
index 0a9046c..cc19847 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingDirectory.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingDirectory.java
@@ -90,4 +90,10 @@
         directory.remove(identifier);
     }
 
+    @Override
+    public void tryAtomically(AtomicDirectoryOperation<ObjectType> operation)
+            throws GuacamoleException {
+        directory.tryAtomically(operation);
+    }
+
 }
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingUserContext.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingUserContext.java
index 85e0259..7431fa8 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingUserContext.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingUserContext.java
@@ -128,6 +128,11 @@
     }
 
     @Override
+    public Collection<Form> getUserPreferenceAttributes() {
+        return userContext.getUserPreferenceAttributes();
+    }
+
+    @Override
     public Collection<Form> getUserGroupAttributes() {
         return userContext.getUserGroupAttributes();
     }
@@ -157,4 +162,9 @@
         return userContext.getPrivileged();
     }
 
+    @Override
+    public boolean isValid() {
+        return userContext.isValid();
+    }
+
 }
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Directory.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Directory.java
index 287145f..bc5c52a 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Directory.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Directory.java
@@ -35,6 +35,104 @@
 public interface Directory<ObjectType extends Identifiable> {
 
     /**
+     * All Directory types that may be found on the {@link UserContext}
+     * interface.
+     */
+    public enum Type {
+
+        /**
+         * The type of a Directory that contains {@link ActiveConnection}
+         * objects.
+         */
+        ACTIVE_CONNECTION(ActiveConnection.class),
+
+        /**
+         * The type of a Directory that contains {@link Connection}
+         * objects.
+         */
+        CONNECTION(Connection.class),
+
+        /**
+         * The type of a Directory that contains {@link ConnectionGroup}
+         * objects.
+         */
+        CONNECTION_GROUP(ConnectionGroup.class),
+
+        /**
+         * The type of a Directory that contains {@link SharingProfile}
+         * objects.
+         */
+        SHARING_PROFILE(SharingProfile.class),
+
+        /**
+         * The type of a Directory that contains {@link User} objects.
+         */
+        USER(User.class),
+
+        /**
+         * The type of a Directory that contains {@link UserGroup}
+         * objects.
+         */
+        USER_GROUP(UserGroup.class);
+
+        /**
+         * The base class of the type of object stored within the type of
+         * Directory represented by this Directory.Type.
+         */
+        private final Class<? extends Identifiable> objectType;
+
+        /**
+         * Creates a new Directory.Type representing the type of a Directory
+         * that contains only subclasses of the given class.
+         *
+         * @param objectType
+         *     The base class of the type of object stored within the type of
+         *     Directory represented by this Directory.Type.
+         */
+        private Type(Class<? extends Identifiable> objectType) {
+            this.objectType = objectType;
+        }
+
+        /**
+         * Returns the base class of the type of object stored within a
+         * {@link Directory} of this type.
+         *
+         * @return
+         *     The base class of the type of object stored within a
+         *     {@link Directory} of this type.
+         */
+        public Class<? extends Identifiable> getObjectType() {
+            return objectType;
+        }
+
+        /**
+         * Returns the Directory.Type representing the type of a Directory that
+         * could contain an object having the given class. The class may be a
+         * subclass of the overall base class of the objects stored within the
+         * Directory.
+         *
+         * @param objectType
+         *     The class to determine the Directory.Type of.
+         *
+         * @return
+         *     The Directory.Type representing the type of a Directory that
+         *     could contain an object having the given class, or null if there
+         *     is no such Directory available via the UserContext interface.
+         */
+        public static Type of(Class<? extends Identifiable> objectType) {
+
+            for (Type type : Type.values()) {
+                if (type.getObjectType().isAssignableFrom(objectType))
+                    return type;
+            }
+
+            return null;
+
+        }
+
+    }
+
+    /**
      * Returns the object having the given identifier. Note that changes to
      * the object returned will not necessarily affect the object stored within
      * the Directory. To update an object stored within an
@@ -117,8 +215,29 @@
      * @param identifier The identifier of the object to remove.
      *
      * @throws GuacamoleException If an error occurs while removing the object,
-     *                            or if removing object is not allowed.
+     *                            or if removing the object is not allowed.
      */
     void remove(String identifier) throws GuacamoleException;
 
+    /**
+     * Attempt to perform the provided operation atomically if possible. If the
+     * operation can be performed atomically, the atomic flag will be set to
+     * true, and the directory passed to the provided operation callback will
+     * peform directory operations atomically within the operation callback.
+     *
+     * @param operation
+     *     The directory operation that should be performed atomically.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs during execution of the provided operation.
+     */
+    default void tryAtomically(AtomicDirectoryOperation<ObjectType> operation)
+            throws GuacamoleException {
+
+        // By default, perform the operation non-atomically. If atomic operation
+        // is supported by an implementation, it must be implemented there.
+        operation.executeOperation(false, this);
+
+    }
+
 }
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Nameable.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Nameable.java
new file mode 100644
index 0000000..d402060
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/Nameable.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.net.auth;
+
+/**
+ * An object which has a human-readable, arbitrary name. No requirement is
+ * imposed by this interface regarding whether this name must be unique,
+ * however implementations are free to impose such restrictions.
+ */
+public interface Nameable {
+
+    /**
+     * Returns the human-readable name assigned to this object.
+     *
+     * @return
+     *     The name assigned to this object.
+     */
+    String getName();
+
+    /**
+     * Sets the human-readable name assigned to this object.
+     *
+     * @param name
+     *     The name to assign.
+     */
+    void setName(String name);
+
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/SharingProfile.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/SharingProfile.java
index 2d4c432..1fe75d9 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/SharingProfile.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/SharingProfile.java
@@ -25,23 +25,7 @@
  * Represents the semantics which apply to an existing connection when shared,
  * along with a human-readable name and unique identifier.
  */
-public interface SharingProfile extends Identifiable, Attributes {
-
-    /**
-     * Returns the human-readable name assigned to this SharingProfile.
-     *
-     * @return
-     *     The name assigned to this SharingProfile.
-     */
-    public String getName();
-
-    /**
-     * Sets the human-readable name assigned to this SharingProfile.
-     *
-     * @param name
-     *     The name to assign.
-     */
-    public void setName(String name);
+public interface SharingProfile extends Identifiable, Attributes, Nameable {
 
     /**
      * Returns the identifier of the primary connection associated with this
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/UserContext.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/UserContext.java
index ccdcaae..a9ea7ea 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/UserContext.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/UserContext.java
@@ -20,6 +20,8 @@
 package org.apache.guacamole.net.auth;
 
 import java.util.Collection;
+import java.util.Collections;
+
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.form.Form;
 
@@ -39,6 +41,27 @@
     User self();
 
     /**
+     * Returns true if the session for the User associated with this user
+     * context is valid, or false otherwise. If the session is not valid,
+     * the webapp can be expected to terminate the session within a short
+     * period of time.
+     *
+     * NOTE: The webapp currently checks once a minute, and terminates any
+     * session marked as invalid.
+     *
+     * @return
+     *     true if the session for the User associated with this user
+     *     context is valid, or false otherwise.
+     */
+    default boolean isValid() {
+
+        // A user context is always valid unless explicitly updated by an
+        // implementation
+        return true;
+
+    }
+
+    /**
      * Returns an arbitrary REST resource representing this UserContext. The
      * REST resource returned must be properly annotated with JSR-311
      * annotations, and may serve as the root resource for any number of
@@ -212,6 +235,21 @@
     Collection<Form> getUserAttributes();
 
     /**
+     * Retrieves a collection of user attributes, specific to the user preferences
+     * page in the UI. Unlike standard user attributes, these should be self-editable.
+     *
+     * @return
+     *     A collection of form of user attributes, specific to the user preferences
+     *     page in the UI.
+     */
+    default Collection<Form> getUserPreferenceAttributes() {
+
+        // By default, a user context does not expose any preference user attributes
+        return Collections.emptyList();
+
+    }
+
+    /**
      * Retrieves a collection of all attributes applicable to user groups. This
      * collection will contain only those attributes which the current user has
      * general permission to view or modify. If there are no such attributes,
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/event/ApplicationShutdownEvent.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/ApplicationShutdownEvent.java
new file mode 100644
index 0000000..ba7f50b
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/ApplicationShutdownEvent.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.net.event;
+
+import org.apache.guacamole.net.auth.AuthenticationProvider;
+import org.apache.guacamole.net.auth.UserContext;
+import org.apache.guacamole.net.event.listener.Listener;
+
+/**
+ * Event that is dispatched when the web application has nearly completely shut
+ * down, including the authentication/authorization portion of extensions. Any
+ * installed extensions are still loaded (such that they may receive this event
+ * via {@link Listener#handleEvent(java.lang.Object)}, but their authentication
+ * providers will have been shut down via {@link AuthenticationProvider#shutdown()},
+ * and resources from user sessions will have been closed and released via
+ * {@link UserContext#invalidate()}.
+ */
+public interface ApplicationShutdownEvent {
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/event/ApplicationStartedEvent.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/ApplicationStartedEvent.java
new file mode 100644
index 0000000..cf2ae2f
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/ApplicationStartedEvent.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.net.event;
+
+/**
+ * Event that is dispatched when the web application has finished starting up,
+ * including all extensions. This event indicates only that the web application
+ * startup process has completed and all extensions have been loaded. It does
+ * not indicate whether all extensions have started successfully.
+ */
+public interface ApplicationStartedEvent {
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/event/AuthenticationFailureEvent.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/AuthenticationFailureEvent.java
index 9808e70..8705900 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/net/event/AuthenticationFailureEvent.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/AuthenticationFailureEvent.java
@@ -19,28 +19,91 @@
 
 package org.apache.guacamole.net.event;
 
+import org.apache.guacamole.net.auth.AuthenticationProvider;
 import org.apache.guacamole.net.auth.Credentials;
+import org.apache.guacamole.net.event.listener.Listener;
 
 /**
  * An event which is triggered whenever a user's credentials fail to be
  * authenticated. The credentials that failed to be authenticated are included
  * within this event, and can be retrieved using getCredentials().
  */
-public class AuthenticationFailureEvent implements CredentialEvent {
+public class AuthenticationFailureEvent implements AuthenticationProviderEvent,
+        CredentialEvent, FailureEvent {
 
     /**
      * The credentials which failed authentication.
      */
-    private Credentials credentials;
+    private final Credentials credentials;
 
     /**
-     * Creates a new AuthenticationFailureEvent which represents the failure
-     * to authenticate the given credentials.
+     * The AuthenticationProvider that encountered the failure. This may be
+     * null if the AuthenticationProvider is not known, such as if the failure
+     * is caused by every AuthenticationProvider passively refusing to
+     * authenticate the user but without explicitly rejecting the user
+     * (returning null for calls to {@link AuthenticationProvider#authenticateUser(org.apache.guacamole.net.auth.Credentials)}),
+     * or if the failure is external to any installed AuthenticationProvider
+     * (such as within a {@link Listener}.
+     */
+    private final AuthenticationProvider authProvider;
+
+    /**
+     * The Throwable that was thrown resulting in the failure, if any. This
+     * may be null if authentication failed without a known error, such as if
+     * the failure is caused by every AuthenticationProvider passively refusing
+     * to authenticate the user but without explicitly rejecting the user
+     * (returning null for calls to {@link AuthenticationProvider#authenticateUser(org.apache.guacamole.net.auth.Credentials)}).
+     */
+    private final Throwable failure;
+
+    /**
+     * Creates a new AuthenticationFailureEvent which represents a failure
+     * to authenticate the given credentials where there is no specific
+     * AuthenticationProvider nor Throwable associated with the failure.
      *
-     * @param credentials The credentials which failed authentication.
+     * @param credentials
+     *     The credentials which failed authentication.
      */
     public AuthenticationFailureEvent(Credentials credentials) {
+        this(credentials, null);
+    }
+
+    /**
+     * Creates a new AuthenticationFailureEvent which represents a failure
+     * to authenticate the given credentials where there is no specific
+     * AuthenticationProvider causing the failure.
+     *
+     * @param credentials
+     *     The credentials which failed authentication.
+     *
+     * @param failure
+     *     The Throwable that was thrown resulting in the failure, or null if
+     *     there is no such Throwable.
+     */
+    public AuthenticationFailureEvent(Credentials credentials, Throwable failure) {
+        this(credentials, null, failure);
+    }
+
+    /**
+     * Creates a new AuthenticationFailureEvent which represents a failure
+     * to authenticate the given credentials.
+     *
+     * @param credentials
+     *     The credentials which failed authentication.
+     *
+     * @param authProvider
+     *     The AuthenticationProvider that caused the failure, or null if there
+     *     is no such AuthenticationProvider.
+     *
+     * @param failure
+     *     The Throwable that was thrown resulting in the failure, or null if
+     *     there is no such Throwable.
+     */
+    public AuthenticationFailureEvent(Credentials credentials,
+            AuthenticationProvider authProvider, Throwable failure) {
         this.credentials = credentials;
+        this.authProvider = authProvider;
+        this.failure = failure;
     }
 
     @Override
@@ -48,4 +111,35 @@
         return credentials;
     }
 
+    /**
+     * {@inheritDoc}
+     *
+     * <p>NOTE: In the case of an authentication failure, cases where this may
+     * be null include if authentication failed without a definite single
+     * AuthenticationProvider causing that failure, such as if the failure is
+     * caused by every AuthenticationProvider passively refusing to
+     * authenticate the user but without explicitly rejecting the user
+     * (returning null for calls to {@link AuthenticationProvider#authenticateUser(org.apache.guacamole.net.auth.Credentials)}),
+     * or if the failure is external to any installed AuthenticationProvider
+     * (such as within a {@link Listener}.
+     */
+    @Override
+    public AuthenticationProvider getAuthenticationProvider() {
+        return authProvider;
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * <p>NOTE: In the case of an authentication failure, cases where this may
+     * be null include if authentication failed without a known error, such as
+     * if the failure is caused by every AuthenticationProvider passively
+     * refusing to authenticate the user but without explicitly rejecting the
+     * user (returning null for calls to {@link AuthenticationProvider#authenticateUser(org.apache.guacamole.net.auth.Credentials)}).
+     */
+    @Override
+    public Throwable getFailure() {
+        return failure;
+    }
+
 }
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/event/AuthenticationProviderEvent.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/AuthenticationProviderEvent.java
new file mode 100644
index 0000000..7faa876
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/AuthenticationProviderEvent.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.net.event;
+
+import org.apache.guacamole.net.auth.AuthenticationProvider;
+
+/**
+ * An event which may be dispatched due to a specific AuthenticationProvider.
+ */
+public interface AuthenticationProviderEvent {
+
+    /**
+     * Returns the AuthenticationProvider that resulted in the event, if any.
+     * If the event occurred without any definite causing
+     * AuthenticationProvider, this may be null.
+     *
+     * @return
+     *     The AuthenticationProvider that resulted in the event, or null if no
+     *     such AuthenticationProvider is known.
+     */
+    AuthenticationProvider getAuthenticationProvider();
+
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/event/AuthenticationRequestReceivedEvent.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/AuthenticationRequestReceivedEvent.java
new file mode 100644
index 0000000..eeefcfc
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/AuthenticationRequestReceivedEvent.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.net.event;
+
+/**
+ * An event which is triggered whenever a user's credentials have been
+ * submitted for authentication, but that latest authentication request has not
+ * yet succeeded or failed. The credentials that were received for
+ * authentication are included within this event, and can be retrieved using
+ * {@link #getCredentials()}.
+ * <p>
+ * If a {@link org.apache.guacamole.net.event.listener.Listener} throws
+ * a GuacamoleException when handling an event of this type, the authentication
+ * request is entirely aborted as if it failed, and will be processed by any
+ * other listener or authentication provider.
+ */
+public interface AuthenticationRequestReceivedEvent extends CredentialEvent {
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/event/AuthenticationSuccessEvent.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/AuthenticationSuccessEvent.java
index 8b63bcf..a727096 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/net/event/AuthenticationSuccessEvent.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/AuthenticationSuccessEvent.java
@@ -20,6 +20,7 @@
 package org.apache.guacamole.net.event;
 
 import org.apache.guacamole.net.auth.AuthenticatedUser;
+import org.apache.guacamole.net.auth.AuthenticationProvider;
 import org.apache.guacamole.net.auth.Credentials;
 
 /**
@@ -32,7 +33,8 @@
  * is effectively <em>vetoed</em> and will be subsequently processed as though the
  * authentication failed.
  */
-public class AuthenticationSuccessEvent implements UserEvent, CredentialEvent {
+public class AuthenticationSuccessEvent implements UserEvent, CredentialEvent,
+        AuthenticationProviderEvent {
 
     /**
      * The AuthenticatedUser identifying the user that successfully
@@ -41,16 +43,46 @@
     private final AuthenticatedUser authenticatedUser;
 
     /**
+     * Whether the successful authentication attempt represented by this event
+     * is related to an established Guacamole session.
+     */
+    private final boolean existingSession;
+    
+    /**
      * Creates a new AuthenticationSuccessEvent which represents a successful
      * authentication attempt by the user identified by the given
-     * AuthenticatedUser object.
+     * AuthenticatedUser object. The authentication attempt is presumed to be
+     * a fresh authentication attempt unrelated to an established session (a
+     * login attempt).
      *
      * @param authenticatedUser
      *     The AuthenticatedUser identifying the user that successfully
      *     authenticated.
      */
     public AuthenticationSuccessEvent(AuthenticatedUser authenticatedUser) {
+        this(authenticatedUser, false);
+    }
+
+    /**
+     * Creates a new AuthenticationSuccessEvent which represents a successful
+     * authentication attempt by the user identified by the given
+     * AuthenticatedUser object. Whether the authentication attempt is
+     * related to an established session (a periodic re-authentication attempt
+     * that updates session status) or not (a fresh login attempt) is
+     * determined by the value of the provided flag.
+     *
+     * @param authenticatedUser
+     *     The AuthenticatedUser identifying the user that successfully
+     *     authenticated.
+     *
+     * @param existingSession
+     *     Whether this AuthenticationSuccessEvent represents an
+     *     re-authentication attempt that updates the status of an established
+     *     Guacamole session.
+     */
+    public AuthenticationSuccessEvent(AuthenticatedUser authenticatedUser, boolean existingSession) {
         this.authenticatedUser = authenticatedUser;
+        this.existingSession = existingSession;
     }
 
     @Override
@@ -60,7 +92,29 @@
 
     @Override
     public Credentials getCredentials() {
-        return authenticatedUser.getCredentials();
+        return getAuthenticatedUser().getCredentials();
+    }
+
+    @Override
+    public AuthenticationProvider getAuthenticationProvider() {
+        return getAuthenticatedUser().getAuthenticationProvider();
+    }
+
+    /**
+     * Returns whether the successful authentication attempt represented by
+     * this event is related to an established Guacamole session. During normal
+     * operation, the Guacamole web application will periodically
+     * re-authenticate with the server to verify its authentication token and
+     * update the session state, in which case the value returned by this
+     * function will be true. If the user was not already authenticated and has
+     * just initially logged in, false is returned.
+     *
+     * @return
+     *     true if this AuthenticationSuccessEvent is related to a Guacamole
+     *     session that was already established, false otherwise.
+     */
+    public boolean isExistingSession() {
+        return existingSession;
     }
 
 }
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/event/DirectoryEvent.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/DirectoryEvent.java
new file mode 100644
index 0000000..468851a
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/DirectoryEvent.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.net.event;
+
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.Identifiable;
+
+/**
+ * Abstract basis for events which involve a modification made to the objects
+ * within a {@link Directory} through the operations exposed by the Directory
+ * interface.
+ *
+ * @param <ObjectType>
+ *     The type of object stored within the {@link Directory}.
+ */
+public interface DirectoryEvent<ObjectType extends Identifiable>
+        extends IdentifiableObjectEvent<ObjectType> {
+
+    /**
+     * The types of directory operations that may be represented by a
+     * DirectoryEvent.
+     */
+    public enum Operation {
+
+        /**
+         * An object was added to the {@link Directory}. The object added can
+         * be accessed with {@link #getObject()}, and its identifier may be
+         * obtained from {@link #getObjectIdentifier()}.
+         */
+        ADD,
+
+        /**
+         * An object was retrieved from a {@link Directory}. The object
+         * retrieved can be accessed with {@link #getObject()}, and its
+         * identifier may be obtained from {@link #getObjectIdentifier()}.
+         */
+        GET,
+
+        /**
+         * An existing object within a {@link Directory} was modified. The
+         * modified object can be accessed with {@link #getObject()}, and its
+         * identifier may be obtained from {@link #getObjectIdentifier()}.
+         */
+        UPDATE,
+
+        /**
+         * An existing object within a {@link Directory} was deleted/removed.
+         * The identifier of the object that was deleted may be obtained from
+         * {@link #getObjectIdentifier()}. The full object that was deleted
+         * will be made available via {@link #getObject()} if possible, but
+         * this is not guaranteed for deletions.
+         */
+        REMOVE
+
+    }
+
+    /**
+     * Returns the operation that was performed/attempted.
+     *
+     * @return
+     *     The operation that was performed or attempted.
+     */
+    Operation getOperation();
+
+    /**
+     * {@inheritDoc}
+     * <p>
+     * If the object was just created, this will be the identifier of the new
+     * object.
+     */
+    @Override
+    String getObjectIdentifier();
+
+    /**
+     * {@inheritDoc}
+     * <p>
+     * Currently, for object creation ({@link Operation#ADD ADD}), retrieval
+     * ({@link Operation#GET GET}), and modification ({@link Operation#UPDATE UPDATE}),
+     * it can be expected that the affected object will be available, however
+     * the caller should verify this regardless. For deletions
+     * ({@link Operation#REMOVE REMOVE}), the object can only be made available for
+     * single deletions, and cannot be made available for batch deletions.
+     */
+    @Override
+    ObjectType getObject();
+
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/event/DirectoryFailureEvent.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/DirectoryFailureEvent.java
new file mode 100644
index 0000000..a9ec474
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/DirectoryFailureEvent.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.net.event;
+
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.Identifiable;
+
+/**
+ * Event that is dispatched whenever a REST API request to create/modify/delete
+ * an object within a {@link Directory} fails. The specific failure is made
+ * available via {@link #getFailure()}.
+ *
+ * @param <ObjectType>
+ *     The type of object stored within the {@link Directory}.
+ */
+public interface DirectoryFailureEvent<ObjectType extends Identifiable>
+        extends DirectoryEvent<ObjectType>, FailureEvent {
+
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/event/DirectorySuccessEvent.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/DirectorySuccessEvent.java
new file mode 100644
index 0000000..691cee7
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/DirectorySuccessEvent.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.net.event;
+
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.Identifiable;
+
+/**
+ * Event that is dispatched whenever a REST API request to create/modify/delete
+ * an object within a {@link Directory} succeeds.
+ *
+ * @param <ObjectType>
+ *     The type of object stored within the {@link Directory}.
+ */
+public interface DirectorySuccessEvent<ObjectType extends Identifiable>
+        extends DirectoryEvent<ObjectType> {
+
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/event/FailureEvent.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/FailureEvent.java
new file mode 100644
index 0000000..dfc3375
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/FailureEvent.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.net.event;
+
+/**
+ * An event which represents failure of an operation, where that failure may
+ * be associated with a particular Throwable.
+ */
+public interface FailureEvent {
+
+    /**
+     * Returns the Throwable that represents the failure that occurred, if any.
+     * If the failure was recognized but without a definite known error, this
+     * may be null.
+     *
+     * @return
+     *     The Throwable that represents the failure that occurred, or null if
+     *     no such Throwable is known.
+     */
+    Throwable getFailure();
+
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/event/IdentifiableObjectEvent.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/IdentifiableObjectEvent.java
new file mode 100644
index 0000000..69ecfea
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/IdentifiableObjectEvent.java
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.net.event;
+
+import org.apache.guacamole.net.auth.AuthenticatedUser;
+import org.apache.guacamole.net.auth.AuthenticationProvider;
+import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.Identifiable;
+
+/**
+ * Abstract basis for events which affect or directly relate to objects that
+ * are {@link Identifiable} and may be stored within a {@link Directory}.
+ *
+ * @param <ObjectType>
+ *     The type of object affected or related to the event.
+ */
+public interface IdentifiableObjectEvent<ObjectType extends Identifiable>
+        extends AuthenticationProviderEvent, UserEvent {
+
+    /**
+     * {@inheritDoc}
+     * <p>
+     * NOTE: For subclasses of {@link IdentifiableObjectEvent}, this will be the
+     * AuthenticationProvider associated with the affected, {@link Identifiable}
+     * object. This is not necessarily the same as the AuthenticationProvider
+     * that authenticated the user performing the operation, which can be
+     * retrieved via {@link #getAuthenticatedUser()} and
+     * {@link AuthenticatedUser#getAuthenticationProvider()}.
+     */
+    @Override
+    AuthenticationProvider getAuthenticationProvider();
+
+    /**
+     * Returns the type of {@link Directory} that may contains the object
+     * affected by the operation.
+     *
+     * @return
+     *     The type of objects stored within the {@link Directory}.
+     */
+    Directory.Type getDirectoryType();
+
+    /**
+     * Returns the identifier of the object affected by the operation.
+     *
+     * @return
+     *     The identifier of the object affected by the operation.
+     */
+    String getObjectIdentifier();
+
+    /**
+     * Returns the object affected by the operation, if available. Whether the
+     * affected object is available is context- and implementation-dependent.
+     * There is no general guarantee across all implementations of this event
+     * that the affected object will be available. If the object is not
+     * available, null is returned.
+     *
+     * @return
+     *     The object affected by the operation performed, or null if that
+     *     object is not available in the context of this event.
+     */
+    ObjectType getObject();
+
+}
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/event/UserSessionInvalidatedEvent.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/UserSessionInvalidatedEvent.java
new file mode 100644
index 0000000..4398cfa
--- /dev/null
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/event/UserSessionInvalidatedEvent.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.net.event;
+
+/**
+ * Event that is dispatched when a user has logged out or their session has
+ * expired.
+ */
+public interface UserSessionInvalidatedEvent extends UserEvent {
+}
diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json
index badefb1..b46a8a8 100644
--- a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json
+++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/rdp.json
@@ -100,6 +100,7 @@
                         "failsafe",
                         "fr-be-azerty",
                         "fr-fr-azerty",
+                        "fr-ca-qwerty",
                         "fr-ch-qwertz",
                         "hu-hu-qwertz",                        
                         "it-it-qwerty",
@@ -107,6 +108,8 @@
                         "no-no-qwerty",
                         "pl-pl-qwerty",
                         "pt-br-qwerty",
+                        "pt-pt-qwerty",
+                        "ro-ro-qwerty",
                         "sv-se-qwerty",
                         "da-dk-qwerty",
                         "tr-tr-qwerty"
@@ -297,6 +300,11 @@
                     "name"    : "disable-glyph-caching",
                     "type"    : "BOOLEAN",
                     "options" : [ "true" ]
+                },
+                {
+                    "name"    : "disable-gfx",
+                    "type"    : "BOOLEAN",
+                    "options" : [ "true" ]
                 }
             ]
         },
diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/vnc.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/vnc.json
index a14b3dd..525e195 100644
--- a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/vnc.json
+++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/vnc.json
@@ -57,6 +57,10 @@
                     "name"    : "force-lossless",
                     "type"    : "BOOLEAN",
                     "options" : [ "true" ]
+                },
+                {
+                    "name" : "encodings",
+                    "type" : "TEXT"
                 }
             ]
         },
diff --git a/guacamole-ext/src/test/java/org/apache/guacamole/net/auth/DirectoryTest.java b/guacamole-ext/src/test/java/org/apache/guacamole/net/auth/DirectoryTest.java
new file mode 100644
index 0000000..04623aa
--- /dev/null
+++ b/guacamole-ext/src/test/java/org/apache/guacamole/net/auth/DirectoryTest.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.net.auth;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Test that verifies the functionality provided by the Directory interface.
+ */
+public class DirectoryTest {
+
+    /**
+     * Returns a Collection of all classes that have associated Directories
+     * available via the UserContext interface. The classes are retrieved
+     * using reflection by enumerating the type parameters of the return types
+     * of all functions that return a Directory.
+     *
+     * @return
+     *     A Collection of all classes that have associated Directories
+     *     available via the UserContext interface.
+     */
+    @SuppressWarnings("unchecked") // Verified via calls to isAssignableFrom()
+    private Collection<Class<? extends Identifiable>> getDirectoryTypes() {
+
+        Set<Class<? extends Identifiable>> types = new HashSet<>();
+
+        Method[] methods = UserContext.class.getMethods();
+        for (Method method : methods) {
+
+            if (!Directory.class.isAssignableFrom(method.getReturnType()))
+                continue;
+
+            Type retType = method.getGenericReturnType();
+            Assert.assertTrue("UserContext functions that return directories "
+                    + "must have proper type parameters for the returned "
+                    + "directory.", retType instanceof ParameterizedType);
+
+            Type[] typeArgs = ((ParameterizedType) retType).getActualTypeArguments();
+            Assert.assertEquals("UserContext functions that return directories "
+                    + "must properly declare exactly one type argument for "
+                    + "those directories.", 1, typeArgs.length);
+
+            Class<?> directoryType = (Class<?>) typeArgs[0];
+            Assert.assertTrue("Directories returned by UserContext functions "
+                    + "must contain subclasses of Identifiable.",
+                    Identifiable.class.isAssignableFrom(directoryType));
+
+            types.add((Class<? extends Identifiable>) directoryType);
+
+        }
+
+        return Collections.unmodifiableSet(types);
+
+    }
+
+    /**
+     * Verifies that Directory.Type covers the types of all directories exposed
+     * by the UserContext interface.
+     */
+    @Test
+    public void testTypeCoverage() {
+
+        Collection<Class<? extends Identifiable>> types = getDirectoryTypes();
+
+        Assert.assertEquals("Directory.Type must provide exactly one value "
+                + "for each type of directory provideed by the UserContext "
+                + "interface.", types.size(), Directory.Type.values().length);
+
+        for (Class<? extends Identifiable> type : types) {
+
+            Directory.Type dirType = Directory.Type.of(type);
+            Assert.assertNotNull("of() must provide mappings for all directory "
+                    + "types defined on the UserContext interface.", dirType);
+
+            Assert.assertEquals("getObjectType() must return the same base "
+                    + "superclass used by UserContext for all directory "
+                    + "types defined on the UserContext interface.", type,
+                    dirType.getObjectType());
+
+        }
+
+    }
+
+    /**
+     * Verifies that each type declared by Directory.Type exposes an
+     * associated class via getObjectType() which then maps back to the same
+     * type via Directory.Type.of().
+     */
+    @Test
+    public void testTypeIdentity() {
+        for (Directory.Type dirType : Directory.Type.values()) {
+            Assert.assertEquals("For all defined directory types, "
+                    + "Directory.Type.of(theType.getObjectType()) must "
+                    + "correctly map back to theType.", dirType,
+                    Directory.Type.of(dirType.getObjectType()));
+        }
+    }
+
+}
diff --git a/guacamole/src/main/frontend/package-lock.json b/guacamole/src/main/frontend/package-lock.json
index a9f0412..9ad15bd 100644
--- a/guacamole/src/main/frontend/package-lock.json
+++ b/guacamole/src/main/frontend/package-lock.json
@@ -12,14 +12,19 @@
                 "angular-translate": "^2.19.0",
                 "angular-translate-interpolation-messageformat": "^2.19.0",
                 "angular-translate-loader-static-files": "^2.19.0",
-                "blob-polyfill": "^7.0.20220408",
+                "blob-polyfill": ">=7.0.20220408",
+                "csv": "^6.2.5",
                 "datalist-polyfill": "^1.25.1",
                 "file-saver": "^2.0.5",
                 "jquery": "^3.6.4",
                 "jstz": "^2.1.1",
-                "lodash": "^4.17.21"
+                "lodash": "^4.17.21",
+                "yaml": "^2.2.2"
             },
             "devDependencies": {
+                "@babel/core": "^7.20.12",
+                "@babel/preset-env": "^7.20.2",
+                "babel-loader": "^8.3.0",
                 "clean-webpack-plugin": "^4.0.0",
                 "closure-webpack-plugin": "^2.6.1",
                 "copy-webpack-plugin": "^5.1.2",
@@ -34,6 +39,1797 @@
                 "webpack-cli": "^4.10.0"
             }
         },
+        "node_modules/@ampproject/remapping": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
+            "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==",
+            "dev": true,
+            "dependencies": {
+                "@jridgewell/gen-mapping": "^0.1.0",
+                "@jridgewell/trace-mapping": "^0.3.9"
+            },
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/@babel/code-frame": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz",
+            "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==",
+            "dev": true,
+            "dependencies": {
+                "@babel/highlight": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/compat-data": {
+            "version": "7.20.10",
+            "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.20.10.tgz",
+            "integrity": "sha512-sEnuDPpOJR/fcafHMjpcpGN5M2jbUGUHwmuWKM/YdPzeEDJg8bgmbcWQFUfE32MQjti1koACvoPVsDe8Uq+idg==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/core": {
+            "version": "7.20.12",
+            "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.12.tgz",
+            "integrity": "sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg==",
+            "dev": true,
+            "dependencies": {
+                "@ampproject/remapping": "^2.1.0",
+                "@babel/code-frame": "^7.18.6",
+                "@babel/generator": "^7.20.7",
+                "@babel/helper-compilation-targets": "^7.20.7",
+                "@babel/helper-module-transforms": "^7.20.11",
+                "@babel/helpers": "^7.20.7",
+                "@babel/parser": "^7.20.7",
+                "@babel/template": "^7.20.7",
+                "@babel/traverse": "^7.20.12",
+                "@babel/types": "^7.20.7",
+                "convert-source-map": "^1.7.0",
+                "debug": "^4.1.0",
+                "gensync": "^1.0.0-beta.2",
+                "json5": "^2.2.2",
+                "semver": "^6.3.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/babel"
+            }
+        },
+        "node_modules/@babel/core/node_modules/debug": {
+            "version": "4.3.4",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+            "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+            "dev": true,
+            "dependencies": {
+                "ms": "2.1.2"
+            },
+            "engines": {
+                "node": ">=6.0"
+            },
+            "peerDependenciesMeta": {
+                "supports-color": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/@babel/core/node_modules/json5": {
+            "version": "2.2.3",
+            "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+            "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+            "dev": true,
+            "bin": {
+                "json5": "lib/cli.js"
+            },
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/@babel/core/node_modules/ms": {
+            "version": "2.1.2",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+            "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+            "dev": true
+        },
+        "node_modules/@babel/core/node_modules/semver": {
+            "version": "6.3.0",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+            "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+            "dev": true,
+            "bin": {
+                "semver": "bin/semver.js"
+            }
+        },
+        "node_modules/@babel/generator": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.20.7.tgz",
+            "integrity": "sha512-7wqMOJq8doJMZmP4ApXTzLxSr7+oO2jroJURrVEp6XShrQUObV8Tq/D0NCcoYg2uHqUrjzO0zwBjoYzelxK+sw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/types": "^7.20.7",
+                "@jridgewell/gen-mapping": "^0.3.2",
+                "jsesc": "^2.5.1"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/generator/node_modules/@jridgewell/gen-mapping": {
+            "version": "0.3.2",
+            "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
+            "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
+            "dev": true,
+            "dependencies": {
+                "@jridgewell/set-array": "^1.0.1",
+                "@jridgewell/sourcemap-codec": "^1.4.10",
+                "@jridgewell/trace-mapping": "^0.3.9"
+            },
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/@babel/generator/node_modules/jsesc": {
+            "version": "2.5.2",
+            "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+            "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+            "dev": true,
+            "bin": {
+                "jsesc": "bin/jsesc"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/@babel/helper-annotate-as-pure": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz",
+            "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/types": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.18.9.tgz",
+            "integrity": "sha512-yFQ0YCHoIqarl8BCRwBL8ulYUaZpz3bNsA7oFepAzee+8/+ImtADXNOmO5vJvsPff3qi+hvpkY/NYBTrBQgdNw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-explode-assignable-expression": "^7.18.6",
+                "@babel/types": "^7.18.9"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-compilation-targets": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.7.tgz",
+            "integrity": "sha512-4tGORmfQcrc+bvrjb5y3dG9Mx1IOZjsHqQVUz7XCNHO+iTmqxWnVg3KRygjGmpRLJGdQSKuvFinbIb0CnZwHAQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/compat-data": "^7.20.5",
+                "@babel/helper-validator-option": "^7.18.6",
+                "browserslist": "^4.21.3",
+                "lru-cache": "^5.1.1",
+                "semver": "^6.3.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0"
+            }
+        },
+        "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
+            "version": "6.3.0",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+            "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+            "dev": true,
+            "bin": {
+                "semver": "bin/semver.js"
+            }
+        },
+        "node_modules/@babel/helper-create-class-features-plugin": {
+            "version": "7.20.12",
+            "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.20.12.tgz",
+            "integrity": "sha512-9OunRkbT0JQcednL0UFvbfXpAsUXiGjUk0a7sN8fUXX7Mue79cUSMjHGDRRi/Vz9vYlpIhLV5fMD5dKoMhhsNQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-annotate-as-pure": "^7.18.6",
+                "@babel/helper-environment-visitor": "^7.18.9",
+                "@babel/helper-function-name": "^7.19.0",
+                "@babel/helper-member-expression-to-functions": "^7.20.7",
+                "@babel/helper-optimise-call-expression": "^7.18.6",
+                "@babel/helper-replace-supers": "^7.20.7",
+                "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0",
+                "@babel/helper-split-export-declaration": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0"
+            }
+        },
+        "node_modules/@babel/helper-create-regexp-features-plugin": {
+            "version": "7.20.5",
+            "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.20.5.tgz",
+            "integrity": "sha512-m68B1lkg3XDGX5yCvGO0kPx3v9WIYLnzjKfPcQiwntEQa5ZeRkPmo2X/ISJc8qxWGfwUr+kvZAeEzAwLec2r2w==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-annotate-as-pure": "^7.18.6",
+                "regexpu-core": "^5.2.1"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0"
+            }
+        },
+        "node_modules/@babel/helper-define-polyfill-provider": {
+            "version": "0.3.3",
+            "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.3.tgz",
+            "integrity": "sha512-z5aQKU4IzbqCC1XH0nAqfsFLMVSo22SBKUc0BxGrLkolTdPTructy0ToNnlO2zA4j9Q/7pjMZf0DSY+DSTYzww==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-compilation-targets": "^7.17.7",
+                "@babel/helper-plugin-utils": "^7.16.7",
+                "debug": "^4.1.1",
+                "lodash.debounce": "^4.0.8",
+                "resolve": "^1.14.2",
+                "semver": "^6.1.2"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.4.0-0"
+            }
+        },
+        "node_modules/@babel/helper-define-polyfill-provider/node_modules/debug": {
+            "version": "4.3.4",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+            "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+            "dev": true,
+            "dependencies": {
+                "ms": "2.1.2"
+            },
+            "engines": {
+                "node": ">=6.0"
+            },
+            "peerDependenciesMeta": {
+                "supports-color": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/@babel/helper-define-polyfill-provider/node_modules/ms": {
+            "version": "2.1.2",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+            "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+            "dev": true
+        },
+        "node_modules/@babel/helper-define-polyfill-provider/node_modules/semver": {
+            "version": "6.3.0",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+            "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+            "dev": true,
+            "bin": {
+                "semver": "bin/semver.js"
+            }
+        },
+        "node_modules/@babel/helper-environment-visitor": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz",
+            "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-explode-assignable-expression": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz",
+            "integrity": "sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg==",
+            "dev": true,
+            "dependencies": {
+                "@babel/types": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-function-name": {
+            "version": "7.19.0",
+            "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz",
+            "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==",
+            "dev": true,
+            "dependencies": {
+                "@babel/template": "^7.18.10",
+                "@babel/types": "^7.19.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-hoist-variables": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz",
+            "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==",
+            "dev": true,
+            "dependencies": {
+                "@babel/types": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-member-expression-to-functions": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.20.7.tgz",
+            "integrity": "sha512-9J0CxJLq315fEdi4s7xK5TQaNYjZw+nDVpVqr1axNGKzdrdwYBD5b4uKv3n75aABG0rCCTK8Im8Ww7eYfMrZgw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/types": "^7.20.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-module-imports": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
+            "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/types": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-module-transforms": {
+            "version": "7.20.11",
+            "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.20.11.tgz",
+            "integrity": "sha512-uRy78kN4psmji1s2QtbtcCSaj/LILFDp0f/ymhpQH5QY3nljUZCaNWz9X1dEj/8MBdBEFECs7yRhKn8i7NjZgg==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-environment-visitor": "^7.18.9",
+                "@babel/helper-module-imports": "^7.18.6",
+                "@babel/helper-simple-access": "^7.20.2",
+                "@babel/helper-split-export-declaration": "^7.18.6",
+                "@babel/helper-validator-identifier": "^7.19.1",
+                "@babel/template": "^7.20.7",
+                "@babel/traverse": "^7.20.10",
+                "@babel/types": "^7.20.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-optimise-call-expression": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz",
+            "integrity": "sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/types": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-plugin-utils": {
+            "version": "7.20.2",
+            "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.20.2.tgz",
+            "integrity": "sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-remap-async-to-generator": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz",
+            "integrity": "sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-annotate-as-pure": "^7.18.6",
+                "@babel/helper-environment-visitor": "^7.18.9",
+                "@babel/helper-wrap-function": "^7.18.9",
+                "@babel/types": "^7.18.9"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0"
+            }
+        },
+        "node_modules/@babel/helper-replace-supers": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.20.7.tgz",
+            "integrity": "sha512-vujDMtB6LVfNW13jhlCrp48QNslK6JXi7lQG736HVbHz/mbf4Dc7tIRh1Xf5C0rF7BP8iiSxGMCmY6Ci1ven3A==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-environment-visitor": "^7.18.9",
+                "@babel/helper-member-expression-to-functions": "^7.20.7",
+                "@babel/helper-optimise-call-expression": "^7.18.6",
+                "@babel/template": "^7.20.7",
+                "@babel/traverse": "^7.20.7",
+                "@babel/types": "^7.20.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-simple-access": {
+            "version": "7.20.2",
+            "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz",
+            "integrity": "sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/types": "^7.20.2"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-skip-transparent-expression-wrappers": {
+            "version": "7.20.0",
+            "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.20.0.tgz",
+            "integrity": "sha512-5y1JYeNKfvnT8sZcK9DVRtpTbGiomYIHviSP3OQWmDPU3DeH4a1ZlT/N2lyQ5P8egjcRaT/Y9aNqUxK0WsnIIg==",
+            "dev": true,
+            "dependencies": {
+                "@babel/types": "^7.20.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-split-export-declaration": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz",
+            "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/types": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-string-parser": {
+            "version": "7.19.4",
+            "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz",
+            "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-validator-identifier": {
+            "version": "7.19.1",
+            "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
+            "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-validator-option": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz",
+            "integrity": "sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-wrap-function": {
+            "version": "7.20.5",
+            "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.20.5.tgz",
+            "integrity": "sha512-bYMxIWK5mh+TgXGVqAtnu5Yn1un+v8DDZtqyzKRLUzrh70Eal2O3aZ7aPYiMADO4uKlkzOiRiZ6GX5q3qxvW9Q==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-function-name": "^7.19.0",
+                "@babel/template": "^7.18.10",
+                "@babel/traverse": "^7.20.5",
+                "@babel/types": "^7.20.5"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helpers": {
+            "version": "7.20.13",
+            "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.20.13.tgz",
+            "integrity": "sha512-nzJ0DWCL3gB5RCXbUO3KIMMsBY2Eqbx8mBpKGE/02PgyRQFcPQLbkQ1vyy596mZLaP+dAfD+R4ckASzNVmW3jg==",
+            "dev": true,
+            "dependencies": {
+                "@babel/template": "^7.20.7",
+                "@babel/traverse": "^7.20.13",
+                "@babel/types": "^7.20.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/highlight": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz",
+            "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-validator-identifier": "^7.18.6",
+                "chalk": "^2.0.0",
+                "js-tokens": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/highlight/node_modules/ansi-styles": {
+            "version": "3.2.1",
+            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+            "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+            "dev": true,
+            "dependencies": {
+                "color-convert": "^1.9.0"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/@babel/highlight/node_modules/chalk": {
+            "version": "2.4.2",
+            "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+            "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+            "dev": true,
+            "dependencies": {
+                "ansi-styles": "^3.2.1",
+                "escape-string-regexp": "^1.0.5",
+                "supports-color": "^5.3.0"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/@babel/highlight/node_modules/color-convert": {
+            "version": "1.9.3",
+            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+            "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+            "dev": true,
+            "dependencies": {
+                "color-name": "1.1.3"
+            }
+        },
+        "node_modules/@babel/highlight/node_modules/color-name": {
+            "version": "1.1.3",
+            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+            "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+            "dev": true
+        },
+        "node_modules/@babel/highlight/node_modules/has-flag": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+            "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+            "dev": true,
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/@babel/highlight/node_modules/supports-color": {
+            "version": "5.5.0",
+            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+            "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+            "dev": true,
+            "dependencies": {
+                "has-flag": "^3.0.0"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/@babel/parser": {
+            "version": "7.20.13",
+            "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.13.tgz",
+            "integrity": "sha512-gFDLKMfpiXCsjt4za2JA9oTMn70CeseCehb11kRZgvd7+F67Hih3OHOK24cRrWECJ/ljfPGac6ygXAs/C8kIvw==",
+            "dev": true,
+            "bin": {
+                "parser": "bin/babel-parser.js"
+            },
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz",
+            "integrity": "sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0"
+            }
+        },
+        "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.20.7.tgz",
+            "integrity": "sha512-sbr9+wNE5aXMBBFBICk01tt7sBf2Oc9ikRFEcem/ZORup9IMUdNhW7/wVLEbbtlWOsEubJet46mHAL2C8+2jKQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0",
+                "@babel/plugin-proposal-optional-chaining": "^7.20.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.13.0"
+            }
+        },
+        "node_modules/@babel/plugin-proposal-async-generator-functions": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.20.7.tgz",
+            "integrity": "sha512-xMbiLsn/8RK7Wq7VeVytytS2L6qE69bXPB10YCmMdDZbKF4okCqY74pI/jJQ/8U0b/F6NrT2+14b8/P9/3AMGA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-environment-visitor": "^7.18.9",
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/helper-remap-async-to-generator": "^7.18.9",
+                "@babel/plugin-syntax-async-generators": "^7.8.4"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-proposal-class-properties": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz",
+            "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-create-class-features-plugin": "^7.18.6",
+                "@babel/helper-plugin-utils": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-proposal-class-static-block": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.20.7.tgz",
+            "integrity": "sha512-AveGOoi9DAjUYYuUAG//Ig69GlazLnoyzMw68VCDux+c1tsnnH/OkYcpz/5xzMkEFC6UxjR5Gw1c+iY2wOGVeQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-create-class-features-plugin": "^7.20.7",
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/plugin-syntax-class-static-block": "^7.14.5"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.12.0"
+            }
+        },
+        "node_modules/@babel/plugin-proposal-dynamic-import": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz",
+            "integrity": "sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.6",
+                "@babel/plugin-syntax-dynamic-import": "^7.8.3"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-proposal-export-namespace-from": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz",
+            "integrity": "sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.9",
+                "@babel/plugin-syntax-export-namespace-from": "^7.8.3"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-proposal-json-strings": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz",
+            "integrity": "sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.6",
+                "@babel/plugin-syntax-json-strings": "^7.8.3"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-proposal-logical-assignment-operators": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.20.7.tgz",
+            "integrity": "sha512-y7C7cZgpMIjWlKE5T7eJwp+tnRYM89HmRvWM5EQuB5BoHEONjmQ8lSNmBUwOyy/GFRsohJED51YBF79hE1djug==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-proposal-nullish-coalescing-operator": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz",
+            "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.6",
+                "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-proposal-numeric-separator": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz",
+            "integrity": "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.6",
+                "@babel/plugin-syntax-numeric-separator": "^7.10.4"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-proposal-object-rest-spread": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.7.tgz",
+            "integrity": "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==",
+            "dev": true,
+            "dependencies": {
+                "@babel/compat-data": "^7.20.5",
+                "@babel/helper-compilation-targets": "^7.20.7",
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+                "@babel/plugin-transform-parameters": "^7.20.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-proposal-optional-catch-binding": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz",
+            "integrity": "sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.6",
+                "@babel/plugin-syntax-optional-catch-binding": "^7.8.3"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-proposal-optional-chaining": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.20.7.tgz",
+            "integrity": "sha512-T+A7b1kfjtRM51ssoOfS1+wbyCVqorfyZhT99TvxxLMirPShD8CzKMRepMlCBGM5RpHMbn8s+5MMHnPstJH6mQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0",
+                "@babel/plugin-syntax-optional-chaining": "^7.8.3"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-proposal-private-methods": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz",
+            "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-create-class-features-plugin": "^7.18.6",
+                "@babel/helper-plugin-utils": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-proposal-private-property-in-object": {
+            "version": "7.20.5",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.20.5.tgz",
+            "integrity": "sha512-Vq7b9dUA12ByzB4EjQTPo25sFhY+08pQDBSZRtUAkj7lb7jahaHR5igera16QZ+3my1nYR4dKsNdYj5IjPHilQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-annotate-as-pure": "^7.18.6",
+                "@babel/helper-create-class-features-plugin": "^7.20.5",
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/plugin-syntax-private-property-in-object": "^7.14.5"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-proposal-unicode-property-regex": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz",
+            "integrity": "sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+                "@babel/helper-plugin-utils": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=4"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-syntax-async-generators": {
+            "version": "7.8.4",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz",
+            "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.8.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-syntax-class-properties": {
+            "version": "7.12.13",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz",
+            "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.12.13"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-syntax-class-static-block": {
+            "version": "7.14.5",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz",
+            "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.14.5"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-syntax-dynamic-import": {
+            "version": "7.8.3",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz",
+            "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.8.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-syntax-export-namespace-from": {
+            "version": "7.8.3",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz",
+            "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.8.3"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-syntax-import-assertions": {
+            "version": "7.20.0",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.20.0.tgz",
+            "integrity": "sha512-IUh1vakzNoWalR8ch/areW7qFopR2AEw03JlG7BbrDqmQ4X3q9uuipQwSGrUn7oGiemKjtSLDhNtQHzMHr1JdQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.19.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-syntax-json-strings": {
+            "version": "7.8.3",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz",
+            "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.8.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-syntax-logical-assignment-operators": {
+            "version": "7.10.4",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz",
+            "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.10.4"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": {
+            "version": "7.8.3",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz",
+            "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.8.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-syntax-numeric-separator": {
+            "version": "7.10.4",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz",
+            "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.10.4"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-syntax-object-rest-spread": {
+            "version": "7.8.3",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz",
+            "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.8.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-syntax-optional-catch-binding": {
+            "version": "7.8.3",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz",
+            "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.8.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-syntax-optional-chaining": {
+            "version": "7.8.3",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz",
+            "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.8.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-syntax-private-property-in-object": {
+            "version": "7.14.5",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz",
+            "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.14.5"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-syntax-top-level-await": {
+            "version": "7.14.5",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz",
+            "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.14.5"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-arrow-functions": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.20.7.tgz",
+            "integrity": "sha512-3poA5E7dzDomxj9WXWwuD6A5F3kc7VXwIJO+E+J8qtDtS+pXPAhrgEyh+9GBwBgPq1Z+bB+/JD60lp5jsN7JPQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.20.2"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-async-to-generator": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.20.7.tgz",
+            "integrity": "sha512-Uo5gwHPT9vgnSXQxqGtpdufUiWp96gk7yiP4Mp5bm1QMkEmLXBO7PAGYbKoJ6DhAwiNkcHFBol/x5zZZkL/t0Q==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-module-imports": "^7.18.6",
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/helper-remap-async-to-generator": "^7.18.9"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-block-scoped-functions": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz",
+            "integrity": "sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-block-scoping": {
+            "version": "7.20.11",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.20.11.tgz",
+            "integrity": "sha512-tA4N427a7fjf1P0/2I4ScsHGc5jcHPbb30xMbaTke2gxDuWpUfXDuX1FEymJwKk4tuGUvGcejAR6HdZVqmmPyw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.20.2"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-classes": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.20.7.tgz",
+            "integrity": "sha512-LWYbsiXTPKl+oBlXUGlwNlJZetXD5Am+CyBdqhPsDVjM9Jc8jwBJFrKhHf900Kfk2eZG1y9MAG3UNajol7A4VQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-annotate-as-pure": "^7.18.6",
+                "@babel/helper-compilation-targets": "^7.20.7",
+                "@babel/helper-environment-visitor": "^7.18.9",
+                "@babel/helper-function-name": "^7.19.0",
+                "@babel/helper-optimise-call-expression": "^7.18.6",
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/helper-replace-supers": "^7.20.7",
+                "@babel/helper-split-export-declaration": "^7.18.6",
+                "globals": "^11.1.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-computed-properties": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.20.7.tgz",
+            "integrity": "sha512-Lz7MvBK6DTjElHAmfu6bfANzKcxpyNPeYBGEafyA6E5HtRpjpZwU+u7Qrgz/2OR0z+5TvKYbPdphfSaAcZBrYQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/template": "^7.20.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-destructuring": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.20.7.tgz",
+            "integrity": "sha512-Xwg403sRrZb81IVB79ZPqNQME23yhugYVqgTxAhT99h485F4f+GMELFhhOsscDUB7HCswepKeCKLn/GZvUKoBA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.20.2"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-dotall-regex": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz",
+            "integrity": "sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+                "@babel/helper-plugin-utils": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-duplicate-keys": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz",
+            "integrity": "sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.9"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-exponentiation-operator": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz",
+            "integrity": "sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-builder-binary-assignment-operator-visitor": "^7.18.6",
+                "@babel/helper-plugin-utils": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-for-of": {
+            "version": "7.18.8",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.18.8.tgz",
+            "integrity": "sha512-yEfTRnjuskWYo0k1mHUqrVWaZwrdq8AYbfrpqULOJOaucGSp4mNMVps+YtA8byoevxS/urwU75vyhQIxcCgiBQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-function-name": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz",
+            "integrity": "sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-compilation-targets": "^7.18.9",
+                "@babel/helper-function-name": "^7.18.9",
+                "@babel/helper-plugin-utils": "^7.18.9"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-literals": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz",
+            "integrity": "sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.9"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-member-expression-literals": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz",
+            "integrity": "sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-modules-amd": {
+            "version": "7.20.11",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.20.11.tgz",
+            "integrity": "sha512-NuzCt5IIYOW0O30UvqktzHYR2ud5bOWbY0yaxWZ6G+aFzOMJvrs5YHNikrbdaT15+KNO31nPOy5Fim3ku6Zb5g==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-module-transforms": "^7.20.11",
+                "@babel/helper-plugin-utils": "^7.20.2"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-modules-commonjs": {
+            "version": "7.20.11",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.20.11.tgz",
+            "integrity": "sha512-S8e1f7WQ7cimJQ51JkAaDrEtohVEitXjgCGAS2N8S31Y42E+kWwfSz83LYz57QdBm7q9diARVqanIaH2oVgQnw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-module-transforms": "^7.20.11",
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/helper-simple-access": "^7.20.2"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-modules-systemjs": {
+            "version": "7.20.11",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.20.11.tgz",
+            "integrity": "sha512-vVu5g9BPQKSFEmvt2TA4Da5N+QVS66EX21d8uoOihC+OCpUoGvzVsXeqFdtAEfVa5BILAeFt+U7yVmLbQnAJmw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-hoist-variables": "^7.18.6",
+                "@babel/helper-module-transforms": "^7.20.11",
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/helper-validator-identifier": "^7.19.1"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-modules-umd": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz",
+            "integrity": "sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-module-transforms": "^7.18.6",
+                "@babel/helper-plugin-utils": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-named-capturing-groups-regex": {
+            "version": "7.20.5",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.20.5.tgz",
+            "integrity": "sha512-mOW4tTzi5iTLnw+78iEq3gr8Aoq4WNRGpmSlrogqaiCBoR1HFhpU4JkpQFOHfeYx3ReVIFWOQJS4aZBRvuZ6mA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-create-regexp-features-plugin": "^7.20.5",
+                "@babel/helper-plugin-utils": "^7.20.2"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-new-target": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz",
+            "integrity": "sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-object-super": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz",
+            "integrity": "sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.6",
+                "@babel/helper-replace-supers": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-parameters": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.20.7.tgz",
+            "integrity": "sha512-WiWBIkeHKVOSYPO0pWkxGPfKeWrCJyD3NJ53+Lrp/QMSZbsVPovrVl2aWZ19D/LTVnaDv5Ap7GJ/B2CTOZdrfA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.20.2"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-property-literals": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz",
+            "integrity": "sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-regenerator": {
+            "version": "7.20.5",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.20.5.tgz",
+            "integrity": "sha512-kW/oO7HPBtntbsahzQ0qSE3tFvkFwnbozz3NWFhLGqH75vLEg+sCGngLlhVkePlCs3Jv0dBBHDzCHxNiFAQKCQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "regenerator-transform": "^0.15.1"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-reserved-words": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz",
+            "integrity": "sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-shorthand-properties": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz",
+            "integrity": "sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-spread": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.20.7.tgz",
+            "integrity": "sha512-ewBbHQ+1U/VnH1fxltbJqDeWBU1oNLG8Dj11uIv3xVf7nrQu0bPGe5Rf716r7K5Qz+SqtAOVswoVunoiBtGhxw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-sticky-regex": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz",
+            "integrity": "sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-template-literals": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz",
+            "integrity": "sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.9"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-typeof-symbol": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz",
+            "integrity": "sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.9"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-unicode-escapes": {
+            "version": "7.18.10",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.10.tgz",
+            "integrity": "sha512-kKAdAI+YzPgGY/ftStBFXTI1LZFju38rYThnfMykS+IXy8BVx+res7s2fxf1l8I35DV2T97ezo6+SGrXz6B3iQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.18.9"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-unicode-regex": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz",
+            "integrity": "sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+                "@babel/helper-plugin-utils": "^7.18.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/preset-env": {
+            "version": "7.20.2",
+            "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.20.2.tgz",
+            "integrity": "sha512-1G0efQEWR1EHkKvKHqbG+IN/QdgwfByUpM5V5QroDzGV2t3S/WXNQd693cHiHTlCFMpr9B6FkPFXDA2lQcKoDg==",
+            "dev": true,
+            "dependencies": {
+                "@babel/compat-data": "^7.20.1",
+                "@babel/helper-compilation-targets": "^7.20.0",
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/helper-validator-option": "^7.18.6",
+                "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.18.6",
+                "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.18.9",
+                "@babel/plugin-proposal-async-generator-functions": "^7.20.1",
+                "@babel/plugin-proposal-class-properties": "^7.18.6",
+                "@babel/plugin-proposal-class-static-block": "^7.18.6",
+                "@babel/plugin-proposal-dynamic-import": "^7.18.6",
+                "@babel/plugin-proposal-export-namespace-from": "^7.18.9",
+                "@babel/plugin-proposal-json-strings": "^7.18.6",
+                "@babel/plugin-proposal-logical-assignment-operators": "^7.18.9",
+                "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
+                "@babel/plugin-proposal-numeric-separator": "^7.18.6",
+                "@babel/plugin-proposal-object-rest-spread": "^7.20.2",
+                "@babel/plugin-proposal-optional-catch-binding": "^7.18.6",
+                "@babel/plugin-proposal-optional-chaining": "^7.18.9",
+                "@babel/plugin-proposal-private-methods": "^7.18.6",
+                "@babel/plugin-proposal-private-property-in-object": "^7.18.6",
+                "@babel/plugin-proposal-unicode-property-regex": "^7.18.6",
+                "@babel/plugin-syntax-async-generators": "^7.8.4",
+                "@babel/plugin-syntax-class-properties": "^7.12.13",
+                "@babel/plugin-syntax-class-static-block": "^7.14.5",
+                "@babel/plugin-syntax-dynamic-import": "^7.8.3",
+                "@babel/plugin-syntax-export-namespace-from": "^7.8.3",
+                "@babel/plugin-syntax-import-assertions": "^7.20.0",
+                "@babel/plugin-syntax-json-strings": "^7.8.3",
+                "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4",
+                "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
+                "@babel/plugin-syntax-numeric-separator": "^7.10.4",
+                "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+                "@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
+                "@babel/plugin-syntax-optional-chaining": "^7.8.3",
+                "@babel/plugin-syntax-private-property-in-object": "^7.14.5",
+                "@babel/plugin-syntax-top-level-await": "^7.14.5",
+                "@babel/plugin-transform-arrow-functions": "^7.18.6",
+                "@babel/plugin-transform-async-to-generator": "^7.18.6",
+                "@babel/plugin-transform-block-scoped-functions": "^7.18.6",
+                "@babel/plugin-transform-block-scoping": "^7.20.2",
+                "@babel/plugin-transform-classes": "^7.20.2",
+                "@babel/plugin-transform-computed-properties": "^7.18.9",
+                "@babel/plugin-transform-destructuring": "^7.20.2",
+                "@babel/plugin-transform-dotall-regex": "^7.18.6",
+                "@babel/plugin-transform-duplicate-keys": "^7.18.9",
+                "@babel/plugin-transform-exponentiation-operator": "^7.18.6",
+                "@babel/plugin-transform-for-of": "^7.18.8",
+                "@babel/plugin-transform-function-name": "^7.18.9",
+                "@babel/plugin-transform-literals": "^7.18.9",
+                "@babel/plugin-transform-member-expression-literals": "^7.18.6",
+                "@babel/plugin-transform-modules-amd": "^7.19.6",
+                "@babel/plugin-transform-modules-commonjs": "^7.19.6",
+                "@babel/plugin-transform-modules-systemjs": "^7.19.6",
+                "@babel/plugin-transform-modules-umd": "^7.18.6",
+                "@babel/plugin-transform-named-capturing-groups-regex": "^7.19.1",
+                "@babel/plugin-transform-new-target": "^7.18.6",
+                "@babel/plugin-transform-object-super": "^7.18.6",
+                "@babel/plugin-transform-parameters": "^7.20.1",
+                "@babel/plugin-transform-property-literals": "^7.18.6",
+                "@babel/plugin-transform-regenerator": "^7.18.6",
+                "@babel/plugin-transform-reserved-words": "^7.18.6",
+                "@babel/plugin-transform-shorthand-properties": "^7.18.6",
+                "@babel/plugin-transform-spread": "^7.19.0",
+                "@babel/plugin-transform-sticky-regex": "^7.18.6",
+                "@babel/plugin-transform-template-literals": "^7.18.9",
+                "@babel/plugin-transform-typeof-symbol": "^7.18.9",
+                "@babel/plugin-transform-unicode-escapes": "^7.18.10",
+                "@babel/plugin-transform-unicode-regex": "^7.18.6",
+                "@babel/preset-modules": "^0.1.5",
+                "@babel/types": "^7.20.2",
+                "babel-plugin-polyfill-corejs2": "^0.3.3",
+                "babel-plugin-polyfill-corejs3": "^0.6.0",
+                "babel-plugin-polyfill-regenerator": "^0.4.1",
+                "core-js-compat": "^3.25.1",
+                "semver": "^6.3.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/preset-env/node_modules/semver": {
+            "version": "6.3.0",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+            "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+            "dev": true,
+            "bin": {
+                "semver": "bin/semver.js"
+            }
+        },
+        "node_modules/@babel/preset-modules": {
+            "version": "0.1.5",
+            "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.5.tgz",
+            "integrity": "sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.0.0",
+                "@babel/plugin-proposal-unicode-property-regex": "^7.4.4",
+                "@babel/plugin-transform-dotall-regex": "^7.4.4",
+                "@babel/types": "^7.4.4",
+                "esutils": "^2.0.2"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/runtime": {
+            "version": "7.20.13",
+            "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.13.tgz",
+            "integrity": "sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA==",
+            "dev": true,
+            "dependencies": {
+                "regenerator-runtime": "^0.13.11"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/template": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz",
+            "integrity": "sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/code-frame": "^7.18.6",
+                "@babel/parser": "^7.20.7",
+                "@babel/types": "^7.20.7"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/traverse": {
+            "version": "7.20.13",
+            "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.20.13.tgz",
+            "integrity": "sha512-kMJXfF0T6DIS9E8cgdLCSAL+cuCK+YEZHWiLK0SXpTo8YRj5lpJu3CDNKiIBCne4m9hhTIqUg6SYTAI39tAiVQ==",
+            "dev": true,
+            "dependencies": {
+                "@babel/code-frame": "^7.18.6",
+                "@babel/generator": "^7.20.7",
+                "@babel/helper-environment-visitor": "^7.18.9",
+                "@babel/helper-function-name": "^7.19.0",
+                "@babel/helper-hoist-variables": "^7.18.6",
+                "@babel/helper-split-export-declaration": "^7.18.6",
+                "@babel/parser": "^7.20.13",
+                "@babel/types": "^7.20.7",
+                "debug": "^4.1.0",
+                "globals": "^11.1.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/traverse/node_modules/debug": {
+            "version": "4.3.4",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+            "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+            "dev": true,
+            "dependencies": {
+                "ms": "2.1.2"
+            },
+            "engines": {
+                "node": ">=6.0"
+            },
+            "peerDependenciesMeta": {
+                "supports-color": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/@babel/traverse/node_modules/ms": {
+            "version": "2.1.2",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+            "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+            "dev": true
+        },
+        "node_modules/@babel/types": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.20.7.tgz",
+            "integrity": "sha512-69OnhBxSSgK0OzTJai4kyPDiKTIe3j+ctaHdIGVbRahTLAT7L3R9oeXHC2aVSuGYt3cVnoAMDmOCgJ2yaiLMvg==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-string-parser": "^7.19.4",
+                "@babel/helper-validator-identifier": "^7.19.1",
+                "to-fast-properties": "^2.0.0"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
         "node_modules/@discoveryjs/json-ext": {
             "version": "0.5.7",
             "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
@@ -49,6 +1845,53 @@
             "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==",
             "dev": true
         },
+        "node_modules/@jridgewell/gen-mapping": {
+            "version": "0.1.1",
+            "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
+            "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==",
+            "dev": true,
+            "dependencies": {
+                "@jridgewell/set-array": "^1.0.0",
+                "@jridgewell/sourcemap-codec": "^1.4.10"
+            },
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/@jridgewell/resolve-uri": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
+            "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/@jridgewell/set-array": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+            "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/@jridgewell/sourcemap-codec": {
+            "version": "1.4.14",
+            "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
+            "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==",
+            "dev": true
+        },
+        "node_modules/@jridgewell/trace-mapping": {
+            "version": "0.3.17",
+            "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz",
+            "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==",
+            "dev": true,
+            "dependencies": {
+                "@jridgewell/resolve-uri": "3.1.0",
+                "@jridgewell/sourcemap-codec": "1.4.14"
+            }
+        },
         "node_modules/@npmcli/fs": {
             "version": "1.1.1",
             "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz",
@@ -766,6 +2609,216 @@
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/babel-loader": {
+            "version": "8.3.0",
+            "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.3.0.tgz",
+            "integrity": "sha512-H8SvsMF+m9t15HNLMipppzkC+Y2Yq+v3SonZyU70RBL/h1gxPkH08Ot8pEE9Z4Kd+czyWJClmFS8qzIP9OZ04Q==",
+            "dev": true,
+            "dependencies": {
+                "find-cache-dir": "^3.3.1",
+                "loader-utils": "^2.0.0",
+                "make-dir": "^3.1.0",
+                "schema-utils": "^2.6.5"
+            },
+            "engines": {
+                "node": ">= 8.9"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0",
+                "webpack": ">=2"
+            }
+        },
+        "node_modules/babel-loader/node_modules/find-cache-dir": {
+            "version": "3.3.2",
+            "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+            "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
+            "dev": true,
+            "dependencies": {
+                "commondir": "^1.0.1",
+                "make-dir": "^3.0.2",
+                "pkg-dir": "^4.1.0"
+            },
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/avajs/find-cache-dir?sponsor=1"
+            }
+        },
+        "node_modules/babel-loader/node_modules/find-up": {
+            "version": "4.1.0",
+            "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+            "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+            "dev": true,
+            "dependencies": {
+                "locate-path": "^5.0.0",
+                "path-exists": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/babel-loader/node_modules/json5": {
+            "version": "2.2.3",
+            "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+            "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+            "dev": true,
+            "bin": {
+                "json5": "lib/cli.js"
+            },
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/babel-loader/node_modules/loader-utils": {
+            "version": "2.0.4",
+            "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz",
+            "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
+            "dev": true,
+            "dependencies": {
+                "big.js": "^5.2.2",
+                "emojis-list": "^3.0.0",
+                "json5": "^2.1.2"
+            },
+            "engines": {
+                "node": ">=8.9.0"
+            }
+        },
+        "node_modules/babel-loader/node_modules/locate-path": {
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+            "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+            "dev": true,
+            "dependencies": {
+                "p-locate": "^4.1.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/babel-loader/node_modules/make-dir": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+            "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+            "dev": true,
+            "dependencies": {
+                "semver": "^6.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/babel-loader/node_modules/p-locate": {
+            "version": "4.1.0",
+            "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+            "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+            "dev": true,
+            "dependencies": {
+                "p-limit": "^2.2.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/babel-loader/node_modules/path-exists": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+            "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/babel-loader/node_modules/pkg-dir": {
+            "version": "4.2.0",
+            "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+            "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+            "dev": true,
+            "dependencies": {
+                "find-up": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/babel-loader/node_modules/schema-utils": {
+            "version": "2.7.1",
+            "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz",
+            "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==",
+            "dev": true,
+            "dependencies": {
+                "@types/json-schema": "^7.0.5",
+                "ajv": "^6.12.4",
+                "ajv-keywords": "^3.5.2"
+            },
+            "engines": {
+                "node": ">= 8.9.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/webpack"
+            }
+        },
+        "node_modules/babel-loader/node_modules/semver": {
+            "version": "6.3.0",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+            "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+            "dev": true,
+            "bin": {
+                "semver": "bin/semver.js"
+            }
+        },
+        "node_modules/babel-plugin-polyfill-corejs2": {
+            "version": "0.3.3",
+            "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz",
+            "integrity": "sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==",
+            "dev": true,
+            "dependencies": {
+                "@babel/compat-data": "^7.17.7",
+                "@babel/helper-define-polyfill-provider": "^0.3.3",
+                "semver": "^6.1.1"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": {
+            "version": "6.3.0",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+            "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+            "dev": true,
+            "bin": {
+                "semver": "bin/semver.js"
+            }
+        },
+        "node_modules/babel-plugin-polyfill-corejs3": {
+            "version": "0.6.0",
+            "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.6.0.tgz",
+            "integrity": "sha512-+eHqR6OPcBhJOGgsIar7xoAB1GcSwVUA3XjAd7HJNzOXT4wv6/H7KIdA/Nc60cvUlDbKApmqNvD1B1bzOt4nyA==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-define-polyfill-provider": "^0.3.3",
+                "core-js-compat": "^3.25.1"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/babel-plugin-polyfill-regenerator": {
+            "version": "0.4.1",
+            "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz",
+            "integrity": "sha512-NtQGmyQDXjQqQ+IzRkBVwEOz9lQ4zxAQZgoAYEtU9dJjnl1Oc98qnN7jcp+bE7O7aYzVpavXE3/VKXNzUbh7aw==",
+            "dev": true,
+            "dependencies": {
+                "@babel/helper-define-polyfill-provider": "^0.3.3"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
         "node_modules/balanced-match": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1703,6 +3756,12 @@
             "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz",
             "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ=="
         },
+        "node_modules/convert-source-map": {
+            "version": "1.9.0",
+            "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
+            "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
+            "dev": true
+        },
         "node_modules/copy-concurrently": {
             "version": "1.0.5",
             "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz",
@@ -1786,6 +3845,19 @@
                 "url": "https://opencollective.com/core-js"
             }
         },
+        "node_modules/core-js-compat": {
+            "version": "3.27.2",
+            "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.27.2.tgz",
+            "integrity": "sha512-welaYuF7ZtbYKGrIy7y3eb40d37rG1FvzEOfe7hSLd2iD6duMDqUhRfSvCGyC46HhR6Y8JXXdZ2lnRUMkPBpvg==",
+            "dev": true,
+            "dependencies": {
+                "browserslist": "^4.21.4"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/core-js"
+            }
+        },
         "node_modules/core-util-is": {
             "version": "1.0.3",
             "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -2544,6 +4616,35 @@
             "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
             "dev": true
         },
+        "node_modules/csv": {
+            "version": "6.2.5",
+            "resolved": "https://registry.npmjs.org/csv/-/csv-6.2.5.tgz",
+            "integrity": "sha512-T+K0H7MIrlrnP6KxYKo3lK+uLl6OC2Gmwdd81TG/VdkhKvpatl35sR7tyRSpDLGl22y2T+q9KvNHnVtn4OAscQ==",
+            "dependencies": {
+                "csv-generate": "^4.2.1",
+                "csv-parse": "^5.3.3",
+                "csv-stringify": "^6.2.3",
+                "stream-transform": "^3.2.1"
+            },
+            "engines": {
+                "node": ">= 0.1.90"
+            }
+        },
+        "node_modules/csv-generate": {
+            "version": "4.2.1",
+            "resolved": "https://registry.npmjs.org/csv-generate/-/csv-generate-4.2.1.tgz",
+            "integrity": "sha512-w6GFHjvApv6bcJ2xdi9JGsH6ZvUBfC+vUdfefnEzurXG6hMRwzkBLnhztU2H7v7+zfCk1I/knnQ+tGbgpxWrBw=="
+        },
+        "node_modules/csv-parse": {
+            "version": "5.3.3",
+            "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.3.3.tgz",
+            "integrity": "sha512-kEWkAPleNEdhFNkHQpFHu9RYPogsFj3dx6bCxL847fsiLgidzWg0z/O0B1kVWMJUc5ky64zGp18LX2T3DQrOfw=="
+        },
+        "node_modules/csv-stringify": {
+            "version": "6.2.3",
+            "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.2.3.tgz",
+            "integrity": "sha512-4qGjUMwnlaRc00gc2jrIYh2w/h1fo25B0mTuY9K8fBiIgtmCX3LcgUbrEGViL98Ci4Se/F5LFEtu8k+dItJVZQ=="
+        },
         "node_modules/cyclist": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
@@ -3045,6 +5146,15 @@
                 "node": ">=4.0"
             }
         },
+        "node_modules/esutils": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+            "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
         "node_modules/events": {
             "version": "3.3.0",
             "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -3522,6 +5632,15 @@
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/gensync": {
+            "version": "1.0.0-beta.2",
+            "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+            "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
         "node_modules/get-intrinsic": {
             "version": "1.2.0",
             "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz",
@@ -3590,6 +5709,15 @@
                 "node": ">= 6"
             }
         },
+        "node_modules/globals": {
+            "version": "11.12.0",
+            "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+            "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+            "dev": true,
+            "engines": {
+                "node": ">=4"
+            }
+        },
         "node_modules/globalthis": {
             "version": "1.0.3",
             "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz",
@@ -4646,6 +6774,12 @@
             "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.4.tgz",
             "integrity": "sha512-v28EW9DWDFpzcD9O5iyJXg3R3+q+mET5JhnjJzQUZMHOv67bpSIHq81GEYpPNZHG+XXHsfSme3nxp/hndKEcsQ=="
         },
+        "node_modules/js-tokens": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+            "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+            "dev": true
+        },
         "node_modules/js-yaml": {
             "version": "3.14.1",
             "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
@@ -4750,6 +6884,12 @@
             "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz",
             "integrity": "sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA=="
         },
+        "node_modules/lodash.debounce": {
+            "version": "4.0.8",
+            "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+            "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+            "dev": true
+        },
         "node_modules/lodash.memoize": {
             "version": "4.1.2",
             "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@@ -7202,6 +9342,39 @@
                 "node": ">= 0.10"
             }
         },
+        "node_modules/regenerate": {
+            "version": "1.4.2",
+            "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
+            "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==",
+            "dev": true
+        },
+        "node_modules/regenerate-unicode-properties": {
+            "version": "10.1.0",
+            "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz",
+            "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==",
+            "dev": true,
+            "dependencies": {
+                "regenerate": "^1.4.2"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/regenerator-runtime": {
+            "version": "0.13.11",
+            "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+            "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+            "dev": true
+        },
+        "node_modules/regenerator-transform": {
+            "version": "0.15.1",
+            "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.1.tgz",
+            "integrity": "sha512-knzmNAcuyxV+gQCufkYcvOqX/qIIfHLv0u5x79kRxuGojfYVky1f15TzZEu2Avte8QGepvUNTnLskf8E6X6Vyg==",
+            "dev": true,
+            "dependencies": {
+                "@babel/runtime": "^7.8.4"
+            }
+        },
         "node_modules/regex-not": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz",
@@ -7231,6 +9404,50 @@
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/regexpu-core": {
+            "version": "5.2.2",
+            "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.2.2.tgz",
+            "integrity": "sha512-T0+1Zp2wjF/juXMrMxHxidqGYn8U4R+zleSJhX9tQ1PUsS8a9UtYfbsF9LdiVgNX3kiX8RNaKM42nfSgvFJjmw==",
+            "dev": true,
+            "dependencies": {
+                "regenerate": "^1.4.2",
+                "regenerate-unicode-properties": "^10.1.0",
+                "regjsgen": "^0.7.1",
+                "regjsparser": "^0.9.1",
+                "unicode-match-property-ecmascript": "^2.0.0",
+                "unicode-match-property-value-ecmascript": "^2.1.0"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/regjsgen": {
+            "version": "0.7.1",
+            "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.7.1.tgz",
+            "integrity": "sha512-RAt+8H2ZEzHeYWxZ3H2z6tF18zyyOnlcdaafLrm21Bguj7uZy6ULibiAFdXEtKQY4Sy7wDTwDiOazasMLc4KPA==",
+            "dev": true
+        },
+        "node_modules/regjsparser": {
+            "version": "0.9.1",
+            "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz",
+            "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==",
+            "dev": true,
+            "dependencies": {
+                "jsesc": "~0.5.0"
+            },
+            "bin": {
+                "regjsparser": "bin/parser"
+            }
+        },
+        "node_modules/regjsparser/node_modules/jsesc": {
+            "version": "0.5.0",
+            "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz",
+            "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==",
+            "dev": true,
+            "bin": {
+                "jsesc": "bin/jsesc"
+            }
+        },
         "node_modules/relateurl": {
             "version": "0.2.7",
             "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
@@ -7975,6 +10192,11 @@
             "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz",
             "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ=="
         },
+        "node_modules/stream-transform": {
+            "version": "3.2.1",
+            "resolved": "https://registry.npmjs.org/stream-transform/-/stream-transform-3.2.1.tgz",
+            "integrity": "sha512-ApK+WTJ5bCOf0A2tlec1qhvr8bGEBM/sgXXB7mysdCYgZJO5DZeaV3h3G+g0HnAQ372P5IhiGqnW29zoLOfTzQ=="
+        },
         "node_modules/string_decoder": {
             "version": "1.1.1",
             "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
@@ -8399,6 +10621,15 @@
             "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz",
             "integrity": "sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA=="
         },
+        "node_modules/to-fast-properties": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+            "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+            "dev": true,
+            "engines": {
+                "node": ">=4"
+            }
+        },
         "node_modules/to-object-path": {
             "version": "0.3.0",
             "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz",
@@ -8492,6 +10723,46 @@
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/unicode-canonical-property-names-ecmascript": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz",
+            "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/unicode-match-property-ecmascript": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz",
+            "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==",
+            "dev": true,
+            "dependencies": {
+                "unicode-canonical-property-names-ecmascript": "^2.0.0",
+                "unicode-property-aliases-ecmascript": "^2.0.0"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/unicode-match-property-value-ecmascript": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz",
+            "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==",
+            "dev": true,
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/unicode-property-aliases-ecmascript": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
+            "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==",
+            "dev": true,
+            "engines": {
+                "node": ">=4"
+            }
+        },
         "node_modules/union-value": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz",
@@ -9147,6 +11418,14 @@
             "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
             "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
         },
+        "node_modules/yaml": {
+            "version": "2.2.2",
+            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.2.2.tgz",
+            "integrity": "sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==",
+            "engines": {
+                "node": ">= 14"
+            }
+        },
         "node_modules/yocto-queue": {
             "version": "0.1.0",
             "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
@@ -9161,6 +11440,1261 @@
         }
     },
     "dependencies": {
+        "@ampproject/remapping": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
+            "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==",
+            "dev": true,
+            "requires": {
+                "@jridgewell/gen-mapping": "^0.1.0",
+                "@jridgewell/trace-mapping": "^0.3.9"
+            }
+        },
+        "@babel/code-frame": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz",
+            "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==",
+            "dev": true,
+            "requires": {
+                "@babel/highlight": "^7.18.6"
+            }
+        },
+        "@babel/compat-data": {
+            "version": "7.20.10",
+            "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.20.10.tgz",
+            "integrity": "sha512-sEnuDPpOJR/fcafHMjpcpGN5M2jbUGUHwmuWKM/YdPzeEDJg8bgmbcWQFUfE32MQjti1koACvoPVsDe8Uq+idg==",
+            "dev": true
+        },
+        "@babel/core": {
+            "version": "7.20.12",
+            "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.12.tgz",
+            "integrity": "sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg==",
+            "dev": true,
+            "requires": {
+                "@ampproject/remapping": "^2.1.0",
+                "@babel/code-frame": "^7.18.6",
+                "@babel/generator": "^7.20.7",
+                "@babel/helper-compilation-targets": "^7.20.7",
+                "@babel/helper-module-transforms": "^7.20.11",
+                "@babel/helpers": "^7.20.7",
+                "@babel/parser": "^7.20.7",
+                "@babel/template": "^7.20.7",
+                "@babel/traverse": "^7.20.12",
+                "@babel/types": "^7.20.7",
+                "convert-source-map": "^1.7.0",
+                "debug": "^4.1.0",
+                "gensync": "^1.0.0-beta.2",
+                "json5": "^2.2.2",
+                "semver": "^6.3.0"
+            },
+            "dependencies": {
+                "debug": {
+                    "version": "4.3.4",
+                    "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+                    "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+                    "dev": true,
+                    "requires": {
+                        "ms": "2.1.2"
+                    }
+                },
+                "json5": {
+                    "version": "2.2.3",
+                    "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+                    "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+                    "dev": true
+                },
+                "ms": {
+                    "version": "2.1.2",
+                    "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+                    "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+                    "dev": true
+                },
+                "semver": {
+                    "version": "6.3.0",
+                    "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+                    "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+                    "dev": true
+                }
+            }
+        },
+        "@babel/generator": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.20.7.tgz",
+            "integrity": "sha512-7wqMOJq8doJMZmP4ApXTzLxSr7+oO2jroJURrVEp6XShrQUObV8Tq/D0NCcoYg2uHqUrjzO0zwBjoYzelxK+sw==",
+            "dev": true,
+            "requires": {
+                "@babel/types": "^7.20.7",
+                "@jridgewell/gen-mapping": "^0.3.2",
+                "jsesc": "^2.5.1"
+            },
+            "dependencies": {
+                "@jridgewell/gen-mapping": {
+                    "version": "0.3.2",
+                    "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
+                    "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
+                    "dev": true,
+                    "requires": {
+                        "@jridgewell/set-array": "^1.0.1",
+                        "@jridgewell/sourcemap-codec": "^1.4.10",
+                        "@jridgewell/trace-mapping": "^0.3.9"
+                    }
+                },
+                "jsesc": {
+                    "version": "2.5.2",
+                    "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+                    "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+                    "dev": true
+                }
+            }
+        },
+        "@babel/helper-annotate-as-pure": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz",
+            "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==",
+            "dev": true,
+            "requires": {
+                "@babel/types": "^7.18.6"
+            }
+        },
+        "@babel/helper-builder-binary-assignment-operator-visitor": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.18.9.tgz",
+            "integrity": "sha512-yFQ0YCHoIqarl8BCRwBL8ulYUaZpz3bNsA7oFepAzee+8/+ImtADXNOmO5vJvsPff3qi+hvpkY/NYBTrBQgdNw==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-explode-assignable-expression": "^7.18.6",
+                "@babel/types": "^7.18.9"
+            }
+        },
+        "@babel/helper-compilation-targets": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.7.tgz",
+            "integrity": "sha512-4tGORmfQcrc+bvrjb5y3dG9Mx1IOZjsHqQVUz7XCNHO+iTmqxWnVg3KRygjGmpRLJGdQSKuvFinbIb0CnZwHAQ==",
+            "dev": true,
+            "requires": {
+                "@babel/compat-data": "^7.20.5",
+                "@babel/helper-validator-option": "^7.18.6",
+                "browserslist": "^4.21.3",
+                "lru-cache": "^5.1.1",
+                "semver": "^6.3.0"
+            },
+            "dependencies": {
+                "semver": {
+                    "version": "6.3.0",
+                    "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+                    "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+                    "dev": true
+                }
+            }
+        },
+        "@babel/helper-create-class-features-plugin": {
+            "version": "7.20.12",
+            "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.20.12.tgz",
+            "integrity": "sha512-9OunRkbT0JQcednL0UFvbfXpAsUXiGjUk0a7sN8fUXX7Mue79cUSMjHGDRRi/Vz9vYlpIhLV5fMD5dKoMhhsNQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-annotate-as-pure": "^7.18.6",
+                "@babel/helper-environment-visitor": "^7.18.9",
+                "@babel/helper-function-name": "^7.19.0",
+                "@babel/helper-member-expression-to-functions": "^7.20.7",
+                "@babel/helper-optimise-call-expression": "^7.18.6",
+                "@babel/helper-replace-supers": "^7.20.7",
+                "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0",
+                "@babel/helper-split-export-declaration": "^7.18.6"
+            }
+        },
+        "@babel/helper-create-regexp-features-plugin": {
+            "version": "7.20.5",
+            "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.20.5.tgz",
+            "integrity": "sha512-m68B1lkg3XDGX5yCvGO0kPx3v9WIYLnzjKfPcQiwntEQa5ZeRkPmo2X/ISJc8qxWGfwUr+kvZAeEzAwLec2r2w==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-annotate-as-pure": "^7.18.6",
+                "regexpu-core": "^5.2.1"
+            }
+        },
+        "@babel/helper-define-polyfill-provider": {
+            "version": "0.3.3",
+            "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.3.tgz",
+            "integrity": "sha512-z5aQKU4IzbqCC1XH0nAqfsFLMVSo22SBKUc0BxGrLkolTdPTructy0ToNnlO2zA4j9Q/7pjMZf0DSY+DSTYzww==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-compilation-targets": "^7.17.7",
+                "@babel/helper-plugin-utils": "^7.16.7",
+                "debug": "^4.1.1",
+                "lodash.debounce": "^4.0.8",
+                "resolve": "^1.14.2",
+                "semver": "^6.1.2"
+            },
+            "dependencies": {
+                "debug": {
+                    "version": "4.3.4",
+                    "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+                    "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+                    "dev": true,
+                    "requires": {
+                        "ms": "2.1.2"
+                    }
+                },
+                "ms": {
+                    "version": "2.1.2",
+                    "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+                    "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+                    "dev": true
+                },
+                "semver": {
+                    "version": "6.3.0",
+                    "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+                    "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+                    "dev": true
+                }
+            }
+        },
+        "@babel/helper-environment-visitor": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz",
+            "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==",
+            "dev": true
+        },
+        "@babel/helper-explode-assignable-expression": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz",
+            "integrity": "sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg==",
+            "dev": true,
+            "requires": {
+                "@babel/types": "^7.18.6"
+            }
+        },
+        "@babel/helper-function-name": {
+            "version": "7.19.0",
+            "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz",
+            "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==",
+            "dev": true,
+            "requires": {
+                "@babel/template": "^7.18.10",
+                "@babel/types": "^7.19.0"
+            }
+        },
+        "@babel/helper-hoist-variables": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz",
+            "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==",
+            "dev": true,
+            "requires": {
+                "@babel/types": "^7.18.6"
+            }
+        },
+        "@babel/helper-member-expression-to-functions": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.20.7.tgz",
+            "integrity": "sha512-9J0CxJLq315fEdi4s7xK5TQaNYjZw+nDVpVqr1axNGKzdrdwYBD5b4uKv3n75aABG0rCCTK8Im8Ww7eYfMrZgw==",
+            "dev": true,
+            "requires": {
+                "@babel/types": "^7.20.7"
+            }
+        },
+        "@babel/helper-module-imports": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
+            "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==",
+            "dev": true,
+            "requires": {
+                "@babel/types": "^7.18.6"
+            }
+        },
+        "@babel/helper-module-transforms": {
+            "version": "7.20.11",
+            "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.20.11.tgz",
+            "integrity": "sha512-uRy78kN4psmji1s2QtbtcCSaj/LILFDp0f/ymhpQH5QY3nljUZCaNWz9X1dEj/8MBdBEFECs7yRhKn8i7NjZgg==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-environment-visitor": "^7.18.9",
+                "@babel/helper-module-imports": "^7.18.6",
+                "@babel/helper-simple-access": "^7.20.2",
+                "@babel/helper-split-export-declaration": "^7.18.6",
+                "@babel/helper-validator-identifier": "^7.19.1",
+                "@babel/template": "^7.20.7",
+                "@babel/traverse": "^7.20.10",
+                "@babel/types": "^7.20.7"
+            }
+        },
+        "@babel/helper-optimise-call-expression": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz",
+            "integrity": "sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==",
+            "dev": true,
+            "requires": {
+                "@babel/types": "^7.18.6"
+            }
+        },
+        "@babel/helper-plugin-utils": {
+            "version": "7.20.2",
+            "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.20.2.tgz",
+            "integrity": "sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ==",
+            "dev": true
+        },
+        "@babel/helper-remap-async-to-generator": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz",
+            "integrity": "sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-annotate-as-pure": "^7.18.6",
+                "@babel/helper-environment-visitor": "^7.18.9",
+                "@babel/helper-wrap-function": "^7.18.9",
+                "@babel/types": "^7.18.9"
+            }
+        },
+        "@babel/helper-replace-supers": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.20.7.tgz",
+            "integrity": "sha512-vujDMtB6LVfNW13jhlCrp48QNslK6JXi7lQG736HVbHz/mbf4Dc7tIRh1Xf5C0rF7BP8iiSxGMCmY6Ci1ven3A==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-environment-visitor": "^7.18.9",
+                "@babel/helper-member-expression-to-functions": "^7.20.7",
+                "@babel/helper-optimise-call-expression": "^7.18.6",
+                "@babel/template": "^7.20.7",
+                "@babel/traverse": "^7.20.7",
+                "@babel/types": "^7.20.7"
+            }
+        },
+        "@babel/helper-simple-access": {
+            "version": "7.20.2",
+            "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz",
+            "integrity": "sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA==",
+            "dev": true,
+            "requires": {
+                "@babel/types": "^7.20.2"
+            }
+        },
+        "@babel/helper-skip-transparent-expression-wrappers": {
+            "version": "7.20.0",
+            "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.20.0.tgz",
+            "integrity": "sha512-5y1JYeNKfvnT8sZcK9DVRtpTbGiomYIHviSP3OQWmDPU3DeH4a1ZlT/N2lyQ5P8egjcRaT/Y9aNqUxK0WsnIIg==",
+            "dev": true,
+            "requires": {
+                "@babel/types": "^7.20.0"
+            }
+        },
+        "@babel/helper-split-export-declaration": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz",
+            "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==",
+            "dev": true,
+            "requires": {
+                "@babel/types": "^7.18.6"
+            }
+        },
+        "@babel/helper-string-parser": {
+            "version": "7.19.4",
+            "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz",
+            "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==",
+            "dev": true
+        },
+        "@babel/helper-validator-identifier": {
+            "version": "7.19.1",
+            "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
+            "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==",
+            "dev": true
+        },
+        "@babel/helper-validator-option": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz",
+            "integrity": "sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==",
+            "dev": true
+        },
+        "@babel/helper-wrap-function": {
+            "version": "7.20.5",
+            "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.20.5.tgz",
+            "integrity": "sha512-bYMxIWK5mh+TgXGVqAtnu5Yn1un+v8DDZtqyzKRLUzrh70Eal2O3aZ7aPYiMADO4uKlkzOiRiZ6GX5q3qxvW9Q==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-function-name": "^7.19.0",
+                "@babel/template": "^7.18.10",
+                "@babel/traverse": "^7.20.5",
+                "@babel/types": "^7.20.5"
+            }
+        },
+        "@babel/helpers": {
+            "version": "7.20.13",
+            "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.20.13.tgz",
+            "integrity": "sha512-nzJ0DWCL3gB5RCXbUO3KIMMsBY2Eqbx8mBpKGE/02PgyRQFcPQLbkQ1vyy596mZLaP+dAfD+R4ckASzNVmW3jg==",
+            "dev": true,
+            "requires": {
+                "@babel/template": "^7.20.7",
+                "@babel/traverse": "^7.20.13",
+                "@babel/types": "^7.20.7"
+            }
+        },
+        "@babel/highlight": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz",
+            "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-validator-identifier": "^7.18.6",
+                "chalk": "^2.0.0",
+                "js-tokens": "^4.0.0"
+            },
+            "dependencies": {
+                "ansi-styles": {
+                    "version": "3.2.1",
+                    "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+                    "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+                    "dev": true,
+                    "requires": {
+                        "color-convert": "^1.9.0"
+                    }
+                },
+                "chalk": {
+                    "version": "2.4.2",
+                    "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+                    "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+                    "dev": true,
+                    "requires": {
+                        "ansi-styles": "^3.2.1",
+                        "escape-string-regexp": "^1.0.5",
+                        "supports-color": "^5.3.0"
+                    }
+                },
+                "color-convert": {
+                    "version": "1.9.3",
+                    "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+                    "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+                    "dev": true,
+                    "requires": {
+                        "color-name": "1.1.3"
+                    }
+                },
+                "color-name": {
+                    "version": "1.1.3",
+                    "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+                    "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+                    "dev": true
+                },
+                "has-flag": {
+                    "version": "3.0.0",
+                    "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+                    "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+                    "dev": true
+                },
+                "supports-color": {
+                    "version": "5.5.0",
+                    "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+                    "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+                    "dev": true,
+                    "requires": {
+                        "has-flag": "^3.0.0"
+                    }
+                }
+            }
+        },
+        "@babel/parser": {
+            "version": "7.20.13",
+            "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.13.tgz",
+            "integrity": "sha512-gFDLKMfpiXCsjt4za2JA9oTMn70CeseCehb11kRZgvd7+F67Hih3OHOK24cRrWECJ/ljfPGac6ygXAs/C8kIvw==",
+            "dev": true
+        },
+        "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz",
+            "integrity": "sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.6"
+            }
+        },
+        "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.20.7.tgz",
+            "integrity": "sha512-sbr9+wNE5aXMBBFBICk01tt7sBf2Oc9ikRFEcem/ZORup9IMUdNhW7/wVLEbbtlWOsEubJet46mHAL2C8+2jKQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0",
+                "@babel/plugin-proposal-optional-chaining": "^7.20.7"
+            }
+        },
+        "@babel/plugin-proposal-async-generator-functions": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.20.7.tgz",
+            "integrity": "sha512-xMbiLsn/8RK7Wq7VeVytytS2L6qE69bXPB10YCmMdDZbKF4okCqY74pI/jJQ/8U0b/F6NrT2+14b8/P9/3AMGA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-environment-visitor": "^7.18.9",
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/helper-remap-async-to-generator": "^7.18.9",
+                "@babel/plugin-syntax-async-generators": "^7.8.4"
+            }
+        },
+        "@babel/plugin-proposal-class-properties": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz",
+            "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-create-class-features-plugin": "^7.18.6",
+                "@babel/helper-plugin-utils": "^7.18.6"
+            }
+        },
+        "@babel/plugin-proposal-class-static-block": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.20.7.tgz",
+            "integrity": "sha512-AveGOoi9DAjUYYuUAG//Ig69GlazLnoyzMw68VCDux+c1tsnnH/OkYcpz/5xzMkEFC6UxjR5Gw1c+iY2wOGVeQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-create-class-features-plugin": "^7.20.7",
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/plugin-syntax-class-static-block": "^7.14.5"
+            }
+        },
+        "@babel/plugin-proposal-dynamic-import": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz",
+            "integrity": "sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.6",
+                "@babel/plugin-syntax-dynamic-import": "^7.8.3"
+            }
+        },
+        "@babel/plugin-proposal-export-namespace-from": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz",
+            "integrity": "sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.9",
+                "@babel/plugin-syntax-export-namespace-from": "^7.8.3"
+            }
+        },
+        "@babel/plugin-proposal-json-strings": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz",
+            "integrity": "sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.6",
+                "@babel/plugin-syntax-json-strings": "^7.8.3"
+            }
+        },
+        "@babel/plugin-proposal-logical-assignment-operators": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.20.7.tgz",
+            "integrity": "sha512-y7C7cZgpMIjWlKE5T7eJwp+tnRYM89HmRvWM5EQuB5BoHEONjmQ8lSNmBUwOyy/GFRsohJED51YBF79hE1djug==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4"
+            }
+        },
+        "@babel/plugin-proposal-nullish-coalescing-operator": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz",
+            "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.6",
+                "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3"
+            }
+        },
+        "@babel/plugin-proposal-numeric-separator": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz",
+            "integrity": "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.6",
+                "@babel/plugin-syntax-numeric-separator": "^7.10.4"
+            }
+        },
+        "@babel/plugin-proposal-object-rest-spread": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.7.tgz",
+            "integrity": "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==",
+            "dev": true,
+            "requires": {
+                "@babel/compat-data": "^7.20.5",
+                "@babel/helper-compilation-targets": "^7.20.7",
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+                "@babel/plugin-transform-parameters": "^7.20.7"
+            }
+        },
+        "@babel/plugin-proposal-optional-catch-binding": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz",
+            "integrity": "sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.6",
+                "@babel/plugin-syntax-optional-catch-binding": "^7.8.3"
+            }
+        },
+        "@babel/plugin-proposal-optional-chaining": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.20.7.tgz",
+            "integrity": "sha512-T+A7b1kfjtRM51ssoOfS1+wbyCVqorfyZhT99TvxxLMirPShD8CzKMRepMlCBGM5RpHMbn8s+5MMHnPstJH6mQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0",
+                "@babel/plugin-syntax-optional-chaining": "^7.8.3"
+            }
+        },
+        "@babel/plugin-proposal-private-methods": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz",
+            "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-create-class-features-plugin": "^7.18.6",
+                "@babel/helper-plugin-utils": "^7.18.6"
+            }
+        },
+        "@babel/plugin-proposal-private-property-in-object": {
+            "version": "7.20.5",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.20.5.tgz",
+            "integrity": "sha512-Vq7b9dUA12ByzB4EjQTPo25sFhY+08pQDBSZRtUAkj7lb7jahaHR5igera16QZ+3my1nYR4dKsNdYj5IjPHilQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-annotate-as-pure": "^7.18.6",
+                "@babel/helper-create-class-features-plugin": "^7.20.5",
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/plugin-syntax-private-property-in-object": "^7.14.5"
+            }
+        },
+        "@babel/plugin-proposal-unicode-property-regex": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz",
+            "integrity": "sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+                "@babel/helper-plugin-utils": "^7.18.6"
+            }
+        },
+        "@babel/plugin-syntax-async-generators": {
+            "version": "7.8.4",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz",
+            "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.8.0"
+            }
+        },
+        "@babel/plugin-syntax-class-properties": {
+            "version": "7.12.13",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz",
+            "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.12.13"
+            }
+        },
+        "@babel/plugin-syntax-class-static-block": {
+            "version": "7.14.5",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz",
+            "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.14.5"
+            }
+        },
+        "@babel/plugin-syntax-dynamic-import": {
+            "version": "7.8.3",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz",
+            "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.8.0"
+            }
+        },
+        "@babel/plugin-syntax-export-namespace-from": {
+            "version": "7.8.3",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz",
+            "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.8.3"
+            }
+        },
+        "@babel/plugin-syntax-import-assertions": {
+            "version": "7.20.0",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.20.0.tgz",
+            "integrity": "sha512-IUh1vakzNoWalR8ch/areW7qFopR2AEw03JlG7BbrDqmQ4X3q9uuipQwSGrUn7oGiemKjtSLDhNtQHzMHr1JdQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.19.0"
+            }
+        },
+        "@babel/plugin-syntax-json-strings": {
+            "version": "7.8.3",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz",
+            "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.8.0"
+            }
+        },
+        "@babel/plugin-syntax-logical-assignment-operators": {
+            "version": "7.10.4",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz",
+            "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.10.4"
+            }
+        },
+        "@babel/plugin-syntax-nullish-coalescing-operator": {
+            "version": "7.8.3",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz",
+            "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.8.0"
+            }
+        },
+        "@babel/plugin-syntax-numeric-separator": {
+            "version": "7.10.4",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz",
+            "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.10.4"
+            }
+        },
+        "@babel/plugin-syntax-object-rest-spread": {
+            "version": "7.8.3",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz",
+            "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.8.0"
+            }
+        },
+        "@babel/plugin-syntax-optional-catch-binding": {
+            "version": "7.8.3",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz",
+            "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.8.0"
+            }
+        },
+        "@babel/plugin-syntax-optional-chaining": {
+            "version": "7.8.3",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz",
+            "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.8.0"
+            }
+        },
+        "@babel/plugin-syntax-private-property-in-object": {
+            "version": "7.14.5",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz",
+            "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.14.5"
+            }
+        },
+        "@babel/plugin-syntax-top-level-await": {
+            "version": "7.14.5",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz",
+            "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.14.5"
+            }
+        },
+        "@babel/plugin-transform-arrow-functions": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.20.7.tgz",
+            "integrity": "sha512-3poA5E7dzDomxj9WXWwuD6A5F3kc7VXwIJO+E+J8qtDtS+pXPAhrgEyh+9GBwBgPq1Z+bB+/JD60lp5jsN7JPQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.20.2"
+            }
+        },
+        "@babel/plugin-transform-async-to-generator": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.20.7.tgz",
+            "integrity": "sha512-Uo5gwHPT9vgnSXQxqGtpdufUiWp96gk7yiP4Mp5bm1QMkEmLXBO7PAGYbKoJ6DhAwiNkcHFBol/x5zZZkL/t0Q==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-module-imports": "^7.18.6",
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/helper-remap-async-to-generator": "^7.18.9"
+            }
+        },
+        "@babel/plugin-transform-block-scoped-functions": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz",
+            "integrity": "sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.6"
+            }
+        },
+        "@babel/plugin-transform-block-scoping": {
+            "version": "7.20.11",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.20.11.tgz",
+            "integrity": "sha512-tA4N427a7fjf1P0/2I4ScsHGc5jcHPbb30xMbaTke2gxDuWpUfXDuX1FEymJwKk4tuGUvGcejAR6HdZVqmmPyw==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.20.2"
+            }
+        },
+        "@babel/plugin-transform-classes": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.20.7.tgz",
+            "integrity": "sha512-LWYbsiXTPKl+oBlXUGlwNlJZetXD5Am+CyBdqhPsDVjM9Jc8jwBJFrKhHf900Kfk2eZG1y9MAG3UNajol7A4VQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-annotate-as-pure": "^7.18.6",
+                "@babel/helper-compilation-targets": "^7.20.7",
+                "@babel/helper-environment-visitor": "^7.18.9",
+                "@babel/helper-function-name": "^7.19.0",
+                "@babel/helper-optimise-call-expression": "^7.18.6",
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/helper-replace-supers": "^7.20.7",
+                "@babel/helper-split-export-declaration": "^7.18.6",
+                "globals": "^11.1.0"
+            }
+        },
+        "@babel/plugin-transform-computed-properties": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.20.7.tgz",
+            "integrity": "sha512-Lz7MvBK6DTjElHAmfu6bfANzKcxpyNPeYBGEafyA6E5HtRpjpZwU+u7Qrgz/2OR0z+5TvKYbPdphfSaAcZBrYQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/template": "^7.20.7"
+            }
+        },
+        "@babel/plugin-transform-destructuring": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.20.7.tgz",
+            "integrity": "sha512-Xwg403sRrZb81IVB79ZPqNQME23yhugYVqgTxAhT99h485F4f+GMELFhhOsscDUB7HCswepKeCKLn/GZvUKoBA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.20.2"
+            }
+        },
+        "@babel/plugin-transform-dotall-regex": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz",
+            "integrity": "sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+                "@babel/helper-plugin-utils": "^7.18.6"
+            }
+        },
+        "@babel/plugin-transform-duplicate-keys": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz",
+            "integrity": "sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.9"
+            }
+        },
+        "@babel/plugin-transform-exponentiation-operator": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz",
+            "integrity": "sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-builder-binary-assignment-operator-visitor": "^7.18.6",
+                "@babel/helper-plugin-utils": "^7.18.6"
+            }
+        },
+        "@babel/plugin-transform-for-of": {
+            "version": "7.18.8",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.18.8.tgz",
+            "integrity": "sha512-yEfTRnjuskWYo0k1mHUqrVWaZwrdq8AYbfrpqULOJOaucGSp4mNMVps+YtA8byoevxS/urwU75vyhQIxcCgiBQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.6"
+            }
+        },
+        "@babel/plugin-transform-function-name": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz",
+            "integrity": "sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-compilation-targets": "^7.18.9",
+                "@babel/helper-function-name": "^7.18.9",
+                "@babel/helper-plugin-utils": "^7.18.9"
+            }
+        },
+        "@babel/plugin-transform-literals": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz",
+            "integrity": "sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.9"
+            }
+        },
+        "@babel/plugin-transform-member-expression-literals": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz",
+            "integrity": "sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.6"
+            }
+        },
+        "@babel/plugin-transform-modules-amd": {
+            "version": "7.20.11",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.20.11.tgz",
+            "integrity": "sha512-NuzCt5IIYOW0O30UvqktzHYR2ud5bOWbY0yaxWZ6G+aFzOMJvrs5YHNikrbdaT15+KNO31nPOy5Fim3ku6Zb5g==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-module-transforms": "^7.20.11",
+                "@babel/helper-plugin-utils": "^7.20.2"
+            }
+        },
+        "@babel/plugin-transform-modules-commonjs": {
+            "version": "7.20.11",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.20.11.tgz",
+            "integrity": "sha512-S8e1f7WQ7cimJQ51JkAaDrEtohVEitXjgCGAS2N8S31Y42E+kWwfSz83LYz57QdBm7q9diARVqanIaH2oVgQnw==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-module-transforms": "^7.20.11",
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/helper-simple-access": "^7.20.2"
+            }
+        },
+        "@babel/plugin-transform-modules-systemjs": {
+            "version": "7.20.11",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.20.11.tgz",
+            "integrity": "sha512-vVu5g9BPQKSFEmvt2TA4Da5N+QVS66EX21d8uoOihC+OCpUoGvzVsXeqFdtAEfVa5BILAeFt+U7yVmLbQnAJmw==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-hoist-variables": "^7.18.6",
+                "@babel/helper-module-transforms": "^7.20.11",
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/helper-validator-identifier": "^7.19.1"
+            }
+        },
+        "@babel/plugin-transform-modules-umd": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz",
+            "integrity": "sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-module-transforms": "^7.18.6",
+                "@babel/helper-plugin-utils": "^7.18.6"
+            }
+        },
+        "@babel/plugin-transform-named-capturing-groups-regex": {
+            "version": "7.20.5",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.20.5.tgz",
+            "integrity": "sha512-mOW4tTzi5iTLnw+78iEq3gr8Aoq4WNRGpmSlrogqaiCBoR1HFhpU4JkpQFOHfeYx3ReVIFWOQJS4aZBRvuZ6mA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-create-regexp-features-plugin": "^7.20.5",
+                "@babel/helper-plugin-utils": "^7.20.2"
+            }
+        },
+        "@babel/plugin-transform-new-target": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz",
+            "integrity": "sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.6"
+            }
+        },
+        "@babel/plugin-transform-object-super": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz",
+            "integrity": "sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.6",
+                "@babel/helper-replace-supers": "^7.18.6"
+            }
+        },
+        "@babel/plugin-transform-parameters": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.20.7.tgz",
+            "integrity": "sha512-WiWBIkeHKVOSYPO0pWkxGPfKeWrCJyD3NJ53+Lrp/QMSZbsVPovrVl2aWZ19D/LTVnaDv5Ap7GJ/B2CTOZdrfA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.20.2"
+            }
+        },
+        "@babel/plugin-transform-property-literals": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz",
+            "integrity": "sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.6"
+            }
+        },
+        "@babel/plugin-transform-regenerator": {
+            "version": "7.20.5",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.20.5.tgz",
+            "integrity": "sha512-kW/oO7HPBtntbsahzQ0qSE3tFvkFwnbozz3NWFhLGqH75vLEg+sCGngLlhVkePlCs3Jv0dBBHDzCHxNiFAQKCQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "regenerator-transform": "^0.15.1"
+            }
+        },
+        "@babel/plugin-transform-reserved-words": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz",
+            "integrity": "sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.6"
+            }
+        },
+        "@babel/plugin-transform-shorthand-properties": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz",
+            "integrity": "sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.6"
+            }
+        },
+        "@babel/plugin-transform-spread": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.20.7.tgz",
+            "integrity": "sha512-ewBbHQ+1U/VnH1fxltbJqDeWBU1oNLG8Dj11uIv3xVf7nrQu0bPGe5Rf716r7K5Qz+SqtAOVswoVunoiBtGhxw==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0"
+            }
+        },
+        "@babel/plugin-transform-sticky-regex": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz",
+            "integrity": "sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.6"
+            }
+        },
+        "@babel/plugin-transform-template-literals": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz",
+            "integrity": "sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.9"
+            }
+        },
+        "@babel/plugin-transform-typeof-symbol": {
+            "version": "7.18.9",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz",
+            "integrity": "sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.9"
+            }
+        },
+        "@babel/plugin-transform-unicode-escapes": {
+            "version": "7.18.10",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.10.tgz",
+            "integrity": "sha512-kKAdAI+YzPgGY/ftStBFXTI1LZFju38rYThnfMykS+IXy8BVx+res7s2fxf1l8I35DV2T97ezo6+SGrXz6B3iQ==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.18.9"
+            }
+        },
+        "@babel/plugin-transform-unicode-regex": {
+            "version": "7.18.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz",
+            "integrity": "sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+                "@babel/helper-plugin-utils": "^7.18.6"
+            }
+        },
+        "@babel/preset-env": {
+            "version": "7.20.2",
+            "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.20.2.tgz",
+            "integrity": "sha512-1G0efQEWR1EHkKvKHqbG+IN/QdgwfByUpM5V5QroDzGV2t3S/WXNQd693cHiHTlCFMpr9B6FkPFXDA2lQcKoDg==",
+            "dev": true,
+            "requires": {
+                "@babel/compat-data": "^7.20.1",
+                "@babel/helper-compilation-targets": "^7.20.0",
+                "@babel/helper-plugin-utils": "^7.20.2",
+                "@babel/helper-validator-option": "^7.18.6",
+                "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.18.6",
+                "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.18.9",
+                "@babel/plugin-proposal-async-generator-functions": "^7.20.1",
+                "@babel/plugin-proposal-class-properties": "^7.18.6",
+                "@babel/plugin-proposal-class-static-block": "^7.18.6",
+                "@babel/plugin-proposal-dynamic-import": "^7.18.6",
+                "@babel/plugin-proposal-export-namespace-from": "^7.18.9",
+                "@babel/plugin-proposal-json-strings": "^7.18.6",
+                "@babel/plugin-proposal-logical-assignment-operators": "^7.18.9",
+                "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
+                "@babel/plugin-proposal-numeric-separator": "^7.18.6",
+                "@babel/plugin-proposal-object-rest-spread": "^7.20.2",
+                "@babel/plugin-proposal-optional-catch-binding": "^7.18.6",
+                "@babel/plugin-proposal-optional-chaining": "^7.18.9",
+                "@babel/plugin-proposal-private-methods": "^7.18.6",
+                "@babel/plugin-proposal-private-property-in-object": "^7.18.6",
+                "@babel/plugin-proposal-unicode-property-regex": "^7.18.6",
+                "@babel/plugin-syntax-async-generators": "^7.8.4",
+                "@babel/plugin-syntax-class-properties": "^7.12.13",
+                "@babel/plugin-syntax-class-static-block": "^7.14.5",
+                "@babel/plugin-syntax-dynamic-import": "^7.8.3",
+                "@babel/plugin-syntax-export-namespace-from": "^7.8.3",
+                "@babel/plugin-syntax-import-assertions": "^7.20.0",
+                "@babel/plugin-syntax-json-strings": "^7.8.3",
+                "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4",
+                "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
+                "@babel/plugin-syntax-numeric-separator": "^7.10.4",
+                "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+                "@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
+                "@babel/plugin-syntax-optional-chaining": "^7.8.3",
+                "@babel/plugin-syntax-private-property-in-object": "^7.14.5",
+                "@babel/plugin-syntax-top-level-await": "^7.14.5",
+                "@babel/plugin-transform-arrow-functions": "^7.18.6",
+                "@babel/plugin-transform-async-to-generator": "^7.18.6",
+                "@babel/plugin-transform-block-scoped-functions": "^7.18.6",
+                "@babel/plugin-transform-block-scoping": "^7.20.2",
+                "@babel/plugin-transform-classes": "^7.20.2",
+                "@babel/plugin-transform-computed-properties": "^7.18.9",
+                "@babel/plugin-transform-destructuring": "^7.20.2",
+                "@babel/plugin-transform-dotall-regex": "^7.18.6",
+                "@babel/plugin-transform-duplicate-keys": "^7.18.9",
+                "@babel/plugin-transform-exponentiation-operator": "^7.18.6",
+                "@babel/plugin-transform-for-of": "^7.18.8",
+                "@babel/plugin-transform-function-name": "^7.18.9",
+                "@babel/plugin-transform-literals": "^7.18.9",
+                "@babel/plugin-transform-member-expression-literals": "^7.18.6",
+                "@babel/plugin-transform-modules-amd": "^7.19.6",
+                "@babel/plugin-transform-modules-commonjs": "^7.19.6",
+                "@babel/plugin-transform-modules-systemjs": "^7.19.6",
+                "@babel/plugin-transform-modules-umd": "^7.18.6",
+                "@babel/plugin-transform-named-capturing-groups-regex": "^7.19.1",
+                "@babel/plugin-transform-new-target": "^7.18.6",
+                "@babel/plugin-transform-object-super": "^7.18.6",
+                "@babel/plugin-transform-parameters": "^7.20.1",
+                "@babel/plugin-transform-property-literals": "^7.18.6",
+                "@babel/plugin-transform-regenerator": "^7.18.6",
+                "@babel/plugin-transform-reserved-words": "^7.18.6",
+                "@babel/plugin-transform-shorthand-properties": "^7.18.6",
+                "@babel/plugin-transform-spread": "^7.19.0",
+                "@babel/plugin-transform-sticky-regex": "^7.18.6",
+                "@babel/plugin-transform-template-literals": "^7.18.9",
+                "@babel/plugin-transform-typeof-symbol": "^7.18.9",
+                "@babel/plugin-transform-unicode-escapes": "^7.18.10",
+                "@babel/plugin-transform-unicode-regex": "^7.18.6",
+                "@babel/preset-modules": "^0.1.5",
+                "@babel/types": "^7.20.2",
+                "babel-plugin-polyfill-corejs2": "^0.3.3",
+                "babel-plugin-polyfill-corejs3": "^0.6.0",
+                "babel-plugin-polyfill-regenerator": "^0.4.1",
+                "core-js-compat": "^3.25.1",
+                "semver": "^6.3.0"
+            },
+            "dependencies": {
+                "semver": {
+                    "version": "6.3.0",
+                    "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+                    "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+                    "dev": true
+                }
+            }
+        },
+        "@babel/preset-modules": {
+            "version": "0.1.5",
+            "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.5.tgz",
+            "integrity": "sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-plugin-utils": "^7.0.0",
+                "@babel/plugin-proposal-unicode-property-regex": "^7.4.4",
+                "@babel/plugin-transform-dotall-regex": "^7.4.4",
+                "@babel/types": "^7.4.4",
+                "esutils": "^2.0.2"
+            }
+        },
+        "@babel/runtime": {
+            "version": "7.20.13",
+            "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.13.tgz",
+            "integrity": "sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA==",
+            "dev": true,
+            "requires": {
+                "regenerator-runtime": "^0.13.11"
+            }
+        },
+        "@babel/template": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz",
+            "integrity": "sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==",
+            "dev": true,
+            "requires": {
+                "@babel/code-frame": "^7.18.6",
+                "@babel/parser": "^7.20.7",
+                "@babel/types": "^7.20.7"
+            }
+        },
+        "@babel/traverse": {
+            "version": "7.20.13",
+            "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.20.13.tgz",
+            "integrity": "sha512-kMJXfF0T6DIS9E8cgdLCSAL+cuCK+YEZHWiLK0SXpTo8YRj5lpJu3CDNKiIBCne4m9hhTIqUg6SYTAI39tAiVQ==",
+            "dev": true,
+            "requires": {
+                "@babel/code-frame": "^7.18.6",
+                "@babel/generator": "^7.20.7",
+                "@babel/helper-environment-visitor": "^7.18.9",
+                "@babel/helper-function-name": "^7.19.0",
+                "@babel/helper-hoist-variables": "^7.18.6",
+                "@babel/helper-split-export-declaration": "^7.18.6",
+                "@babel/parser": "^7.20.13",
+                "@babel/types": "^7.20.7",
+                "debug": "^4.1.0",
+                "globals": "^11.1.0"
+            },
+            "dependencies": {
+                "debug": {
+                    "version": "4.3.4",
+                    "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+                    "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+                    "dev": true,
+                    "requires": {
+                        "ms": "2.1.2"
+                    }
+                },
+                "ms": {
+                    "version": "2.1.2",
+                    "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+                    "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+                    "dev": true
+                }
+            }
+        },
+        "@babel/types": {
+            "version": "7.20.7",
+            "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.20.7.tgz",
+            "integrity": "sha512-69OnhBxSSgK0OzTJai4kyPDiKTIe3j+ctaHdIGVbRahTLAT7L3R9oeXHC2aVSuGYt3cVnoAMDmOCgJ2yaiLMvg==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-string-parser": "^7.19.4",
+                "@babel/helper-validator-identifier": "^7.19.1",
+                "to-fast-properties": "^2.0.0"
+            }
+        },
         "@discoveryjs/json-ext": {
             "version": "0.5.7",
             "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
@@ -9173,6 +12707,44 @@
             "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==",
             "dev": true
         },
+        "@jridgewell/gen-mapping": {
+            "version": "0.1.1",
+            "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
+            "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==",
+            "dev": true,
+            "requires": {
+                "@jridgewell/set-array": "^1.0.0",
+                "@jridgewell/sourcemap-codec": "^1.4.10"
+            }
+        },
+        "@jridgewell/resolve-uri": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
+            "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
+            "dev": true
+        },
+        "@jridgewell/set-array": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+            "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+            "dev": true
+        },
+        "@jridgewell/sourcemap-codec": {
+            "version": "1.4.14",
+            "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
+            "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==",
+            "dev": true
+        },
+        "@jridgewell/trace-mapping": {
+            "version": "0.3.17",
+            "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz",
+            "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==",
+            "dev": true,
+            "requires": {
+                "@jridgewell/resolve-uri": "3.1.0",
+                "@jridgewell/sourcemap-codec": "1.4.14"
+            }
+        },
         "@npmcli/fs": {
             "version": "1.1.1",
             "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz",
@@ -9775,6 +13347,155 @@
             "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==",
             "dev": true
         },
+        "babel-loader": {
+            "version": "8.3.0",
+            "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.3.0.tgz",
+            "integrity": "sha512-H8SvsMF+m9t15HNLMipppzkC+Y2Yq+v3SonZyU70RBL/h1gxPkH08Ot8pEE9Z4Kd+czyWJClmFS8qzIP9OZ04Q==",
+            "dev": true,
+            "requires": {
+                "find-cache-dir": "^3.3.1",
+                "loader-utils": "^2.0.0",
+                "make-dir": "^3.1.0",
+                "schema-utils": "^2.6.5"
+            },
+            "dependencies": {
+                "find-cache-dir": {
+                    "version": "3.3.2",
+                    "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+                    "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
+                    "dev": true,
+                    "requires": {
+                        "commondir": "^1.0.1",
+                        "make-dir": "^3.0.2",
+                        "pkg-dir": "^4.1.0"
+                    }
+                },
+                "find-up": {
+                    "version": "4.1.0",
+                    "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+                    "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+                    "dev": true,
+                    "requires": {
+                        "locate-path": "^5.0.0",
+                        "path-exists": "^4.0.0"
+                    }
+                },
+                "json5": {
+                    "version": "2.2.3",
+                    "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+                    "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+                    "dev": true
+                },
+                "loader-utils": {
+                    "version": "2.0.4",
+                    "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz",
+                    "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
+                    "dev": true,
+                    "requires": {
+                        "big.js": "^5.2.2",
+                        "emojis-list": "^3.0.0",
+                        "json5": "^2.1.2"
+                    }
+                },
+                "locate-path": {
+                    "version": "5.0.0",
+                    "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+                    "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+                    "dev": true,
+                    "requires": {
+                        "p-locate": "^4.1.0"
+                    }
+                },
+                "make-dir": {
+                    "version": "3.1.0",
+                    "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+                    "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+                    "dev": true,
+                    "requires": {
+                        "semver": "^6.0.0"
+                    }
+                },
+                "p-locate": {
+                    "version": "4.1.0",
+                    "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+                    "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+                    "dev": true,
+                    "requires": {
+                        "p-limit": "^2.2.0"
+                    }
+                },
+                "path-exists": {
+                    "version": "4.0.0",
+                    "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+                    "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+                    "dev": true
+                },
+                "pkg-dir": {
+                    "version": "4.2.0",
+                    "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+                    "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+                    "dev": true,
+                    "requires": {
+                        "find-up": "^4.0.0"
+                    }
+                },
+                "schema-utils": {
+                    "version": "2.7.1",
+                    "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz",
+                    "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==",
+                    "dev": true,
+                    "requires": {
+                        "@types/json-schema": "^7.0.5",
+                        "ajv": "^6.12.4",
+                        "ajv-keywords": "^3.5.2"
+                    }
+                },
+                "semver": {
+                    "version": "6.3.0",
+                    "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+                    "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+                    "dev": true
+                }
+            }
+        },
+        "babel-plugin-polyfill-corejs2": {
+            "version": "0.3.3",
+            "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz",
+            "integrity": "sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==",
+            "dev": true,
+            "requires": {
+                "@babel/compat-data": "^7.17.7",
+                "@babel/helper-define-polyfill-provider": "^0.3.3",
+                "semver": "^6.1.1"
+            },
+            "dependencies": {
+                "semver": {
+                    "version": "6.3.0",
+                    "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+                    "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+                    "dev": true
+                }
+            }
+        },
+        "babel-plugin-polyfill-corejs3": {
+            "version": "0.6.0",
+            "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.6.0.tgz",
+            "integrity": "sha512-+eHqR6OPcBhJOGgsIar7xoAB1GcSwVUA3XjAd7HJNzOXT4wv6/H7KIdA/Nc60cvUlDbKApmqNvD1B1bzOt4nyA==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-define-polyfill-provider": "^0.3.3",
+                "core-js-compat": "^3.25.1"
+            }
+        },
+        "babel-plugin-polyfill-regenerator": {
+            "version": "0.4.1",
+            "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz",
+            "integrity": "sha512-NtQGmyQDXjQqQ+IzRkBVwEOz9lQ4zxAQZgoAYEtU9dJjnl1Oc98qnN7jcp+bE7O7aYzVpavXE3/VKXNzUbh7aw==",
+            "dev": true,
+            "requires": {
+                "@babel/helper-define-polyfill-provider": "^0.3.3"
+            }
+        },
         "balanced-match": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -10518,6 +14239,12 @@
             "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz",
             "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ=="
         },
+        "convert-source-map": {
+            "version": "1.9.0",
+            "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
+            "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
+            "dev": true
+        },
         "copy-concurrently": {
             "version": "1.0.5",
             "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz",
@@ -10584,6 +14311,15 @@
             "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.30.0.tgz",
             "integrity": "sha512-hQotSSARoNh1mYPi9O2YaWeiq/cEB95kOrFb4NCrO4RIFt1qqNpKsaE+vy/L3oiqvND5cThqXzUU3r9F7Efztg=="
         },
+        "core-js-compat": {
+            "version": "3.27.2",
+            "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.27.2.tgz",
+            "integrity": "sha512-welaYuF7ZtbYKGrIy7y3eb40d37rG1FvzEOfe7hSLd2iD6duMDqUhRfSvCGyC46HhR6Y8JXXdZ2lnRUMkPBpvg==",
+            "dev": true,
+            "requires": {
+                "browserslist": "^4.21.4"
+            }
+        },
         "core-util-is": {
             "version": "1.0.3",
             "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -11160,6 +14896,32 @@
                 }
             }
         },
+        "csv": {
+            "version": "6.2.5",
+            "resolved": "https://registry.npmjs.org/csv/-/csv-6.2.5.tgz",
+            "integrity": "sha512-T+K0H7MIrlrnP6KxYKo3lK+uLl6OC2Gmwdd81TG/VdkhKvpatl35sR7tyRSpDLGl22y2T+q9KvNHnVtn4OAscQ==",
+            "requires": {
+                "csv-generate": "^4.2.1",
+                "csv-parse": "^5.3.3",
+                "csv-stringify": "^6.2.3",
+                "stream-transform": "^3.2.1"
+            }
+        },
+        "csv-generate": {
+            "version": "4.2.1",
+            "resolved": "https://registry.npmjs.org/csv-generate/-/csv-generate-4.2.1.tgz",
+            "integrity": "sha512-w6GFHjvApv6bcJ2xdi9JGsH6ZvUBfC+vUdfefnEzurXG6hMRwzkBLnhztU2H7v7+zfCk1I/knnQ+tGbgpxWrBw=="
+        },
+        "csv-parse": {
+            "version": "5.3.3",
+            "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.3.3.tgz",
+            "integrity": "sha512-kEWkAPleNEdhFNkHQpFHu9RYPogsFj3dx6bCxL847fsiLgidzWg0z/O0B1kVWMJUc5ky64zGp18LX2T3DQrOfw=="
+        },
+        "csv-stringify": {
+            "version": "6.2.3",
+            "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.2.3.tgz",
+            "integrity": "sha512-4qGjUMwnlaRc00gc2jrIYh2w/h1fo25B0mTuY9K8fBiIgtmCX3LcgUbrEGViL98Ci4Se/F5LFEtu8k+dItJVZQ=="
+        },
         "cyclist": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
@@ -11563,6 +15325,12 @@
             "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
             "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="
         },
+        "esutils": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+            "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+            "dev": true
+        },
         "events": {
             "version": "3.3.0",
             "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -11935,6 +15703,12 @@
             "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
             "dev": true
         },
+        "gensync": {
+            "version": "1.0.0-beta.2",
+            "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+            "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+            "dev": true
+        },
         "get-intrinsic": {
             "version": "1.2.0",
             "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz",
@@ -11982,6 +15756,12 @@
                 "is-glob": "^4.0.1"
             }
         },
+        "globals": {
+            "version": "11.12.0",
+            "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+            "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+            "dev": true
+        },
         "globalthis": {
             "version": "1.0.3",
             "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz",
@@ -12735,6 +16515,12 @@
             "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.4.tgz",
             "integrity": "sha512-v28EW9DWDFpzcD9O5iyJXg3R3+q+mET5JhnjJzQUZMHOv67bpSIHq81GEYpPNZHG+XXHsfSme3nxp/hndKEcsQ=="
         },
+        "js-tokens": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+            "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+            "dev": true
+        },
         "js-yaml": {
             "version": "3.14.1",
             "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
@@ -12812,6 +16598,12 @@
             "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz",
             "integrity": "sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA=="
         },
+        "lodash.debounce": {
+            "version": "4.0.8",
+            "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+            "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+            "dev": true
+        },
         "lodash.memoize": {
             "version": "4.1.2",
             "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@@ -14783,6 +18575,36 @@
                 "resolve": "^1.9.0"
             }
         },
+        "regenerate": {
+            "version": "1.4.2",
+            "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
+            "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==",
+            "dev": true
+        },
+        "regenerate-unicode-properties": {
+            "version": "10.1.0",
+            "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz",
+            "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==",
+            "dev": true,
+            "requires": {
+                "regenerate": "^1.4.2"
+            }
+        },
+        "regenerator-runtime": {
+            "version": "0.13.11",
+            "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+            "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+            "dev": true
+        },
+        "regenerator-transform": {
+            "version": "0.15.1",
+            "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.1.tgz",
+            "integrity": "sha512-knzmNAcuyxV+gQCufkYcvOqX/qIIfHLv0u5x79kRxuGojfYVky1f15TzZEu2Avte8QGepvUNTnLskf8E6X6Vyg==",
+            "dev": true,
+            "requires": {
+                "@babel/runtime": "^7.8.4"
+            }
+        },
         "regex-not": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz",
@@ -14803,6 +18625,43 @@
                 "functions-have-names": "^1.2.2"
             }
         },
+        "regexpu-core": {
+            "version": "5.2.2",
+            "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.2.2.tgz",
+            "integrity": "sha512-T0+1Zp2wjF/juXMrMxHxidqGYn8U4R+zleSJhX9tQ1PUsS8a9UtYfbsF9LdiVgNX3kiX8RNaKM42nfSgvFJjmw==",
+            "dev": true,
+            "requires": {
+                "regenerate": "^1.4.2",
+                "regenerate-unicode-properties": "^10.1.0",
+                "regjsgen": "^0.7.1",
+                "regjsparser": "^0.9.1",
+                "unicode-match-property-ecmascript": "^2.0.0",
+                "unicode-match-property-value-ecmascript": "^2.1.0"
+            }
+        },
+        "regjsgen": {
+            "version": "0.7.1",
+            "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.7.1.tgz",
+            "integrity": "sha512-RAt+8H2ZEzHeYWxZ3H2z6tF18zyyOnlcdaafLrm21Bguj7uZy6ULibiAFdXEtKQY4Sy7wDTwDiOazasMLc4KPA==",
+            "dev": true
+        },
+        "regjsparser": {
+            "version": "0.9.1",
+            "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz",
+            "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==",
+            "dev": true,
+            "requires": {
+                "jsesc": "~0.5.0"
+            },
+            "dependencies": {
+                "jsesc": {
+                    "version": "0.5.0",
+                    "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz",
+                    "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==",
+                    "dev": true
+                }
+            }
+        },
         "relateurl": {
             "version": "0.2.7",
             "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
@@ -15414,6 +19273,11 @@
             "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz",
             "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ=="
         },
+        "stream-transform": {
+            "version": "3.2.1",
+            "resolved": "https://registry.npmjs.org/stream-transform/-/stream-transform-3.2.1.tgz",
+            "integrity": "sha512-ApK+WTJ5bCOf0A2tlec1qhvr8bGEBM/sgXXB7mysdCYgZJO5DZeaV3h3G+g0HnAQ372P5IhiGqnW29zoLOfTzQ=="
+        },
         "string_decoder": {
             "version": "1.1.1",
             "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
@@ -15753,6 +19617,12 @@
             "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz",
             "integrity": "sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA=="
         },
+        "to-fast-properties": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+            "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+            "dev": true
+        },
         "to-object-path": {
             "version": "0.3.0",
             "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz",
@@ -15830,6 +19700,34 @@
                 "which-boxed-primitive": "^1.0.2"
             }
         },
+        "unicode-canonical-property-names-ecmascript": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz",
+            "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==",
+            "dev": true
+        },
+        "unicode-match-property-ecmascript": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz",
+            "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==",
+            "dev": true,
+            "requires": {
+                "unicode-canonical-property-names-ecmascript": "^2.0.0",
+                "unicode-property-aliases-ecmascript": "^2.0.0"
+            }
+        },
+        "unicode-match-property-value-ecmascript": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz",
+            "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==",
+            "dev": true
+        },
+        "unicode-property-aliases-ecmascript": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
+            "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==",
+            "dev": true
+        },
         "union-value": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz",
@@ -16342,6 +20240,11 @@
             "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
             "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
         },
+        "yaml": {
+            "version": "2.2.2",
+            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.2.2.tgz",
+            "integrity": "sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA=="
+        },
         "yocto-queue": {
             "version": "0.1.0",
             "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/guacamole/src/main/frontend/package.json b/guacamole/src/main/frontend/package.json
index cb4982d..4b7f986 100644
--- a/guacamole/src/main/frontend/package.json
+++ b/guacamole/src/main/frontend/package.json
@@ -12,13 +12,18 @@
         "angular-translate-interpolation-messageformat": "^2.19.0",
         "angular-translate-loader-static-files": "^2.19.0",
         "blob-polyfill": ">=7.0.20220408",
+        "csv": "^6.2.5",
         "datalist-polyfill": "^1.25.1",
         "file-saver": "^2.0.5",
         "jquery": "^3.6.4",
         "jstz": "^2.1.1",
-        "lodash": "^4.17.21"
+        "lodash": "^4.17.21",
+        "yaml": "^2.2.2"
     },
     "devDependencies": {
+        "@babel/core": "^7.20.12",
+        "@babel/preset-env": "^7.20.2",
+        "babel-loader": "^8.3.0",
         "clean-webpack-plugin": "^4.0.0",
         "closure-webpack-plugin": "^2.6.1",
         "copy-webpack-plugin": "^5.1.2",
diff --git a/guacamole/src/main/frontend/src/app/auth/service/authenticationService.js b/guacamole/src/main/frontend/src/app/auth/service/authenticationService.js
index 944fd4a..af5a9ab 100644
--- a/guacamole/src/main/frontend/src/app/auth/service/authenticationService.js
+++ b/guacamole/src/main/frontend/src/app/auth/service/authenticationService.js
@@ -18,25 +18,47 @@
  */
 
 /**
- * A service for authenticating a user against the REST API.
+ * A service for authenticating a user against the REST API. Invoking the
+ * authenticate() or login() functions of this service will automatically
+ * affect the login dialog, if visible.
  *
- * This service broadcasts two events on $rootScope depending on the result of
- * authentication operations: 'guacLogin' if authentication was successful and
- * a new token was created, and 'guacLogout' if an existing token is being
- * destroyed or replaced. Both events will be passed the related token as their
- * sole parameter.
+ * This service broadcasts events on $rootScope depending on the status and
+ * result of authentication operations:
  *
- * If a login attempt results in an existing token being replaced, 'guacLogout'
- * will be broadcast first for the token being replaced, followed by
- * 'guacLogin' for the new token.
- * 
- * Failed logins may also result in guacInsufficientCredentials or
- * guacInvalidCredentials events, if the provided credentials were rejected for
- * being insufficient or invalid respectively. Both events will be provided
- * the set of parameters originally given to authenticate() and the error that
- * rejected the credentials. The Error object provided will contain set of
- * expected credentials returned by the REST endpoint. This set of credentials
- * will be in the form of a Field array.
+ *  "guacLoginPending"
+ *      An authentication request is being submitted and we are awaiting the
+ *      result. The request may not yet have been submitted if the parameters
+ *      for that request are not ready. This event receives a promise that
+ *      resolves with the HTTP parameters that were ultimately submitted as its
+ *      sole parameter.
+ *
+ *  "guacLogin"
+ *      Authentication was successful and a new token was created. This event
+ *      receives the authentication token as its sole parameter.
+ *
+ *  "guacLogout"
+ *      An existing token is being destroyed. This event receives the
+ *      authentication token as its sole parameter. If the existing token for
+ *      the current session is being replaced without destroying that session,
+ *      this event is not fired.
+ *
+ *  "guacLoginFailed"
+ *      An authentication request has failed for any reason. This event is
+ *      broadcast before any other events that are specific to the nature of
+ *      the failure, and may be used to detect login failures in lieu of those
+ *      events. This event receives two parameters: the HTTP parameters
+ *      submitted and the Error object received from the REST endpoint.
+ *
+ *  "guacInsufficientCredentials"
+ *      An authentication request failed because additional credentials are
+ *      needed before the request can be processed. This event receives two
+ *      parameters: the HTTP parameters submitted and the Error object received
+ *      from the REST endpoint.
+ *
+ *  "guacInvalidCredentials"
+ *      An authentication request failed because the credentials provided are
+ *      invalid. This event receives two parameters: the HTTP parameters
+ *      submitted and the Error object received from the REST endpoint.
  */
 angular.module('auth').factory('authenticationService', ['$injector',
         function authenticationService($injector) {
@@ -46,6 +68,7 @@
     var Error                = $injector.get('Error');
 
     // Required services
+    var $q                  = $injector.get('$q');
     var $rootScope          = $injector.get('$rootScope');
     var localStorageService = $injector.get('localStorageService');
     var requestService      = $injector.get('requestService');
@@ -61,18 +84,21 @@
     var cachedResult = null;
 
     /**
-     * The unique identifier of the local storage key which stores the result
-     * of the last authentication attempt.
+     * The unique identifier of the local storage key which stores the latest
+     * authentication token.
      *
      * @type String
      */
-    var AUTH_STORAGE_KEY = 'GUAC_AUTH';
+    var AUTH_TOKEN_STORAGE_KEY = 'GUAC_AUTH_TOKEN';
 
     /**
-     * Retrieves the last successful authentication result. If the user has not
+     * Retrieves the authentication result cached in memory. If the user has not
      * yet authenticated, the user has logged out, or the last authentication
      * attempt failed, null is returned.
      *
+     * NOTE: setAuthenticationResult() will be called upon page load, so the
+     * cache should always be populated after the page has successfully loaded.
+     *
      * @returns {AuthenticationResult}
      *     The last successful authentication result, or null if the user is not
      *     currently authenticated.
@@ -84,12 +110,7 @@
             return cachedResult;
 
         // Return explicit null if no auth data is currently stored
-        var data = localStorageService.getItem(AUTH_STORAGE_KEY);
-        if (!data)
-            return null;
-
-        // Update cache and return retrieved auth result
-        return (cachedResult = new AuthenticationResult(data));
+        return null;
 
     };
 
@@ -103,21 +124,28 @@
      */
     var setAuthenticationResult = function setAuthenticationResult(data) {
 
-        // Clear the currently-stored result if the last attempt failed
+        // Clear the currently-stored result and auth token if the last
+        // attempt failed
         if (!data) {
             cachedResult = null;
-            localStorageService.removeItem(AUTH_STORAGE_KEY);
+            localStorageService.removeItem(AUTH_TOKEN_STORAGE_KEY);
         }
 
-        // Otherwise store the authentication attempt directly
+        // Otherwise, store the authentication attempt directly.
+        // Note that only the auth token is stored in persistent local storage.
+        // To re-obtain an autentication result upon a fresh page load,
+        // reauthenticate with the persistent token, which can be obtained by
+        // calling getCurrentToken().
         else {
 
             // Always store in cache
             cachedResult = data;
 
-            // Persist result past tab/window closure ONLY if not anonymous
+            // Persist only the auth token past tab/window closure, and only
+            // if not anonymous
             if (data.username !== AuthenticationResult.ANONYMOUS_USERNAME)
-                localStorageService.setItem(AUTH_STORAGE_KEY, data);
+                localStorageService.setItem(
+                        AUTH_TOKEN_STORAGE_KEY, data.authToken);
 
         }
 
@@ -136,7 +164,8 @@
      * and given arbitrary parameters, returning a promise that succeeds only
      * if the authentication operation was successful. The resulting
      * authentication data can be retrieved later via getCurrentToken() or
-     * getCurrentUsername().
+     * getCurrentUsername(). Invoking this function will affect the UI,
+     * including the login screen if visible.
      * 
      * The provided parameters can be virtually any object, as each property
      * will be sent as an HTTP parameter in the authentication request.
@@ -146,64 +175,93 @@
      * 
      * If a token is provided, it will be reused if possible.
      * 
-     * @param {Object} parameters 
-     *     Arbitrary parameters to authenticate with.
+     * @param {Object|Promise} parameters
+     *     Arbitrary parameters to authenticate with. If a Promise is provided,
+     *     that Promise must resolve with the parameters to be submitted when
+     *     those parameters are available, and any error will be handled as if
+     *     from the authentication endpoint of the REST API itself.
      *
      * @returns {Promise}
      *     A promise which succeeds only if the login operation was successful.
      */
     service.authenticate = function authenticate(parameters) {
 
-        // Attempt authentication
-        return requestService({
-            method: 'POST',
-            url: 'api/tokens',
-            headers: {
-                'Content-Type': 'application/x-www-form-urlencoded'
-            },
-            data: $.param(parameters)
-        })
+        // Coerce received parameters object into a Promise, if it isn't
+        // already a Promise
+        parameters = $q.resolve(parameters);
 
-        // If authentication succeeds, handle received auth data
-        .then(function authenticationSuccessful(data) {
+        // Notify that a fresh authentication request is underway
+        $rootScope.$broadcast('guacLoginPending', parameters);
 
-            var currentToken = service.getCurrentToken();
+        // Attempt authentication after auth parameters are available ...
+        return parameters.then(function requestParametersReady(requestParams) {
 
-            // If a new token was received, ensure the old token is invalidated,
-            // if any, and notify listeners of the new token
-            if (data.authToken !== currentToken) {
+            // Strip any properties that are from AngularJS core, such as the
+            // '$$state' property added by $q. Properties added by AngularJS
+            // core will have a '$' prefix. The '$$state' property is
+            // particularly problematic, as it is self-referential and explodes
+            // the stack when fed to $.param().
+            requestParams = _.omitBy(requestParams, (value, key) => key.startsWith('$'));
 
-                // If an old token existed, request that the token be revoked
-                if (currentToken) {
-                    service.revokeToken(currentToken).catch(angular.noop);
+            return requestService({
+                method: 'POST',
+                url: 'api/tokens',
+                headers: {
+                    'Content-Type': 'application/x-www-form-urlencoded'
+                },
+                data: $.param(requestParams)
+            })
+
+            // ... if authentication succeeds, handle received auth data ...
+            .then(function authenticationSuccessful(data) {
+
+                var currentToken = service.getCurrentToken();
+
+                // If a new token was received, ensure the old token is invalidated,
+                // if any, and notify listeners of the new token
+                if (data.authToken !== currentToken) {
+
+                    // If an old token existed, request that the token be revoked
+                    if (currentToken) {
+                        service.revokeToken(currentToken).catch(angular.noop);
+                    }
+
+                    // Notify of login and new token
+                    setAuthenticationResult(new AuthenticationResult(data));
+                    $rootScope.$broadcast('guacLogin', data.authToken);
+
                 }
 
-                // Notify of login and new token
-                setAuthenticationResult(new AuthenticationResult(data));
-                $rootScope.$broadcast('guacLogin', data.authToken);
+                // Update cached authentication result, even if the token remains
+                // the same
+                else
+                    setAuthenticationResult(new AuthenticationResult(data));
 
-            }
+                // Authentication was successful
+                return data;
 
-            // Update cached authentication result, even if the token remains
-            // the same
-            else
-                setAuthenticationResult(new AuthenticationResult(data));
-
-            // Authentication was successful
-            return data;
+            });
 
         })
 
-        // If authentication fails, propogate failure to returned promise
+        // ... if authentication fails, propogate failure to returned promise
         ['catch'](requestService.createErrorCallback(function authenticationFailed(error) {
 
+            // Notify of generic login failure, for any event consumers that
+            // wish to handle all types of failures at once
+            $rootScope.$broadcast('guacLoginFailed', parameters, error);
+
             // Request credentials if provided credentials were invalid
-            if (error.type === Error.Type.INVALID_CREDENTIALS)
+            if (error.type === Error.Type.INVALID_CREDENTIALS) {
                 $rootScope.$broadcast('guacInvalidCredentials', parameters, error);
+                clearAuthenticationResult();
+            }
 
             // Request more credentials if provided credentials were not enough 
-            else if (error.type === Error.Type.INSUFFICIENT_CREDENTIALS)
+            else if (error.type === Error.Type.INSUFFICIENT_CREDENTIALS) {
                 $rootScope.$broadcast('guacInsufficientCredentials', parameters, error);
+                clearAuthenticationResult();
+            }
 
             // Abort rendering of page if an internal error occurs
             else if (error.type === Error.Type.INTERNAL_ERROR)
@@ -253,6 +311,43 @@
     };
 
     /**
+     * Determines whether the session associated with a particular token is
+     * still valid, without performing an operation that would result in that
+     * session being marked as active. If no token is provided, the session of
+     * the current user is checked.
+     *
+     * @param {string} [token]
+     *     The authentication token to pass with the "Guacamole-Token" header.
+     *     If omitted, and the user is logged in, the user's current
+     *     authentication token will be used.
+     *
+     * @returns {Promise.<!boolean>}
+     *     A promise that resolves with the boolean value "true" if the session
+     *     is valid, and resolves with the boolean value "false" otherwise,
+     *     including if an error prevents session validity from being
+     *     determined. The promise is never rejected.
+     */
+    service.getValidity = function getValidity(token) {
+
+        // NOTE: Because this is a HEAD request, we will not receive a JSON
+        // response body. We will only have a simple yes/no regarding whether
+        // the auth token can be expected to be usable.
+        return service.request({
+            method: 'HEAD',
+            url: 'api/session'
+        }, token)
+
+        .then(function sessionIsValid() {
+            return true;
+        })
+
+        ['catch'](function sessionIsNotValid() {
+            return false;
+        });
+
+    };
+
+    /**
      * Makes a request to revoke an authentication token using the token REST
      * API endpoint, returning a promise that succeeds only if the token was
      * successfully revoked.
@@ -275,7 +370,8 @@
      * with a username and password, ignoring any currently-stored token, 
      * returning a promise that succeeds only if the login operation was
      * successful. The resulting authentication data can be retrieved later
-     * via getCurrentToken() or getCurrentUsername().
+     * via getCurrentToken() or getCurrentUsername(). Invoking this function
+     * will affect the UI, including the login screen if visible.
      * 
      * @param {String} username
      *     The username to log in with.
@@ -296,7 +392,9 @@
     /**
      * Makes a request to logout a user using the token REST API endpoint,
      * returning a promise that succeeds only if the logout operation was
-     * successful.
+     * successful. Invoking this function will affect the UI, causing the
+     * visible components of the application to be replaced with a status
+     * message noting that the user has been logged out.
      * 
      * @returns {Promise}
      *     A promise which succeeds only if the logout operation was
@@ -359,13 +457,13 @@
      */
     service.getCurrentToken = function getCurrentToken() {
 
-        // Return auth token, if available
+        // Return cached auth token, if available
         var authData = getAuthenticationResult();
         if (authData)
             return authData.authToken;
 
-        // No auth data present
-        return null;
+        // Fall back to the value from local storage if not found in cache
+        return localStorageService.getItem(AUTH_TOKEN_STORAGE_KEY);
 
     };
 
diff --git a/guacamole/src/main/frontend/src/app/client/types/ManagedClient.js b/guacamole/src/main/frontend/src/app/client/types/ManagedClient.js
index 6a32235..5dc8bce 100644
--- a/guacamole/src/main/frontend/src/app/client/types/ManagedClient.js
+++ b/guacamole/src/main/frontend/src/app/client/types/ManagedClient.js
@@ -448,25 +448,25 @@
                 switch (clientState) {
 
                     // Idle
-                    case 0:
+                    case Guacamole.Client.State.IDLE:
                         ManagedClientState.setConnectionState(managedClient.clientState,
                             ManagedClientState.ConnectionState.IDLE);
                         break;
 
                     // Connecting
-                    case 1:
+                    case Guacamole.Client.State.CONNECTING:
                         ManagedClientState.setConnectionState(managedClient.clientState,
                             ManagedClientState.ConnectionState.CONNECTING);
                         break;
 
                     // Connected + waiting
-                    case 2:
+                    case Guacamole.Client.State.WAITING:
                         ManagedClientState.setConnectionState(managedClient.clientState,
                             ManagedClientState.ConnectionState.WAITING);
                         break;
 
                     // Connected
-                    case 3:
+                    case Guacamole.Client.State.CONNECTED:
                         ManagedClientState.setConnectionState(managedClient.clientState,
                             ManagedClientState.ConnectionState.CONNECTED);
 
@@ -482,9 +482,9 @@
                         ManagedClient.updateThumbnail(managedClient);
                         break;
 
-                    // Update history when disconnecting
-                    case 4: // Disconnecting
-                    case 5: // Disconnected
+                    // Update history during disconnect phases
+                    case Guacamole.Client.State.DISCONNECTING:
+                    case Guacamole.Client.State.DISCONNECTED:
                         ManagedClient.updateThumbnail(managedClient);
                         break;
 
diff --git a/guacamole/src/main/frontend/src/app/client/types/ManagedFileUpload.js b/guacamole/src/main/frontend/src/app/client/types/ManagedFileUpload.js
index 56587fc..95eef0c 100644
--- a/guacamole/src/main/frontend/src/app/client/types/ManagedFileUpload.js
+++ b/guacamole/src/main/frontend/src/app/client/types/ManagedFileUpload.js
@@ -163,17 +163,19 @@
 
                 // Upload complete
                 managedFileUpload.progress = file.size;
+
+                // Close the stream
+                stream.sendEnd();
                 ManagedFileTransferState.setStreamState(managedFileUpload.transferState,
                     ManagedFileTransferState.StreamState.CLOSED);
 
                 // Notify of upload completion
                 $rootScope.$broadcast('guacUploadComplete', file.name);
-
             },
 
             // Notify if upload fails
             requestService.createErrorCallback(function uploadFailed(error) {
-
+                
                 // Use provide status code if the error is coming from the stream
                 if (error.type === Error.Type.STREAM_ERROR)
                     ManagedFileTransferState.setStreamState(managedFileUpload.transferState,
@@ -185,11 +187,15 @@
                     ManagedFileTransferState.setStreamState(managedFileUpload.transferState,
                         ManagedFileTransferState.StreamState.ERROR,
                         Guacamole.Status.Code.INTERNAL_ERROR);
+                
+                // Close the stream
+                stream.sendEnd();
 
             }));
 
             // Ignore all further acks
             stream.onack = null;
+    
 
         };
 
diff --git a/guacamole/src/main/frontend/src/app/clipboard/directives/guacClipboard.js b/guacamole/src/main/frontend/src/app/clipboard/directives/guacClipboard.js
index f89494c..789756b 100644
--- a/guacamole/src/main/frontend/src/app/clipboard/directives/guacClipboard.js
+++ b/guacamole/src/main/frontend/src/app/clipboard/directives/guacClipboard.js
@@ -49,11 +49,29 @@
 
         /**
          * The DOM element which will contain the clipboard contents within the
-         * user interface provided by this directive.
+         * user interface provided by this directive. We populate the clipboard
+         * editor via this DOM element rather than updating a model so that we
+         * are prepared for future support of rich text contents.
          *
          * @type Element
          */
-        var element = $element[0];
+        var element = $element[0].querySelectorAll('.clipboard.active')[0];
+
+        /**
+         * When isActive is set to true then the Clipboard data will be
+         * displayed in the Clipboard Editor. When false, the Clipboard Editor
+         * will not be displayed with Clipboard data.
+         *
+         * @type Boolean
+         */
+        $scope.isActive = false;
+
+        /**
+         * Updates clipboard editor to be active.
+         */
+        $scope.setActive = function setActive() {
+            $scope.isActive = true;
+        };
 
         /**
          * Rereads the contents of the clipboard field, updating the
diff --git a/guacamole/src/main/frontend/src/app/clipboard/styles/clipboard.css b/guacamole/src/main/frontend/src/app/clipboard/styles/clipboard.css
index 91fb328..3bb602c 100644
--- a/guacamole/src/main/frontend/src/app/clipboard/styles/clipboard.css
+++ b/guacamole/src/main/frontend/src/app/clipboard/styles/clipboard.css
@@ -31,8 +31,6 @@
     width: 100%;
     height: 2in;
     white-space: pre;
-    font-size: 1em;
-    overflow: auto;
     padding: 0.25em;
 }
 
@@ -59,3 +57,14 @@
     white-space: pre;
     overflow: hidden;
 }
+
+#clipboard-settings .clipboard.active {
+    overflow: auto;
+    font-size: 1em;
+}
+
+#clipboard-settings .clipboard.inactive {
+    overflow: hidden;
+    font-size: 0.9em;
+    opacity: 0.5;
+}
diff --git a/guacamole/src/main/frontend/src/app/clipboard/templates/guacClipboard.html b/guacamole/src/main/frontend/src/app/clipboard/templates/guacClipboard.html
index b860f03..4e1ebda 100644
--- a/guacamole/src/main/frontend/src/app/clipboard/templates/guacClipboard.html
+++ b/guacamole/src/main/frontend/src/app/clipboard/templates/guacClipboard.html
@@ -1 +1,4 @@
-<textarea class="clipboard"></textarea>
+<div>
+    <textarea ng-show="isActive" class="clipboard active"></textarea>
+    <textarea ng-show="!isActive" class="clipboard inactive" ng-focus="setActive()">{{'CLIENT.TEXT_CLIPBOARD_AWAITING_FOCUS' | translate}}</textarea>
+</div>
diff --git a/guacamole/src/main/frontend/src/app/element/directives/guacDrop.js b/guacamole/src/main/frontend/src/app/element/directives/guacDrop.js
new file mode 100644
index 0000000..3f70706
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/element/directives/guacDrop.js
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * A directive which allows multiple files to be uploaded. Dragging files onto
+ * the associated element will call the provided callback function with any
+ * dragged files.
+ */
+angular.module('element').directive('guacDrop', ['$injector', function guacDrop($injector) {
+
+    // Required services
+    const guacNotification = $injector.get('guacNotification');
+
+    return {
+        restrict: 'A',
+
+        link: function linkGuacDrop($scope, $element, $attrs) {
+
+            /**
+             * The function to call whenever files are dragged. The callback is
+             * provided a single parameter: the FileList containing all dragged
+             * files.
+             *
+             * @type Function
+             */
+            const guacDrop = $scope.$eval($attrs.guacDrop);
+
+            /**
+             * Any number of space-seperated classes to be applied to the
+             * element a drop is pending: when the user has dragged something
+             * over the element, but not yet dropped. These classes will be 
+             * removed when a drop is not pending.
+             *
+             * @type String
+             */
+            const guacDraggedClass = $scope.$eval($attrs.guacDraggedClass);
+
+            /**
+             * Whether upload of multiple files should be allowed. If false, an
+             * error will be displayed explaining the restriction, otherwise
+             * any number of files may be dragged. Defaults to true if not set.
+             *
+             * @type Boolean
+             */
+            const guacMultiple = 'guacMultiple' in $attrs
+                ? $scope.$eval($attrs.guacMultiple) : true;
+
+            /**
+             * The element which will register drag event.
+             *
+             * @type Element
+             */
+            const element = $element[0];
+
+            /**
+             * Applies any classes provided in the guacDraggedClass attribute.
+             * Further propagation and default behavior of the given event is
+             * automatically prevented.
+             *
+             * @param {Event} e
+             *     The event related to the in-progress drag/drop operation.
+             */
+            const notifyDragStart = function notifyDragStart(e) {
+
+                e.preventDefault();
+                e.stopPropagation();
+
+                // Skip further processing if no classes were provided
+                if (!guacDraggedClass)
+                    return;
+
+                // Add each provided class
+                guacDraggedClass.split(' ').forEach(classToApply =>
+                    element.classList.add(classToApply));
+
+            };
+
+            /**
+             * Removes any classes provided in the guacDraggedClass attribute.
+             * Further propagation and default behavior of the given event is
+             * automatically prevented.
+             *
+             * @param {Event} e
+             *     The event related to the end of the drag/drop operation.
+             */
+            const notifyDragEnd = function notifyDragEnd(e) {
+
+                e.preventDefault();
+                e.stopPropagation();
+
+                // Skip further processing if no classes were provided
+                if (!guacDraggedClass)
+                    return;
+
+                // Remove each provided class
+                guacDraggedClass.split(' ').forEach(classToRemove =>
+                    element.classList.remove(classToRemove));
+
+            };
+
+            // Add listeners to the drop target to ensure that the visual state
+            // stays up to date
+            element.addEventListener('dragenter', notifyDragStart);
+            element.addEventListener('dragover',  notifyDragStart);
+            element.addEventListener('dragleave', notifyDragEnd);
+
+            /**
+             * Event listener that will be invoked if the user drops anything
+             * onto the event. If a valid file is provided, the onFile callback
+             * provided to this directive will be called; otherwise an error
+             * will be displayed, if appropriate.
+             *
+             * @param {Event} e
+             *     The drop event that triggered this handler.
+             */
+            element.addEventListener('drop', e => {
+
+                notifyDragEnd(e);
+
+                const files = e.dataTransfer.files;
+
+                // Ignore any non-files that are dragged into the drop area
+                if (files.length < 1)
+                    return;
+
+                // If multi-file upload is disabled, If more than one file was
+                // provided, print an error explaining the problem
+                if (!guacMultiple && files.length >= 2) {
+
+                    guacNotification.showStatus({
+                        className   : 'error',
+                        title       : 'APP.DIALOG_HEADER_ERROR',
+                        text: { key : 'APP.ERROR_SINGLE_FILE_ONLY'},
+
+                        // Add a button to hide the error
+                        actions    : [{
+                            name      : 'APP.ACTION_ACKNOWLEDGE',
+                            callback  : () => guacNotification.showStatus(false)
+                        }]
+                    });
+                    return;
+
+                }
+
+                // Invoke the callback with the files. Note that if guacMultiple
+                // is set to false, this will always be a single file.
+                guacDrop(files);
+
+            });
+
+        } // end guacDrop link function
+
+    };
+
+}]);
diff --git a/guacamole/src/main/frontend/src/app/element/directives/guacUpload.js b/guacamole/src/main/frontend/src/app/element/directives/guacUpload.js
index b7d75e9..d1c10a9 100644
--- a/guacamole/src/main/frontend/src/app/element/directives/guacUpload.js
+++ b/guacamole/src/main/frontend/src/app/element/directives/guacUpload.js
@@ -18,9 +18,9 @@
  */
 
 /**
- * A directive which allows multiple files to be uploaded. Clicking on the
- * associated element will result in a file selector dialog, which then calls
- * the provided callback function with any chosen files.
+ * A directive which allows files to be uploaded. Clicking on the associated
+ * element will result in a file selector dialog, which then calls the provided
+ * callback function with any chosen files.
  */
 angular.module('element').directive('guacUpload', ['$document', function guacUpload($document) {
 
@@ -36,32 +36,43 @@
              *
              * @type Function 
              */
-            var guacUpload = $scope.$eval($attrs.guacUpload);
+            const guacUpload = $scope.$eval($attrs.guacUpload);
 
             /**
-             * The element which will register the drag gesture.
+             * Whether upload of multiple files should be allowed. If false, the
+             * file dialog will only allow a single file to be chosen at once,
+             * otherwise any number of files may be chosen. Defaults to true if
+             * not set.
+             *
+             * @type Boolean
+             */
+            const guacMultiple = 'guacMultiple' in $attrs
+                ? $scope.$eval($attrs.guacMultiple) : true;
+
+            /**
+             * The element which will register the click.
              *
              * @type Element
              */
-            var element = $element[0];
+            const element = $element[0];
 
             /**
              * Internal form, containing a single file input element.
              *
              * @type HTMLFormElement
              */
-            var form = $document[0].createElement('form');
+            const form = $document[0].createElement('form');
 
             /**
              * Internal file input element.
              *
              * @type HTMLInputElement
              */
-            var input = $document[0].createElement('input');
+            const input = $document[0].createElement('input');
 
             // Init input element
             input.type = 'file';
-            input.multiple = true;
+            input.multiple = guacMultiple;
 
             // Add input element to internal form
             form.appendChild(input);
diff --git a/guacamole/src/main/frontend/src/app/home/templates/home.html b/guacamole/src/main/frontend/src/app/home/templates/home.html
index b2b3db5..06c0f22 100644
--- a/guacamole/src/main/frontend/src/app/home/templates/home.html
+++ b/guacamole/src/main/frontend/src/app/home/templates/home.html
@@ -4,14 +4,14 @@
 
         <!-- The recent connections for this user -->
         <div class="header">
-            <h2>{{'HOME.SECTION_HEADER_RECENT_CONNECTIONS' | translate}}</h2>
+            <h2 id="section-header-recent-connections">{{'HOME.SECTION_HEADER_RECENT_CONNECTIONS' | translate}}</h2>
             <guac-user-menu></guac-user-menu>
         </div>
         <guac-recent-connections root-groups="rootConnectionGroups"></guac-recent-connections>
 
         <!-- All connections for this user -->
         <div class="header">
-            <h2>{{'HOME.SECTION_HEADER_ALL_CONNECTIONS' | translate}}</h2>
+            <h2 id="section-header-all-connections">{{'HOME.SECTION_HEADER_ALL_CONNECTIONS' | translate}}</h2>
             <guac-group-list-filter connection-groups="rootConnectionGroups"
                 filtered-connection-groups="filteredRootConnectionGroups"
                 placeholder="'HOME.FIELD_PLACEHOLDER_FILTER' | translate"
diff --git a/guacamole/src/main/frontend/src/app/import/controllers/importConnectionsController.js b/guacamole/src/main/frontend/src/app/import/controllers/importConnectionsController.js
new file mode 100644
index 0000000..fad7af8
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/import/controllers/importConnectionsController.js
@@ -0,0 +1,699 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/* global _ */
+
+/**
+ * The allowed MIME type for CSV files.
+ * 
+ * @type String
+ */
+const CSV_MIME_TYPE = 'text/csv';
+
+/**
+ * A fallback regular expression for CSV filenames, if no MIME type is provided
+ * by the browser. Any file that matches this regex will be considered to be a
+ * CSV file.
+ *
+ * @type RegExp
+ */
+const CSV_FILENAME_REGEX = /\.csv$/i;
+
+/**
+ * The allowed MIME type for JSON files.
+ *
+ * @type String
+ */
+const JSON_MIME_TYPE = 'application/json';
+
+/**
+ * A fallback regular expression for JSON filenames, if no MIME type is provided
+ * by the browser. Any file that matches this regex will be considered to be a
+ * JSON file.
+ *
+ * @type RegExp
+ */
+const JSON_FILENAME_REGEX = /\.json$/i;
+
+/**
+ * The allowed MIME types for YAML files.
+ * NOTE: There is no registered MIME type for YAML files. This may result in a
+ * wide variety of possible browser-supplied MIME types.
+ *
+ * @type String[]
+ */
+const YAML_MIME_TYPES = [
+    'text/x-yaml',
+    'text/yaml',
+    'text/yml',
+    'application/x-yaml',
+    'application/x-yml',
+    'application/yaml',
+    'application/yml'
+];
+
+/**
+ * A fallback regular expression for YAML filenames, if no MIME type is provided
+ * by the browser. Any file that matches this regex will be considered to be a
+ * YAML file.
+ *
+ * @type RegExp
+ */
+const YAML_FILENAME_REGEX = /\.ya?ml$/i;
+
+/**
+ * Possible signatures for zip files (which include most modern Microsoft office
+ * documents - most notable excel). If any file, regardless of extension, has
+ * these starting bytes, it's invalid and must be rejected.
+ * For more, see https://en.wikipedia.org/wiki/List_of_file_signatures and
+ * https://en.wikipedia.org/wiki/Magic_number_(programming)#Magic_numbers_in_files.
+ *
+ * @type String[]
+ */
+const ZIP_SIGNATURES = [
+    'PK\u0003\u0004',
+    'PK\u0005\u0006',
+    'PK\u0007\u0008'
+];
+
+/*
+ * All file types supported for connection import.
+ * 
+ * @type {String[]}
+ */
+const LEGAL_MIME_TYPES = [CSV_MIME_TYPE, JSON_MIME_TYPE, ...YAML_MIME_TYPES];
+
+/**
+ * The controller for the connection import page.
+ */
+angular.module('import').controller('importConnectionsController', ['$scope', '$injector',
+        function importConnectionsController($scope, $injector) {
+            
+    // Required services
+    const $location              = $injector.get('$location');
+    const $q                     = $injector.get('$q');
+    const $routeParams           = $injector.get('$routeParams');
+    const connectionParseService = $injector.get('connectionParseService');
+    const connectionService      = $injector.get('connectionService');
+    const guacNotification       = $injector.get('guacNotification');
+    const permissionService      = $injector.get('permissionService');
+    const userService            = $injector.get('userService');
+    const userGroupService       = $injector.get('userGroupService');
+
+    // Required types
+    const ConnectionImportConfig = $injector.get('ConnectionImportConfig');
+    const DirectoryPatch         = $injector.get('DirectoryPatch');
+    const Error                  = $injector.get('Error');
+    const ParseError             = $injector.get('ParseError');
+    const PermissionSet          = $injector.get('PermissionSet');
+    const User                   = $injector.get('User');
+    const UserGroup              = $injector.get('UserGroup');
+
+    /**
+     * The result of parsing the current upload, if successful.
+     *
+     * @type {ParseResult}
+     */
+    $scope.parseResult = null;
+
+    /**
+     * The failure associated with the current attempt to create connections
+     * through the API, if any.
+     *
+     * @type {Error}
+     */
+    $scope.patchFailure = null;;
+
+    /**
+     * True if the file is fully uploaded and ready to be processed, or false
+     * otherwise.
+     *
+     * @type {Boolean}
+     */
+    $scope.dataReady = false;
+
+    /**
+     * True if the file upload has been aborted mid-upload, or false otherwise.
+     */
+    $scope.aborted = false;
+
+    /**
+     * True if fully-uploaded data is being processed, or false otherwise.
+     */
+    $scope.processing = false;
+
+    /**
+     * The MIME type of the uploaded file, if any.
+     *
+     * @type {String}
+     */
+    $scope.mimeType = null;
+
+    /**
+     * The name of the file that's currently being uploaded, or has yet to
+     * be imported, if any.
+     *
+     * @type {String}
+     */
+    $scope.fileName = null;
+
+    /**
+     * The raw string contents of the uploaded file, if any.
+     *
+     * @type {String}
+     */
+    $scope.fileData = null;
+
+    /**
+     * The file reader currently being used to upload the file, if any. If
+     * null, no file upload is currently in progress.
+     *
+     * @type {FileReader}
+     */
+    $scope.fileReader = null;
+
+    /**
+     * The configuration options for this import, to be chosen by the user.
+     *
+     * @type {ConnectionImportConfig}
+     */
+    $scope.importConfig = new ConnectionImportConfig();
+
+    /**
+     * Clear all file upload state.
+     */
+    function resetUploadState() {
+
+        $scope.aborted = false;
+        $scope.dataReady = false;
+        $scope.processing = false;
+        $scope.fileData = null;
+        $scope.mimeType = null;
+        $scope.fileReader = null;
+        $scope.parseResult = null;
+        $scope.patchFailure = null;
+        $scope.fileName = null;
+
+    }
+
+    // Indicate that data is currently being loaded / processed if the the file
+    // has been provided but not yet fully uploaded, or if the the file is
+    // fully loaded and is currently being processed.
+    $scope.isLoading = () => (
+            ($scope.fileName && !$scope.dataReady && !$scope.patchFailure)
+            || $scope.processing);
+
+    /**
+     * Create all users and user groups mentioned in the import file that don't
+     * already exist in the current data source. Return an object describing the
+     * result of the creation requests.
+     * 
+     * @param {ParseResult} parseResult
+     *     The result of parsing the user-supplied import file.
+     *
+     * @return {Promise.<Object>}
+     *     A promise resolving to an object containing the results of the calls
+     *     to create the users and groups.
+     */
+    function createUsersAndGroups(parseResult) {
+
+        const dataSource = $routeParams.dataSource;
+
+        return $q.all({
+            existingUsers : userService.getUsers(dataSource),
+            existingGroups : userGroupService.getUserGroups(dataSource)
+        }).then(({existingUsers, existingGroups}) => {
+
+            const userPatches = Object.keys(parseResult.users)
+
+                // Filter out any existing users
+                .filter(identifier => !existingUsers[identifier])
+
+                // A patch to create each new user
+                .map(username => new DirectoryPatch({
+                    op: 'add',
+                    path: '/',
+                    value: new User({ username })
+                }));
+
+            const groupPatches = Object.keys(parseResult.groups)
+
+                // Filter out any existing groups
+                .filter(identifier => !existingGroups[identifier])
+
+                // A patch to create each new user group
+                .map(identifier => new DirectoryPatch({
+                    op: 'add',
+                    path: '/',
+                    value: new UserGroup({ identifier })
+                }));
+
+            // Create all the users and groups
+            return $q.all({
+                userResponse: userService.patchUsers(dataSource, userPatches),
+                userGroupResponse: userGroupService.patchUserGroups(
+                        dataSource, groupPatches)
+            });
+
+        });
+
+    }
+
+    /**
+     * Grant read permissions for each user and group in the supplied parse
+     * result to each connection in their connection list. Note that there will
+     * be a seperate request for each user and group.
+     *
+     * @param {ParseResult} parseResult
+     *     The result of successfully parsing a user-supplied import file.
+     *
+     * @param {Object} response
+     *     The response from the PATCH API request.
+     *
+     * @returns {Promise.<Object>}
+     *     A promise that will resolve with the result of every permission
+     *     granting request.
+     */
+    function grantConnectionPermissions(parseResult, response) {
+
+        const dataSource = $routeParams.dataSource;
+
+        // All connection grant requests, one per user/group
+        const userRequests = {};
+        const groupRequests = {};
+
+        // Create a PermissionSet granting access to all connections at
+        // the provided indices within the provided parse result
+        const createPermissionSet = indices =>
+            new PermissionSet({ connectionPermissions: indices.reduce(
+                    (permissions, index) => {
+                const connectionId = response.patches[index].identifier;
+                permissions[connectionId] = [
+                        PermissionSet.ObjectPermissionType.READ];
+                return permissions;
+            }, {}) });
+
+        // Now that we've created all the users, grant access to each
+        _.forEach(parseResult.users, (connectionIndices, identifier) =>
+
+            // Grant the permissions - note the group flag is `false`
+            userRequests[identifier] = permissionService.patchPermissions(
+                dataSource, identifier,
+
+                // Create the permissions to these connections for this user
+                createPermissionSet(connectionIndices),
+
+                // Do not remove any permissions
+                new PermissionSet(),
+
+                // This call is not for a group
+                false));
+
+        // Now that we've created all the groups, grant access to each
+        _.forEach(parseResult.groups, (connectionIndices, identifier) =>
+
+            // Grant the permissions - note the group flag is `true`
+            groupRequests[identifier] = permissionService.patchPermissions(
+                dataSource, identifier,
+
+                // Create the permissions to these connections for this user
+                createPermissionSet(connectionIndices),
+
+                // Do not remove any permissions
+                new PermissionSet(),
+
+                // This call is for a group
+                true));
+
+        // Return the result from all the permission granting calls
+        return $q.all({ ...userRequests, ...groupRequests });
+    }
+
+    /**
+     * Process a successfully parsed import file, creating any specified
+     * connections, creating and granting permissions to any specified users
+     * and user groups. If successful, the user will be shown a success message.
+     * If not, any errors will be displayed and any already-created entities
+     * will be rolled back.
+     *
+     * @param {ParseResult} parseResult
+     *     The result of parsing the user-supplied import file.
+     */
+    function handleParseSuccess(parseResult) {
+
+        $scope.processing = false;
+        $scope.parseResult = parseResult;
+
+        // If errors were encounted during file parsing, abort further
+        // processing - the user will have a chance to fix the errors and try
+        // again
+        if (parseResult.hasErrors)
+            return;
+
+        const dataSource = $routeParams.dataSource;
+
+        // First, attempt to create the connections
+        connectionService.patchConnections(dataSource, parseResult.patches)
+                .then(connectionResponse =>
+
+            // If connection creation is successful, create users and groups
+            createUsersAndGroups(parseResult).then(() =>
+
+                // Grant any new permissions to users and groups. NOTE: Any
+                // existing permissions for updated connections will NOT be
+                // removed - only new permissions will be added.
+                grantConnectionPermissions(parseResult, connectionResponse)
+                        .then(() => {
+
+                    $scope.processing = false;
+
+                    // Display a success message if everything worked
+                    guacNotification.showStatus({
+                        className  : 'success',
+                        title      : 'IMPORT.DIALOG_HEADER_SUCCESS',
+                        text       : {
+                            key: 'IMPORT.INFO_CONNECTIONS_IMPORTED_SUCCESS',
+                            variables: { NUMBER: parseResult.connectionCount }
+                        },
+
+                        // Add a button to acknowledge and redirect to
+                        // the connection listing page
+                        actions    : [{
+                            name      : 'IMPORT.ACTION_ACKNOWLEDGE',
+                            callback  : () => {
+
+                                // Close the notification
+                                guacNotification.showStatus(false);
+
+                                // Redirect to connection list page
+                                $location.url('/settings/' + dataSource + '/connections');
+                            }
+                        }]
+                    });
+                }))
+
+            // If an error occurs while trying to create users or groups, 
+            // display the error to the user.
+            .catch(handleError)
+        )
+
+        // If an error occurred when the call to create the connections was made,
+        // skip any further processing - the user will have a chance to fix the
+        // problems and try again
+        .catch(patchFailure => {
+            $scope.processing = false;
+            $scope.patchFailure = patchFailure;
+        });
+    }
+
+    /**
+     * Display the provided error to the user in a dismissable dialog.
+     *
+     * @argument {ParseError|Error} error
+     *     The error to display.
+     */
+    const handleError = error => {
+
+        // Any error indicates that processing of the file has failed, so clear
+        // all upload state to allow for a fresh retry
+        resetUploadState();
+
+        let text;
+
+        // If it's a import file parsing error
+        if (error instanceof ParseError)
+            text = {
+
+                // Use the translation key if available
+                key: error.key || error.message,
+                variables: error.variables
+            };
+
+        // If it's a generic REST error
+        else if (error instanceof Error)
+            text = error.translatableMessage;
+
+        // If it's an unknown type, just use the message directly
+        else
+            text = { key: error };
+
+        guacNotification.showStatus({
+            className  : 'error',
+            title      : 'IMPORT.DIALOG_HEADER_ERROR',
+            text,
+
+            // Add a button to hide the error
+            actions    : [{
+                name      : 'IMPORT.ACTION_ACKNOWLEDGE',
+                callback  : () => guacNotification.showStatus(false)
+            }]
+        });
+
+    };
+
+    /**
+     * Process the uploaded import file, importing the connections, granting
+     * connection permissions, or displaying errors to the user if there are
+     * problems with the provided file.
+     *
+     * @param {String} mimeType
+     *     The MIME type of the uploaded data file.
+     *
+     * @param {String} data
+     *     The raw string contents of the import file.
+     */
+    function processData(mimeType, data) {
+
+        // Data processing has begun
+        $scope.processing = true;
+
+        // The function that will process all the raw data and return a list of
+        // patches to be submitted to the API
+        let processDataCallback;
+
+        // Choose the appropriate parse function based on the mimetype
+        if (mimeType === JSON_MIME_TYPE)
+            processDataCallback = connectionParseService.parseJSON;
+
+        else if (mimeType === CSV_MIME_TYPE)
+            processDataCallback = connectionParseService.parseCSV;
+
+        else if (YAML_MIME_TYPES.indexOf(mimeType) >= 0)
+            processDataCallback = connectionParseService.parseYAML;
+
+        // The file type was validated before being uploaded - this should
+        // never happen
+        else
+            processDataCallback = () => {
+                throw new ParseError({
+                    message: "Unexpected invalid file type: " + mimeType
+                });
+            };
+
+        // Make the call to process the data into a series of patches
+        processDataCallback($scope.importConfig, data)
+
+            // Send the data off to be imported if parsing is successful
+            .then(handleParseSuccess)
+
+            // Display any error found while parsing the file
+            .catch(handleError);
+    }
+
+    /**
+     * Process the uploaded import data. Only usuable if the upload is fully
+     * complete.
+     */
+    $scope.import = () => processData($scope.mimeType, $scope.fileData);
+
+    /**
+     * Returns true if import should be disabled, or false if import should be
+     * allowed.
+     *
+     * @return {Boolean}
+     *     True if import should be disabled, otherwise false.
+     */
+    $scope.importDisabled = () =>
+
+        // Disable import if no data is ready
+        !$scope.dataReady ||
+
+        // Disable import if the file is currently being processed
+        $scope.processing;
+
+    /**
+     * Cancel any in-progress upload, or clear any uploaded-but-errored-out
+     * batch.
+     */
+    $scope.cancel = function() {
+
+        // If the upload is in progress, stop it now; the FileReader will
+        // reset the upload state when it stops
+        if ($scope.fileReader) {
+            $scope.aborted = true;
+            $scope.fileReader.abort();
+        }
+
+        // Clear any upload state - there's no FileReader handler to do it
+        else
+            resetUploadState();
+
+    };
+
+    /**
+     * Returns true if cancellation should be disabled, or false if
+     * cancellation should be allowed.
+     *
+     * @return {Boolean}
+     *     True if cancellation should be disabled, or false if cancellation
+     *     should be allowed.
+     */
+    $scope.cancelDisabled = () =>
+
+        // Disable cancellation if the import has already been cancelled
+        $scope.aborted ||
+
+        // Disable cancellation if the file is currently being processed
+        $scope.processing ||
+
+        // Disable cancellation if no data is ready or being uploaded
+        !($scope.fileReader || $scope.dataReady);
+
+    /**
+     * Handle a provided File upload, reading all data onto the scope for
+     * import processing, should the user request an import. Note that this
+     * function is used as a callback for directives that invoke it with a file
+     * list, but directive-level checking should ensure that there is only ever
+     * one file provided at a time.
+     *
+     * @argument {File[]} files
+     *     The files to upload onto the scope for further processing. There
+     *     should only ever be a single file in the array.
+     */
+    $scope.handleFiles = files => {
+
+        // There should only ever be a single file in the array
+        const file = files[0];
+
+        // The name and MIME type of the file as provided by the browser
+        let fileName = file.name;
+        let mimeType = file.type;
+
+        // If no MIME type was provided by the browser at all, use REGEXes as a
+        // fallback to try to determine the file type. NOTE: Windows 10/11 are
+        // known to do this with YAML files.
+        if (!_.trim(mimeType).length) {
+
+            // If the file name matches what we'd expect for a CSV file, set the
+            // CSV MIME type and move on
+            if (CSV_FILENAME_REGEX.test(fileName))
+                mimeType = CSV_MIME_TYPE;
+
+            // If the file name matches what we'd expect for a JSON file, set
+            // the JSON MIME type and move on
+            else if (JSON_FILENAME_REGEX.test(fileName))
+                mimeType = JSON_MIME_TYPE;
+
+            // If the file name matches what we'd expect for a JSON file, set
+            // one of the allowed YAML MIME types and move on
+            else if (YAML_FILENAME_REGEX.test(fileName))
+                mimeType = YAML_MIME_TYPES[0];
+
+            else {
+
+                // If none of the REGEXes pass, there's nothing more to be tried
+                handleError(new ParseError({
+                    message: "Unknown type for file: " + fileName,
+                    key: 'IMPORT.ERROR_DETECTED_INVALID_TYPE'
+                }));
+                return;
+                
+            }
+
+        }
+
+        // Check if the mimetype is one of the supported types,
+        // e.g. "application/json" or "text/csv"
+        else if (LEGAL_MIME_TYPES.indexOf(mimeType) < 0) {
+
+            // If the provided file is not one of the supported types,
+            // display an error and abort processing
+            handleError(new ParseError({
+                message: "Invalid file type: " + mimeType,
+                key: 'IMPORT.ERROR_INVALID_MIME_TYPE',
+                variables: { TYPE: mimeType }
+            }));
+            return;
+            
+        }
+
+        // Save the name and type to the scope
+        $scope.fileName = fileName;
+        $scope.mimeType = mimeType;
+
+        // Initialize upload state
+        $scope.aborted = false;
+        $scope.dataReady = false;
+        $scope.processing = false;
+        $scope.uploadStarted = true;
+
+        // Save the file to the scope when ready
+        $scope.fileReader = new FileReader();
+        $scope.fileReader.onloadend = (e => {
+
+            // If the upload was explicitly aborted, clear any upload state and
+            // do not process the data
+            if ($scope.aborted)
+                resetUploadState();
+
+            else {
+
+                const fileData = e.target.result;
+
+                // Check if the file has a header of a known-bad type
+                if (_.some(ZIP_SIGNATURES,
+                        signature => fileData.startsWith(signature))) {
+
+                    // Throw an error and abort processing
+                    handleError(new ParseError({
+                        message: "Invalid file type detected",
+                        key: 'IMPORT.ERROR_DETECTED_INVALID_TYPE'
+                    }));
+                    return;
+
+                }
+
+                // Save the uploaded data
+                $scope.fileData = fileData;
+
+                // Mark the data as ready
+                $scope.dataReady = true;
+
+                // Clear the file reader from the scope now that this file is
+                // fully uploaded
+                $scope.fileReader = null;
+
+            }
+        });
+
+        // Read all the data into memory
+        $scope.fileReader.readAsBinaryString(file);
+    };
+    
+}]);
diff --git a/guacamole/src/main/frontend/src/app/import/directives/connectionImportErrors.js b/guacamole/src/main/frontend/src/app/import/directives/connectionImportErrors.js
new file mode 100644
index 0000000..17d2fef
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/import/directives/connectionImportErrors.js
@@ -0,0 +1,319 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/* global _ */
+
+/**
+ * A directive that displays errors that occurred during parsing of a connection
+ * import file, or errors that were returned from the API during the connection
+ * batch creation attempt.
+ */
+angular.module('import').directive('connectionImportErrors', [
+        function connectionImportErrors() {
+
+    const directive = {
+        restrict: 'E',
+        replace: true,
+        templateUrl: 'app/import/templates/connectionErrors.html',
+        scope: {
+
+            /**
+             * The result of parsing the import file. Any errors in this file
+             * will be displayed to the user.
+             *
+             * @type ParseResult
+             */
+            parseResult : '=',
+
+            /**
+             * The error associated with an attempt to batch create the
+             * connections represented by the ParseResult, if the ParseResult
+             * had no errors. If the provided ParseResult has errors, no request
+             * should have been made, and any provided patch error will be
+             * ignored.
+             *
+             * @type Error
+             */
+            patchFailure : '=',
+
+        }
+    };
+
+    directive.controller = ['$scope', '$injector',
+            function connectionImportErrorsController($scope, $injector) {
+
+        // Required types
+        const DirectoryPatch        = $injector.get('DirectoryPatch');
+        const DisplayErrorList      = $injector.get('DisplayErrorList');
+        const ImportConnectionError = $injector.get('ImportConnectionError');
+        const ParseError            = $injector.get('ParseError');
+        const SortOrder             = $injector.get('SortOrder');
+
+        // Required services
+        const $q                    = $injector.get('$q');
+        const $translate            = $injector.get('$translate');
+
+        // There are errors to display if the parse result generated errors, or
+        // if the patch request failed
+        $scope.hasErrors = () =>
+            !!_.get($scope, 'parseResult.hasErrors') || !!$scope.patchFailure;
+
+        /**
+         * All connections with their associated errors for display. These may
+         * be either parsing failures, or errors returned from the API. Both
+         * error types will be adapted to a common display format, though the
+         * error types will never be mixed, because no REST request should ever
+         * be made if there are client-side parse errors.
+         *
+         * @type {ImportConnectionError[]}
+         */
+        $scope.connectionErrors = [];
+
+        /**
+         * SortOrder instance which maintains the sort order of the visible
+         * connection errors.
+         *
+         * @type SortOrder
+         */
+        $scope.errorOrder = new SortOrder([
+            'rowNumber',
+            'name',
+            'group',
+            'protocol',
+            'errors',
+        ]);
+
+        /**
+         * Array of all connection error properties that are filterable.
+         *
+         * @type String[]
+         */
+        $scope.filteredErrorProperties = [
+            'rowNumber',
+            'name',
+            'group',
+            'protocol',
+            'errors',
+        ];
+
+        /**
+         * Generate a ImportConnectionError representing any errors associated
+         * with the row at the given index within the given parse result.
+         *
+         * @param {ParseResult} parseResult
+         *     The result of parsing the connection import file.
+         *
+         * @param {Integer} index
+         *     The current row within the patches array, 0-indexed.
+         *
+         * @param {Integer} row
+         *     The current row within the original connection, 0-indexed.
+         *     If any REMOVE patches are present, this may be greater than
+         *     the index.
+         *
+         * @returns {ImportConnectionError}
+         *     The connection error object associated with the given row in the
+         *     given parse result.
+         */
+        const generateConnectionError = (parseResult, index, row) => {
+
+            // Get the patch associated with the current row
+            const patch = parseResult.patches[index];
+
+            // The value of a patch is just the Connection object
+            const connection = patch.value;
+
+            return new ImportConnectionError({
+
+                // Add 1 to the provided row to get the position in the file
+                rowNumber: row + 1,
+
+                // Basic connection information - name, group, and protocol.
+                name: connection.name,
+                group: parseResult.groupPaths[index],
+                protocol: connection.protocol,
+
+                // The human-readable error messages
+                errors: new DisplayErrorList(
+                        [ ...(parseResult.errors[index] || []) ])
+            });
+        };
+
+        // If a new connection patch failure is seen, update the display list
+        $scope.$watch('patchFailure', function patchFailureChanged(patchFailure) {
+
+            const { parseResult } = $scope;
+
+            // Do not attempt to process anything before the data has loaded
+            if (!patchFailure || !parseResult)
+                return;
+
+            // All promises from all translation requests. The scope will not be
+            // updated until all translations are ready.
+            const translationPromises = [];
+
+            // Any error returned from the API specifically associated with the
+            // preceding REMOVE patch
+            let removeError = null;
+
+            // Fetch the API error, if any, of the patch at the given index
+            const getAPIError = index =>
+                    _.get(patchFailure, ['patches', index, 'error']);
+
+            // The row number for display. Unlike the index, this number will
+            // skip any REMOVE patches. In other words, this is the index of
+            // connections within the original import file.
+            let row = 0;
+
+            // Set up the list of connection errors based on the existing parse
+            // result, with error messages fetched from the patch failure
+            const connectionErrors = parseResult.patches.reduce(
+                    (errors, patch, index) => {
+
+                // Do not process display REMOVE patches - they are always
+                // followed by ADD patches containing the actual content
+                // (and errors, if any)
+                if (patch.op === DirectoryPatch.Operation.REMOVE) {
+
+                    // Save the API error, if any, so it can be displayed
+                    // alongside the connection information associated with the
+                    // following ADD patch
+                    removeError = getAPIError(index);
+
+                    // Do not add an entry for this remove patch - it should
+                    // always be followed by a corresponding CREATE patch
+                    // containing the relevant connection information
+                    return errors;
+                    
+                }
+
+                // Generate a connection error for display
+                const connectionError = generateConnectionError(
+                        parseResult, index, row++);
+
+                // Add the error associated with the previous REMOVE patch, if
+                // any, to the error associated with the current patch, if any
+                const apiErrors = [ removeError, getAPIError(index) ];
+
+                // Clear the previous REMOVE patch error after consuming it
+                removeError = null;
+
+                // Go through each potential API error
+                apiErrors.forEach(error =>
+
+                    // If the API error exists, fetch the translation and
+                    // update it when it's ready
+                    error && translationPromises.push($translate(
+                        error.key, error.variables)
+                        .then(translatedError =>
+                            connectionError.errors.getArray().push(translatedError)
+                        )));
+
+                errors.push(connectionError);
+                return errors;
+                
+            }, []);
+
+            // Once all the translations have been completed, update the
+            // connectionErrors all in one go, to ensure no excessive reloading
+            $q.all(translationPromises).then(() => {
+                $scope.connectionErrors = connectionErrors;
+            });
+            
+        });
+
+        // If a new parse result with errors is seen, update the display list
+        $scope.$watch('parseResult', function parseResultChanged(parseResult) {
+
+            // Do not process if there are no errors in the provided result
+            if (!parseResult || !parseResult.hasErrors)
+                return;
+
+            // All promises from all translation requests. The scope will not be
+            // updated until all translations are ready.
+            const translationPromises = [];
+
+            // The parse result should only be updated on a fresh file import;
+            // therefore it should be safe to skip checking the patch errors
+            // entirely - if set, they will be from the previous file and no
+            // longer relevant.
+
+            // The row number for display. Unlike the index, this number will
+            // skip any REMOVE patches. In other words, this is the index of
+            // connections within the original import file.
+            let row = 0;
+
+            // Set up the list of connection errors based on the updated parse
+            // result
+            const connectionErrors = parseResult.patches.reduce(
+                    (errors, patch, index) => {
+
+                // Do not process display REMOVE patches - they are always
+                // followed by ADD patches containing the actual content
+                // (and errors, if any)
+                if (patch.op === DirectoryPatch.Operation.REMOVE)
+                    return errors;
+
+                // Generate a connection error for display
+                const connectionError = generateConnectionError(
+                        parseResult, index, row++);
+
+                // Go through the errors and check if any are translateable
+                connectionError.errors.getArray().forEach(
+                        (error, errorIndex) => {
+
+                    // If this error is a ParseError, it can be translated.
+                    // NOTE: Generally one would translate error messages in the
+                    // template, but in this case, the connection errors need to
+                    // be raw strings in order to enable sorting and filtering.
+                    if (error instanceof ParseError)
+
+                        // Fetch the translation and update it when it's ready
+                        translationPromises.push($translate(
+                            error.key, error.variables)
+                            .then(translatedError => {
+                                connectionError.errors.getArray()[errorIndex] = translatedError;
+                            }));
+
+                    // If the error is not a known translatable type, add the
+                    // message directly to the error array
+                    else
+                         connectionError.errors.getArray()[errorIndex] = (
+                             error.message ? error.message : error);
+
+                });
+
+                errors.push(connectionError);
+                return errors;
+
+            }, []);
+
+            // Once all the translations have been completed, update the
+            // connectionErrors all in one go, to ensure no excessive reloading
+            $q.all(translationPromises).then(() => {
+                $scope.connectionErrors = connectionErrors;
+            });
+
+        });
+
+    }];
+
+    return directive;
+
+}]);
diff --git a/guacamole/src/main/frontend/src/app/import/importModule.js b/guacamole/src/main/frontend/src/app/import/importModule.js
new file mode 100644
index 0000000..45551e3
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/import/importModule.js
@@ -0,0 +1,24 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * The module for code supporting importing user-supplied files. Currently, only
+ * connection import is supported.
+ */
+angular.module('import', ['element', 'list', 'notification', 'rest']);
diff --git a/guacamole/src/main/frontend/src/app/import/services/connectionCSVService.js b/guacamole/src/main/frontend/src/app/import/services/connectionCSVService.js
new file mode 100644
index 0000000..641318b
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/import/services/connectionCSVService.js
@@ -0,0 +1,461 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * 'License'); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/* global _ */
+
+// A suffix that indicates that a particular header refers to a parameter
+const PARAMETER_SUFFIX = ' (parameter)';
+
+// A suffix that indicates that a particular header refers to an attribute
+const ATTRIBUTE_SUFFIX = ' (attribute)';
+
+/**
+ * A service for parsing user-provided CSV connection data for bulk import.
+ */
+angular.module('import').factory('connectionCSVService',
+        ['$injector', function connectionCSVService($injector) {
+
+    // Required types
+    const ParseError          = $injector.get('ParseError');
+    const ImportConnection    = $injector.get('ImportConnection');
+    const TranslatableMessage = $injector.get('TranslatableMessage');
+
+    // Required services
+    const $q            = $injector.get('$q');
+    const $routeParams  = $injector.get('$routeParams');
+    const schemaService = $injector.get('schemaService');
+
+    const service = {};
+
+    /**
+     * Returns a promise that resolves to a object detailing the connection
+     * attributes for the current data source, as well as the connection
+     * paremeters for every protocol, for the current data source.
+     *
+     * The object that the promise will contain an "attributes" key that maps to
+     * a set of attribute names, and a "protocolParameters" key that maps to an
+     * object mapping protocol names to sets of parameter names for that protocol.
+     *
+     * The intended use case for this object is to determine if there is a
+     * connection parameter or attribute with a given name, by e.g. checking the
+     * path `.protocolParameters[protocolName]` to see if a protocol exists,
+     * checking the path `.protocolParameters[protocolName][fieldName]` to see
+     * if a parameter exists for a given protocol, or checking the path
+     * `.attributes[fieldName]` to check if a connection attribute exists.
+     *
+     * @returns {Promise.<Object>}
+     *     A promise that resolves to a object detailing the connection
+     *     attributes and parameters for every protocol, for the current data
+     *     source.
+     */
+    function getFieldLookups() {
+
+        // The current data source - the one that the connections will be
+        // imported into
+        const dataSource = $routeParams.dataSource;
+
+        // Fetch connection attributes and protocols for the current data source
+        return $q.all({
+            attributes : schemaService.getConnectionAttributes(dataSource),
+            protocols  : schemaService.getProtocols(dataSource)
+        })
+        .then(function connectionStructureRetrieved({attributes, protocols}) {
+
+            return {
+
+                // Translate the forms and fields into a flat map of attribute
+                // name to `true` boolean value
+                attributes: attributes.reduce(
+                    (attributeMap, form) => {
+                        form.fields.forEach(
+                            field => attributeMap[field.name] = true);
+                        return attributeMap
+                    }, {}),
+
+                // Translate the protocol definitions into a map of protocol
+                // name to map of field name to `true` boolean value
+                protocolParameters: _.mapValues(
+                    protocols, protocol => protocol.connectionForms.reduce(
+                        (protocolFieldMap, form) => {
+                            form.fields.forEach(
+                                field => protocolFieldMap[field.name] = true);
+                            return protocolFieldMap;
+                        }, {}))
+            };
+        });
+    }
+
+    /**
+     * Split a raw user-provided, semicolon-seperated list of identifiers into
+     * an array of identifiers. If identifiers contain semicolons, they can be
+     * escaped with backslashes, and backslashes can also be escaped using other
+     * backslashes.
+     *
+     * @param {String} rawIdentifiers
+     *     The raw string value as fetched from the CSV.
+     *
+     * @returns {Array.<String>}
+     *     An array of identifier values.
+     */
+    function splitIdentifiers(rawIdentifiers) {
+
+        // Keep track of whether a backslash was seen
+        let escaped = false;
+
+        return _.reduce(rawIdentifiers, (identifiers, ch) => {
+
+            // The current identifier will be the last one in the final list
+            let identifier = identifiers[identifiers.length - 1];
+
+            // If a semicolon is seen, set the "escaped" flag and continue
+            // to the next character
+            if (!escaped && ch == '\\') {
+                escaped = true;
+                return identifiers;
+            }
+
+            // End the current identifier and start a new one if there's an
+            // unescaped semicolon
+            else if (!escaped && ch == ';') {
+                identifiers.push('');
+                return identifiers;
+            }
+
+            // In all other cases, just append to the identifier
+            else {
+                identifier += ch;
+                escaped = false;
+            }
+
+            // Save the updated identifier to the list
+            identifiers[identifiers.length - 1] = identifier;
+
+            return identifiers;
+
+        }, [''])
+
+        // Filter out any 0-length (empty) identifiers
+        .filter(identifier => identifier.length);
+
+    }
+
+    /**
+     * Given a CSV header row, create and return a promise that will resolve to
+     * a function that can take a CSV data row and return a ImportConnection
+     * object. If an error occurs while parsing a particular row, the resolved
+     * function will throw a ParseError describing the failure.
+     *
+     * The provided CSV must contain columns for name and protocol. Optionally,
+     * the parentIdentifier of the target parent connection group, or a connection
+     * name path e.g. "ROOT/parent/child" may be included. Additionallty,
+     * connection parameters or attributes can be included.
+     *
+     * The names of connection attributes and parameters are not guaranteed to
+     * be mutually exclusive, so the CSV import format supports a distinguishing
+     * suffix. A column may be explicitly declared to be a parameter using a
+     * " (parameter)" suffix, or an attribute using an " (attribute)" suffix.
+     * No suffix is required if the name is unique across connections and
+     * attributes.
+     *
+     * If a parameter or attribute name conflicts with the standard
+     * "name", "protocol", "group", or "parentIdentifier" fields, the suffix is
+     * required.
+     *
+     * If a failure occurs while attempting to create the transformer function,
+     * the promise will be rejected with a ParseError describing the failure.
+     *
+     * @returns {Promise.<Function.<String[], ImportConnection>>}
+     *     A promise that will resolve to a function that translates a CSV data
+     *     row (array of strings) to a ImportConnection object.
+     */
+    service.getCSVTransformer = function getCSVTransformer(headerRow) {
+
+        // A promise that will be resolved with the transformer or rejected if
+        // an error occurs
+        const deferred = $q.defer();
+
+        getFieldLookups().then(({attributes, protocolParameters}) => {
+
+            // All configuration required to generate a function that can
+            // transform a row of CSV into a connection object.
+            // NOTE: This is a single object instead of a collection of variables
+            // to ensure that no stale references are used - e.g. when one getter
+            // invokes another getter
+            const transformConfig = {
+
+                // Callbacks for required fields
+                nameGetter: undefined,
+                protocolGetter: undefined,
+
+                // Callbacks for a parent group ID or group path
+                groupGetter: undefined,
+                parentIdentifierGetter: undefined,
+
+                // Callbacks for user and user group identifiers
+                usersGetter: () => [],
+                userGroupsGetter: () => [],
+
+                // Callbacks that will generate either connection attributes or
+                // parameters. These callbacks will return a {type, name, value}
+                // object containing the type ("parameter" or "attribute"),
+                // the name of the attribute or parameter, and the corresponding
+                // value.
+                parameterOrAttributeGetters: []
+
+            };
+
+            // A set of all headers that have been seen so far. If any of these
+            // are duplicated, the CSV is invalid.
+            const headerSet = {};
+
+            // Iterate through the headers one by one
+            headerRow.forEach((rawHeader, index) => {
+
+                // Trim to normalize all headers
+                const header = rawHeader.trim();
+
+                // Check if the header is duplicated
+                if (headerSet[header]) {
+                    deferred.reject(new ParseError({
+                        message: 'Duplicate CSV Header: ' + header,
+                        translatableMessage: new TranslatableMessage({
+                            key: 'IMPORT.ERROR_DUPLICATE_CSV_HEADER',
+                            variables: { HEADER: header }
+                        })
+                    }));
+                    return;
+                }
+
+                // Mark that this particular header has already been seen
+                headerSet[header] = true;
+
+                // A callback that returns the field at the current index
+                const fetchFieldAtIndex = row => row[index];
+
+                // A callback that splits raw string identifier lists by
+                // semicolon characters into an array of identifiers
+                const identifierListCallback = row =>
+                    splitIdentifiers(fetchFieldAtIndex(row));
+
+                // Set up the name callback
+                if (header == 'name')
+                    transformConfig.nameGetter = fetchFieldAtIndex;
+
+                // Set up the protocol callback
+                else if (header == 'protocol')
+                    transformConfig.protocolGetter = fetchFieldAtIndex;
+
+                // Set up the group callback
+                else if (header == 'group')
+                    transformConfig.groupGetter = fetchFieldAtIndex;
+
+                // Set up the group parent ID callback
+                else if (header == 'parentIdentifier')
+                    transformConfig.parentIdentifierGetter = fetchFieldAtIndex;
+
+                // Set the user identifiers callback
+                else if (header == 'users')
+                    transformConfig.usersGetter = (
+                        identifierListCallback);
+
+                // Set the user group identifiers callback
+                else if (header == 'groups')
+                    transformConfig.userGroupsGetter = (
+                        identifierListCallback);
+
+                // At this point, any other header might refer to a connection
+                // parameter or to an attribute
+
+                // A field may be explicitly specified as a parameter
+                else if (header.endsWith(PARAMETER_SUFFIX)) {
+
+                    // Push as an explicit parameter getter
+                    const parameterName = header.replace(PARAMETER_SUFFIX);
+                    transformConfig.parameterOrAttributeGetters.push(
+                        row => ({
+                            type: 'parameters',
+                            name: parameterName,
+                            value: fetchFieldAtIndex(row)
+                        })
+                    );
+                }
+
+                // A field may be explicitly specified as a parameter
+                else if (header.endsWith(ATTRIBUTE_SUFFIX)) {
+
+                    // Push as an explicit attribute getter
+                    const attributeName = header.replace(ATTRIBUTE_SUFFIX);
+                    transformConfig.parameterOrAttributeGetters.push(
+                        row => ({
+                            type: 'attributes',
+                            name: attributeName,
+                            value: fetchFieldAtIndex(row)
+                        })
+                    );
+                }
+
+                // The field is ambiguous, either an attribute or parameter,
+                // so the getter will have to determine this for every row
+                else
+                    transformConfig.parameterOrAttributeGetters.push(row => {
+
+                        // The name is just the value of the current header
+                        const name = header;
+
+                        // The value is at the index that matches the position
+                        // of the header
+                        const value = fetchFieldAtIndex(row);
+
+                        // If no value is provided, do not check the validity
+                        // of the parameter/attribute. Doing so would prevent
+                        // the import of a list of mixed protocol types, where
+                        // fields are only populated for protocols for which
+                        // they are valid parameters. If a value IS provided,
+                        // it must be a valid parameter or attribute for the
+                        // current protocol, which will be checked below.
+                        if (!value)
+                            return {};
+
+                        // The protocol may determine whether a field is
+                        // a parameter or an attribute (or both)
+                        const protocol = transformConfig.protocolGetter(row);
+
+                        // Any errors encountered while processing this row
+                        const errors = [];
+
+                        // Before checking whether it's an attribute or protocol,
+                        // make sure this is a valid protocol to start
+                        if (!protocolParameters[protocol])
+
+                            // If the protocol is invalid, do not throw an error
+                            // here - this will be handled further downstream
+                            // by non-CSV-specific error handling
+                            return {};
+
+                        // Determine if the field refers to an attribute or a
+                        // parameter (or both, which is an error)
+                        const isAttribute = !!attributes[name];
+                        const isParameter = !!_.get(
+                                protocolParameters, [protocol, name]);
+
+                        // If there is both an attribute and a protocol-specific
+                        // parameter with the provided name, it's impossible to
+                        // figure out which this should be
+                        if (isAttribute && isParameter)
+                            errors.push(new ParseError({
+                                message: 'Ambiguous CSV Header: ' + header,
+                                key: 'IMPORT.ERROR_AMBIGUOUS_CSV_HEADER',
+                                variables: { HEADER: header }
+                            }));
+
+                        // It's neither an attribute or a parameter
+                        else if (!isAttribute && !isParameter)
+                            errors.push(new ParseError({
+                                message: 'Invalid CSV Header: ' + header,
+                                key: 'IMPORT.ERROR_INVALID_CSV_HEADER',
+                                variables: { HEADER: header }
+                            }));
+
+                        // Choose the appropriate type
+                        const type = isAttribute ? 'attributes' : 'parameters';
+
+                        return { type, name, value, errors };
+                    });
+            });
+
+            const {
+                nameGetter, protocolGetter,
+                parentIdentifierGetter, groupGetter,
+                usersGetter, userGroupsGetter,
+                parameterOrAttributeGetters
+            } = transformConfig;
+
+            // Fail if the name wasn't provided. Note that this is a file-level
+            // error, not specific to any connection.
+            if (!nameGetter)
+                deferred.reject(new ParseError({
+                    message: 'The connection name must be provided',
+                    key: 'IMPORT.ERROR_REQUIRED_NAME_FILE'
+                }));
+
+            // Fail if the protocol wasn't provided
+            if (!protocolGetter)
+                deferred.reject(new ParseError({
+                    message: 'The connection protocol must be provided',
+                    key: 'IMPORT.ERROR_REQUIRED_PROTOCOL_FILE'
+                }));
+
+            // The function to transform a CSV row into a connection object
+            deferred.resolve(function transformCSVRow(row) {
+
+                // Get name and protocol
+                const name = nameGetter(row);
+                const protocol = protocolGetter(row);
+
+                // Get any users or user groups who should be granted access
+                const users = usersGetter(row);
+                const groups = userGroupsGetter(row);
+
+                // Get the parent group ID and/or group path
+                const group = groupGetter && groupGetter(row);
+                const parentIdentifier = (
+                        parentIdentifierGetter && parentIdentifierGetter(row));
+
+                return new ImportConnection({
+
+                    // Fields that are not protocol-specific
+                    name,
+                    protocol,
+                    parentIdentifier,
+                    group,
+                    users,
+                    groups,
+
+                    // Fields that might potentially be either attributes or
+                    // parameters, depending on the protocol
+                    ...parameterOrAttributeGetters.reduce((values, getter) => {
+
+                        // Determine the type, name, and value
+                        const { type, name, value, errors } = getter(row);
+
+                        // Set the value if available
+                        if (type && name && value)
+                            values[type][name] = value;
+
+                        // If there were errors
+                        if (errors && errors.length)
+                            values.errors = [...values.errors, ...errors];
+
+                        // Continue on to the next attribute or parameter
+                        return values;
+
+                    }, {parameters: {}, attributes: {}, errors: []})
+
+                });
+
+            });
+
+        });
+
+        return deferred.promise;
+    };
+
+    return service;
+
+}]);
diff --git a/guacamole/src/main/frontend/src/app/import/services/connectionParseService.js b/guacamole/src/main/frontend/src/app/import/services/connectionParseService.js
new file mode 100644
index 0000000..5971700
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/import/services/connectionParseService.js
@@ -0,0 +1,843 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * 'License'); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/* global _ */
+
+import { parse as parseCSVData } from 'csv-parse/lib/sync'
+import { parse as parseYAMLData } from 'yaml'
+
+/**
+ * A particularly unfriendly looking error that the CSV parser throws if a
+ * binary file parse attempt is made. If at all possible, this message should
+ * never be displayed to the user since it makes it look like the application
+ * is broken. As such, the code will attempt to filter out this error and print
+ * something a bit more generic. Lowercased for slightly fuzzier matching.
+ *
+ * @type String
+ */
+const BINARY_CSV_ERROR_MESSAGE = "Argument must be a Buffer".toLowerCase();
+
+/**
+ * A service for parsing user-provided JSON, YAML, or JSON connection data into
+ * an appropriate format for bulk uploading using the PATCH REST endpoint.
+ */
+angular.module('import').factory('connectionParseService',
+        ['$injector', function connectionParseService($injector) {
+
+    // Required types
+    const Connection             = $injector.get('Connection');
+    const ConnectionImportConfig = $injector.get('ConnectionImportConfig');
+    const DirectoryPatch         = $injector.get('DirectoryPatch');
+    const ImportConnection       = $injector.get('ImportConnection');
+    const ParseError             = $injector.get('ParseError');
+    const ParseResult            = $injector.get('ParseResult');
+    const TranslatableMessage    = $injector.get('TranslatableMessage');
+
+    // Required services
+    const $q                     = $injector.get('$q');
+    const $routeParams           = $injector.get('$routeParams');
+    const schemaService          = $injector.get('schemaService');
+    const connectionCSVService   = $injector.get('connectionCSVService');
+    const connectionGroupService = $injector.get('connectionGroupService');
+
+    const service = {};
+
+    /**
+     * The identifier of the root connection group, under which all other groups
+     * and connections exist.
+     * 
+     * @type String
+     */
+    const ROOT_GROUP_IDENTIFIER = 'ROOT';
+
+    /**
+     * Perform basic checks, common to all file types - namely that the parsed
+     * data is an array, and contains at least one connection entry. Returns an
+     * error if any of these basic checks fails.
+     *
+     * @returns {ParseError}
+     *     An error describing the parsing failure, if one of the basic checks
+     *     fails.
+     */
+    function performBasicChecks(parsedData) {
+
+        // Make sure that the file data parses to an array (connection list)
+        if (!(parsedData instanceof Array))
+            return new ParseError({
+                message: 'Import data must be a list of connections',
+                key: 'IMPORT.ERROR_ARRAY_REQUIRED'
+            });
+
+        // Make sure that the connection list is not empty - contains at least
+        // one connection
+        if (!parsedData.length)
+            return new ParseError({
+                message: 'The provided file is empty',
+                key: 'IMPORT.ERROR_EMPTY_FILE'
+            });
+    }
+
+    /**
+     * A collection of connection-group-tree-derived maps that are useful for
+     * processing connections.
+     *
+     * @constructor
+     * @param {TreeLookups|{}} template
+     *     The object whose properties should be copied within the new
+     *     ConnectionImportConfig.
+     */
+    const TreeLookups = template => ({
+
+        /**
+         * A map of all known group paths to the corresponding identifier for
+         * that group. The is that a user-provided import file might directly
+         * specify a named group path like "ROOT", "ROOT/parent", or
+         * "ROOT/parent/child". This field field will map all of the above to
+         * the identifier of the appropriate group, if defined.
+         *
+         * @type Object.<String, String>
+         */
+        groupPathsByIdentifier: template.groupPathsByIdentifier || {},
+
+        /**
+         * A map of all known group identifiers to the path of the corresponding
+         * group. These paths are all of the form "ROOT/parent/child".
+         *
+         * @type Object.<String, String>
+         */
+        groupIdentifiersByPath: template.groupIdentifiersByPath || {},
+
+        /**
+         * A map of group identifier, to connection name, to connection 
+         * identifier. These paths are all of the form "ROOT/parent/child". The 
+         * idea is that existing connections can be found by checking if a
+         * connection already exists with the same parent group, and with the
+         * same name as an user-supplied import connection.
+         *
+         * @type Object.<String, String>
+         */
+        connectionIdsByGroupAndName : template.connectionIdsByGroupAndName || {}
+
+    });
+
+    /**
+     * Returns a promise that resolves to a TreeLookups object containing maps
+     * useful for processing user-supplied connections to be imported, derived
+     * from the current connection group tree, starting at the ROOT group.
+     *
+     * @returns {Promise.<TreeLookups>}
+     *     A promise that resolves to a TreeLookups object containing maps
+     *     useful for processing connections.
+     */
+    function getTreeLookups() {
+
+        // The current data source - defines all the groups that the connections
+        // might be imported into
+        const dataSource = $routeParams.dataSource;
+
+        const deferredTreeLookups = $q.defer();
+
+        connectionGroupService.getConnectionGroupTree(dataSource).then(
+                rootGroup => {
+
+            const lookups = new TreeLookups({});
+
+            // Add the specified group to the lookup, appending all specified
+            // prefixes, and then recursively call saveLookups for all children
+            // of the group, appending to the prefix for each level
+            const saveLookups = (prefix, group) => {
+
+                // To get the path for the current group, add the name
+                const currentPath = prefix + group.name;
+
+                // Add the current path to the identifier map
+                lookups.groupPathsByIdentifier[currentPath] = group.identifier;
+
+                // Add the current identifier to the path map
+                lookups.groupIdentifiersByPath[group.identifier] = currentPath;
+
+                // Add each connection to the connection map
+                _.forEach(group.childConnections,
+                    connection => _.setWith(
+                        lookups.connectionIdsByGroupAndName,
+                        [group.identifier, connection.name],
+                        connection.identifier, Object));
+
+                // Add each child group to the lookup
+                const nextPrefix = currentPath + "/";
+                _.forEach(group.childConnectionGroups,
+                        childGroup => saveLookups(nextPrefix, childGroup));
+
+            }
+
+            // Start at the root group
+            saveLookups("", rootGroup);
+
+            // Resolve with the now fully-populated lookups
+            deferredTreeLookups.resolve(lookups);
+
+        });
+
+        return deferredTreeLookups.promise;
+    }
+
+    /**
+     * Returns a promise that will resolve to a transformer function that will
+     * perform various checks and transforms relating to the connection group
+     * tree hierarchy, pushing any errors into the resolved connection object.
+     * It will:
+     * - Ensure that a connection specifies either a valid group path (no path
+     *   defaults to ROOT), or a valid parent group identifier, but not both
+     * - Ensure that this connection does not duplicate another connection
+     *   earlier in the import file
+     * - Handle import connections that match existing connections connections
+     *   based on the provided import config.
+     *
+     * The group set on the connection may begin with the root identifier, a
+     * leading slash, or may omit the root identifier entirely. The group may
+     * optionally end with a trailing slash.
+     *
+     * @param {ConnectionImportConfig} importConfig
+     *     The configuration options selected by the user prior to import.
+     *
+     * @returns {Promise.<Function<ImportConnection, ImportConnection>>}
+     *     A promise that will resolve to a function that will apply various
+     *     connection tree based checks and transforms to this connection.
+     */
+    function getTreeTransformer(importConfig) {
+
+        // A map of group path with connection name, to connection object, used
+        // for detecting duplicate connections within the import file itself
+        const connectionsInFile = {};
+
+        return getTreeLookups().then(treeLookups => connection => {
+
+            const { groupPathsByIdentifier, groupIdentifiersByPath,
+                    connectionIdsByGroupAndName } = treeLookups;
+
+            const providedIdentifier = connection.parentIdentifier;
+
+            // The normalized group path for this connection, of the form
+            // "ROOT/parent/child"
+            let group;
+            
+            // The identifier for the parent group of this connection
+            let parentIdentifier;
+            
+            // The operator to apply for this connection
+            let op = DirectoryPatch.Operation.ADD;
+
+            // If both are specified, the parent group is ambigious
+            if (providedIdentifier && connection.group) {
+                connection.errors.push(new ParseError({
+                    message: 'Only one of group or parentIdentifier can be set',
+                    key: 'IMPORT.ERROR_AMBIGUOUS_PARENT_GROUP'
+                }));
+                return connection;
+            }
+
+            // If a parent group identifier is present, but not valid
+            else if (providedIdentifier
+                    && !groupPathsByIdentifier[providedIdentifier]) {
+                connection.errors.push(new ParseError({
+                    message: 'No group with identifier: ' + providedIdentifier,
+                    key: 'IMPORT.ERROR_INVALID_GROUP_IDENTIFIER',
+                    variables: { IDENTIFIER: providedIdentifier }
+                }));
+                return connection;
+            }
+
+            // If the parent identifier is valid, use it to determine the path
+            else if (providedIdentifier) {
+                parentIdentifier = providedIdentifier;
+                group = groupPathsByIdentifier[providedIdentifier];
+            }
+
+            // If a user-supplied group path is provided, attempt to normalize
+            // and match it to an existing connection group
+            else if (connection.group) {
+
+                // The group path extracted from the user-provided connection,
+                // to be translated into an absolute path starting at the root
+                group = connection.group;
+
+                // If the provided group isn't a string, it can never be valid
+                if (typeof group !== 'string') {
+                    connection.errors.push(new ParseError({
+                        message: 'Invalid group type - must be a string',
+                        key: 'IMPORT.ERROR_INVALID_GROUP_TYPE'
+                    }));
+                    return connection;
+                }
+
+                // Allow the group to start with a leading slash instead instead
+                // of explicitly requiring the root connection group
+                if (group.startsWith('/'))
+                    group = ROOT_GROUP_IDENTIFIER + group;
+
+                // Allow groups to begin directly with the path under the root
+                else if (!group.startsWith(ROOT_GROUP_IDENTIFIER))
+                    group = ROOT_GROUP_IDENTIFIER + '/' + group;
+
+                // Allow groups to end with a trailing slash
+                if (group.endsWith('/'))
+                    group = group.slice(0, -1);
+
+                // Look up the parent identifier for the specified group path
+                parentIdentifier = groupPathsByIdentifier[group];
+
+                // If the group doesn't match anything in the tree
+                if (!parentIdentifier) {
+                    connection.errors.push(new ParseError({
+                        message: 'No group found named: ' + connection.group,
+                        key: 'IMPORT.ERROR_INVALID_GROUP',
+                        variables: { GROUP: connection.group }
+                    }));
+                    return connection;
+                }
+
+            }
+
+            // If no group is specified at all, default to the root group
+            else {
+                parentIdentifier = ROOT_GROUP_IDENTIFIER;
+                group = ROOT_GROUP_IDENTIFIER;
+            }
+
+            // The full path, of the form "ROOT/Child Group/Connection Name"
+            const path = group + '/' + connection.name;
+
+            // Error out if this is a duplicate of a connection already in the
+            // file
+            if (!!_.get(connectionsInFile, path)) 
+                connection.errors.push(new ParseError({
+                    message: 'Duplicate connection in file: ' + path,
+                    key: 'IMPORT.ERROR_DUPLICATE_CONNECTION_IN_FILE',
+                    variables: { NAME: connection.name, PATH: group }
+                }));
+
+            // Mark the current path as already seen in the file
+            _.setWith(connectionsInFile, path, connection, Object);
+
+            // Check if this would be an update to an existing connection
+            const existingIdentifier = _.get(connectionIdsByGroupAndName,
+                    [parentIdentifier, connection.name]);
+
+            // The default behavior is to create connections if no conflict
+            let importMode = ImportConnection.ImportMode.CREATE;
+            let identifier;
+
+            // If updates to existing connections are disallowed
+            if (existingIdentifier && importConfig.existingConnectionMode ===
+                    ConnectionImportConfig.ExistingConnectionMode.REJECT)
+                connection.errors.push(new ParseError({
+                    message: 'Rejecting update to existing connection: ' + path,
+                    key: 'IMPORT.ERROR_REJECT_UPDATE_CONNECTION',
+                    variables: { NAME: connection.name, PATH: group }
+                }));
+
+            // If the connection is being replaced, set the existing identifer
+            else if (existingIdentifier) {
+                identifier = existingIdentifier;
+                importMode = ImportConnection.ImportMode.REPLACE;
+            }
+
+            else
+                importMode = ImportConnection.ImportMode.CREATE;
+
+            // Set the import mode, normalized path, and validated identifier
+            return new ImportConnection({ ...connection, 
+                    importMode, group, identifier, parentIdentifier });
+
+        });
+    }
+
+    /**
+     * Returns a promise that resolves to a map of all valid protocols to a map
+     * of connection parameter names to a map of lower-cased and trimmed option
+     * values for that parameter to the actual valid option value.
+     *
+     * This format is designed for easy retrieval of corrected parameter values
+     * if the user-provided value matches a valid option except for case or
+     * leading/trailing whitespace.
+     *
+     * If a parameter has no options (i.e. any string value is allowed), the
+     * parameter name will map to a null value.
+     *
+     * @returns {Promise.<Object.<String, Object.<String, Object.<String, String>>>>}
+     *     A promise that resolves to a map of all valid protocols to parameter
+     *     names to valid values.
+     */
+    function getProtocolParameterOptions() {
+
+        // The current data source - the one that the connections will be
+        // imported into
+        const dataSource = $routeParams.dataSource;
+
+        // Fetch the protocols and convert to a set of valid protocol names
+        return schemaService.getProtocols(dataSource).then(
+                protocols => _.mapValues(protocols, ({connectionForms}) => {
+
+            const fieldMap = {};
+
+            // Go through all the connection forms and get the fields for each
+            connectionForms.forEach(({fields}) => fields.forEach(field => {
+
+                const { name, options } = field;
+
+                // Set the value to null to indicate that there are no options
+                if (!options)
+                    fieldMap[name] = null;
+
+                // Set the value to a map of lowercased/trimmed option values 
+                // to actual option values
+                else
+                    fieldMap[name] = _.mapKeys(
+                        options, option => option.trim().toLowerCase());
+               
+            }));
+
+            return fieldMap;
+        }));
+    }
+
+    /**
+     * Resolves to function that will perform field-level (not connection
+     * hierarchy dependent) checks and transforms to a provided connection,
+     * returning the transformed connection.
+     *
+     * @returns {Promise.<Function.<ImportConnection, ImportConnection>>}
+     *     A promise resolving to a function that will apply field-level
+     *     transforms and checks to a provided connection, returning the
+     *     transformed connection.
+     */
+    function getFieldTransformer() {
+
+        return getProtocolParameterOptions().then(protocols => connection => {
+
+            // Ensure that a protocol was specified for this connection
+            const protocol = connection.protocol;
+            if (!protocol)
+                connection.errors.push(new ParseError({
+                    message: 'Missing required protocol field',
+                    key: 'IMPORT.ERROR_REQUIRED_PROTOCOL_CONNECTION'
+                }));
+
+            // Ensure that a valid protocol was specified for this connection
+            if (!protocols[protocol])
+                connection.errors.push(new ParseError({
+                    message: 'Invalid protocol: ' + protocol,
+                    key: 'IMPORT.ERROR_INVALID_PROTOCOL',
+                    variables: { PROTOCOL: protocol }
+                }));
+
+            // Ensure that a name was specified for this connection
+            if (!connection.name)
+                connection.errors.push(new ParseError({
+                    message: 'Missing required name field',
+                    key: 'IMPORT.ERROR_REQUIRED_NAME_CONNECTION'
+                }));
+
+            // Ensure that the specified user list, if any, is an array
+            const users = connection.users;
+            if (users) {
+
+                // Ensure all users in the array are trimmed strings
+                if (Array.isArray(users))
+                    connection.users = users.map(user => String(user).trim());
+
+                else
+                    connection.errors.push(new ParseError({
+                        message: 'Invalid users list - must be an array',
+                        key: 'IMPORT.ERROR_INVALID_USERS_TYPE'
+                    }));
+
+            }
+
+            // Ensure that the specified user group list, if any, is an array
+            const groups = connection.groups;
+            if (groups) {
+
+                // Ensure all groups in the array are trimmed strings
+                if (Array.isArray(groups))
+                    connection.groups = groups.map(group => String(group).trim());
+
+                else
+                    connection.errors.push(new ParseError({
+                        message: 'Invalid groups list - must be an array',
+                        key: 'IMPORT.ERROR_INVALID_USER_GROUPS_TYPE'
+                    }));
+                
+            }
+            
+            // If the protocol is not valid, there's no point in trying to check
+            // parameter case sensitivity
+            if (!protocols[protocol])
+                return connection;
+
+            _.forEach(connection.parameters, (value, name) => {
+
+                // An explicit null value for a parameter is valid - do not
+                // process it further
+                if (value === null)
+                    return;
+
+                // All non-null connection parameters must be strings.
+                const stringValue = String(value);
+
+                // Convert the provided value to the format that would match
+                // the lookup object format
+                const comparisonValue = stringValue.toLowerCase().trim();
+
+                // The validated / corrected option value for this connection
+                // parameter, if any
+                const validOptionValue = _.get(
+                        protocols, [protocol, name, comparisonValue]);
+
+                // If the provided value fuzzily matches a valid option value,
+                // use the valid option value instead
+                if (validOptionValue)
+                    connection.parameters[name] = validOptionValue;
+
+                // Even if no option is found, the value must be a string
+                else
+                    connection.parameters[name] = stringValue;
+
+            });
+
+            _.forEach(connection.attributes, (value, name) => {
+
+                // An explicit null value for an attribute is valid - do not
+                // process it further
+                if (value === null)
+                    return;
+
+                // All non-null connection attributes must be strings
+                connection.attributes[name] = String(value);
+
+            });
+
+            return connection;
+        });
+    }
+
+    /**
+     * Convert a provided connection array into a ParseResult. Any provided
+     * transform functions will be run on each entry in `connectionData` before
+     * any other processing is done.
+     *
+     * @param {ConnectionImportConfig} importConfig
+     *     The configuration options selected by the user prior to import.
+     *
+     * @param {*[]} connectionData
+     *     An arbitrary array of data. This must evaluate to a ImportConnection
+     *     object after being run through all functions in `transformFunctions`.
+     *
+     * @param {Function[]} transformFunctions
+     *     An array of transformation functions to run on each entry in
+     *     `connection` data.
+     *
+     * @return {Promise.<ParseResult>}
+     *     A promise resolving to ParseResult object representing the result of
+     *     parsing all provided connection data.
+     */
+    function parseConnectionData(
+            importConfig, connectionData, transformFunctions) {
+
+        // Check that the provided connection data array is not empty
+        const checkError = performBasicChecks(connectionData);
+        if (checkError) {
+            const deferred = $q.defer();
+            deferred.reject(checkError);
+            return deferred.promise;
+        }
+
+        let index = 0;
+
+        // Get the tree transformer and relevant protocol information
+        return $q.all({
+            fieldTransformer : getFieldTransformer(),
+            treeTransformer  : getTreeTransformer(importConfig),
+        })
+        .then(({fieldTransformer, treeTransformer}) =>
+                connectionData.reduce((parseResult, data) => {
+
+            const { patches, users, groups, groupPaths } = parseResult;
+
+            // Run the array data through each provided transform
+            let connectionObject = data;
+            _.forEach(transformFunctions, transform => {
+                connectionObject = transform(connectionObject);
+            });
+
+            // Apply the field level transforms
+            connectionObject = fieldTransformer(connectionObject);
+
+            // Apply the connection group hierarchy transforms
+            connectionObject = treeTransformer(connectionObject);
+
+            // If there are any errors for this connection, fail the whole batch
+            if (connectionObject.errors.length)
+                parseResult.hasErrors = true;
+
+            // The value for the patch is a full-fledged Connection
+            const value = new Connection(connectionObject);
+
+            // If a new connection is being created
+            if (connectionObject.importMode 
+                    === ImportConnection.ImportMode.CREATE) 
+
+                // Add a patch for creating the connection
+                patches.push(new DirectoryPatch({
+                    op: DirectoryPatch.Operation.ADD,
+                    path: '/',
+                    value
+                }));
+
+            // The connection is being replaced, and permissions are only being
+            // added, not replaced
+            else if (importConfig.existingPermissionMode ===
+                    ConnectionImportConfig.ExistingPermissionMode.PRESERVE)
+
+                // Add a patch for replacing the connection
+                patches.push(new DirectoryPatch({
+                    op: DirectoryPatch.Operation.REPLACE,
+                    path: '/' + connectionObject.identifier,
+                    value
+                }));
+
+            // The connection is being replaced, and permissions are also being
+            // replaced
+            else {
+
+                // Add a patch for removing the existing connection
+                patches.push(new DirectoryPatch({
+                    op: DirectoryPatch.Operation.REMOVE,
+                    path: '/' + connectionObject.identifier
+                }));
+
+                // Increment the index for the additional remove patch
+                index += 1;
+
+                // Add a second patch for creating the replacement connection
+                patches.push(new DirectoryPatch({
+                    op: DirectoryPatch.Operation.ADD,
+                    path: '/',
+                    value
+                }));
+
+            }
+
+            // Save the connection group path into the parse result
+            groupPaths[index] = connectionObject.group;
+
+            // Save the errors for this connection into the parse result
+            parseResult.errors[index] = connectionObject.errors;
+
+            // Add this connection index to the list for each user
+            _.forEach(connectionObject.users, identifier => {
+
+                // If there's an existing list, add the index to that
+                if (users[identifier])
+                    users[identifier].push(index);
+
+                // Otherwise, create a new list with just this index
+                else
+                    users[identifier] = [index];
+            });
+
+            // Add this connection index to the list for each group
+            _.forEach(connectionObject.groups, identifier => {
+
+                // If there's an existing list, add the index to that
+                if (groups[identifier])
+                    groups[identifier].push(index);
+
+                // Otherwise, create a new list with just this index
+                else
+                    groups[identifier] = [index];
+            });
+
+            // Return the existing parse result state and continue on to the
+            // next connection in the file
+            index++;
+            parseResult.connectionCount++;
+            return parseResult;
+
+        }, new ParseResult()));
+    }
+
+    /**
+     * Convert a provided CSV representation of a connection list into a JSON
+     * object to be submitted to the PATCH REST endpoint, as well as a list of
+     * objects containing lists of user and user group identifiers to be granted
+     * to each connection.
+     *
+     * @param {ConnectionImportConfig} importConfig
+     *     The configuration options selected by the user prior to import.
+     *
+     * @param {String} csvData
+     *     The CSV-encoded connection list to process.
+     *
+     * @return {Promise.<Object>}
+     *     A promise resolving to ParseResult object representing the result of
+     *     parsing all provided connection data.
+     */
+    service.parseCSV = function parseCSV(importConfig, csvData) {
+
+        // Convert to an array of arrays, one per CSV row (including the header)
+        // NOTE: skip_empty_lines is required, or a trailing newline will error
+        let parsedData;
+        try {
+            parsedData = parseCSVData(csvData, {skip_empty_lines: true});
+        }
+
+        // If the CSV parser throws an error, reject with that error
+        catch(error) {
+
+            const message = error.message;
+            console.error(error);
+
+            const deferred = $q.defer();
+
+            // If the error message looks like the expected (and ugly) message
+            // that's thrown when a binary file is provided, throw a more
+            // friendy error.
+            if (_.trim(message).toLowerCase() == BINARY_CSV_ERROR_MESSAGE)
+                deferred.reject(new ParseError({
+                    message: "CSV binary parse attempt error: " + error.message,
+                    key: "IMPORT.ERROR_DETECTED_INVALID_TYPE"
+                }));
+
+            // Otherwise, pass the error from the library through to the user
+            else
+                deferred.reject(new ParseError({
+                    message: "CSV Parse Failure: " + error.message,
+                    key: "IMPORT.ERROR_PARSE_FAILURE_CSV",
+                    variables: { ERROR: error.message }
+                }));
+
+            return deferred.promise;
+        }
+
+        // The header row - an array of string header values
+        const header = parsedData.length ? parsedData[0] : [];
+
+        // Slice off the header row to get the data rows
+        const connectionData = parsedData.slice(1);
+
+        // Generate the CSV transform function, and apply it to every row
+        // before applying all the rest of the standard transforms
+        return connectionCSVService.getCSVTransformer(header).then(
+            csvTransformer =>
+
+                // Apply the CSV transform to every row
+                parseConnectionData(
+                        importConfig, connectionData, [csvTransformer]));
+
+    };
+
+    /**
+     * Convert a provided YAML representation of a connection list into a JSON
+     * object to be submitted to the PATCH REST endpoint, as well as a list of
+     * objects containing lists of user and user group identifiers to be granted
+     * to each connection.
+     *
+     * @param {ConnectionImportConfig} importConfig
+     *     The configuration options selected by the user prior to import.
+     *
+     * @param {String} yamlData
+     *     The YAML-encoded connection list to process.
+     *
+     * @return {Promise.<Object>}
+     *     A promise resolving to ParseResult object representing the result of
+     *     parsing all provided connection data.
+     */
+    service.parseYAML = function parseYAML(importConfig, yamlData) {
+
+        // Parse from YAML into a javascript array
+        let connectionData;
+        try {
+            connectionData = parseYAMLData(yamlData);
+        }
+
+        // If the YAML parser throws an error, reject with that error
+        catch(error) {
+            console.error(error);
+            const deferred = $q.defer();
+            deferred.reject(new ParseError({
+                message: "YAML Parse Failure: " + error.message,
+                key: "IMPORT.ERROR_PARSE_FAILURE_YAML",
+                variables: { ERROR: error.message }
+            }));
+            return deferred.promise;
+        }
+
+        // Produce a ParseResult, making sure that each record is converted to
+        // the ImportConnection type before further parsing
+        return parseConnectionData(importConfig, connectionData,
+                [connection => new ImportConnection(connection)]);
+    };
+
+    /**
+     * Convert a provided JSON-encoded representation of a connection list into
+     * an array of patches to be submitted to the PATCH REST endpoint, as well
+     * as a list of objects containing lists of user and user group identifiers
+     * to be granted to each connection.
+     *
+     * @param {ConnectionImportConfig} importConfig
+     *     The configuration options selected by the user prior to import.
+     *
+     * @param {String} jsonData
+     *     The JSON-encoded connection list to process.
+     *
+     * @return {Promise.<Object>}
+     *     A promise resolving to ParseResult object representing the result of
+     *     parsing all provided connection data.
+     */
+    service.parseJSON = function parseJSON(importConfig, jsonData) {
+
+        // Parse from JSON into a javascript array
+        let connectionData;
+        try {
+            connectionData = JSON.parse(jsonData);
+        }
+
+        // If the JSON parse attempt throws an error, reject with that error
+        catch(error) {
+            console.error(error);
+            const deferred = $q.defer();
+            deferred.reject(new ParseError({
+                message: "JSON Parse Failure: " + error.message,
+                key: "IMPORT.ERROR_PARSE_FAILURE_JSON",
+                variables: { ERROR: error.message }
+            }));
+            return deferred.promise;
+        }
+
+        // Produce a ParseResult, making sure that each record is converted to
+        // the ImportConnection type before further parsing
+        return parseConnectionData(importConfig, connectionData,
+                [connection => new ImportConnection(connection)]);
+
+    };
+
+    return service;
+
+}]);
diff --git a/guacamole/src/main/frontend/src/app/import/styles/help.css b/guacamole/src/main/frontend/src/app/import/styles/help.css
new file mode 100644
index 0000000..9cbdb5b
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/import/styles/help.css
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.import.help {
+
+    text-transform: none;
+
+}
+
+.import.help p {
+
+    max-width: 70em;
+
+}
+
+.import.help h2 {
+
+    padding-bottom: 0px;
+
+}
+
+.import.help p, .import.help pre {
+
+    margin-left: 1em;
+
+}
+
+.import.help pre {
+
+    background-color: rgba(0,0,0,0.15);
+    padding: 10px;
+    width: fit-content;
+
+}
+
+.import.help .footnotes {
+
+    border-top: 1px solid gray;
+    padding-top: 1em;
+    width: fit-content;
+    margin-left: 1em;
+
+}
diff --git a/guacamole/src/main/frontend/src/app/import/styles/import.css b/guacamole/src/main/frontend/src/app/import/styles/import.css
new file mode 100644
index 0000000..5e2cd71
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/import/styles/import.css
@@ -0,0 +1,190 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.import .import-buttons {
+
+    margin-top: 10px;
+    display: flex;
+    gap: 10px;
+    justify-content: center;
+
+}
+
+.import .errors table {
+
+    width: 100%;
+
+}
+
+.import .errors .error-message {
+
+    color: red;
+
+}
+
+.import .errors .error-message ul {
+
+    margin: 0px;
+
+}
+
+.file-upload-container {
+
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    padding: 24px 24px 24px;
+
+    width: fit-content;
+
+    border: 1px solid rgba(0,0,0,.25);
+    box-shadow: 1px 1px 2px rgb(0 0 0 / 25%);
+
+    margin-left: auto;
+    margin-right: auto;
+
+}
+
+.file-upload-container.file-selected {
+
+    display: flex;
+    flex-direction: row;
+    gap: 100px;
+
+}
+
+.file-upload-container .clear {
+
+    margin: 0;
+
+}
+
+.file-upload-container .upload-header {
+
+    display: flex;
+    flex-direction: row;
+    width: 500px;
+    margin-bottom: 5px;
+    justify-content: space-between;
+
+}
+
+.file-upload-container .file-error {
+
+    color: red;
+
+}
+
+.file-upload-container .file-options {
+
+    font-weight: bold;
+
+}
+
+.file-upload-container .file-upload-input {
+
+    display: none;
+
+}
+
+.file-upload-container .drop-target {
+
+    display: flex;
+    flex-direction: column;
+
+    align-items: center;
+    justify-content: space-evenly;
+
+    width: 500px;
+    height: 200px;
+
+    background: rgba(0,0,0,.04);
+    border: 1px solid black;
+
+}
+
+.file-upload-container .drop-target.file-present {
+
+    background: rgba(0,0,0,.15);
+
+}
+
+
+.file-upload-container .drop-target .file-name {
+
+    font-weight: bold;
+    font-size: 1.5em;
+
+}
+
+.file-upload-container .drop-target.drop-pending {
+
+    background: #3161a9;
+
+}
+
+.file-upload-container .drop-target.drop-pending > * {
+
+    opacity: 0.5;
+
+}
+
+.file-upload-container .drop-target .title {
+
+    font-weight: bold;
+    font-size: 1.25em;
+
+}
+
+.file-upload-container .drop-target .browse-link {
+
+    text-decoration: underline;
+    cursor: pointer;
+
+}
+
+.file-upload-container .import-config {
+
+    margin-top: 0.5em;
+    list-style: none;
+    width: 100%;
+    padding-left: 0;
+
+}
+
+.file-upload-container .import-config .help {
+
+    visibility: hidden;
+    cursor: help;
+    
+}
+
+.file-upload-container .import-config .help::after {
+
+    content: '';
+    visibility: visible;
+    display: inline-block;
+    background-image: url('images/question.svg');
+    background-size: contain;
+    width: 20px;
+    height: 20px;
+    position: relative;
+    top: 4px;
+
+}
\ No newline at end of file
diff --git a/guacamole/src/main/frontend/src/app/import/templates/connectionErrors.html b/guacamole/src/main/frontend/src/app/import/templates/connectionErrors.html
new file mode 100644
index 0000000..f2d486c
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/import/templates/connectionErrors.html
@@ -0,0 +1,49 @@
+<div ng-show="hasErrors()" class="errors">
+
+    <!-- Connection / Error filter -->
+    <guac-filter filtered-items="filteredErrors" items="connectionErrors"
+                 placeholder="'IMPORT.FIELD_PLACEHOLDER_FILTER' | translate"
+                 properties="filteredErrorProperties"></guac-filter>
+
+    <!-- List of connection import errors -->
+    <table class="sorted">
+        <thead>
+            <tr>
+                <th guac-sort-order="errorOrder" guac-sort-property="'rowNumber'">
+                    {{'IMPORT.TABLE_HEADER_ROW_NUMBER' | translate}}
+                </th>
+                <th guac-sort-order="errorOrder" guac-sort-property="'name'">
+                    {{'IMPORT.TABLE_HEADER_NAME' | translate}}
+                </th>
+                <th guac-sort-order="errorOrder" guac-sort-property="'group'">
+                    {{'IMPORT.TABLE_HEADER_GROUP' | translate}}
+                </th>
+                <th guac-sort-order="errorOrder" guac-sort-property="'protocol'">
+                    {{'IMPORT.TABLE_HEADER_PROTOCOL' | translate}}
+                </th>
+                <th guac-sort-order="errorOrder" guac-sort-property="'errors'">
+                    {{'IMPORT.TABLE_HEADER_ERRORS' | translate}}
+                </th>
+            </tr>
+        </thead>
+        <tbody>
+            <tr ng-repeat="error in errorPage">
+                <td>{{error.rowNumber}}</td>
+                <td>{{error.name}}</td>
+                <td>{{error.group}}</td>
+                <td>{{error.protocol}}</td>
+                <td class="error-message" ng-class="{ 'has-errors' : error.errors.getArray().length }">
+                    <ul>
+                        <li ng-repeat="message in error.errors.getArray()">
+                            {{ message }}
+                        </li>
+                    </ul>
+                </td>
+            </tr>
+        </tbody>
+    </table>
+
+    <!-- Pager for connection error list -->
+    <guac-pager page="errorPage" page-size="25"
+                items="filteredErrors | orderBy : errorOrder.predicate"></guac-pager>
+</div>
diff --git a/guacamole/src/main/frontend/src/app/import/templates/connectionImport.html b/guacamole/src/main/frontend/src/app/import/templates/connectionImport.html
new file mode 100644
index 0000000..a6cc0fe
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/import/templates/connectionImport.html
@@ -0,0 +1,82 @@
+<div class="settings-view view import">
+
+    <div class="header">
+        <h2>{{'IMPORT.SECTION_HEADER_CONNECTION_IMPORT' | translate}}</h2>
+        <guac-user-menu></guac-user-menu>
+    </div>
+
+    <div ng-show="fileName" class="file-upload-container file-selected">
+        <div class="file-name"> {{fileName}} </div>
+        <button class="danger clear" ng-click="cancel()">
+            {{'IMPORT.ACTION_CLEAR' | translate}}
+        </button>
+    </div>
+
+    <div ng-show="!fileName" class="file-upload-container">
+
+        <div class="upload-header">
+            <span class="file-options">{{'IMPORT.HELP_UPLOAD_FILE_TYPES' | translate}}</span>
+            <a
+                href="#/import/connection/file-format-help" target="_blank"
+                class="file-help-link">{{'IMPORT.ACTION_VIEW_FORMAT_HELP' | translate}}
+            </a>
+        </div>
+
+        <div class="drop-target" guac-upload="handleFiles" 
+             guac-drop="handleFiles" guac-multiple="false"
+             guac-dragged-class="'drop-pending'"
+             ng-class="{'file-present': fileName}">
+
+            <div class="title">{{'IMPORT.HELP_UPLOAD_DROP_TITLE' | translate}}</div>
+
+            <input type="file" class="file-upload-input"/>
+            <a ng-click="openFileBrowser()" class="browse-link">
+                {{'IMPORT.ACTION_BROWSE' | translate}}
+            </a>
+
+            <div class="file-name"> {{fileName}} </div>
+
+        </div>
+
+        <ul class="import-config">
+            <li>
+                <input type="checkbox"
+                    id="existing-connection-mode" ng-model="importConfig.existingConnectionMode"
+                    ng-true-value="'REPLACE'" ng-false-value="'REJECT'" />
+                <label for="existing-connection-mode">
+                    {{'IMPORT.FIELD_HEADER_EXISTING_CONNECTION_MODE' | translate}}
+                </label>
+                <span ng-attr-title="{{'IMPORT.HELP_EXISTING_CONNECTION_MODE' | translate}}" class="help"></span>
+            </li>
+            <li>
+                <input type="checkbox"
+                    id="existing-permission-mode" ng-model="importConfig.existingPermissionMode"
+                    ng-disabled="importConfig.existingConnectionMode === 'REJECT'"
+                    ng-true-value="'REPLACE'" ng-false-value="'PRESERVE'" />
+                <label for="existing-permission-mode">
+                    {{'IMPORT.FIELD_HEADER_EXISTING_PERMISSION_MODE' | translate}}
+                </label>
+                <span ng-attr-title="{{'IMPORT.HELP_EXISTING_PERMISSION_MODE' | translate}}" class="help"></span>
+            </li>
+        </ul>
+
+    </div>
+
+    <div class="import-buttons">
+        <button
+            ng-click="import()" ng-disabled="importDisabled()" class="save import">
+                {{'IMPORT.ACTION_IMPORT' | translate}}
+        </button>
+        <button
+            ng-click="cancel()" ng-disabled="cancelDisabled()" class="cancel">
+                {{'IMPORT.ACTION_CANCEL' | translate}}
+        </button>
+    </div>
+
+    <div ng-show="isLoading()" class="loading"></div>
+
+    <!-- Connection specific errors, if there are any -->
+    <connection-import-errors parse-result="parseResult" patch-failure="patchFailure" />
+
+
+</div>
diff --git a/guacamole/src/main/frontend/src/app/import/templates/connectionImportFileHelp.html b/guacamole/src/main/frontend/src/app/import/templates/connectionImportFileHelp.html
new file mode 100644
index 0000000..d3abda1
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/import/templates/connectionImportFileHelp.html
@@ -0,0 +1,29 @@
+<div class="import view help">
+
+    <div class="header">
+        <h2>{{'IMPORT.SECTION_HEADER_HELP_CONNECTION_IMPORT_FILE' | translate}}</h2>
+        <guac-user-menu></guac-user-menu>
+    </div>
+
+    <h2>{{'IMPORT.HELP_FILE_TYPE_HEADER' | translate}}</h2>
+    <p>{{'IMPORT.HELP_FILE_TYPE_DESCRIPTION' | translate}}</p>
+
+    <h2>{{'IMPORT.SECTION_HEADER_CSV' | translate}}</h2>
+    <p>{{'IMPORT.HELP_CSV_DESCRIPTION' | translate}}</p>
+    <p>{{'IMPORT.HELP_CSV_MORE_DETAILS' | translate}}</p>
+    <pre>{{'IMPORT.HELP_CSV_EXAMPLE' | translate }}</pre>
+
+    <h2>{{'IMPORT.SECTION_HEADER_JSON' | translate}}</h2>
+    <p>{{'IMPORT.HELP_JSON_DESCRIPTION' | translate}}</p>
+    <p>{{'IMPORT.HELP_JSON_MORE_DETAILS' | translate}}</p>
+    <pre>{{'IMPORT.HELP_JSON_EXAMPLE' | translate }}</pre>
+
+    <h2>{{'IMPORT.SECTION_HEADER_YAML' | translate}}</h2>
+    <p>{{'IMPORT.HELP_YAML_DESCRIPTION' | translate}}</p>
+    <pre>{{'IMPORT.HELP_YAML_EXAMPLE' | translate}}</pre>
+
+    <ol class="footnotes">
+        <li>{{'IMPORT.HELP_SEMICOLON_FOOTNOTE' | translate}}</li>
+    </ol>
+
+</div>
diff --git a/guacamole/src/main/frontend/src/app/import/types/ConnectionImportConfig.js b/guacamole/src/main/frontend/src/app/import/types/ConnectionImportConfig.js
new file mode 100644
index 0000000..fb06730
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/import/types/ConnectionImportConfig.js
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Service which defines the ConnectionImportConfig class.
+ */
+angular.module('import').factory('ConnectionImportConfig', [
+        function defineConnectionImportConfig() {
+
+    /**
+     * A representation of any user-specified configuration when
+     * batch-importing connections.
+     *
+     * @constructor
+     * @param {ConnectionImportConfig|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     ConnectionImportConfig.
+     */
+    const ConnectionImportConfig = function ConnectionImportConfig(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The mode for handling connections that match existing connections.
+         *
+         * @type ConnectionImportConfig.ExistingConnectionMode
+         */
+        this.existingConnectionMode = template.existingConnectionMode
+                || ConnectionImportConfig.ExistingConnectionMode.REJECT;
+
+        /**
+         * The mode for handling permissions on existing connections that are
+         * being updated. Only meaningful if the importer is configured to
+         * replace existing connections.
+         *
+         * @type ConnectionImportConfig.ExistingPermissionMode
+         */
+        this.existingPermissionMode = template.existingPermissionMode
+                || ConnectionImportConfig.ExistingPermissionMode.PRESERVE;
+
+    };
+
+    /**
+     * Valid modes for the behavior of the importer when an imported connection
+     * already exists.
+     */
+    ConnectionImportConfig.ExistingConnectionMode = {
+
+        /**
+         * Any Connection that has the same name and parent group as an existing
+         * connection will cause the entire import to be rejected with an error.
+         */
+        REJECT : "REJECT",
+
+        /**
+         * Replace/update any existing connections.
+         */
+        REPLACE : "REPLACE"
+
+    };
+
+    /**
+     * Valid modes for the behavior of the importer with respect to connection
+     * permissions when existing connections are being replaced.
+     */
+    ConnectionImportConfig.ExistingPermissionMode = {
+
+        /**
+         * Any new permissions specified in the imported connection will be
+         * added to the existing connection, without removing any existing
+         * permissions.
+         */
+        PRESERVE : "PRESERVE",
+
+        /**
+         * Any existing permissions will be removed, ensuring that only the
+         * users or groups specified in the import file will be granted to the
+         * replaced connection after import.
+         */
+        REPLACE : "REPLACE"
+
+    };
+
+    return ConnectionImportConfig;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/frontend/src/app/import/types/DisplayErrorList.js b/guacamole/src/main/frontend/src/app/import/types/DisplayErrorList.js
new file mode 100644
index 0000000..26ee65d
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/import/types/DisplayErrorList.js
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Service which defines the DisplayErrorList class.
+ */
+angular.module('import').factory('DisplayErrorList', [
+        function defineDisplayErrorList() {
+
+    /**
+     * A list of human-readable error messages, intended to be usable in a
+     * sortable / filterable table.
+     *
+     * @constructor
+     * @param {String[]} messages
+     *     The error messages that should be prepared for display.
+     */
+    const DisplayErrorList = function DisplayErrorList(messages) {
+
+        /**
+         * The error messages that should be prepared for display.
+         *
+         * @type {String[]}
+         */
+        this.messages = messages || [];
+
+        /**
+         * The single String message composed of all messages concatenated
+         * together. This will be used for filtering / sorting, and should only
+         * be calculated once, when toString() is called.
+         *
+         * @type {String}
+         */
+        this.concatenatedMessage = null;
+
+    };
+
+    /**
+     * Return a sortable / filterable representation of all the error messages
+     * wrapped by this DisplayErrorList.
+     *
+     * NOTE: Once this method is called, any changes to the underlying array
+     * will have no effect. This is to ensure that repeated calls to toString()
+     * by sorting / filtering UI code will not regenerate the concatenated
+     * message every time.
+     *
+     * @returns {String}
+     *     A sortable / filterable representation of the error messages wrapped
+     *     by this DisplayErrorList
+     */
+    DisplayErrorList.prototype.toString = function messageListToString() {
+
+        // Generate the concatenated message if not already generated
+        if (!this.concatenatedMessage)
+            this.concatenatedMessage = this.messages.join(' ');
+
+        return this.concatenatedMessage;
+
+    }
+
+    /**
+     * Return the underlying array containing the raw error messages, wrapped
+     * by this DisplayErrorList.
+     *
+     * @returns {String[]}
+     *     The underlying array containing the raw error messages, wrapped by
+     *     this DisplayErrorList
+     */
+    DisplayErrorList.prototype.getArray = function getUnderlyingArray() {
+        return this.messages;
+    }
+
+    return DisplayErrorList;
+
+}]);
diff --git a/guacamole/src/main/frontend/src/app/import/types/ImportConnection.js b/guacamole/src/main/frontend/src/app/import/types/ImportConnection.js
new file mode 100644
index 0000000..47c13a8
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/import/types/ImportConnection.js
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Service which defines the ImportConnection class.
+ */
+angular.module('import').factory('ImportConnection', ['$injector',
+        function defineImportConnection($injector) {
+
+    /**
+     * A representation of a connection to be imported, as parsed from an
+     * user-supplied import file.
+     *
+     * @constructor
+     * @param {ImportConnection|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     Connection.
+     */
+    const ImportConnection = function ImportConnection(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The unique identifier of the connection group that contains this
+         * connection.
+         *
+         * @type String
+         */
+        this.parentIdentifier = template.parentIdentifier;
+
+        /**
+         * The path to the connection group that contains this connection,
+         * written as e.g. "ROOT/parent/child/group".
+         *
+         * @type String
+         */
+        this.group = template.group;
+
+        /**
+         * The identifier of the connection being updated. Only meaningful if
+         * the replace operation is set.
+         */
+        this.identifier = template.identifier;
+
+        /**
+         * The human-readable name of this connection, which is not necessarily
+         * unique.
+         *
+         * @type String
+         */
+        this.name = template.name;
+
+        /**
+         * The name of the protocol associated with this connection, such as
+         * "vnc" or "rdp".
+         *
+         * @type String
+         */
+        this.protocol = template.protocol;
+
+        /**
+         * Connection configuration parameters, as dictated by the protocol in
+         * use, arranged as name/value pairs.
+         *
+         * @type Object.<String, String>
+         */
+        this.parameters = template.parameters || {};
+
+        /**
+         * Arbitrary name/value pairs which further describe this connection.
+         * The semantics and validity of these attributes are dictated by the
+         * extension which defines them.
+         *
+         * @type Object.<String, String>
+         */
+        this.attributes = template.attributes || {};
+
+        /**
+         * The identifiers of all users who should be granted read access to
+         * this connection.
+         *
+         * @type String[]
+         */
+        this.users = template.users || [];
+
+        /**
+         * The identifiers of all user groups who should be granted read access
+         * to this connection.
+         *
+         * @type String[]
+         */
+        this.groups = template.groups || [];
+
+        /**
+         * The mode import mode for this connection. If not otherwise specified,
+         * a brand new connection should be created.
+         */
+        this.importMode = template.importMode || ImportConnection.ImportMode.CREATE;
+
+        /**
+         * Any errors specific to this connection encountered while parsing.
+         *
+         * @type ParseError[]
+         */
+        this.errors = template.errors || [];
+        
+    };
+
+    /**
+     * The possible import modes for a given connection.
+     */
+    ImportConnection.ImportMode = {
+
+        /**
+         * The connection should be created fresh. This mode is valid IFF there
+         * is no existing connection with the same name and parent group.
+         */
+        CREATE : "CREATE",
+
+        /**
+         * This connection will replace the existing connection with the same
+         * name and parent group.
+         */
+        REPLACE : "REPLACE"
+
+    };
+
+    return ImportConnection;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/frontend/src/app/import/types/ImportConnectionError.js b/guacamole/src/main/frontend/src/app/import/types/ImportConnectionError.js
new file mode 100644
index 0000000..9fabe23
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/import/types/ImportConnectionError.js
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Service which defines the ImportConnectionError class.
+ */
+angular.module('import').factory('ImportConnectionError', ['$injector',
+        function defineImportConnectionError($injector) {
+
+    // Required types
+    const DisplayErrorList = $injector.get('DisplayErrorList');
+
+    /**
+     * A representation of the errors associated with a connection to be
+     * imported, along with some basic information connection information to
+     * identify the connection having the error, as returned from a parsed
+     * user-supplied import file.
+     *
+     * @constructor
+     * @param {ImportConnectionError|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     ImportConnectionError.
+     */
+    const ImportConnectionError = function ImportConnectionError(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The row number within the original connection import file for this
+         * connection. This should be 1-indexed.
+         */
+        this.rowNumber = template.rowNumber;
+
+        /**
+         * The human-readable name of this connection, which is not necessarily
+         * unique.
+         *
+         * @type String
+         */
+        this.name = template.name;
+
+        /**
+         * The human-readable connection group path for this connection, of the
+         * form "ROOT/Parent/Child".
+         *
+         * @type String
+         */
+        this.group = template.group;
+
+        /**
+         * The name of the protocol associated with this connection, such as
+         * "vnc" or "rdp".
+         *
+         * @type String
+         */
+        this.protocol = template.protocol;
+
+        /**
+         * The error messages associated with this particular connection, if any.
+         *
+         * @type ImportConnectionError
+         */
+        this.errors = template.errors || new DisplayErrorList();
+
+    };
+
+    return ImportConnectionError;
+
+}]);
diff --git a/guacamole/src/main/frontend/src/app/import/types/ParseError.js b/guacamole/src/main/frontend/src/app/import/types/ParseError.js
new file mode 100644
index 0000000..b4b3dd6
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/import/types/ParseError.js
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Service which defines the ParseError class.
+ */
+angular.module('import').factory('ParseError', [function defineParseError() {
+
+    /**
+     * An error representing a parsing failure when attempting to convert
+     * user-provided data into a list of Connection objects.
+     *
+     * @constructor
+     * @param {ParseError|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     ParseError.
+     */
+    const ParseError = function ParseError(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * A human-readable message describing the error that occurred.
+         *
+         * @type String
+         */
+        this.message = template.message;
+
+        /**
+         * The key associated with the translation string that used when
+         * displaying this message.
+         *
+         * @type String
+         */
+        this.key = template.key;
+
+        /**
+         * The object which should be passed through to the translation service
+         * for the sake of variable substitution. Each property of the provided
+         * object will be substituted for the variable of the same name within
+         * the translation string.
+         *
+         * @type Object
+         */
+        this.variables = template.variables;
+
+        // If no translation key is available, fall back to the untranslated
+        // key, passing the raw message directly through the translation system
+        if (!this.key) {
+            this.key = 'APP.TEXT_UNTRANSLATED';
+            this.variables = { MESSAGE: this.message };
+        }
+
+    };
+
+    return ParseError;
+
+}]);
diff --git a/guacamole/src/main/frontend/src/app/import/types/ParseResult.js b/guacamole/src/main/frontend/src/app/import/types/ParseResult.js
new file mode 100644
index 0000000..3d15511
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/import/types/ParseResult.js
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Service which defines the ParseResult class.
+ */
+angular.module('import').factory('ParseResult', [function defineParseResult() {
+
+    /**
+     * The result of parsing a connection import file - containing a list of
+     * API patches ready to be submitted to the PATCH REST API for batch
+     * connection creation/replacement, a set of users and user groups to grant
+     * access to each connection, a group path for every connection, and any
+     * errors that may have occurred while parsing each connection.
+     *
+     * @constructor
+     * @param {ParseResult|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     ParseResult.
+     */
+    const ParseResult = function ParseResult(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * An array of patches, ready to be submitted to the PATCH REST API for
+         * batch connection creation / replacement. Note that this array may 
+         * contain more patches than connections from the original file - in the 
+         * case that connections are being fully replaced, there will be a 
+         * remove and a create patch for each replaced connection.
+         *
+         * @type {DirectoryPatch[]}
+         */
+        this.patches = template.patches || [];
+
+        /**
+         * An object whose keys are the user identifiers of users specified
+         * in the batch import, and whose values are an array of indices of
+         * connections within the patches array to which those users should be
+         * granted access.
+         *
+         * @type {Object.<String, Integer[]>}
+         */
+        this.users = template.users || {};
+
+        /**
+         * An object whose keys are the user group identifiers of every user
+         * group specified in the batch import. i.e. a set of all user group
+         * identifiers.
+         *
+         * @type {Object.<String, Boolean>}
+         */
+        this.groups = template.users || {};
+
+        /**
+         * A map of connection index within the patch array, to connection group
+         * path for that connection, of the form "ROOT/Parent/Child".
+         *
+         * @type {Object.<String, String>}
+         */
+        this.groupPaths = template.groupPaths || {};
+
+        /**
+         * An array of errors encountered while parsing the corresponding
+         * connection (at the same array index in the patches array). Each 
+         * connection should have an array of errors. If empty, no errors
+         * occurred for this connection.
+         *
+         * @type {ParseError[][]}
+         */
+        this.errors = template.errors || [];
+
+        /**
+         * True if any errors were encountered while parsing the connections
+         * represented by this ParseResult. This should always be true if there
+         * are a non-zero number of elements in the errors list for any
+         * connection, or false otherwise.
+         *
+         * @type {Boolean}
+         */
+        this.hasErrors = template.hasErrors || false;
+
+        /**
+         * The integer number of unique connections present in the parse result.
+         * This may be less than the length of the patches array, if any REMOVE
+         * patches are present.
+         *
+         * @Type {Number}
+         */
+        this.connectionCount = template.connectionCount || 0;
+
+    };
+
+    return ParseResult;
+
+}]);
diff --git a/guacamole/src/main/frontend/src/app/index/config/indexRouteConfig.js b/guacamole/src/main/frontend/src/app/index/config/indexRouteConfig.js
index d1e7875..d24df91 100644
--- a/guacamole/src/main/frontend/src/app/index/config/indexRouteConfig.js
+++ b/guacamole/src/main/frontend/src/app/index/config/indexRouteConfig.js
@@ -126,6 +126,23 @@
             resolve       : { routeToUserHomePage: routeToUserHomePage }
         })
 
+        // Connection import page
+        .when('/import/:dataSource/connection', {
+            title         : 'APP.NAME',
+            bodyClassName : 'settings',
+            templateUrl   : 'app/import/templates/connectionImport.html',
+            controller    : 'importConnectionsController',
+            resolve       : { updateCurrentToken: updateCurrentToken }
+        })
+
+        // Connection import file format help page
+        .when('/import/connection/file-format-help', {
+            title         : 'APP.NAME',
+            bodyClassName : 'settings',
+            templateUrl   : 'app/import/templates/connectionImportFileHelp.html',
+            resolve       : { updateCurrentToken: updateCurrentToken }
+        })
+
         // Management screen
         .when('/settings/:dataSource?/:tab', {
             title         : 'APP.NAME',
diff --git a/guacamole/src/main/frontend/src/app/index/controllers/indexController.js b/guacamole/src/main/frontend/src/app/index/controllers/indexController.js
index 7fc18d0..2f472a6 100644
--- a/guacamole/src/main/frontend/src/app/index/controllers/indexController.js
+++ b/guacamole/src/main/frontend/src/app/index/controllers/indexController.js
@@ -23,17 +23,31 @@
 angular.module('index').controller('indexController', ['$scope', '$injector',
         function indexController($scope, $injector) {
 
+    /**
+     * The number of milliseconds that should elapse between client-side
+     * session checks. This DOES NOT impact whether a session expires at all;
+     * such checks will always be server-side. This only affects how quickly
+     * the client-side view can recognize that a user's session has expired
+     * absent any action taken by the user.
+     *
+     * @type {!number}
+     */
+    const SESSION_VALIDITY_RECHECK_INTERVAL = 15000;
+
     // Required types
-    const Error = $injector.get('Error');
+    const Error              = $injector.get('Error');
+    const ManagedClientState = $injector.get('ManagedClientState');
 
     // Required services
-    const $document         = $injector.get('$document');
-    const $location         = $injector.get('$location');
-    const $route            = $injector.get('$route');
-    const $window           = $injector.get('$window');
-    const clipboardService  = $injector.get('clipboardService');
-    const guacNotification  = $injector.get('guacNotification');
-    const guacClientManager = $injector.get('guacClientManager');
+    const $document              = $injector.get('$document');
+    const $interval              = $injector.get('$interval');
+    const $location              = $injector.get('$location');
+    const $route                 = $injector.get('$route');
+    const $window                = $injector.get('$window');
+    const authenticationService  = $injector.get('authenticationService');
+    const clipboardService       = $injector.get('clipboardService');
+    const guacNotification       = $injector.get('guacNotification');
+    const guacClientManager      = $injector.get('guacClientManager');
 
     /**
      * The error that prevents the current page from rendering at all. If no
@@ -210,6 +224,48 @@
         keyboard.reset();
     };
 
+    /**
+     * Returns whether the current user has at least one active connection
+     * running within the current tab.
+     *
+     * @returns {!boolean}
+     *     true if the current user has at least one active connection running
+     *     in the current browser tab, false otherwise.
+     */
+    var hasActiveTunnel = function hasActiveTunnel() {
+
+        var clients = guacClientManager.getManagedClients();
+        for (var id in clients) {
+
+            switch (clients[id].clientState.connectionState) {
+                case ManagedClientState.ConnectionState.CONNECTING:
+                case ManagedClientState.ConnectionState.WAITING:
+                case ManagedClientState.ConnectionState.CONNECTED:
+                    return true;
+            }
+
+        }
+
+        return false;
+
+    };
+
+    // If we're logged in and not connected to anything, periodically check
+    // whether the current session is still valid. If the session has expired,
+    // refresh the auth state to reshow the login screen (rather than wait for
+    // the user to take some action and discover that they are not logged in
+    // after all). There is no need to do this if a connection is active as
+    // that connection activity will already automatically check session
+    // validity.
+    $interval(function cleanUpViewIfSessionInvalid() {
+        if (!!authenticationService.getCurrentToken() && !hasActiveTunnel()) {
+            authenticationService.getValidity().then(function validityDetermined(valid) {
+                if (!valid)
+                    $scope.reAuthenticate();
+            });
+        }
+    }, SESSION_VALIDITY_RECHECK_INTERVAL);
+
     // Release all keys upon form submission (there may not be corresponding
     // keyup events for key presses involved in submitting a form)
     $document.on('submit', function formSubmitted() {
diff --git a/guacamole/src/main/frontend/src/app/index/indexModule.js b/guacamole/src/main/frontend/src/app/index/indexModule.js
index e0281f4..2bcc945 100644
--- a/guacamole/src/main/frontend/src/app/index/indexModule.js
+++ b/guacamole/src/main/frontend/src/app/index/indexModule.js
@@ -35,6 +35,7 @@
     'client',
     'clipboard',
     'home',
+    'import',
     'login',
     'manage',
     'navigation',
diff --git a/guacamole/src/main/frontend/src/app/login/directives/login.js b/guacamole/src/main/frontend/src/app/login/directives/login.js
index b7967d7..80ce00f 100644
--- a/guacamole/src/main/frontend/src/app/login/directives/login.js
+++ b/guacamole/src/main/frontend/src/app/login/directives/login.js
@@ -177,51 +177,7 @@
          * authentication service, redirecting to the main view if successful.
          */
         $scope.login = function login() {
-
-            // Authentication is now in progress
-            $scope.submitted = true;
-
-            // Start with cleared status
-            $scope.loginError = null;
-
-            // Attempt login once existing session is destroyed
-            authenticationService.authenticate($scope.enteredValues)
-
-            // Retry route upon success (entered values will be cleared only
-            // after route change has succeeded as this can take time)
-            .then(function loginSuccessful() {
-                $route.reload();
-            })
-
-            // Reset upon failure
-            ['catch'](requestService.createErrorCallback(function loginFailed(error) {
-
-                // Initial submission is complete and has failed
-                $scope.submitted = false;
-
-                // Clear out passwords if the credentials were rejected for any reason
-                if (error.type !== Error.Type.INSUFFICIENT_CREDENTIALS) {
-
-                    // Flag generic error for invalid login
-                    if (error.type === Error.Type.INVALID_CREDENTIALS)
-                        $scope.loginError = {
-                            'key' : 'LOGIN.ERROR_INVALID_LOGIN'
-                        };
-
-                    // Display error if anything else goes wrong
-                    else
-                        $scope.loginError = error.translatableMessage;
-
-                    // Reset all remaining fields to default values, but
-                    // preserve any usernames
-                    angular.forEach($scope.remainingFields, function clearEnteredValueIfPassword(field) {
-                        if (field.type !== Field.Type.USERNAME && field.name in $scope.enteredValues)
-                            $scope.enteredValues[field.name] = DEFAULT_FIELD_VALUE;
-                    });
-                }
-
-            }));
-
+            authenticationService.authenticate($scope.enteredValues)['catch'](requestService.IGNORE);
         };
 
         /**
@@ -244,6 +200,48 @@
 
         };
 
+        // Update UI to reflect in-progress auth status (clear any previous
+        // errors, flag as pending)
+        $rootScope.$on('guacLoginPending', function loginSuccessful() {
+            $scope.submitted = true;
+            $scope.loginError = null;
+        });
+
+        // Retry route upon success (entered values will be cleared only
+        // after route change has succeeded as this can take time)
+        $rootScope.$on('guacLogin', function loginSuccessful() {
+            $route.reload();
+        });
+
+        // Reset upon failure
+        $rootScope.$on('guacLoginFailed', function loginFailed(event, parameters, error) {
+
+            // Initial submission is complete and has failed
+            $scope.submitted = false;
+
+            // Clear out passwords if the credentials were rejected for any reason
+            if (error.type !== Error.Type.INSUFFICIENT_CREDENTIALS) {
+
+                // Flag generic error for invalid login
+                if (error.type === Error.Type.INVALID_CREDENTIALS)
+                    $scope.loginError = {
+                        'key' : 'LOGIN.ERROR_INVALID_LOGIN'
+                    };
+
+                // Display error if anything else goes wrong
+                else
+                    $scope.loginError = error.translatableMessage;
+
+                // Reset all remaining fields to default values, but
+                // preserve any usernames
+                angular.forEach($scope.remainingFields, function clearEnteredValueIfPassword(field) {
+                    if (field.type !== Field.Type.USERNAME && field.name in $scope.enteredValues)
+                        $scope.enteredValues[field.name] = DEFAULT_FIELD_VALUE;
+                });
+            }
+
+        });
+
         // Reset state after authentication and routing have succeeded
         $rootScope.$on('$routeChangeSuccess', function routeChanged() {
             $scope.enteredValues = {};
diff --git a/guacamole/src/main/frontend/src/app/rest/services/connectionService.js b/guacamole/src/main/frontend/src/app/rest/services/connectionService.js
index 3531177..9d41db6 100644
--- a/guacamole/src/main/frontend/src/app/rest/services/connectionService.js
+++ b/guacamole/src/main/frontend/src/app/rest/services/connectionService.js
@@ -24,7 +24,6 @@
         function connectionService($injector) {
 
     // Required services
-    var requestService        = $injector.get('requestService');
     var authenticationService = $injector.get('authenticationService');
     var cacheService          = $injector.get('cacheService');
     
@@ -154,6 +153,49 @@
         }
 
     };
+
+    /**
+     * Makes a request to the REST API to apply a supplied list of connection
+     * patches, returning a promise that can be used for processing the results 
+     * of the call. 
+     * 
+     * This operation is atomic - if any errors are encountered during the 
+     * connection patching process, the entire request will fail, and no 
+     * changes will be persisted.
+     *
+     * @param {String} dataSource
+     *     The identifier of the data source associated with the connections to
+     *     be patched.
+     *
+     * @param {DirectoryPatch.<Connection>[]} patches 
+     *     An array of patches to apply.
+     *
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     patch operation is successful.
+     */
+    service.patchConnections = function patchConnections(dataSource, patches) {
+
+        // Make the PATCH request
+        return authenticationService.request({
+            method  : 'PATCH',
+            url     : 'api/session/data/' + encodeURIComponent(dataSource) + '/connections',
+            data    : patches
+        })
+
+        // Clear the cache
+        .then(function connectionsPatched(patchResponse){
+            cacheService.connections.removeAll();
+
+            // Clear users cache to force reload of permissions for any
+            // newly created or replaced connections
+            cacheService.users.removeAll();
+
+            return patchResponse;
+            
+        });
+
+    };
     
     /**
      * Makes a request to the REST API to delete a connection,
diff --git a/guacamole/src/main/frontend/src/app/rest/services/schemaService.js b/guacamole/src/main/frontend/src/app/rest/services/schemaService.js
index dee10e1..d6afa2b 100644
--- a/guacamole/src/main/frontend/src/app/rest/services/schemaService.js
+++ b/guacamole/src/main/frontend/src/app/rest/services/schemaService.js
@@ -59,6 +59,34 @@
     };
 
     /**
+     * Makes a request to the REST API to get the list of available user preference
+     * attributes, returning a promise that provides an array of @link{Form} objects
+     * if successful. Each element of the array describes a logical grouping of
+     * possible user preference attributes.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the users whose
+     *     available user preference attributes are to be retrieved. This
+     *     identifier corresponds to an AuthenticationProvider within the
+     *     Guacamole web application.
+     *
+     * @returns {Promise.<Form[]>}
+     *     A promise which will resolve with an array of @link{Form}
+     *     objects, where each @link{Form} describes a logical grouping of
+     *     possible attributes.
+     */
+    service.getUserPreferenceAttributes = function getUserPreferenceAttributes(dataSource) {
+
+        // Retrieve available user attributes
+        return authenticationService.request({
+            cache   : cacheService.schema,
+            method  : 'GET',
+            url     : 'api/session/data/' + encodeURIComponent(dataSource) + '/schema/userPreferenceAttributes'
+        });
+
+    };
+
+    /**
      * Makes a request to the REST API to get the list of available attributes
      * for user group objects, returning a promise that provides an array of
      * @link{Form} objects if successful. Each element of the array describes
diff --git a/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js b/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js
index 33a8d71..2ebb08b 100644
--- a/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js
+++ b/guacamole/src/main/frontend/src/app/rest/services/tunnelService.js
@@ -55,6 +55,15 @@
     var DOWNLOAD_CLEANUP_WAIT = 5000;
 
     /**
+     * The maximum size a chunk may be during uploadToStream() in bytes.
+     * 
+     * @private
+     * @constant
+     * @type Number
+     */
+    const CHUNK_SIZE = 1024 * 1024 * 4;
+
+    /**
      * Makes a request to the REST API to get the list of all tunnels
      * associated with in-progress connections, returning a promise that
      * provides an array of their UUIDs (strings) if successful.
@@ -294,51 +303,103 @@
                 + '/' + encodeURIComponent(sanitizeFilename(file.name))
                 + '?token=' + encodeURIComponent(authenticationService.getCurrentToken());
 
-        var xhr = new XMLHttpRequest();
+        /**
+         * Creates a chunk of the inputted file to be uploaded.
+         * 
+         * @param {Number} offset
+         *      The byte at which to begin the chunk. 
+         * 
+         * @return {File}
+         *      The file chunk created by this function.
+         */
+        const createChunk = (offset) => {
+            var chunkEnd = Math.min(offset + CHUNK_SIZE, file.size);
+            const chunk = file.slice(offset, chunkEnd);
+            return chunk;
+        };
 
-        // Invoke provided callback if upload tracking is supported
-        if (progressCallback && xhr.upload) {
-            xhr.upload.addEventListener('progress', function updateProgress(e) {
-                progressCallback(e.loaded);
-            });
-        }
+        /**
+         * POSTs the inputted chunks and recursively calls uploadHandler()
+         * until the upload is complete.
+         * 
+         * @param {File} chunk
+         *      The chunk to be uploaded to the stream.
+         * 
+         * @param {Number} offset
+         *      The byte at which the inputted chunk begins.
+         */ 
+        const uploadChunk = (chunk, offset) => {
+            var xhr = new XMLHttpRequest();
+            xhr.open('POST', url, true);
 
-        // Resolve/reject promise once upload has stopped
-        xhr.onreadystatechange = function uploadStatusChanged() {
+            // Invoke provided callback if upload tracking is supported.
+            if (progressCallback && xhr.upload) {
+                xhr.upload.addEventListener('progress', function updateProgress(e) {
+                    progressCallback(e.loaded + offset);
+                });
+            };
 
-            // Ignore state changes prior to completion
-            if (xhr.readyState !== 4)
-                return;
+            // Continue to next chunk, resolve, or reject promise as appropriate
+            // once upload has stopped
+            xhr.onreadystatechange = function uploadStatusChanged() {
 
-            // Resolve if HTTP status code indicates success
-            if (xhr.status >= 200 && xhr.status < 300)
-                deferred.resolve();
+                // Ignore state changes prior to completion.
+                if (xhr.readyState !== 4)
+                    return;
 
-            // Parse and reject with resulting JSON error
-            else if (xhr.getResponseHeader('Content-Type') === 'application/json')
-                deferred.reject(new Error(angular.fromJson(xhr.responseText)));
+                // Resolve if last chunk or begin next chunk if HTTP status
+                // code indicates success.
+                if (xhr.status >= 200 && xhr.status < 300) {
+                    offset += CHUNK_SIZE;
 
-            // Warn of lack of permission of a proxy rejects the upload
-            else if (xhr.status >= 400 && xhr.status < 500)
-                deferred.reject(new Error({
-                    'type'       : Error.Type.STREAM_ERROR,
-                    'statusCode' : Guacamole.Status.Code.CLIENT_FORBIDDEN,
-                    'message'    : 'HTTP ' + xhr.status
-                }));
+                    if (offset < file.size)
+                        uploadHandler(offset);
+                    else
+                        deferred.resolve();
+                }
 
-            // Assume internal error for all other cases
-            else
-                deferred.reject(new Error({
-                    'type'       : Error.Type.STREAM_ERROR,
-                    'statusCode' : Guacamole.Status.Code.INTERNAL_ERROR,
-                    'message'    : 'HTTP ' + xhr.status
-                }));
+                // Parse and reject with resulting JSON error
+                else if (xhr.getResponseHeader('Content-Type') === 'application/json')
+                    deferred.reject(new Error(angular.fromJson(xhr.responseText)));
+
+                // Warn of lack of permission of a proxy rejects the upload
+                else if (xhr.status >= 400 && xhr.status < 500)
+                    deferred.reject(new Error({
+                        'type': Error.Type.STREAM_ERROR,
+                        'statusCode': Guacamole.Status.Code.CLIENT_FORBIDDEN,
+                        'message': 'HTTP ' + xhr.status
+                    }));
+
+                // Assume internal error for all other cases
+                else
+                    deferred.reject(new Error({
+                        'type': Error.Type.STREAM_ERROR,
+                        'statusCode': Guacamole.Status.Code.INTERNAL_ERROR,
+                        'message': 'HTTP ' + xhr.status
+                    }));
+
+            };
+
+            // Perform upload
+            xhr.send(chunk);
 
         };
 
-        // Perform upload
-        xhr.open('POST', url, true);
-        xhr.send(file);
+        /**
+         * Handles the recursive upload process. Each time it is called, a 
+         * chunk is made with createChunk(), starting at the offset parameter.
+         * The chunk is then sent by uploadChunk(), which recursively calls 
+         * this handler until the upload process is either completed and the 
+         * promise is resolved, or fails and the promise is rejected.
+         * 
+         * @param {Number} offset
+         *      The byte at which to begin the chunk.
+         */
+        const uploadHandler = (offset) => {
+            uploadChunk(createChunk(offset), offset);
+        };
+
+        uploadHandler(0);
 
         return deferred.promise;
 
diff --git a/guacamole/src/main/frontend/src/app/rest/services/userGroupService.js b/guacamole/src/main/frontend/src/app/rest/services/userGroupService.js
index 090efa9..1476218 100644
--- a/guacamole/src/main/frontend/src/app/rest/services/userGroupService.js
+++ b/guacamole/src/main/frontend/src/app/rest/services/userGroupService.js
@@ -190,6 +190,43 @@
 
     };
 
+    /**
+     * Makes a request to the REST API to apply a supplied list of user group
+     * patches, returning a promise that can be used for processing the results
+     * of the call.
+     *
+     * This operation is atomic - if any errors are encountered during the
+     * connection patching process, the entire request will fail, and no
+     * changes will be persisted.
+     *
+     * @param {String} dataSource
+     *     The identifier of the data source associated with the user groups to
+     *     be patched.
+     *
+     * @param {DirectoryPatch.<UserGroup>[]} patches
+     *     An array of patches to apply.
+     *
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     patch operation is successful.
+     */
+    service.patchUserGroups = function patchUserGroups(dataSource, patches) {
+
+        // Make the PATCH request
+        return authenticationService.request({
+            method  : 'PATCH',
+            url     : 'api/session/data/' + encodeURIComponent(dataSource) + '/userGroups',
+            data    : patches
+        })
+
+        // Clear the cache
+        .then(function userGroupsPatched(patchResponse){
+            cacheService.users.removeAll();
+            return patchResponse;
+        });
+
+    };
+
     return service;
 
 }]);
diff --git a/guacamole/src/main/frontend/src/app/rest/services/userService.js b/guacamole/src/main/frontend/src/app/rest/services/userService.js
index faa26b4..c1d3c35 100644
--- a/guacamole/src/main/frontend/src/app/rest/services/userService.js
+++ b/guacamole/src/main/frontend/src/app/rest/services/userService.js
@@ -235,6 +235,43 @@
         });
 
     };
+
+    /**
+     * Makes a request to the REST API to apply a supplied list of user patches,
+     * returning a promise that can be used for processing the results of the
+     * call.
+     *
+     * This operation is atomic - if any errors are encountered during the
+     * connection patching process, the entire request will fail, and no
+     * changes will be persisted.
+     *
+     * @param {String} dataSource
+     *     The identifier of the data source associated with the users to be
+     *     patched.
+     *
+     * @param {DirectoryPatch.<User>[]} patches
+     *     An array of patches to apply.
+     *
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     patch operation is successful.
+     */
+    service.patchUsers = function patchUsers(dataSource, patches) {
+
+        // Make the PATCH request
+        return authenticationService.request({
+            method  : 'PATCH',
+            url     : 'api/session/data/' + encodeURIComponent(dataSource) + '/users',
+            data    : patches
+        })
+
+        // Clear the cache
+        .then(function usersPatched(patchResponse){
+            cacheService.users.removeAll();
+            return patchResponse;
+        });
+
+    };
     
     return service;
 
diff --git a/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatch.js b/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatch.js
new file mode 100644
index 0000000..664dcdc
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatch.js
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Service which defines the DirectoryPatch class.
+ */
+angular.module('rest').factory('DirectoryPatch', [function defineDirectoryPatch() {
+            
+    /**
+     * The object consumed by REST API calls when representing changes to an
+     * arbitrary set of directory-based objects.
+     * @constructor
+     * 
+     * @template DirectoryObject
+     *     The directory-based object type that this DirectoryPatch will 
+     *     operate on.
+     * 
+     * @param {DirectoryObject|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     DirectoryPatch.
+     */
+    var DirectoryPatch = function DirectoryPatch(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The operation to apply to the objects indicated by the path. Valid
+         * operation values are defined within DirectoryPatch.Operation.
+         *
+         * @type {String}
+         */
+        this.op = template.op;
+
+        /**
+         * The path of the objects to modify. For creation of new objects, this
+         * should be "/". Otherwise, it should be "/{identifier}", specifying
+         * the identifier of the existing object being modified.
+         *
+         * @type {String}
+         * @default '/'
+         */
+        this.path = template.path || '/';
+
+        /**
+         * The object being added/replaced, or undefined if deleting.
+         *
+         * @type {DirectoryObject}
+         */
+        this.value = template.value;
+
+    };
+
+    /**
+     * All valid patch operations for directory-based objects.
+     */
+    DirectoryPatch.Operation = {
+
+        /**
+         * Adds the specified object to the relation.
+         */
+        ADD : 'add',
+
+        /**
+         * Replaces (updates) the specified object from the relation.
+         */
+        REPLACE : 'replace',
+
+        /**
+         * Removes the specified object from the relation.
+         */
+        REMOVE : 'remove'
+
+    };
+
+    return DirectoryPatch;
+
+}]);
diff --git a/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatchOutcome.js b/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatchOutcome.js
new file mode 100644
index 0000000..324f1ae
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatchOutcome.js
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Service which defines the DirectoryPatchOutcome class.
+ */
+angular.module('rest').factory('DirectoryPatchOutcome', [
+        function defineDirectoryPatchOutcome() {
+            
+    /**
+     * An object returned by a PATCH request to a directory REST API, 
+     * representing the outcome associated with a particular patch in the
+     * request. This object can indicate either a successful or unsuccessful 
+     * response. The error field is only meaningful for unsuccessful patches.
+     * @constructor
+     * 
+     * @param {DirectoryPatchOutcome|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     DirectoryPatchOutcome.
+     */
+    const DirectoryPatchOutcome = function DirectoryPatchOutcome(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The operation to apply to the objects indicated by the path. Valid
+         * operation values are defined within DirectoryPatch.Operation.
+         *
+         * @type {String}
+         */
+        this.op = template.op;
+
+        /**
+         * The path of the object operated on by the corresponding patch in the
+         * request.
+         *
+         * @type {String}
+         */
+        this.path = template.path;
+
+        /**
+         * The identifier of the object operated on by the corresponding patch
+         * in the request. If the object was newly created and the PATCH request
+         * did not fail, this will be the identifier of the newly created object.
+         *
+         * @type {String}
+         */
+        this.identifier = template.identifier;
+
+        /**
+         * The error message associated with the failure, if the patch failed to
+         * apply.
+         *
+         * @type {TranslatableMessage}
+         */
+        this.error = template.error;
+
+    };
+
+    return DirectoryPatchOutcome;
+
+}]);
diff --git a/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatchResponse.js b/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatchResponse.js
new file mode 100644
index 0000000..9538c07
--- /dev/null
+++ b/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatchResponse.js
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Service which defines the DirectoryPatchResponse class.
+ */
+angular.module('rest').factory('DirectoryPatchResponse', [
+        function defineDirectoryPatchResponse() {
+
+    /**
+     * An object returned by a PATCH request to a directory REST API,
+     * representing the successful response to a patch request.
+     *
+     * @param {DirectoryPatchResponse|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     DirectoryPatchResponse.
+     */
+    const DirectoryPatchResponse = function DirectoryPatchResponse(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * An outcome for each patch in the corresponding patch request.
+         *
+         * @type {DirectoryPatchOutcome[]}
+         */
+        this.patches = template.patches;
+
+    };
+
+    return DirectoryPatchResponse;
+
+}]);
diff --git a/guacamole/src/main/frontend/src/app/rest/types/Error.js b/guacamole/src/main/frontend/src/app/rest/types/Error.js
index 47f9cf7..74b28eb 100644
--- a/guacamole/src/main/frontend/src/app/rest/types/Error.js
+++ b/guacamole/src/main/frontend/src/app/rest/types/Error.js
@@ -78,6 +78,15 @@
          */
         this.expected = template.expected;
 
+        /**
+         * The outcome for each patch that was submitted as part of the request
+         * that generated this error, if the request was a directory PATCH
+         * request. In all other cases, this will be null.
+         *
+         * @type DirectoryPatchOutcome[]
+         */
+        this.patches = template.patches || null;
+
     };
 
     /**
diff --git a/guacamole/src/main/frontend/src/app/rest/types/RelatedObjectPatch.js b/guacamole/src/main/frontend/src/app/rest/types/RelatedObjectPatch.js
index bb82def..c064938 100644
--- a/guacamole/src/main/frontend/src/app/rest/types/RelatedObjectPatch.js
+++ b/guacamole/src/main/frontend/src/app/rest/types/RelatedObjectPatch.js
@@ -82,4 +82,4 @@
 
     return RelatedObjectPatch;
 
-}]);
\ No newline at end of file
+}]);
diff --git a/guacamole/src/main/frontend/src/app/settings/controllers/connectionHistoryPlayerController.js b/guacamole/src/main/frontend/src/app/settings/controllers/connectionHistoryPlayerController.js
index b40f5f9..12c368b 100644
--- a/guacamole/src/main/frontend/src/app/settings/controllers/connectionHistoryPlayerController.js
+++ b/guacamole/src/main/frontend/src/app/settings/controllers/connectionHistoryPlayerController.js
@@ -20,7 +20,7 @@
 /**
  * The controller for the session recording player page.
  */
-angular.module('manage').controller('connectionHistoryPlayerController', ['$scope', '$injector', 
+angular.module('settings').controller('connectionHistoryPlayerController', ['$scope', '$injector',
         function connectionHistoryPlayerController($scope, $injector) {
 
     // Required services
diff --git a/guacamole/src/main/frontend/src/app/settings/controllers/settingsController.js b/guacamole/src/main/frontend/src/app/settings/controllers/settingsController.js
index a462d87..9961d29 100644
--- a/guacamole/src/main/frontend/src/app/settings/controllers/settingsController.js
+++ b/guacamole/src/main/frontend/src/app/settings/controllers/settingsController.js
@@ -20,7 +20,7 @@
 /**
  * The controller for the general settings page.
  */
-angular.module('manage').controller('settingsController', ['$scope', '$injector', 
+angular.module('settings').controller('settingsController', ['$scope', '$injector',
         function settingsController($scope, $injector) {
 
     // Required services
diff --git a/guacamole/src/main/frontend/src/app/settings/directives/guacSettingsConnections.js b/guacamole/src/main/frontend/src/app/settings/directives/guacSettingsConnections.js
index 05c86ef..38bb343 100644
--- a/guacamole/src/main/frontend/src/app/settings/directives/guacSettingsConnections.js
+++ b/guacamole/src/main/frontend/src/app/settings/directives/guacSettingsConnections.js
@@ -111,6 +111,24 @@
             };
 
             /**
+             * Returns whether the current user has the ADMINISTER system
+             * permission (i.e. they are an administrator).
+             *
+             * @return {Boolean}
+             *     true if the current user is an administrator.
+             */
+            $scope.canAdminister = function canAdminister() {
+
+                // Abort if permissions have not yet loaded
+                if (!$scope.permissions)
+                    return false;
+
+                // Return whether the current user is an administrator
+                return PermissionSet.hasSystemPermission(
+                        $scope.permissions, PermissionSet.SystemPermissionType.ADMINISTER);
+            };
+
+            /**
              * Returns whether the current user can create new connections
              * within the current data source.
              *
diff --git a/guacamole/src/main/frontend/src/app/settings/directives/guacSettingsPreferences.js b/guacamole/src/main/frontend/src/app/settings/directives/guacSettingsPreferences.js
index aad0a2e..005d239 100644
--- a/guacamole/src/main/frontend/src/app/settings/directives/guacSettingsPreferences.js
+++ b/guacamole/src/main/frontend/src/app/settings/directives/guacSettingsPreferences.js
@@ -21,7 +21,7 @@
  * A directive for managing preferences local to the current user.
  */
 angular.module('settings').directive('guacSettingsPreferences', [function guacSettingsPreferences() {
-    
+
     return {
         // Element only
         restrict: 'E',
@@ -33,16 +33,18 @@
         controller: ['$scope', '$injector', function settingsPreferencesController($scope, $injector) {
 
             // Get required types
-            var PermissionSet = $injector.get('PermissionSet');
+            const Form          = $injector.get('Form');
+            const PermissionSet = $injector.get('PermissionSet');
 
             // Required services
-            var $translate            = $injector.get('$translate');
-            var authenticationService = $injector.get('authenticationService');
-            var guacNotification      = $injector.get('guacNotification');
-            var permissionService     = $injector.get('permissionService');
-            var preferenceService     = $injector.get('preferenceService');
-            var requestService        = $injector.get('requestService');
-            var userService           = $injector.get('userService');
+            const $translate            = $injector.get('$translate');
+            const authenticationService = $injector.get('authenticationService');
+            const guacNotification      = $injector.get('guacNotification');
+            const permissionService     = $injector.get('permissionService');
+            const preferenceService     = $injector.get('preferenceService');
+            const requestService        = $injector.get('requestService');
+            const schemaService         = $injector.get('schemaService');
+            const userService           = $injector.get('userService');
 
             /**
              * An action to be provided along with the object sent to
@@ -57,6 +59,27 @@
             };
 
             /**
+             * An action which closes the current dialog, and refreshes
+             * the user data on dialog close.
+             */
+            const ACKNOWLEDGE_ACTION_RELOAD = {
+                name        : 'SETTINGS_PREFERENCES.ACTION_ACKNOWLEDGE',
+                // Handle action
+                callback    : function acknowledgeCallback() {
+                    userService.getUser(dataSource, username)
+                        .then(user => $scope.user = user)
+                        .then(() => guacNotification.showStatus(false));
+                }
+            };
+
+            /**
+             * The user being modified.
+             *
+             * @type User
+             */
+            $scope.user = null;
+
+            /**
              * The username of the current user.
              *
              * @type String
@@ -79,6 +102,15 @@
             $scope.preferences = preferenceService.preferences;
 
             /**
+             * All available user attributes. This is only the set of attribute
+             * definitions, organized as logical groupings of attributes, not attribute
+             * values.
+             *
+             * @type Form[]
+             */
+            $scope.attributes = null;
+
+            /**
              * The fields which should be displayed for choosing locale
              * preferences. Each field name must be a property on
              * $scope.preferences.
@@ -111,12 +143,13 @@
             $scope.newPasswordMatch = null;
 
             /**
-             * Whether the current user can change their own password, or null
-             * if this is not yet known.
+             * Whether the current user can edit themselves - i.e. update their
+             * password or change user preference attributes, or null if this
+             * is not yet known.
              *
              * @type Boolean
              */
-            $scope.canChangePassword = null;
+            $scope.canUpdateSelf = null;
 
             /**
              * Update the current user's password to the password currently set within
@@ -174,13 +207,13 @@
             permissionService.getEffectivePermissions(dataSource, username)
             .then(function permissionsRetrieved(permissions) {
 
-                // Add action for changing password if permission is granted
-                $scope.canChangePassword = PermissionSet.hasUserPermission(permissions,
+                // Add action for updaing password or user preferences if permission is granted
+                $scope.canUpdateSelf = PermissionSet.hasUserPermission(permissions,
                         PermissionSet.ObjectPermissionType.UPDATE, username);
                         
             })
             ['catch'](requestService.createErrorCallback(function permissionsFailed(error) {
-                $scope.canChangePassword = false;
+                $scope.canUpdateSelf = false;
             }));
 
             /**
@@ -192,12 +225,38 @@
              */
             $scope.isLoaded = function isLoaded() {
 
-                return $scope.canChangePassword !== null
-                    && $scope.languages         !== null;
+                return $scope.canUpdateSelf !== null
+                    && $scope.languages     !== null;
 
             };
 
+
+            /**
+             * Saves the current user, displaying an acknowledgement message if
+             * saving was successful, or an error if the save failed.
+             */
+            $scope.saveUser = function saveUser() {
+                return userService.saveUser(dataSource, $scope.user)
+                    .then(() =>  guacNotification.showStatus({
+                        text    : {
+                            key : 'SETTINGS_PREFERENCES.INFO_PREFERENCE_ATTRIBUTES_CHANGED'
+                        },
+
+                        // Reload the user on successful save in case any attributes changed
+                        actions : [ ACKNOWLEDGE_ACTION_RELOAD ]
+                    }),
+                    guacNotification.SHOW_REQUEST_ERROR);
+            };
+
+            // Fetch the user record
+            userService.getUser(dataSource, username).then(function saveUserData(user) {
+                $scope.user = user;
+            })
+
+            // Fetch all user preference attribute forms defined
+            schemaService.getUserPreferenceAttributes(dataSource).then(function saveAttributes(attributes) {
+                $scope.attributes = attributes;
+            });
         }]
     };
-    
 }]);
diff --git a/guacamole/src/main/frontend/src/app/settings/styles/buttons.css b/guacamole/src/main/frontend/src/app/settings/styles/buttons.css
index f925c51..294bbef 100644
--- a/guacamole/src/main/frontend/src/app/settings/styles/buttons.css
+++ b/guacamole/src/main/frontend/src/app/settings/styles/buttons.css
@@ -20,7 +20,8 @@
 a.button.add-user,
 a.button.add-user-group,
 a.button.add-connection,
-a.button.add-connection-group {
+a.button.add-connection-group,
+a.button.import-connections {
     font-size: 0.8em;
     padding-left: 1.8em;
     position: relative;
@@ -29,7 +30,8 @@
 a.button.add-user::before,
 a.button.add-user-group::before,
 a.button.add-connection::before,
-a.button.add-connection-group::before {
+a.button.add-connection-group::before,
+a.button.import-connections::before {
 
     content: ' ';
     position: absolute;
@@ -59,3 +61,7 @@
 a.button.add-connection-group::before {
     background-image: url('images/action-icons/guac-group-add.svg');
 }
+
+a.button.import-connections::before {
+    background-image: url('images/action-icons/guac-file-import.svg');
+}
diff --git a/guacamole/src/main/frontend/src/app/settings/templates/settingsConnections.html b/guacamole/src/main/frontend/src/app/settings/templates/settingsConnections.html
index ca7bc30..e90d30c 100644
--- a/guacamole/src/main/frontend/src/app/settings/templates/settingsConnections.html
+++ b/guacamole/src/main/frontend/src/app/settings/templates/settingsConnections.html
@@ -9,6 +9,10 @@
         <!-- Form action buttons -->
         <div class="action-buttons">
 
+            <a class="import-connections button"
+                ng-show="canAdminister()"
+                href="#/import/{{dataSource | escape}}/connection/">{{'SETTINGS_CONNECTIONS.ACTION_IMPORT' | translate}}</a>
+
             <a class="add-connection button"
                ng-show="canCreateConnections()"
                href="#/manage/{{dataSource | escape}}/connections/">{{'SETTINGS_CONNECTIONS.ACTION_NEW_CONNECTION' | translate}}</a>
diff --git a/guacamole/src/main/frontend/src/app/settings/templates/settingsPreferences.html b/guacamole/src/main/frontend/src/app/settings/templates/settingsPreferences.html
index cabd2ae..7c154df 100644
--- a/guacamole/src/main/frontend/src/app/settings/templates/settingsPreferences.html
+++ b/guacamole/src/main/frontend/src/app/settings/templates/settingsPreferences.html
@@ -7,8 +7,8 @@
     </div>
     
     <!-- Password update -->
-    <h2 class="header" ng-show="canChangePassword">{{'SETTINGS_PREFERENCES.SECTION_HEADER_UPDATE_PASSWORD' | translate}}</h2>
-    <div class="settings section update-password" ng-show="canChangePassword">
+    <h2 class="header" ng-show="canUpdateSelf">{{'SETTINGS_PREFERENCES.SECTION_HEADER_UPDATE_PASSWORD' | translate}}</h2>
+    <div class="settings section update-password" ng-show="canUpdateSelf">
         <p>{{'SETTINGS_PREFERENCES.HELP_UPDATE_PASSWORD' | translate}}</p>
 
         <!-- Password editor -->
@@ -89,4 +89,13 @@
         </div>
     </div>
 
+    <!-- User attributes section -->
+    <div class="attributes" ng-show="canUpdateSelf && attributes.length">
+        <guac-form namespace="'USER_ATTRIBUTES'" content="attributes"
+                   model="user.attributes"></guac-form>
+
+        <!-- User attributes save button -->
+        <button ng-show="attributes.length" ng-click="saveUser()" class="save">{{'SETTINGS_PREFERENCES.ACTION_SAVE' | translate}}</button>
+    </div>
+
 </div>
diff --git a/guacamole/src/main/frontend/src/images/action-icons/guac-file-import.svg b/guacamole/src/main/frontend/src/images/action-icons/guac-file-import.svg
new file mode 100644
index 0000000..4b29411
--- /dev/null
+++ b/guacamole/src/main/frontend/src/images/action-icons/guac-file-import.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><path d="M8.813 0A1.813 1.813 0 0 0 7 1.814v60.373C7 63.188 7.812 64 8.813 64h48.374C58.188 64 59 63.188 59 62.187V13.89c0-.482-.19-.943-.531-1.284L46.375.531c-.34-.34-.8-.53-1.281-.531H8.812zm37.49 3.02 10.885 10.867H46.303V3.02zM32.422 12a1.642 1.642 0 0 1 1.258.586L50.459 32.6a1.642 1.642 0 0 1-1.258 2.697h-8.84v17.98a1.642 1.642 0 0 1-1.64 1.643H26.623a1.642 1.642 0 0 1-1.643-1.643v-17.98h-9.337a1.642 1.642 0 0 1-1.258-2.697l16.78-20.014A1.642 1.642 0 0 1 32.421 12z" style="fill:#fff;stroke-width:1.35948"/></svg>
\ No newline at end of file
diff --git a/guacamole/src/main/frontend/src/images/question.svg b/guacamole/src/main/frontend/src/images/question.svg
new file mode 100644
index 0000000..062ccfa
--- /dev/null
+++ b/guacamole/src/main/frontend/src/images/question.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><g style="stroke-width:1.04766"><g style="font-style:normal;font-weight:400;font-size:142.558px;line-height:100%;font-family:Sans;letter-spacing:0;word-spacing:0;fill:#000;fill-opacity:1;stroke:none;stroke-width:3.20748px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"><path d="M169.301 354.693c-60.575-41.36-30.288-20.68 0 0zm-.627 90.839c-60.157-101.919-30.079-50.96 0 0z" aria-label="!" style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-size:142.558px;line-height:100%;font-family:Roboto;-inkscape-font-specification:&quot;Roboto Heavy&quot;;text-align:center;text-anchor:middle;fill:#000;stroke-width:3.20748px" transform="matrix(.3118 0 0 .31173 -24.457 -91.229)"/></g><path d="M32 .75A31.25 31.25 0 0 0 .75 32 31.25 31.25 0 0 0 32 63.25 31.25 31.25 0 0 0 63.25 32 31.25 31.25 0 0 0 32 .75Zm0 5A26.25 26.25 0 0 1 58.25 32 26.25 26.25 0 0 1 32 58.25 26.25 26.25 0 0 1 5.75 32 26.25 26.25 0 0 1 32 5.75Z" style="fill:#000"/><g style="font-size:40px;line-height:1.25"><path d="M27.775 45.836h5.551v6.945h-5.55zm5.387-4.02H27.94v-4.21q0-2.762.766-4.54.766-1.777 3.227-4.129l2.46-2.433q1.56-1.45 2.243-2.734.71-1.286.71-2.625 0-2.434-1.804-3.938-1.777-1.504-4.73-1.504-2.16 0-4.622.957-2.433.957-5.085 2.79v-5.141q2.57-1.559 5.195-2.325 2.652-.765 5.469-.765 5.03 0 8.066 2.652 3.062 2.652 3.062 7 0 2.078-.984 3.965-.984 1.86-3.445 4.21l-2.406 2.352q-1.286 1.286-1.832 2.024-.52.71-.739 1.394-.164.575-.246 1.395-.082.82-.082 2.242z" aria-label="?" style="font-size:56px;-inkscape-font-specification:&quot;sans-serif, Normal&quot;"/></g></g></svg>
\ No newline at end of file
diff --git a/guacamole/src/main/frontend/src/translations/ca.json b/guacamole/src/main/frontend/src/translations/ca.json
index 1a7ed51..21e0d48 100644
--- a/guacamole/src/main/frontend/src/translations/ca.json
+++ b/guacamole/src/main/frontend/src/translations/ca.json
@@ -554,6 +554,7 @@
         "FIELD_OPTION_SERVER_LAYOUT_JA_JP_QWERTY" : "Japonès (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_PL_PL_QWERTY" : "Polonès (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_PT_BR_QWERTY" : "Portuguès brasiler (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_PT_PT_QWERTY" : "Portuguès (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Suec (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_DA_DK_QWERTY" : "Danès (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_TR_TR_QWERTY" : "Q-Turc (Qwerty)",
diff --git a/guacamole/src/main/frontend/src/translations/cs.json b/guacamole/src/main/frontend/src/translations/cs.json
index 04c7a3a..53ba7f5 100644
--- a/guacamole/src/main/frontend/src/translations/cs.json
+++ b/guacamole/src/main/frontend/src/translations/cs.json
@@ -13,6 +13,7 @@
         "ACTION_DELETE_SESSIONS"    : "Ukončit sezení",
         "ACTION_DOWNLOAD"           : "Stáhnout",
         "ACTION_LOGIN"              : "Přihlásit",
+        "ACTION_LOGIN_AGAIN"        : "Znovu přihlásit",
         "ACTION_LOGOUT"             : "Odhlásit",
         "ACTION_MANAGE_CONNECTIONS" : "Připojení",
         "ACTION_MANAGE_PREFERENCES" : "Vlastnosti",
@@ -22,11 +23,14 @@
         "ACTION_MANAGE_USER_GROUPS" : "Skupiny",
         "ACTION_NAVIGATE_BACK"      : "Zpět",
         "ACTION_NAVIGATE_HOME"      : "Domů",
+        "ACTION_PAUSE"              : "Pozastavit",
+        "ACTION_PLAY"               : "Přehrát",
         "ACTION_SAVE"               : "Uložit",
         "ACTION_SEARCH"             : "Hledat",
         "ACTION_SHARE"              : "Sdílet",
         "ACTION_UPDATE_PASSWORD"    : "Změnit heslo",
         "ACTION_VIEW_HISTORY"       : "Historie",
+        "ACTION_VIEW_RECORDING"     : "Zobrazit",
 
         "DIALOG_HEADER_ERROR" : "Chyba",
 
@@ -42,6 +46,7 @@
         "FORMAT_DATE_TIME_PRECISE" : "yyyy-MM-dd HH:mm:ss",
 
         "INFO_ACTIVE_USER_COUNT" : "V současné době používá {USERS} {USERS, plural, one{uživatel} other{uživatelů}}.",
+        "INFO_LOGGED_OUT"        : "Byli jste odhlášeni.",
 
         "TEXT_ANONYMOUS_USER"   : "Neznámý uživatel",
         "TEXT_HISTORY_DURATION" : "{VALUE} {UNIT, select, second{{VALUE, plural, one{sekundu} other{sekund}}} minute{{VALUE, plural, one{minutu} other{minut}}} hour{{VALUE, plural, one{hodinu} other{hodin}}} day{{VALUE, plural, one{den} other{dnů}}} other{}}",
@@ -52,7 +57,10 @@
     "CLIENT" : {
 
         "ACTION_ACKNOWLEDGE"               : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"                    : "@:APP.ACTION_CANCEL",
+        "ACTION_CLEAR_CLIENT_MESSAGES"     : "Vyčistit",
         "ACTION_CLEAR_COMPLETED_TRANSFERS" : "Vyčistit",
+        "ACTION_CONTINUE"                  : "@:APP.ACTION_CONTINUE",
         "ACTION_DISCONNECT"                : "Odpojit",
         "ACTION_LOGOUT"                    : "@:APP.ACTION_LOGOUT",
         "ACTION_NAVIGATE_BACK"             : "@:APP.ACTION_NAVIGATE_BACK",
@@ -116,8 +124,9 @@
         "HELP_MOUSE_MODE_RELATIVE" : "Přetažením myši posuňte ukazatel myši a klepněte na tlačítko. Kliknutí nastane v místě ukazatele.",
         "HELP_SHARE_LINK"          : "Aktuální připojení je sdíleno a může k němu přistupovat kdokoli s následujícím {LINKS, plural, one{odkazem} other{odkazy}}:",
 
-        "INFO_CONNECTION_SHARED" : "Toto připojení je nyní sdíleno.",
-        "INFO_NO_FILE_TRANSFERS" : "Žádné přenosy souborů.",
+        "INFO_ANONYMOUS_USER_COUNT" : "Anonymní{COUNT, plural, one{} other{ (#)}}",
+        "INFO_CONNECTION_SHARED"    : "Toto připojení je nyní sdíleno.",
+        "INFO_NO_FILE_TRANSFERS"    : "Žádné přenosy souborů.",
 
         "NAME_INPUT_METHOD_NONE"   : "Žádné",
         "NAME_INPUT_METHOD_OSK"    : "Na obrazovce, klávesnice",
@@ -129,21 +138,28 @@
         "NAME_MOUSE_MODE_ABSOLUTE" : "Dotyková obrazovka",
         "NAME_MOUSE_MODE_RELATIVE" : "Touchpad",
 
-        "SECTION_HEADER_CLIPBOARD"      : "Schránka",
-        "SECTION_HEADER_DEVICES"        : "Zařízení",
-        "SECTION_HEADER_DISPLAY"        : "Zobrazení",
-        "SECTION_HEADER_FILE_TRANSFERS" : "Přenos souborů",
-        "SECTION_HEADER_INPUT_METHOD"   : "Metoda vstupu",
-        "SECTION_HEADER_MOUSE_MODE"     : "Mód emulace myši",
+        "SECTION_HEADER_CLIENT_MESSAGES" : "Zprávy",
+        "SECTION_HEADER_CLIPBOARD"       : "Schránka",
+        "SECTION_HEADER_DEVICES"         : "Zařízení",
+        "SECTION_HEADER_DISPLAY"         : "Zobrazení",
+        "SECTION_HEADER_FILE_TRANSFERS"  : "Přenos souborů",
+        "SECTION_HEADER_INPUT_METHOD"    : "Metoda vstupu",
+        
+        "SECTION_HEADER_MOUSE_MODE"      : "Mód emulace myši",
 
+        "TEXT_ANONYMOUS_USER_JOINED"      : "K připojení se připojil anonymní uživatel.",
+        "TEXT_ANONYMOUS_USER_LEFT"        : "Anonymní uživatel opustil připojení.",
         "TEXT_ZOOM_AUTO_FIT"              : "Automaticky přizpůsobit prohlížeč oknu",
         "TEXT_CLIENT_STATUS_IDLE"         : "Nečinný",
         "TEXT_CLIENT_STATUS_CONNECTING"   : "Připojuji ke Guacamole...",
         "TEXT_CLIENT_STATUS_DISCONNECTED" : "Byl jste odpojen.",
         "TEXT_CLIENT_STATUS_UNSTABLE"     : "Síťové spojení ke  Guacamole serveru se zdá nestabilní.",
         "TEXT_CLIENT_STATUS_WAITING"      : "Připojen ke Guacamole. Čekání na odpověď...",
+        "TEXT_USER_JOINED"                : "{USERNAME} se připojil ke spojení.",
+        "TEXT_USER_LEFT"                  : "{USERNAME} opustil spojení.",
         "TEXT_RECONNECT_COUNTDOWN"        : "Znovu připojuji  {REMAINING} {REMAINING, plural, one{sekundu} other{sekund}}...",
         "TEXT_FILE_TRANSFER_PROGRESS"     : "{PROGRESS} {UNIT, select, b{B} kb{KB} mb{MB} gb{GB} other{}}",
+        "TEXT_CLIPBOARD_AWAITING_FOCUS"   : "Kliknutím zobrazíte obsah schránky...",
 
         "URL_OSK_LAYOUT" : "layouts/en-us-qwerty.json"
 
@@ -380,33 +396,45 @@
 
     },
 
+    "PLAYER" : {
+
+        "ACTION_CANCEL" : "@:APP.ACTION_CANCEL",
+        "ACTION_PAUSE"  : "@:APP.ACTION_PAUSE",
+        "ACTION_PLAY"   : "@:APP.ACTION_PLAY",
+
+        "INFO_LOADING_RECORDING" : "Vaše nahrávka se nyní načítá. Čekejte prosím...",
+        "INFO_SEEK_IN_PROGRESS"  : "Hledání požadované pozice. Čekejte prosím..."
+
+    },
+
     "PROTOCOL_KUBERNETES" : {
 
-        "FIELD_HEADER_BACKSPACE"                : "Klávesa Zpět odešle:",
-        "FIELD_HEADER_CA_CERT"                  : "Certifikát certifikační autority:",
-        "FIELD_HEADER_CLIENT_CERT"              : "Klientský certifikát:",
-        "FIELD_HEADER_CLIENT_KEY"               : "Klientský klíč:",
-        "FIELD_HEADER_COLOR_SCHEME"             : "Barevné schéma:",
-        "FIELD_HEADER_CONTAINER"                : "Jméno kontejneru:",
-        "FIELD_HEADER_CREATE_RECORDING_PATH"    : "Automaticky vytvořit cestu k záznamu:",
-        "FIELD_HEADER_CREATE_TYPESCRIPT_PATH"   : "Automaticky vytvořit cestu ke strojopisu:",
-        "FIELD_HEADER_FONT_NAME"                : "Jméno fontu:",
-        "FIELD_HEADER_FONT_SIZE"                : "Velikost fontu:",
-        "FIELD_HEADER_HOSTNAME"                 : "Jméno hostitele:",
-        "FIELD_HEADER_IGNORE_CERT"              : "Ignorovat serverový certifikát:",
-        "FIELD_HEADER_NAMESPACE"                : "Obor názvů:",
-        "FIELD_HEADER_POD"                      : "Jméno podu:",
-        "FIELD_HEADER_PORT"                     : "Port:",
-        "FIELD_HEADER_READ_ONLY"                : "Pouze ke čtení:",
+        "FIELD_HEADER_BACKSPACE"       : "Klávesa Zpět odešle:",
+        "FIELD_HEADER_CA_CERT"         : "Certifikát certifikační autority:",
+        "FIELD_HEADER_CLIENT_CERT"     : "Klientský certifikát:",
+        "FIELD_HEADER_CLIENT_KEY"      : "Klientský klíč:",
+        "FIELD_HEADER_COLOR_SCHEME"    : "Barevné schéma:",
+        "FIELD_HEADER_CONTAINER"       : "Jméno kontejneru:",
+        "FIELD_HEADER_CREATE_RECORDING_PATH"  : "Automaticky vytvořit cestu k záznamu:",
+        "FIELD_HEADER_CREATE_TYPESCRIPT_PATH" : "Automaticky vytvořit cestu ke strojopisu:",
+        "FIELD_HEADER_EXEC_COMMAND"    : "Příkaz (exec):",
+        "FIELD_HEADER_FONT_NAME"       : "Jméno fontu:",
+        "FIELD_HEADER_FONT_SIZE"       : "Velikost fontu:",
+        "FIELD_HEADER_HOSTNAME"        : "Jméno hostitele:",
+        "FIELD_HEADER_IGNORE_CERT"     : "Ignorovat serverový certifikát:",
+        "FIELD_HEADER_NAMESPACE"       : "Obor názvů:",
+        "FIELD_HEADER_POD"             : "Jméno podu:",
+        "FIELD_HEADER_PORT"            : "Port:",
+        "FIELD_HEADER_READ_ONLY"       : "Pouze ke čtení:",
         "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "Vyloučit myš:",
         "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Vyloučit grafiku/streamování:",
         "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "Zahrnout události kláves:",
-        "FIELD_HEADER_RECORDING_NAME"           : "Jméno záznamu:",
-        "FIELD_HEADER_RECORDING_PATH"           : "Cesta k záznamu:",
-        "FIELD_HEADER_SCROLLBACK"               : "Maximální délka historie:",
-        "FIELD_HEADER_TYPESCRIPT_NAME"          : "Jméno strojopisu:",
-        "FIELD_HEADER_TYPESCRIPT_PATH"          : "Cesta ke strojopisu:",
-        "FIELD_HEADER_USE_SSL"                  : "Použít SSL/TLS",
+        "FIELD_HEADER_RECORDING_NAME"  : "Jméno záznamu:",
+        "FIELD_HEADER_RECORDING_PATH"  : "Cesta k záznamu:",
+        "FIELD_HEADER_SCROLLBACK"      : "Maximální délka historie:",
+        "FIELD_HEADER_TYPESCRIPT_NAME" : "Jméno strojopisu:",
+        "FIELD_HEADER_TYPESCRIPT_PATH" : "Cesta ke strojopisu:",
+        "FIELD_HEADER_USE_SSL"         : "Použít SSL/TLS",
 
         "FIELD_OPTION_BACKSPACE_EMPTY" : "",
         "FIELD_OPTION_BACKSPACE_8"     : "Zpět (Ctrl-H)",
@@ -448,22 +476,22 @@
 
     "PROTOCOL_RDP" : {
 
-        "FIELD_HEADER_CLIENT_NAME"                : "Jméno klienta:",
-        "FIELD_HEADER_COLOR_DEPTH"                : "Barevná hloubka:",
-        "FIELD_HEADER_CONSOLE"                    : "Konzola pro správu:",
-        "FIELD_HEADER_CONSOLE_AUDIO"              : "Podpora zvuku v konzole:",
-        "FIELD_HEADER_CREATE_DRIVE_PATH"          : "Automaticky vytvořit disk:",
-        "FIELD_HEADER_CREATE_RECORDING_PATH"      : "Automaticky vytvořit cestu k záznamu:",
-        "FIELD_HEADER_DISABLE_AUDIO"              : "Zakázat zvuk:",
-        "FIELD_HEADER_DISABLE_AUTH"               : "Zakázat ověřování:",
-        "FIELD_HEADER_DISABLE_COPY"               : "Zakázat kopírování ze vzdálené plochy:",
-        "FIELD_HEADER_DISABLE_DOWNLOAD"           : "Zakázat stahování souborů:",
-        "FIELD_HEADER_DISABLE_PASTE"              : "Zakázat vkládání z klienta:",
-        "FIELD_HEADER_DISABLE_UPLOAD"             : "Zakázat nahrávání souborů:",
-        "FIELD_HEADER_DOMAIN"                     : "Doména:",
-        "FIELD_HEADER_DPI"                        : "Rozlišení (DPI):",
-        "FIELD_HEADER_DRIVE_NAME"                 : "Název jednotky:",
-        "FIELD_HEADER_DRIVE_PATH"                 : "Cesta na disku:",
+        "FIELD_HEADER_CLIENT_NAME"     : "Jméno klienta:",
+        "FIELD_HEADER_COLOR_DEPTH"     : "Barevná hloubka:",
+        "FIELD_HEADER_CONSOLE"         : "Konzola pro správu:",
+        "FIELD_HEADER_CONSOLE_AUDIO"   : "Podpora zvuku v konzole:",
+        "FIELD_HEADER_CREATE_DRIVE_PATH" : "Automaticky vytvořit disk:",
+        "FIELD_HEADER_CREATE_RECORDING_PATH" : "Automaticky vytvořit cestu k záznamu:",
+        "FIELD_HEADER_DISABLE_AUDIO"   : "Zakázat zvuk:",
+        "FIELD_HEADER_DISABLE_AUTH"    : "Zakázat ověřování:",
+        "FIELD_HEADER_DISABLE_COPY"    : "Zakázat kopírování ze vzdálené plochy:",
+        "FIELD_HEADER_DISABLE_DOWNLOAD" : "Zakázat stahování souborů:",
+        "FIELD_HEADER_DISABLE_PASTE"   : "Zakázat vkládání z klienta:",
+        "FIELD_HEADER_DISABLE_UPLOAD"   : "Zakázat nahrávání souborů:",
+        "FIELD_HEADER_DOMAIN"          : "Doména:",
+        "FIELD_HEADER_DPI"             : "Rozlišení (DPI):",
+        "FIELD_HEADER_DRIVE_NAME"       : "Název jednotky:",
+        "FIELD_HEADER_DRIVE_PATH"       : "Cesta na disku:",
         "FIELD_HEADER_ENABLE_AUDIO_INPUT"         : "Povolit zvukový vstup (mikrofon):",
         "FIELD_HEADER_ENABLE_DESKTOP_COMPOSITION" : "Povolit kompozici pracovní plochy (Aero):",
         "FIELD_HEADER_ENABLE_DRIVE"               : "Povolit jednotku:",
@@ -473,42 +501,47 @@
         "FIELD_HEADER_DISABLE_BITMAP_CACHING"     : "Zakázat ukládání do mezipaměti bitmap:",
         "FIELD_HEADER_DISABLE_OFFSCREEN_CACHING"  : "Zakázat ukládání do mezipaměti mimo obrazovku:",
         "FIELD_HEADER_DISABLE_GLYPH_CACHING"      : "Zakázat ukládání do mezipaměti glyfů:",
+        "FIELD_HEADER_DISABLE_GFX"                : "Zakázat rozšíření Graphics Pipeline:",
         "FIELD_HEADER_ENABLE_PRINTING"            : "Povolit tisk:",
-        "FIELD_HEADER_ENABLE_SFTP"                : "Povolit SFTP:",
+        "FIELD_HEADER_ENABLE_SFTP"     : "Povolit SFTP:",
         "FIELD_HEADER_ENABLE_THEMING"             : "Povolit motivy:",
+        "FIELD_HEADER_ENABLE_TOUCH"               : "Povolit vícedotykové ovládání:",
         "FIELD_HEADER_ENABLE_WALLPAPER"           : "Povolit tapetu:",
-        "FIELD_HEADER_GATEWAY_DOMAIN"             : "Doména:",
-        "FIELD_HEADER_GATEWAY_HOSTNAME"           : "Jméno hostitele:",
-        "FIELD_HEADER_GATEWAY_PASSWORD"           : "Heslo:",
-        "FIELD_HEADER_GATEWAY_PORT"               : "Port:",
-        "FIELD_HEADER_GATEWAY_USERNAME"           : "Uživatelské jméno:",
-        "FIELD_HEADER_HEIGHT"                     : "Výška:",
-        "FIELD_HEADER_HOSTNAME"                   : "Jméno hostitele:",
-        "FIELD_HEADER_IGNORE_CERT"                : "Ignorovat serverový certifikát:",
-        "FIELD_HEADER_INITIAL_PROGRAM"            : "Úvodní program:",
-        "FIELD_HEADER_LOAD_BALANCE_INFO"          : "Vyvážení zátěže info/cookie:",
-        "FIELD_HEADER_PASSWORD"                   : "Heslo:",
-        "FIELD_HEADER_PORT"                       : "Port:",
-        "FIELD_HEADER_PRINTER_NAME"               : "Název přesměrované tiskárny:",
-        "FIELD_HEADER_PRECONNECTION_BLOB"         : "Preconnection BLOB (VM ID):",
-        "FIELD_HEADER_PRECONNECTION_ID"           : "Zdrojové ID RDP:",
-        "FIELD_HEADER_READ_ONLY"                  : "Pouze ke čtení:",
-        "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"    : "Vyloučit myš:",
-        "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT"   : "Vyloučit grafiku/strímování:",
-        "FIELD_HEADER_RECORDING_INCLUDE_KEYS"     : "Zahrnout klíčové události:",
-        "FIELD_HEADER_RECORDING_NAME"             : "Název záznamu:",
-        "FIELD_HEADER_RECORDING_PATH"             : "Cesta pro záznam:",
-        "FIELD_HEADER_RESIZE_METHOD"              : "Metoda změny velikosti:",
-        "FIELD_HEADER_REMOTE_APP_ARGS"            : "Parametry:",
-        "FIELD_HEADER_REMOTE_APP_DIR"             : "Pracovní adresář:",
-        "FIELD_HEADER_REMOTE_APP"                 : "Program:",
-        "FIELD_HEADER_SECURITY"                   : "Bezpečnostní mód:",
-        "FIELD_HEADER_SERVER_LAYOUT"              : "Rozložení klávesnice:",
+        "FIELD_HEADER_FORCE_LOSSLESS"             : "Vynutit bezeztrátovou kompresi:",
+        "FIELD_HEADER_GATEWAY_DOMAIN"   : "Doména:",
+        "FIELD_HEADER_GATEWAY_HOSTNAME" : "Jméno hostitele:",
+        "FIELD_HEADER_GATEWAY_PASSWORD" : "Heslo:",
+        "FIELD_HEADER_GATEWAY_PORT"     : "Port:",
+        "FIELD_HEADER_GATEWAY_USERNAME" : "Uživatelské jméno:",
+        "FIELD_HEADER_HEIGHT"          : "Výška:",
+        "FIELD_HEADER_HOSTNAME"        : "Jméno hostitele:",
+        "FIELD_HEADER_IGNORE_CERT"     : "Ignorovat serverový certifikát:",
+        "FIELD_HEADER_INITIAL_PROGRAM" : "Úvodní program:",
+        "FIELD_HEADER_LOAD_BALANCE_INFO" : "Vyvážení zátěže info/cookie:",
+        "FIELD_HEADER_NORMALIZE_CLIPBOARD" : "Konce řádků:",
+        "FIELD_HEADER_PASSWORD"        : "Heslo:",
+        "FIELD_HEADER_PORT"            : "Port:",
+        "FIELD_HEADER_PRINTER_NAME"    : "Název přesměrované tiskárny:",
+        "FIELD_HEADER_PRECONNECTION_BLOB" : "Preconnection BLOB (VM ID):",
+        "FIELD_HEADER_PRECONNECTION_ID"   : "Zdrojové ID RDP:",
+        "FIELD_HEADER_READ_ONLY"      : "Pouze ke čtení:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "Vyloučit myš:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Vyloučit grafiku/strímování:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_TOUCH"  : "Vyloučit dotykové události:",
+        "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "Zahrnout klíčové události:",
+        "FIELD_HEADER_RECORDING_NAME" : "Název záznamu:",
+        "FIELD_HEADER_RECORDING_PATH" : "Cesta pro záznam:",
+        "FIELD_HEADER_RESIZE_METHOD" : "Metoda změny velikosti:",
+        "FIELD_HEADER_REMOTE_APP_ARGS" : "Parametry:",
+        "FIELD_HEADER_REMOTE_APP_DIR"  : "Pracovní adresář:",
+        "FIELD_HEADER_REMOTE_APP"      : "Program:",
+        "FIELD_HEADER_SECURITY"        : "Bezpečnostní mód:",
+        "FIELD_HEADER_SERVER_LAYOUT"   : "Rozložení klávesnice:",
         "FIELD_HEADER_SFTP_DIRECTORY"             : "Výchozí složka pro uložení záznamu:",
         "FIELD_HEADER_SFTP_DISABLE_DOWNLOAD"      : "Zakázat stahování souborů:",
         "FIELD_HEADER_SFTP_HOST_KEY"              : "Veřejný klíč hosta (Base64):",
         "FIELD_HEADER_SFTP_HOSTNAME"              : "Jméno hostitele:",
-        "FIELD_HEADER_SFTP_SERVER_ALIVE_INTERVAL" : "SFTP keepalive interval:",
+        "FIELD_HEADER_SFTP_SERVER_ALIVE_INTERVAL" : "Interval udržování SFTP:",
         "FIELD_HEADER_SFTP_PASSPHRASE"            : "Přístupová fráze:",
         "FIELD_HEADER_SFTP_PASSWORD"              : "Heslo:",
         "FIELD_HEADER_SFTP_PORT"                  : "Port:",
@@ -516,14 +549,20 @@
         "FIELD_HEADER_SFTP_ROOT_DIRECTORY"        : "Souborový prohlížeč kořenové složky:",
         "FIELD_HEADER_SFTP_DISABLE_UPLOAD"        : "Zakázat nahrávání souborů:",
         "FIELD_HEADER_SFTP_USERNAME"              : "Uživatelské jméno:",
-        "FIELD_HEADER_STATIC_CHANNELS"            : "Názvy statických kanálů:",
-        "FIELD_HEADER_TIMEZONE"                   : "Časová zóna:",
-        "FIELD_HEADER_USERNAME"                   : "Uživatelské jméno:",
-        "FIELD_HEADER_WIDTH"                      : "Šířka:",
-        "FIELD_HEADER_WOL_BROADCAST_ADDR"         : "Vysílací adresa pro paket WoL:",
-        "FIELD_HEADER_WOL_MAC_ADDR"               : "MAC adresa vzdáleného hosta:",
-        "FIELD_HEADER_WOL_SEND_PACKET"            : "Odeslat paket WoL:",
-        "FIELD_HEADER_WOL_WAIT_TIME"              : "Čekací doba potřebná pro spuštení hosta:",
+        "FIELD_HEADER_STATIC_CHANNELS" : "Názvy statických kanálů:",
+        "FIELD_HEADER_TIMEZONE"        : "Časová zóna:",
+        "FIELD_HEADER_USERNAME"        : "Uživatelské jméno:",
+        "FIELD_HEADER_WIDTH"           : "Šířka:",
+        "FIELD_HEADER_WOL_BROADCAST_ADDR" : "Vysílací adresa pro paket WoL:",
+        "FIELD_HEADER_WOL_MAC_ADDR"       : "MAC adresa vzdáleného hosta:",
+        "FIELD_HEADER_WOL_SEND_PACKET"    : "Odeslat paket WoL:",
+        "FIELD_HEADER_WOL_UDP_PORT"       : "UDP port pro paket WoL: ",
+        "FIELD_HEADER_WOL_WAIT_TIME"      : "Doba čekání na spuštění hostitele:",
+
+        "FIELD_OPTION_NORMALIZE_CLIPBOARD_EMPTY"    : "",
+        "FIELD_OPTION_NORMALIZE_CLIPBOARD_PRESERVE" : "Zachovat tak, jak je",
+        "FIELD_OPTION_NORMALIZE_CLIPBOARD_UNIX"     : "Linux/Mac/Unix (LF)",
+        "FIELD_OPTION_NORMALIZE_CLIPBOARD_WINDOWS"  : "Windows (CRLF)",
 
         "FIELD_OPTION_COLOR_DEPTH_16"    : "Nízké barvy (16-bitů)",
         "FIELD_OPTION_COLOR_DEPTH_24"    : "Opravdové barvy (24-bitů)",
@@ -542,25 +581,28 @@
         "FIELD_OPTION_SECURITY_TLS"       : "Šifrování TLS",
         "FIELD_OPTION_SECURITY_VMCONNECT" : "Hyper-V / VMConnect",
 
-        "FIELD_OPTION_SERVER_LAYOUT_DE_CH_QWERTZ"    : "Švícarská Němčina (Qwertz)",
-        "FIELD_OPTION_SERVER_LAYOUT_DE_DE_QWERTZ"    : "Němčina (Qwertz)",
-        "FIELD_OPTION_SERVER_LAYOUT_EMPTY"           : "",
-        "FIELD_OPTION_SERVER_LAYOUT_EN_GB_QWERTY"    : "UK Angličtina (Qwerty)",
-        "FIELD_OPTION_SERVER_LAYOUT_EN_US_QWERTY"    : "US Angličtina (Qwerty)",
-        "FIELD_OPTION_SERVER_LAYOUT_ES_ES_QWERTY"    : "Španělština (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_DE_CH_QWERTZ" : "Švícarská Němčina (Qwertz)",
+        "FIELD_OPTION_SERVER_LAYOUT_DE_DE_QWERTZ" : "Němčina (Qwertz)",
+        "FIELD_OPTION_SERVER_LAYOUT_EMPTY"        : "",
+        "FIELD_OPTION_SERVER_LAYOUT_EN_GB_QWERTY" : "UK Angličtina (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_EN_US_QWERTY" : "US Angličtina (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_ES_ES_QWERTY" : "Španělština (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_ES_LATAM_QWERTY" : "Standardní Angličtina (Qwerty)",
-        "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE"        : "Unicode",
-        "FIELD_OPTION_SERVER_LAYOUT_FR_BE_AZERTY"    : "Belgická Francouzština (Azerty)",
-        "FIELD_OPTION_SERVER_LAYOUT_FR_CH_QWERTZ"    : "Švícarská Francouzština (Qwertz)",
-        "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY"    : "Francouzština (Azerty)",
-        "FIELD_OPTION_SERVER_LAYOUT_HU_HU_QWERTZ"    : "Maďarština (Qwertz)",
-        "FIELD_OPTION_SERVER_LAYOUT_IT_IT_QWERTY"    : "Italština (Qwerty)",
-        "FIELD_OPTION_SERVER_LAYOUT_JA_JP_QWERTY"    : "Japonština (Qwerty)",
-        "FIELD_OPTION_SERVER_LAYOUT_PL_PL_QWERTY"    : "Polské (Qwerty)",
-        "FIELD_OPTION_SERVER_LAYOUT_PT_BR_QWERTY"    : "Portugalská Brazilština (Qwerty)",
-        "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY"    : "Švédština (Qwerty)",
-        "FIELD_OPTION_SERVER_LAYOUT_DA_DK_QWERTY"    : "Dánština (Qwerty)",
-        "FIELD_OPTION_SERVER_LAYOUT_TR_TR_QWERTY"    : "Turečtina (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE"     : "Unicode",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_BE_AZERTY" : "Belgická Francouzština (Azerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_CA_QWERTY" : "Kanadská francouzština (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_CH_QWERTZ" : "Švícarská Francouzština (Qwertz)",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "Francouzština (Azerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_HU_HU_QWERTZ" : "Maďarština (Qwertz)",
+        "FIELD_OPTION_SERVER_LAYOUT_IT_IT_QWERTY" : "Italština (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_JA_JP_QWERTY" : "Japonština (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_NO_NO_QWERTY" : "Norština (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_PL_PL_QWERTY" : "Polské (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_PT_BR_QWERTY" : "Portugalská Brazilština (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_PT_PT_QWERTY" : "Portugalská (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Švédština (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_DA_DK_QWERTY" : "Dánština (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_TR_TR_QWERTY" : "Turečtina (Qwerty)",
 
         "NAME" : "RDP",
 
@@ -583,43 +625,44 @@
 
     "PROTOCOL_SSH" : {
 
-        "FIELD_HEADER_BACKSPACE"                : "Klávesa Zpět odešle:",
-        "FIELD_HEADER_COLOR_SCHEME"             : "Barva:",
-        "FIELD_HEADER_COMMAND"                  : "Provést příkaz:",
-        "FIELD_HEADER_CREATE_RECORDING_PATH"    : "Automaticky vytvořit cestu pro uložení záznamu:",
-        "FIELD_HEADER_CREATE_TYPESCRIPT_PATH"   : "Automaticky vytvořit cestu ke strojopisu:",
-        "FIELD_HEADER_DISABLE_COPY"             : "Zakázat kopírování ze vzdáleného terminálu:",
-        "FIELD_HEADER_DISABLE_PASTE"            : "Zakázat vkládání z klienta:",
-        "FIELD_HEADER_FONT_NAME"                : "Typ fontu:",
-        "FIELD_HEADER_FONT_SIZE"                : "Velikost písma:",
-        "FIELD_HEADER_ENABLE_SFTP"              : "Povolit SFTP:",
-        "FIELD_HEADER_HOST_KEY"                 : "Veřejný klíč hosta (Base64):",
-        "FIELD_HEADER_HOSTNAME"                 : "Jméno hostitele:",
-        "FIELD_HEADER_LOCALE"                   : "Jazyk/Lokalizace ($LANG):",
-        "FIELD_HEADER_USERNAME"                 : "Uživatelské jméno:",
-        "FIELD_HEADER_PASSWORD"                 : "Heslo:",
-        "FIELD_HEADER_PASSPHRASE"               : "Přístupová fráze:",
-        "FIELD_HEADER_PORT"                     : "Port:",
-        "FIELD_HEADER_PRIVATE_KEY"              : "Privátní klíč:",
-        "FIELD_HEADER_SCROLLBACK"               : "Maximální délka historie:",
-        "FIELD_HEADER_READ_ONLY"                : "Pouze ke čtení:",
+        "FIELD_HEADER_BACKSPACE"    : "Klávesa Zpět odešle:",
+        "FIELD_HEADER_COLOR_SCHEME" : "Barva:",
+        "FIELD_HEADER_COMMAND"      : "Provést příkaz:",
+        "FIELD_HEADER_CREATE_RECORDING_PATH" : "Automaticky vytvořit cestu pro uložení záznamu:",
+        "FIELD_HEADER_CREATE_TYPESCRIPT_PATH" : "Automaticky vytvořit cestu ke strojopisu:",
+        "FIELD_HEADER_DISABLE_COPY"  : "Zakázat kopírování ze vzdáleného terminálu:",
+        "FIELD_HEADER_DISABLE_PASTE" : "Zakázat vkládání z klienta:",
+        "FIELD_HEADER_FONT_NAME"     : "Typ fontu:",
+        "FIELD_HEADER_FONT_SIZE"     : "Velikost písma:",
+        "FIELD_HEADER_ENABLE_SFTP"   : "Povolit SFTP:",
+        "FIELD_HEADER_HOST_KEY"      : "Veřejný klíč hosta (Base64):",
+        "FIELD_HEADER_HOSTNAME"      : "Jméno hostitele:",
+        "FIELD_HEADER_LOCALE"        : "Jazyk/Lokalizace ($LANG):",
+        "FIELD_HEADER_USERNAME"      : "Uživatelské jméno:",
+        "FIELD_HEADER_PASSWORD"      : "Heslo:",
+        "FIELD_HEADER_PASSPHRASE"    : "Přístupová fráze:",
+        "FIELD_HEADER_PORT"          : "Port:",
+        "FIELD_HEADER_PRIVATE_KEY"   : "Privátní klíč:",
+        "FIELD_HEADER_SCROLLBACK"    : "Maximální délka historie:",
+        "FIELD_HEADER_READ_ONLY"     : "Pouze ke čtení:",
         "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "Vynechat myš:",
         "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Vynechat grafiku/streamování:",
         "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "Zahrnout události kláves:",
-        "FIELD_HEADER_RECORDING_NAME"           : "Název záznamu:",
-        "FIELD_HEADER_RECORDING_PATH"           : "Cesta pro záznam:",
-        "FIELD_HEADER_SERVER_ALIVE_INTERVAL"    : "Serverový keepalive interval:",
-        "FIELD_HEADER_SFTP_DISABLE_DOWNLOAD"    : "Zakázat stahování souborů:",
-        "FIELD_HEADER_SFTP_ROOT_DIRECTORY"      : "Souborový prohlížeč kořenové složky:",
-        "FIELD_HEADER_SFTP_DISABLE_UPLOAD"      : "Zakázat nahrávání souborů:",
-        "FIELD_HEADER_TERMINAL_TYPE"            : "Typ terminálu:",
-        "FIELD_HEADER_TIMEZONE"                 : "Časová zóna ($TZ):",
-        "FIELD_HEADER_TYPESCRIPT_NAME"          : "Jméno strojopisu:",
-        "FIELD_HEADER_TYPESCRIPT_PATH"          : "Cesta ke strojopisu:",
-        "FIELD_HEADER_WOL_BROADCAST_ADDR"       : "Vysílací adresa pro paket WoL:",
-        "FIELD_HEADER_WOL_MAC_ADDR"             : "MAC adresa vzdáleného hosta:",
-        "FIELD_HEADER_WOL_SEND_PACKET"          : "Odeslat paket WoL:",
-        "FIELD_HEADER_WOL_WAIT_TIME"            : "Čekací doba potřebná pro spuštení hosta:",
+        "FIELD_HEADER_RECORDING_NAME" : "Název záznamu:",
+        "FIELD_HEADER_RECORDING_PATH" : "Cesta pro záznam:",
+        "FIELD_HEADER_SERVER_ALIVE_INTERVAL" : "Serverový keepalive interval:",
+        "FIELD_HEADER_SFTP_DISABLE_DOWNLOAD" : "Zakázat stahování souborů:",
+        "FIELD_HEADER_SFTP_ROOT_DIRECTORY"   : "Souborový prohlížeč kořenové složky:",
+        "FIELD_HEADER_SFTP_DISABLE_UPLOAD"   : "Zakázat nahrávání souborů:",
+        "FIELD_HEADER_TERMINAL_TYPE"   : "Typ terminálu:",
+        "FIELD_HEADER_TIMEZONE"        : "Časová zóna ($TZ):",
+        "FIELD_HEADER_TYPESCRIPT_NAME" : "Jméno strojopisu:",
+        "FIELD_HEADER_TYPESCRIPT_PATH" : "Cesta ke strojopisu:",
+        "FIELD_HEADER_WOL_BROADCAST_ADDR" : "Vysílací adresa pro paket WoL:",
+        "FIELD_HEADER_WOL_MAC_ADDR"       : "MAC adresa vzdáleného hosta:",
+        "FIELD_HEADER_WOL_SEND_PACKET"    : "Odeslat paket WoL:",
+        "FIELD_HEADER_WOL_UDP_PORT"       : "UDP port pro paket WoL:",
+        "FIELD_HEADER_WOL_WAIT_TIME"      : "Doba čekání na spuštění hostitele:",
 
         "FIELD_OPTION_BACKSPACE_EMPTY" : "",
         "FIELD_OPTION_BACKSPACE_8"     : "Zpět (Ctrl-H)",
@@ -672,36 +715,37 @@
 
     "PROTOCOL_TELNET" : {
 
-        "FIELD_HEADER_BACKSPACE"                : "Klávesa Zpět odešle:",
-        "FIELD_HEADER_COLOR_SCHEME"             : "Barevné schéma:",
-        "FIELD_HEADER_CREATE_RECORDING_PATH"    : "Automaticky vytvořit cestu pro uložení záznamu:",
-        "FIELD_HEADER_CREATE_TYPESCRIPT_PATH"   : "Automaticky vytvořit cestu ke strojopisu:",
-        "FIELD_HEADER_DISABLE_COPY"             : "Zakázat kopírování z terminálu:",
-        "FIELD_HEADER_DISABLE_PASTE"            : "Zakázat vkládání z klienta:",
-        "FIELD_HEADER_FONT_NAME"                : "Jméno fontu:",
-        "FIELD_HEADER_FONT_SIZE"                : "Velikost fontu:",
-        "FIELD_HEADER_HOSTNAME"                 : "Jméno hostitele:",
-        "FIELD_HEADER_LOGIN_FAILURE_REGEX"      : "Selhání přihlášení regulární výraz:",
-        "FIELD_HEADER_LOGIN_SUCCESS_REGEX"      : "Úspěch přihlášení regulární výraz:",
-        "FIELD_HEADER_USERNAME"                 : "Uživatelské jméno:",
-        "FIELD_HEADER_USERNAME_REGEX"           : "Uživatelské jméno regulární výraz:",
-        "FIELD_HEADER_PASSWORD"                 : "Heslo:",
-        "FIELD_HEADER_PASSWORD_REGEX"           : "Heslo regulární výraz:",
-        "FIELD_HEADER_PORT"                     : "Port:",
-        "FIELD_HEADER_READ_ONLY"                : "Pouze ke čtení:",
+        "FIELD_HEADER_BACKSPACE"      : "Klávesa Zpět odešle:",
+        "FIELD_HEADER_COLOR_SCHEME"   : "Barevné schéma:",
+        "FIELD_HEADER_CREATE_RECORDING_PATH" : "Automaticky vytvořit cestu pro uložení záznamu:",
+        "FIELD_HEADER_CREATE_TYPESCRIPT_PATH" : "Automaticky vytvořit cestu ke strojopisu:",
+        "FIELD_HEADER_DISABLE_COPY"   : "Zakázat kopírování z terminálu:",
+        "FIELD_HEADER_DISABLE_PASTE"  : "Zakázat vkládání z klienta:",
+        "FIELD_HEADER_FONT_NAME"      : "Jméno fontu:",
+        "FIELD_HEADER_FONT_SIZE"      : "Velikost fontu:",
+        "FIELD_HEADER_HOSTNAME"       : "Jméno hostitele:",
+        "FIELD_HEADER_LOGIN_FAILURE_REGEX" : "Přihlášení se nezdařilo regulární výraz:",
+        "FIELD_HEADER_LOGIN_SUCCESS_REGEX" : "Přihlášení bylo úspěšné regulární výraz:",
+        "FIELD_HEADER_USERNAME"       : "Uživatelské jméno:",
+        "FIELD_HEADER_USERNAME_REGEX" : "Uživatelské jméno regulární výraz:",
+        "FIELD_HEADER_PASSWORD"       : "Heslo:",
+        "FIELD_HEADER_PASSWORD_REGEX" : "Heslo regulární výraz:",
+        "FIELD_HEADER_PORT"           : "Port:",
+        "FIELD_HEADER_READ_ONLY"      : "Pouze ke čtení:",
         "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "Vyloučit myš:",
         "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Vyloučit grafiku/streamování:",
         "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "Zahrnout klíčové události:",
-        "FIELD_HEADER_RECORDING_NAME"           : "Název záznamu:",
-        "FIELD_HEADER_RECORDING_PATH"           : "Cesta pro záznam:",
-        "FIELD_HEADER_SCROLLBACK"               : "Maximální délka historie:",
-        "FIELD_HEADER_TERMINAL_TYPE"            : "Typ terminálu:",
-        "FIELD_HEADER_TYPESCRIPT_NAME"          : "Jméno strojopisu:",
-        "FIELD_HEADER_TYPESCRIPT_PATH"          : "Cesta ke strojopisu:",
-        "FIELD_HEADER_WOL_BROADCAST_ADDR"       : "Vysílací adresa pro paket WoL:",
-        "FIELD_HEADER_WOL_MAC_ADDR"             : "MAC adresa vzdáleného hosta:",
-        "FIELD_HEADER_WOL_SEND_PACKET"          : "Odeslat paket WoL:",
-        "FIELD_HEADER_WOL_WAIT_TIME"            : "Čekací doba potřebná pro spuštení hosta:",
+        "FIELD_HEADER_RECORDING_NAME" : "Název záznamu:",
+        "FIELD_HEADER_RECORDING_PATH" : "Cesta pro záznam:",
+        "FIELD_HEADER_SCROLLBACK"     : "Maximální délka historie:",
+        "FIELD_HEADER_TERMINAL_TYPE"   : "Typ terminálu:",
+        "FIELD_HEADER_TYPESCRIPT_NAME" : "Jméno strojopisu:",
+        "FIELD_HEADER_TYPESCRIPT_PATH" : "Cesta ke strojopisu:",
+        "FIELD_HEADER_WOL_BROADCAST_ADDR" : "Vysílací adresa pro paket WoL:",
+        "FIELD_HEADER_WOL_MAC_ADDR"       : "MAC adresa vzdáleného hosta:",
+        "FIELD_HEADER_WOL_SEND_PACKET"    : "Odeslat paket WoL:",
+        "FIELD_HEADER_WOL_UDP_PORT"       : "UDP port pro paket WoL:",
+        "FIELD_HEADER_WOL_WAIT_TIME"      : "Doba čekání na spuštění hostitele:",
 
         "FIELD_OPTION_BACKSPACE_EMPTY" : "",
         "FIELD_OPTION_BACKSPACE_8"     : "Zpět (Ctrl-H)",
@@ -752,32 +796,33 @@
 
     "PROTOCOL_VNC" : {
 
-        "FIELD_HEADER_AUDIO_SERVERNAME"           : "Název zvukového serveru:",
-        "FIELD_HEADER_CLIPBOARD_ENCODING"         : "Kódovávání:",
-        "FIELD_HEADER_COLOR_DEPTH"                : "Hloubka barev:",
-        "FIELD_HEADER_CREATE_RECORDING_PATH"      : "Automaticky vytvořit cestu pro uložení záznamu:",
-        "FIELD_HEADER_CURSOR"                     : "Kurzor:",
-        "FIELD_HEADER_DEST_HOST"                  : "Cílový host:",
-        "FIELD_HEADER_DEST_PORT"                  : "Vzdálený port:",
-        "FIELD_HEADER_DISABLE_COPY"               : "Zakázat kopírování ze vzdálené plochy:",
-        "FIELD_HEADER_DISABLE_PASTE"              : "Zakázat vkládání z klienta:",
-        "FIELD_HEADER_ENABLE_AUDIO"               : "Zapnout audio:",
-        "FIELD_HEADER_ENABLE_SFTP"                : "Povolit SFTP:",
-        "FIELD_HEADER_HOSTNAME"                   : "Jméno hostitele:",
-        "FIELD_HEADER_USERNAME"                   : "Uživatelské jméno:",
-        "FIELD_HEADER_PASSWORD"                   : "Heslo:",
-        "FIELD_HEADER_PORT"                       : "Port:",
-        "FIELD_HEADER_READ_ONLY"                  : "Pouze čtení:",
-        "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"    : "Vynechat myš:",
-        "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT"   : "Vynechat grafiku/stremování:",
-        "FIELD_HEADER_RECORDING_INCLUDE_KEYS"     : "Zahrnout události kláves:",
-        "FIELD_HEADER_RECORDING_NAME"             : "Název záznamu:",
-        "FIELD_HEADER_RECORDING_PATH"             : "Cesta pro záznam:",
+        "FIELD_HEADER_AUDIO_SERVERNAME" : "Název zvukového serveru:",
+        "FIELD_HEADER_CLIPBOARD_ENCODING" : "Kódovávání:",
+        "FIELD_HEADER_COLOR_DEPTH"      : "Hloubka barev:",
+        "FIELD_HEADER_CREATE_RECORDING_PATH" : "Automaticky vytvořit cestu pro uložení záznamu:",
+        "FIELD_HEADER_CURSOR"           : "Kurzor:",
+        "FIELD_HEADER_DEST_HOST"        : "Cílový host:",
+        "FIELD_HEADER_DEST_PORT"        : "Vzdálený port:",
+        "FIELD_HEADER_DISABLE_COPY"     : "Zakázat kopírování ze vzdálené plochy:",
+        "FIELD_HEADER_DISABLE_PASTE"    : "Zakázat vkládání z klienta:",
+        "FIELD_HEADER_ENABLE_AUDIO"     : "Zapnout audio:",
+        "FIELD_HEADER_ENABLE_SFTP"      : "Povolit SFTP:",
+        "FIELD_HEADER_FORCE_LOSSLESS"   : "Vynutit bezeztrátovou kompresi:",
+        "FIELD_HEADER_HOSTNAME"         : "Jméno hostitele:",
+        "FIELD_HEADER_USERNAME"         : "Uživatelské jméno:",
+        "FIELD_HEADER_PASSWORD"         : "Heslo:",
+        "FIELD_HEADER_PORT"             : "Port:",
+        "FIELD_HEADER_READ_ONLY"        : "Pouze čtení:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "Vynechat myš:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Vynechat grafiku/stremování:",
+        "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "Zahrnout události kláves:",
+        "FIELD_HEADER_RECORDING_NAME" : "Název záznamu:",
+        "FIELD_HEADER_RECORDING_PATH" : "Cesta pro záznam:",
         "FIELD_HEADER_SFTP_DIRECTORY"             : "Výchozí složka pro uložení záznamu:",
         "FIELD_HEADER_SFTP_DISABLE_DOWNLOAD"      : "Zakázat stahování souborů:",
         "FIELD_HEADER_SFTP_HOST_KEY"              : "Veřejný klíč hosta (Base64):",
         "FIELD_HEADER_SFTP_HOSTNAME"              : "Jméno hostitele:",
-        "FIELD_HEADER_SFTP_SERVER_ALIVE_INTERVAL" : "SFTP keepalive interval:",
+        "FIELD_HEADER_SFTP_SERVER_ALIVE_INTERVAL" : "Interval udržování SFTP:",
         "FIELD_HEADER_SFTP_PASSPHRASE"            : "Přístupová fráze:",
         "FIELD_HEADER_SFTP_PASSWORD"              : "Heslo:",
         "FIELD_HEADER_SFTP_PORT"                  : "Port:",
@@ -785,11 +830,12 @@
         "FIELD_HEADER_SFTP_ROOT_DIRECTORY"        : "Souborový prohlížeč kořenové složky:",
         "FIELD_HEADER_SFTP_DISABLE_UPLOAD"        : "Zakázat nahrávání souborů:",
         "FIELD_HEADER_SFTP_USERNAME"              : "Uživatelské jméno:",
-        "FIELD_HEADER_SWAP_RED_BLUE"              : "Přehodit červené/modré komponenty:",
-        "FIELD_HEADER_WOL_BROADCAST_ADDR"         : "Vysílací adresa pro paket WoL:",
-        "FIELD_HEADER_WOL_MAC_ADDR"               : "MAC adresa vzdáleného hosta:",
-        "FIELD_HEADER_WOL_SEND_PACKET"            : "Odeslat paket WoL:",
-        "FIELD_HEADER_WOL_WAIT_TIME"              : "Čekací doba potřebná pro spuštení hosta:",
+        "FIELD_HEADER_SWAP_RED_BLUE"    : "Přehodit červené/modré komponenty:",
+        "FIELD_HEADER_WOL_BROADCAST_ADDR" : "Vysílací adresa pro paket WoL:",
+        "FIELD_HEADER_WOL_MAC_ADDR"       : "MAC adresa vzdáleného hosta:",
+        "FIELD_HEADER_WOL_SEND_PACKET"    : "Odeslat paket WoL:",
+        "FIELD_HEADER_WOL_UDP_PORT"       : "UDP port pro paket WoL:",
+        "FIELD_HEADER_WOL_WAIT_TIME"      : "Doba čekání na spuštění hostitele:",
 
         "FIELD_OPTION_COLOR_DEPTH_8"     : "256 barev",
         "FIELD_OPTION_COLOR_DEPTH_16"    : "Nízké barvy (16-bitů)",
@@ -831,6 +877,7 @@
 
         "ACTION_DOWNLOAD" : "@:APP.ACTION_DOWNLOAD",
         "ACTION_SEARCH"   : "@:APP.ACTION_SEARCH",
+        "ACTION_VIEW_RECORDING" : "@:APP.ACTION_VIEW_RECORDING",
 
         "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
 
@@ -845,8 +892,9 @@
 
         "TABLE_HEADER_SESSION_CONNECTION_NAME" : "Název připojení",
         "TABLE_HEADER_SESSION_DURATION"        : "Doba trvání",
+        "TABLE_HEADER_SESSION_LOGS"            : "Protokoly",
         "TABLE_HEADER_SESSION_REMOTEHOST"      : "Vzdálený host",
-        "TABLE_HEADER_SESSION_STARTDATE"       : "Počáteční čas",
+        "TABLE_HEADER_SESSION_STARTDATE"       : "Doba spuštění",
         "TABLE_HEADER_SESSION_USERNAME"        : "Uživatelské jméno",
 
         "TEXT_HISTORY_DURATION" : "@:APP.TEXT_HISTORY_DURATION"
@@ -876,6 +924,7 @@
 
         "ACTION_ACKNOWLEDGE"        : "@:APP.ACTION_ACKNOWLEDGE",
         "ACTION_CANCEL"             : "@:APP.ACTION_CANCEL",
+        "ACTION_SAVE"               : "@:APP.ACTION_SAVE",
         "ACTION_UPDATE_PASSWORD"    : "@:APP.ACTION_UPDATE_PASSWORD",
 
         "DIALOG_HEADER_ERROR"    : "@:APP.DIALOG_HEADER_ERROR",
@@ -902,6 +951,7 @@
         "HELP_UPDATE_PASSWORD"      : "Pokud chcete změnit heslo, zadejte své aktuální heslo a níže požadované nové heslo a klikněte na tlačítko „Aktualizovat heslo“. Změna se projeví okamžitě.",
 
         "INFO_PASSWORD_CHANGED" : "Heslo bylo změněno.",
+        "INFO_PREFERENCE_ATTRIBUTES_CHANGED" : "Uživatelská nastavení uložena.",
 
         "NAME_INPUT_METHOD_NONE" : "@:CLIENT.NAME_INPUT_METHOD_NONE",
         "NAME_INPUT_METHOD_OSK"  : "@:CLIENT.NAME_INPUT_METHOD_OSK",
diff --git a/guacamole/src/main/frontend/src/translations/de.json b/guacamole/src/main/frontend/src/translations/de.json
index 982cf59..f252c61 100644
--- a/guacamole/src/main/frontend/src/translations/de.json
+++ b/guacamole/src/main/frontend/src/translations/de.json
@@ -459,6 +459,7 @@
         "FIELD_OPTION_SERVER_LAYOUT_JA_JP_QWERTY" : "Japanisch (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_PL_PL_QWERTY" : "Polnisches (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_PT_BR_QWERTY" : "Portugiesisch (BR) (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_PT_PT_QWERTY" : "Portugiesisch (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Schwedisch (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_TR_TR_QWERTY" : "Türkisch (Qwerty)",
 
diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json
index 036949b..0826f33 100644
--- a/guacamole/src/main/frontend/src/translations/en.json
+++ b/guacamole/src/main/frontend/src/translations/en.json
@@ -9,11 +9,13 @@
 
         "ACTION_ACKNOWLEDGE"        : "OK",
         "ACTION_CANCEL"             : "Cancel",
+        "ACTION_CLEAR"              : "Clear",
         "ACTION_CLONE"              : "Clone",
         "ACTION_CONTINUE"           : "Continue",
         "ACTION_DELETE"             : "Delete",
         "ACTION_DELETE_SESSIONS"    : "Kill Sessions",
         "ACTION_DOWNLOAD"           : "Download",
+        "ACTION_IMPORT"             : "Import",
         "ACTION_LOGIN"              : "Login",
         "ACTION_LOGIN_AGAIN"        : "Re-login",
         "ACTION_LOGOUT"             : "Logout",
@@ -39,6 +41,7 @@
         "ERROR_PAGE_UNAVAILABLE"  : "An error has occurred and this action cannot be completed. If the problem persists, please notify your system administrator or check your system logs.",
         "ERROR_PASSWORD_BLANK"    : "Your password cannot be blank.",
         "ERROR_PASSWORD_MISMATCH" : "The provided passwords do not match.",
+        "ERROR_SINGLE_FILE_ONLY"  : "Please upload only a single file at a time",
         
         "FIELD_HEADER_PASSWORD"       : "Password:",
         "FIELD_HEADER_PASSWORD_AGAIN" : "Re-enter Password:",
@@ -60,8 +63,8 @@
 
         "ACTION_ACKNOWLEDGE"               : "@:APP.ACTION_ACKNOWLEDGE",
         "ACTION_CANCEL"                    : "@:APP.ACTION_CANCEL",
-        "ACTION_CLEAR_CLIENT_MESSAGES"     : "Clear",
-        "ACTION_CLEAR_COMPLETED_TRANSFERS" : "Clear",
+        "ACTION_CLEAR_CLIENT_MESSAGES"     : "@:APP.ACTION_CLEAR",
+        "ACTION_CLEAR_COMPLETED_TRANSFERS" : "@:APP.ACTION_CLEAR",
         "ACTION_CONTINUE"                  : "@:APP.ACTION_CONTINUE",
         "ACTION_DISCONNECT"                : "Disconnect",
         "ACTION_LOGOUT"                    : "@:APP.ACTION_LOGOUT",
@@ -162,6 +165,7 @@
         "TEXT_USER_LEFT"                  : "{USERNAME} has left the connection.",
         "TEXT_RECONNECT_COUNTDOWN"        : "Reconnecting in {REMAINING} {REMAINING, plural, one{second} other{seconds}}...",
         "TEXT_FILE_TRANSFER_PROGRESS"     : "{PROGRESS} {UNIT, select, b{B} kb{KB} mb{MB} gb{GB} other{}}",
+        "TEXT_CLIPBOARD_AWAITING_FOCUS"   : "Click to view clipboard data...",
 
         "URL_OSK_LAYOUT" : "layouts/en-us-qwerty.json"
 
@@ -183,6 +187,80 @@
 
     },
 
+    "IMPORT": {
+
+        "ACTION_ACKNOWLEDGE"        : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_BROWSE"             : "Browse for File",
+        "ACTION_CANCEL"             : "@:APP.ACTION_CANCEL",
+        "ACTION_CLEAR"              : "@:APP.ACTION_CLEAR",
+        "ACTION_VIEW_FORMAT_HELP"   : "View Format Tips",
+        "ACTION_IMPORT"             : "@:APP.ACTION_IMPORT",
+        "ACTION_IMPORT_CONNECTIONS" : "Import Connections",
+
+        "DIALOG_HEADER_ERROR"   : "@:APP.DIALOG_HEADER_ERROR",
+        "DIALOG_HEADER_SUCCESS" : "Success",
+
+        "ERROR_AMBIGUOUS_CSV_HEADER"         : "Ambiguous CSV Header \"{HEADER}\" could be either a connection attribute or parameter",
+        "ERROR_AMBIGUOUS_PARENT_GROUP"       : "Both group and parentIdentifier may be not specified at the same time",
+        "ERROR_ARRAY_REQUIRED"               : "The provided file must contain a list of connections",
+        "ERROR_DETECTED_INVALID_TYPE"        : "Unsupported file type. Please make sure the file is valid CSV, JSON, or YAML.",
+        "ERROR_DUPLICATE_CONNECTION_IN_FILE" : "Duplicated connection \"{NAME}\" at \"{PATH}\" in import file",
+        "ERROR_DUPLICATE_CSV_HEADER"         : "Duplicate CSV Header: {HEADER}",
+        "ERROR_EMPTY_FILE"                   : "The provided file is empty",
+        "ERROR_INVALID_CSV_HEADER"           : "Invalid CSV Header \"{HEADER}\" is neither an attribute or parameter",
+        "ERROR_INVALID_MIME_TYPE"            : "Unsupported file type: \"{TYPE}\"",
+        "ERROR_INVALID_GROUP"                : "No group matching \"{GROUP}\" found",
+        "ERROR_INVALID_GROUP_IDENTIFIER"     : "No connection group with identifier \"{IDENTIFIER}\" found",
+        "ERROR_INVALID_GROUP_TYPE"           : "Invalid group - must be a string.",
+        "ERROR_INVALID_PROTOCOL"             : "Invalid protocol \"{PROTOCOL}\"",
+        "ERROR_INVALID_USER_GROUPS_TYPE"     : "Invalid user groups - must be an array of user group identifiers.",
+        "ERROR_INVALID_USERS_TYPE"           : "Invalid users - must be an array of user identifiers.",
+        "ERROR_NO_FILE_SUPPLIED"             : "Please select a file to import",
+        "ERROR_PARSE_FAILURE_CSV"            : "Please make sure your file is valid CSV. Parsing failed with error \"{ERROR}\". ",
+        "ERROR_PARSE_FAILURE_JSON"           : "Please make sure your file is valid JSON. Parsing failed with error \"{ERROR}\". ",
+        "ERROR_PARSE_FAILURE_YAML"           : "Please make sure your file is valid YAML. Parsing failed with error \"{ERROR}\". ",
+        "ERROR_REJECT_UPDATE_CONNECTION"     : "Connection \"{NAME}\" already exists at \"{PATH}\"",
+        "ERROR_REQUIRED_NAME_CONNECTION"     : "The connection name is required",
+        "ERROR_REQUIRED_PROTOCOL_CONNECTION" : "The connection protocol is required",
+        "ERROR_REQUIRED_NAME_FILE"           : "No connection name found in the provided file",
+        "ERROR_REQUIRED_PROTOCOL_FILE"       : "No connection protocol found in the provided file",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "FIELD_HEADER_EXISTING_CONNECTION_MODE" : "Replace/Update existing connections",
+        "FIELD_HEADER_EXISTING_PERMISSION_MODE" : "Reset permissions",
+
+        "HELP_CSV_DESCRIPTION"              : "A connection import CSV file has one connection record per row. Each column will specify a connection field. At minimum the connection name and protocol must be specified.",
+        "HELP_CSV_EXAMPLE"                  : "name,protocol,username,password,hostname,group,users,groups,guacd-encryption (attribute)\nconn1,vnc,alice,pass1,conn1.web.com,ROOT,guac user 1;guac user 2,Connection 1 Users,none\nconn2,rdp,bob,pass2,conn2.web.com,ROOT/Parent Group,guac user 1,,ssl\nconn3,ssh,carol,pass3,conn3.web.com,ROOT/Parent Group/Child Group,guac user 2;guac user 3,,\nconn4,kubernetes,,,,,,,",
+        "HELP_CSV_MORE_DETAILS"             : "The CSV header for each row specifies the connection field. The connection group ID that the connection should be imported into may be directly specified with \"parentIdentifier\", or the path to the parent group may be specified using \"group\" as shown below. In most cases, there should be no conflict between fields, but if needed, an \" (attribute)\" or \" (parameter)\" suffix may be added to disambiguate. Lists of user or user group identifiers must be semicolon-separated.¹",
+        "HELP_FILE_TYPE_DESCRIPTION"        : "Three file types are supported for connection import: CSV, JSON, and YAML. The same data may be specified by each file type. This must include the connection name and protocol. Optionally, a connection group location, a list of users and/or user groups to grant access, connection parameters, or connection protocols may also be specified. Any users or user groups that do not exist in the current data source will be automatically created. Note that any existing connection permissions will not be removed for updated connections, unless \"Reset permissions\" is checked.",
+        "HELP_FILE_TYPE_HEADER"             : "File Types",
+        "HELP_JSON_DESCRIPTION"             : "A connection import JSON file is a list of connection objects. At minimum the connection name and protocol must be specified in each connection object.",
+        "HELP_JSON_EXAMPLE"                 : "[\n  \\{\n    \"name\": \"conn1\",\n    \"protocol\": \"vnc\",\n    \"parameters\": \\{ \"username\": \"alice\", \"password\": \"pass1\", \"hostname\": \"conn1.web.com\" \\},\n    \"parentIdentifier\": \"ROOT\",\n    \"users\": [ \"guac user 1\", \"guac user 2\" ],\n    \"groups\": [ \"Connection 1 Users\" ],\n    \"attributes\": \\{ \"guacd-encryption\": \"none\" \\}\n  \\},\n  \\{\n    \"name\": \"conn2\",\n    \"protocol\": \"rdp\",\n    \"parameters\": \\{ \"username\": \"bob\", \"password\": \"pass2\", \"hostname\": \"conn2.web.com\" \\},\n    \"group\": \"ROOT/Parent Group\",\n    \"users\": [ \"guac user 1\" ],\n    \"attributes\": \\{ \"guacd-encryption\": \"none\" \\}\n  \\},\n  \\{\n    \"name\": \"conn3\",\n    \"protocol\": \"ssh\",\n    \"parameters\": \\{ \"username\": \"carol\", \"password\": \"pass3\", \"hostname\": \"conn3.web.com\" \\},\n    \"group\": \"ROOT/Parent Group/Child Group\",\n    \"users\": [ \"guac user 2\", \"guac user 3\" ]\n  \\},\n  \\{\n    \"name\": \"conn4\",\n    \"protocol\": \"kubernetes\"\n  \\}\n]",
+        "HELP_JSON_MORE_DETAILS"            : "The connection group ID that the connection should be imported into may be directly specified with a \"parentIdentifier\" field, or the path to the parent group may be specified using a \"group\" field as shown below. An array of user and user group identifiers to grant access to may be specified per connection.",
+        "HELP_EXISTING_CONNECTION_MODE"     : "Entirely replace/update existing connections if their names and parent connection groups match the values in the provided file. If unchecked, attempting to import a connection with the same name and parent connection group of an existing connection will be considered an error.",
+        "HELP_EXISTING_PERMISSION_MODE"     : "Fully reset the permissions granted for all connections in the provided file to the permissions specified in that file. If no permissions are specified, all relevant connection permissions will be revoked. If unchecked, existing permissions are preserved, and any permissions specified in the file will be added.",
+        "HELP_SEMICOLON_FOOTNOTE"           : "If present, semicolons can be escaped with a backslash, e.g. \"first\\\\;last\"",
+        "HELP_UPLOAD_DROP_TITLE"            : "Drop a File Here",
+        "HELP_UPLOAD_FILE_TYPES"            : "CSV, JSON, or YAML",
+        "HELP_YAML_DESCRIPTION"             : "A connection import YAML file is a list of connection objects with exactly the same structure as the JSON format.",
+        "HELP_YAML_EXAMPLE"                 : "---\n  - name: conn1\n    protocol: vnc\n    parameters:\n      username: alice\n      password: pass1\n      hostname: conn1.web.com\n    group: ROOT\n    users:\n      - guac user 1\n      - guac user 2\n    groups:\n    - Connection 1 Users\n    attributes:\n      guacd-encryption: none\n  - name: conn2\n    protocol: rdp\n    parameters:\n      username: bob\n      password: pass2\n      hostname: conn2.web.com\n    group: ROOT/Parent Group\n    users:\n      - guac user 1\n    attributes:\n      guacd-encryption: none\n  - name: conn3\n    protocol: ssh\n    parameters:\n      username: carol\n      password: pass3\n      hostname: conn3.web.com\n    group: ROOT/Parent Group/Child Group\n    users:\n      - guac user 2\n      - guac user 3\n  - name: conn4\n    protocol: kubernetes",
+
+        "INFO_CONNECTIONS_IMPORTED_SUCCESS" : "{NUMBER} {NUMBER, plural, one{connection} other{connections}} imported successfully.",
+        
+        "SECTION_HEADER_CONNECTION_IMPORT"           : "Connection Import",
+        "SECTION_HEADER_HELP_CONNECTION_IMPORT_FILE" : "Connection Import File Format",
+        "SECTION_HEADER_CSV"                         : "CSV Format",
+        "SECTION_HEADER_JSON"                        : "JSON Format",
+        "SECTION_HEADER_YAML"                        : "YAML Format",
+
+        "TABLE_HEADER_ERRORS"     : "Errors",
+        "TABLE_HEADER_GROUP"      : "Group",
+        "TABLE_HEADER_NAME"       : "Name",
+        "TABLE_HEADER_PROTOCOL"   : "Protocol",
+        "TABLE_HEADER_ROW_NUMBER" : "Row #"
+    },
+
     "DATA_SOURCE_DEFAULT" : {
         "NAME" : "Default (XML)"
     },
@@ -503,6 +581,7 @@
         "FIELD_HEADER_DISABLE_BITMAP_CACHING"     : "Disable bitmap caching:",
         "FIELD_HEADER_DISABLE_OFFSCREEN_CACHING"  : "Disable off-screen caching:",
         "FIELD_HEADER_DISABLE_GLYPH_CACHING"      : "Disable glyph caching:",
+        "FIELD_HEADER_DISABLE_GFX"                : "Disable Graphics Pipeline Extension:",
         "FIELD_HEADER_ENABLE_PRINTING"            : "Enable printing:",
         "FIELD_HEADER_ENABLE_SFTP"     : "Enable SFTP:",
         "FIELD_HEADER_ENABLE_THEMING"             : "Enable theming:",
@@ -591,6 +670,7 @@
         "FIELD_OPTION_SERVER_LAYOUT_ES_LATAM_QWERTY" : "Latin American (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE"     : "Unicode",
         "FIELD_OPTION_SERVER_LAYOUT_FR_BE_AZERTY" : "Belgian French (Azerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_CA_QWERTY" : "Canadian French (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_FR_CH_QWERTZ" : "Swiss French (Qwertz)",
         "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "French (Azerty)",
         "FIELD_OPTION_SERVER_LAYOUT_HU_HU_QWERTZ" : "Hungarian (Qwertz)",
@@ -599,6 +679,8 @@
         "FIELD_OPTION_SERVER_LAYOUT_NO_NO_QWERTY" : "Norwegian (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_PL_PL_QWERTY" : "Polish (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_PT_BR_QWERTY" : "Portuguese Brazilian (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_PT_PT_QWERTY" : "Portuguese (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_RO_RO_QWERTY" : "Romanian (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Swedish (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_DA_DK_QWERTY" : "Danish (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_TR_TR_QWERTY" : "Turkish-Q (Qwerty)",
@@ -903,6 +985,7 @@
     "SETTINGS_CONNECTIONS" : {
 
         "ACTION_ACKNOWLEDGE"          : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_IMPORT"               : "@:APP.ACTION_IMPORT",
         "ACTION_NEW_CONNECTION"       : "New Connection",
         "ACTION_NEW_CONNECTION_GROUP" : "New Group",
         "ACTION_NEW_SHARING_PROFILE"  : "New Sharing Profile",
@@ -923,6 +1006,7 @@
 
         "ACTION_ACKNOWLEDGE"        : "@:APP.ACTION_ACKNOWLEDGE",
         "ACTION_CANCEL"             : "@:APP.ACTION_CANCEL",
+        "ACTION_SAVE"               : "@:APP.ACTION_SAVE",
         "ACTION_UPDATE_PASSWORD"    : "@:APP.ACTION_UPDATE_PASSWORD",
 
         "DIALOG_HEADER_ERROR"    : "@:APP.DIALOG_HEADER_ERROR",
@@ -949,6 +1033,7 @@
         "HELP_UPDATE_PASSWORD"      : "If you wish to change your password, enter your current password and the desired new password below, and click \"Update Password\". The change will take effect immediately.",
 
         "INFO_PASSWORD_CHANGED" : "Password changed.",
+        "INFO_PREFERENCE_ATTRIBUTES_CHANGED" : "User settings saved.",
 
         "NAME_INPUT_METHOD_NONE" : "@:CLIENT.NAME_INPUT_METHOD_NONE",
         "NAME_INPUT_METHOD_OSK"  : "@:CLIENT.NAME_INPUT_METHOD_OSK",
diff --git a/guacamole/src/main/frontend/src/translations/es.json b/guacamole/src/main/frontend/src/translations/es.json
index 896d365..ffbb25c 100644
--- a/guacamole/src/main/frontend/src/translations/es.json
+++ b/guacamole/src/main/frontend/src/translations/es.json
@@ -562,6 +562,8 @@
         "FIELD_OPTION_SERVER_LAYOUT_JA_JP_QWERTY"    : "Japones (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_PL_PL_QWERTY"    : "Polaco (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_PT_BR_QWERTY"    : "Portugués Brazileño (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_PT_PT_QWERTY"    : "Portugués (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_RO_RO_QWERTY"    : "Rumano (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY"    : "Sueco (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_DA_DK_QWERTY"    : "Danés (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_TR_TR_QWERTY"    : "Turco-Q (Qwerty)",
diff --git a/guacamole/src/main/frontend/src/translations/fr.json b/guacamole/src/main/frontend/src/translations/fr.json
index 110bbe0..2c7d5bc 100644
--- a/guacamole/src/main/frontend/src/translations/fr.json
+++ b/guacamole/src/main/frontend/src/translations/fr.json
@@ -550,6 +550,7 @@
         "FIELD_OPTION_SERVER_LAYOUT_ES_LATAM_QWERTY" : "Latino-Américain (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE"     : "Unicode",
         "FIELD_OPTION_SERVER_LAYOUT_FR_BE_AZERTY" : "Français Belge (Azerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_CA_QWERTY" : "Français Canada (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_FR_CH_QWERTZ" : "Français Suisse (Qwertz)",
         "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "Français (Azerty)",
         "FIELD_OPTION_SERVER_LAYOUT_HU_HU_QWERTZ" : "Hongrois (Qwertz)",
@@ -557,6 +558,8 @@
         "FIELD_OPTION_SERVER_LAYOUT_JA_JP_QWERTY" : "Japonais (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_PL_PL_QWERTY" : "Polonais (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_PT_BR_QWERTY" : "Portugais Brésilien (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_PT_PT_QWERTY" : "Portugais (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_RO_RO_QWERTY" : "Roumaine (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Suédois (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_DA_DK_QWERTY" : "Danois (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_TR_TR_QWERTY" : "Turque (Qwerty)",
diff --git a/guacamole/src/main/frontend/src/translations/it.json b/guacamole/src/main/frontend/src/translations/it.json
index 64aad1f..51f5846 100644
--- a/guacamole/src/main/frontend/src/translations/it.json
+++ b/guacamole/src/main/frontend/src/translations/it.json
@@ -329,6 +329,7 @@
         "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "French (Azerty)",
         "FIELD_OPTION_SERVER_LAYOUT_IT_IT_QWERTY" : "Italian (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_PL_PL_QWERTY" : "Polacca (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_RO_RO_QWERTY" : "Rumeno (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Swedish (Qwerty)",
 
         "NAME" : "RDP",
diff --git a/guacamole/src/main/frontend/src/translations/ja.json b/guacamole/src/main/frontend/src/translations/ja.json
index a26d589..e4c2ad0 100644
--- a/guacamole/src/main/frontend/src/translations/ja.json
+++ b/guacamole/src/main/frontend/src/translations/ja.json
@@ -132,7 +132,8 @@
         "TEXT_CLIENT_STATUS_DISCONNECTED" : "切断されました。",
         "TEXT_CLIENT_STATUS_UNSTABLE"     : "Guacamoleサーバへのネットワーク接続が不安定です。",
         "TEXT_CLIENT_STATUS_WAITING"      : "Guacamoleサーバに接続しました。応答を待っています",
-        "TEXT_RECONNECT_COUNTDOWN"        : "再接続しています... {REMAINING} {REMAINING, plural, one{second} other{seconds}}..."
+        "TEXT_RECONNECT_COUNTDOWN"        : "再接続しています... {REMAINING} {REMAINING, plural, one{second} other{seconds}}...",
+        "TEXT_CLIPBOARD_AWAITING_FOCUS"   : "クリックをしてコピー/カットされたテキストが表示されます..."
 
     },
 
diff --git a/guacamole/src/main/frontend/src/translations/ko.json b/guacamole/src/main/frontend/src/translations/ko.json
index 6c43ff9..957b3b5 100644
--- a/guacamole/src/main/frontend/src/translations/ko.json
+++ b/guacamole/src/main/frontend/src/translations/ko.json
@@ -551,6 +551,7 @@
         "FIELD_OPTION_SERVER_LAYOUT_JA_JP_QWERTY" : "Japanese (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_PL_PL_QWERTY" : "Polish (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_PT_BR_QWERTY" : "Portuguese Brazilian (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_PT_PT_QWERTY" : "Portuguese (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Swedish (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_DA_DK_QWERTY" : "Danish (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_TR_TR_QWERTY" : "Turkish-Q (Qwerty)",
diff --git a/guacamole/src/main/frontend/src/translations/pl.json b/guacamole/src/main/frontend/src/translations/pl.json
new file mode 100644
index 0000000..2be8d8f
--- /dev/null
+++ b/guacamole/src/main/frontend/src/translations/pl.json
@@ -0,0 +1,1052 @@
+{
+
+    "NAME" : "Polski",
+
+    "APP" : {
+
+        "NAME"    : "Apache Guacamole",
+        "VERSION" : "${project.version}",
+
+        "ACTION_ACKNOWLEDGE"        : "OK",
+        "ACTION_CANCEL"             : "Anuluj",
+        "ACTION_CLONE"              : "Klonuj",
+        "ACTION_CONTINUE"           : "Kontynuuj",
+        "ACTION_DELETE"             : "Usuń",
+        "ACTION_DELETE_SESSIONS"    : "Zakończ Sesję",
+        "ACTION_DOWNLOAD"           : "Pobierz",
+        "ACTION_LOGIN"              : "Zaloguj",
+        "ACTION_LOGIN_AGAIN"        : "Zaloguj Ponownie",
+        "ACTION_LOGOUT"             : "Wyloguj",
+        "ACTION_MANAGE_CONNECTIONS" : "Połączenia",
+        "ACTION_MANAGE_PREFERENCES" : "Preferencje",
+        "ACTION_MANAGE_SETTINGS"    : "Ustawienia",
+        "ACTION_MANAGE_SESSIONS"    : "Aktywne Sesje",
+        "ACTION_MANAGE_USERS"       : "Użytkownicy",
+        "ACTION_MANAGE_USER_GROUPS" : "Grupy",
+        "ACTION_NAVIGATE_BACK"      : "Wstecz",
+        "ACTION_NAVIGATE_HOME"      : "Główna",
+        "ACTION_PAUSE"              : "Pauza",
+        "ACTION_PLAY"               : "Odtwórz",
+        "ACTION_SAVE"               : "Zapisz",
+        "ACTION_SEARCH"             : "Szukaj",
+        "ACTION_SHARE"              : "Udostępnij",
+        "ACTION_UPDATE_PASSWORD"    : "Zmień Hasło",
+        "ACTION_VIEW_HISTORY"       : "Historia",
+        "ACTION_VIEW_RECORDING"     : "Obejrzyj",
+
+        "DIALOG_HEADER_ERROR" : "Błąd",
+
+        "ERROR_PAGE_UNAVAILABLE"  : "Wystąpił błąd i ta akcja nie może zostać zakończona. Jeśli problem nie zniknie, powiadom administratora systemu.",
+        "ERROR_PASSWORD_BLANK"    : "Hasło nie może być puste.",
+        "ERROR_PASSWORD_MISMATCH" : "Podane hasła nie pasują do siebie.",
+
+        "FIELD_HEADER_PASSWORD"       : "Hasło:",
+        "FIELD_HEADER_PASSWORD_AGAIN" : "Powtórz Hasło:",
+
+        "FIELD_PLACEHOLDER_FILTER" : "Filtruj",
+
+        "FORMAT_DATE_TIME_PRECISE" : "yyyy-MM-dd HH:mm:ss",
+
+        "INFO_ACTIVE_USER_COUNT" : "Aktualnie w użyciu przez {USERS} {USERS, plural, one{użytkownika} other{użytkowników}}.",
+        "INFO_LOGGED_OUT"        : "Zostałeś wylogowany.",
+
+        "TEXT_ANONYMOUS_USER"   : "Anonimowy",
+        "TEXT_HISTORY_DURATION" : "{VALUE} {UNIT, select, second{{VALUE, plural, one{sekunda} other{sekund(-y)}}} minute{{VALUE, plural, one{minuta} other{minut(y)}}} hour{{VALUE, plural, one{godzina} other{godzin(y)}}} day{{VALUE, plural, one{dzień} other{dni}}} other{}}",
+        "TEXT_UNTRANSLATED" : "{MESSAGE}"
+
+    },
+
+    "CLIENT" : {
+
+        "ACTION_ACKNOWLEDGE"               : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"                    : "@:APP.ACTION_CANCEL",
+        "ACTION_CLEAR_COMPLETED_TRANSFERS" : "Wyczyść",
+        "ACTION_CONTINUE"                  : "@:APP.ACTION_CONTINUE",
+        "ACTION_DISCONNECT"                : "Rozłącz",
+        "ACTION_LOGOUT"                    : "@:APP.ACTION_LOGOUT",
+        "ACTION_NAVIGATE_BACK"             : "@:APP.ACTION_NAVIGATE_BACK",
+        "ACTION_NAVIGATE_HOME"             : "@:APP.ACTION_NAVIGATE_HOME",
+        "ACTION_RECONNECT"                 : "Połącz Ponownie",
+        "ACTION_SAVE_FILE"                 : "@:APP.ACTION_SAVE",
+        "ACTION_SHARE"                     : "@:APP.ACTION_SHARE",
+        "ACTION_UPLOAD_FILES"              : "Wyślij Pliki",
+
+        "DIALOG_HEADER_CONNECTING"       : "Łączenie",
+        "DIALOG_HEADER_CONNECTION_ERROR" : "Błąd Połączenia",
+        "DIALOG_HEADER_DISCONNECTED"     : "Rozłączono",
+
+        "ERROR_CLIENT_201"     : "Połączenie zostało zamknięte, ponieważ serwer jest zajęty. Odczekaj kilka minut i spróbuj ponownie.",
+        "ERROR_CLIENT_202"     : "Serwer Guacamole zamknął połączenie, ponieważ zdalny pulpit nie odpowiada przez zbyt długi czas. Spróbuj ponownie lub skontaktuj się z administratorem.",
+        "ERROR_CLIENT_203"     : "Serwer zdalnego pulpitu napotkał błąd i zamknął połączenie. Spróbuj ponownie lub skontaktuj się z administratorem.",
+        "ERROR_CLIENT_207"     : "Serwer zdalnego pulpitu jest aktualnie nieosiągalny. Jeśli problem nie zniknie powiadom administratora systemu lub sprawdź log systemu.",
+        "ERROR_CLIENT_208"     : "Serwer zdalnego pulpitu jest aktualnie nieosiągalny. Jeśli problem nie zniknie powiadom administratora systemu lub sprawdź log systemu.",
+        "ERROR_CLIENT_209"     : "Serwer zdalnego pulpitu zamknął połączenie, ponieważ koliduje z innym połączeniem. Spróbuj ponownie później.",
+        "ERROR_CLIENT_20A"     : "Serwer zdalnego pulpitu zamknął połączenie, ponieważ wydaje się być nieaktywne. Jeśli nie jest to pożądane zachowanie powiadom administratora systemu lub sprawdź ustawienia.",
+        "ERROR_CLIENT_20B"     : "Serwer zdalnego pulpitu wymusił zamknięcie połączenia. Jeśli nie jest to pożądane zachowanie powiadom administratora systemu lub sprawdź log systemu.",
+        "ERROR_CLIENT_301"     : "Logowanie nieudane. Spróbuj ponownie.",
+        "ERROR_CLIENT_303"     : "Serwer zdalnego pulpitu nie zezwolił na połączenie. Poproś administratora systemu o nadanie uprawnień dla Twojego konta lub sprawdź ustawienia.",
+        "ERROR_CLIENT_308"     : "Serwer Guacamole zamknął połączenie, ponieważ nie otrzymał odpowiedzi od Twojej przeglądarki przez zbyt długi czas. Zazwyczaj jest to spowodowane niską szybkością i jakością połączenia. Sprawdź swoje ustawienia sieci i spróbuj ponownie.",
+        "ERROR_CLIENT_31D"     : "Serwer Guacamole nie zezwolił na połączenie, ponieważ wykorzystałeś limit jednoczesnych połączeń dla pojedynczego użytkownika. Zamknij jedno lub kilka połączeń i spróbuj ponownie.",
+        "ERROR_CLIENT_DEFAULT" : "Wystąpił błąd wewnętrzny serwera Guacamole i połączenie zostało zakończone. Jeśli problem nie zniknie, powiadom administratora systemu lub sprawdź log systemu.",
+
+        "ERROR_TUNNEL_201"     : "Serwer Guacamole odrzucił tę próbę połączenia, ponieważ istnieje zbyt wiele aktywnych połączeń. Poczekaj kilka minut i spróbuj ponownie.",
+        "ERROR_TUNNEL_202"     : "Połączenie zostało zamknięte, ponieważ serwer nie odpowiada przez zbyt długo czas. Zazwyczaj spowodowane jest to niską szybkością i jakością połączenia. Sprawdź swoje ustawienia sieci i spróbuj ponownie lub poinformuj administratora systemu.",
+        "ERROR_TUNNEL_203"     : "Serwer napotkał błąd i zamknął połączenie. Spróbuj ponownie lub skontaktuj się z administratorem systemu",
+        "ERROR_TUNNEL_204"     : "Żądane połączenie nie istnieje. Sprawdź nazwę połączenia i spróbuj ponownie",
+        "ERROR_TUNNEL_205"     : "To połączenie jest aktualnie w użyciu oraz jednoczesny dostęp dla tego połączenia nie jest dozwolony. Spróbuj ponownie później.",
+        "ERROR_TUNNEL_207"     : "Serwer Guacamole jest aktualnie nieosiągalny. Sprawdź swoją sieć i spróbuj ponownie.",
+        "ERROR_TUNNEL_208"     : "Serwer Guacamole nie akceptuje połączeń. Sprawdź swoją sieć i spróbuj ponownie.",
+        "ERROR_TUNNEL_301"     : "Nie masz dostępu do tego połączenia, ponieważ nie jesteś zalogowany. Zaloguj się i spróbuj ponownie.",
+        "ERROR_TUNNEL_303"     : "Nie masz uprawnień do tego połączenia. Jeśli potrzebujesz uprawnień poproś administratora systemu o dodanie Cię do listy użytkowników z zezwoleniem lub sprawdź ustawienia systemu. ",
+        "ERROR_TUNNEL_308"     : "Serwer Guacamole zamknął połączenie, ponieważ nie otrzymał odpowiedzi od Twojej przeglądarki przez zbyt długi czas. Zazwyczaj jest to spowodowane niską szybkością i jakością połączenia. Sprawdź swoje ustawienia sieci i spróbuj ponownie.",
+        "ERROR_TUNNEL_31D"     : "Serwer Guacamole nie zezwolił na połączenie, ponieważ wykorzystałeś limit jednoczesnych połączeń dla pojedynczego użytkownika. Zamknij jedno lub kilka połączeń i spróbuj ponownie.",
+        "ERROR_TUNNEL_DEFAULT" : "Wystąpił błąd wewnętrzny serwera Guacamole i połączenie zostało zakończone. Jeśli problem nie zniknie, powiadom administratora systemu lub sprawdź log systemu.",
+
+        "ERROR_UPLOAD_100"     : "Transfer plików jest nie wspierany lub wyłączony. Skontaktuj się z administratorem systemu lub sprawdź log systemu.",
+        "ERROR_UPLOAD_201"     : "Zbyt wiele plików jest aktualnie transferowanych. Poczekaj na zakończenie trwających transferów i spróbuj ponownie.",
+        "ERROR_UPLOAD_202"     : "Plik nie może zostać przesłany, ponieważ Serwer zdalnego pulpitu nie odpowiada przez zbyt długi czas. Spróbuj ponownie lub skontaktuj się z administratorem systemu",
+        "ERROR_UPLOAD_203"     : "Serwer zdalnego pulpitu napotkał na błąd podczas transferu. Spróbuj ponownie lub skontaktuj się z administratorem systemu",
+        "ERROR_UPLOAD_204"     : "Docelowa ścieżka transferu pliku nie istnieje. Upewnij się, że ścieżka docelowa istnieje i spróbuj ponownie.",
+        "ERROR_UPLOAD_205"     : "Lokalizacja docelowa dla transferu pliku jest aktualnie zablokowana. Poczekaj na zakończenie trwający zadań i spróbuj ponownie.",
+        "ERROR_UPLOAD_301"     : "Nie masz uprawnień do przesłania tego pliku, ponieważ jest nie zalogowany. Zaloguj się i spróbuj ponownie.",
+        "ERROR_UPLOAD_303"     : "Nie masz uprawnień do przesłania tego pliku. Jeśli potrzebujesz dostępu sprawdź ustawienia systemu lub skontaktuj się z administratorem systemu.",
+        "ERROR_UPLOAD_308"     : "Transfer pliku utknął w martwym punkcie. Zazwyczaj jest to spowodowane niską szybkością i jakością połączenia. Sprawdź swoje ustawienia sieci i spróbuj ponownie.",
+        "ERROR_UPLOAD_31D"     : "Zbyt wiele plików jest aktualnie transferowanych. Poczekaj na zakończenie trwających transferów i spróbuj ponownie.",
+        "ERROR_UPLOAD_DEFAULT" : "Wystąpił błąd wewnętrzny serwera Guacamole i połączenie zostało zakończone. Jeśli problem nie zniknie, powiadom administratora systemu lub sprawdź log systemu.",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_CLIPBOARD"           : "Tekst skopiowany/wycięty wewnątrz serwera Guacamole pojawi się tutaj. Zmiany dokonane w tym miejscu będą miały wpływ na zawartość zdalnego schowka",
+        "HELP_INPUT_METHOD_NONE"   : "Żadna metoda wejścia nie jest w użyciu. Wejście klawiatury jest akceptowane z podłączonej fizycznej klawiatury.",
+        "HELP_INPUT_METHOD_OSK"    : "Wyświetl i zaakceptuj dane wejściowe z wbudowanej klawiatury ekranowej Guacamole. Klawiatura ekranowa umożliwia wpisywanie kombinacji klawiszy, które w innym przypadku byłyby niemożliwe (takich jak Ctrl-Alt-Del)",
+        "HELP_INPUT_METHOD_TEXT"   : "Zezwalaj na pisanie tekstu i emuluj zdarzenia klawiatury na podstawie wpisanego tekstu. Jest to konieczne w przypadku urządzeń takich jak telefony komórkowe, które nie mają fizycznej klawiatury.",
+        "HELP_MOUSE_MODE"          : "Określa zachowanie zdalnej myszy w odniesieniu do dotknięć.",
+        "HELP_MOUSE_MODE_ABSOLUTE" : "Dotknij, aby kliknąć. Kliknięcie następuje w miejscu dotknięcia.",
+        "HELP_MOUSE_MODE_RELATIVE" : "Przeciągnij, aby przesunąć wskaźnik myszy i dotknij, aby kliknąć. Kliknięcie następuje w miejscu, w którym znajduje się wskaźnik.",
+        "HELP_SHARE_LINK"          : "Aktualne połączenie jest udostępniane, dostęp może uzyskać każdy posiadający {LINKS, plural, one{link} other{linki}}:",
+
+        "INFO_CONNECTION_SHARED" : "To połączenie jest teraz udostępniane.",
+        "INFO_NO_FILE_TRANSFERS" : "Brak transferów plików.",
+
+        "NAME_INPUT_METHOD_NONE"   : "Brak",
+        "NAME_INPUT_METHOD_OSK"    : "Klawiatura ekranowa",
+        "NAME_INPUT_METHOD_TEXT"   : "Pole tekstowe",
+        "NAME_KEY_CTRL"            : "Ctrl",
+        "NAME_KEY_ALT"             : "Alt",
+        "NAME_KEY_ESC"             : "Esc",
+        "NAME_KEY_TAB"             : "Tab",
+        "NAME_MOUSE_MODE_ABSOLUTE" : "Ekran dotykowy",
+        "NAME_MOUSE_MODE_RELATIVE" : "Touchpad",
+
+        "SECTION_HEADER_CLIPBOARD"      : "Schowek",
+        "SECTION_HEADER_DEVICES"        : "Urządzenia",
+        "SECTION_HEADER_DISPLAY"        : "Wyświetlanie",
+        "SECTION_HEADER_FILE_TRANSFERS" : "Transfer Plików",
+        "SECTION_HEADER_INPUT_METHOD"   : "Metoda wejścia",
+        "SECTION_HEADER_MOUSE_MODE"     : "Tryb emulacji myszy",
+
+        "TEXT_ZOOM_AUTO_FIT"              : "Automatycznie dopasuj do okna przeglądarki",
+        "TEXT_CLIENT_STATUS_IDLE"         : "Bezczynne.",
+        "TEXT_CLIENT_STATUS_CONNECTING"   : "Łączenie z Guacamole...",
+        "TEXT_CLIENT_STATUS_DISCONNECTED" : "Zostałeś rozłączony.",
+        "TEXT_CLIENT_STATUS_UNSTABLE"     : "Połączenie sieciowe z serwerem Guacamole wydaje się być niestabilne.",
+        "TEXT_CLIENT_STATUS_WAITING"      : "Połączono z Guacamole. Czekanie na odpowiedź...",
+        "TEXT_RECONNECT_COUNTDOWN"        : "Ponowne połączenie za {REMAINING} {REMAINING, plural, one{sekundę} other{sekund(-y)}}...",
+        "TEXT_FILE_TRANSFER_PROGRESS"     : "{PROGRESS} {UNIT, select, b{B} kb{KB} mb{MB} gb{GB} other{}}",
+
+        "URL_OSK_LAYOUT" : "layouts/en-us-qwerty.json"
+
+    },
+
+    "COLOR_SCHEME" : {
+
+        "ACTION_CANCEL"       : "@:APP.ACTION_CANCEL",
+        "ACTION_HIDE_DETAILS" : "Ukryj",
+        "ACTION_SAVE"         : "@:APP.ACTION_SAVE",
+        "ACTION_SHOW_DETAILS" : "Pokaż",
+
+        "FIELD_HEADER_BACKGROUND" : "Drugi plan",
+        "FIELD_HEADER_FOREGROUND" : "Pierwszy plan",
+
+        "FIELD_OPTION_CUSTOM" : "Niestandardowy...",
+
+        "SECTION_HEADER_DETAILS" : "Szczegóły:"
+
+    },
+
+    "DATA_SOURCE_DEFAULT" : {
+        "NAME" : "Domyślny (XML)"
+    },
+
+    "FORM" : {
+
+        "FIELD_PLACEHOLDER_DATE" : "YYYY-MM-DD",
+        "FIELD_PLACEHOLDER_TIME" : "HH:MM:SS",
+
+        "HELP_SHOW_PASSWORD" : "Naciśnij aby pokazać hasło",
+        "HELP_HIDE_PASSWORD" : "Naciśnij aby ukryć hasło"
+
+    },
+
+    "HOME" : {
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "INFO_ACTIVE_USER_COUNT" : "@:APP.INFO_ACTIVE_USER_COUNT",
+
+        "INFO_NO_RECENT_CONNECTIONS" : "Brak ostatnich połączeń.",
+
+        "PASSWORD_CHANGED" : "Hasło zmienione.",
+
+        "SECTION_HEADER_ALL_CONNECTIONS"    : "Wszystkie połączenia",
+        "SECTION_HEADER_RECENT_CONNECTIONS" : "Ostatnie połączenia"
+
+    },
+
+    "LIST" : {
+
+        "TEXT_ANONYMOUS_USER" : "Anonimowy"
+
+    },
+
+    "LOGIN": {
+
+        "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CONTINUE"    : "@:APP.ACTION_CONTINUE",
+        "ACTION_LOGIN"       : "@:APP.ACTION_LOGIN",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_INVALID_LOGIN" : "Niepoprawny Login",
+
+        "FIELD_HEADER_USERNAME" : "Użytkownik",
+        "FIELD_HEADER_PASSWORD" : "Hasło"
+
+    },
+
+    "MANAGE_CONNECTION" : {
+
+        "ACTION_ACKNOWLEDGE"          : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"               : "@:APP.ACTION_CANCEL",
+        "ACTION_CLONE"                : "@:APP.ACTION_CLONE",
+        "ACTION_DELETE"               : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"                 : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Usuń Połączenie",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_LOCATION" : "Lokalizacja:",
+        "FIELD_HEADER_NAME"     : "Nazwa:",
+        "FIELD_HEADER_PROTOCOL" : "Protokół:",
+
+        "FORMAT_HISTORY_START" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "INFO_CONNECTION_DURATION_UNKNOWN" : "--",
+        "INFO_CONNECTION_ACTIVE_NOW"       : "Aktywne Teraz",
+        "INFO_CONNECTION_NOT_USED"         : "To połączenie nie zostało jeszcze użyte.",
+
+        "SECTION_HEADER_EDIT_CONNECTION" : "Edytuj Połączenie",
+        "SECTION_HEADER_HISTORY"         : "Historia Użycia",
+        "SECTION_HEADER_PARAMETERS"      : "Parametry",
+
+        "TABLE_HEADER_HISTORY_USERNAME"   : "Użytkownik",
+        "TABLE_HEADER_HISTORY_START"      : "Czas Rozpoczęcia",
+        "TABLE_HEADER_HISTORY_DURATION"   : "Czas Trwania",
+        "TABLE_HEADER_HISTORY_REMOTEHOST" : "Zdalny Host",
+
+        "TEXT_CONFIRM_DELETE"   : "Połączenia nie mogą zostać przywrócone po ich usunięciu. Czy na pewno chcesz usunąć to połączenie?",
+        "TEXT_HISTORY_DURATION" : "@:APP.TEXT_HISTORY_DURATION"
+
+    },
+
+    "MANAGE_CONNECTION_GROUP" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_CLONE"         : "@:APP.ACTION_CLONE",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Usuń Grupę Połączeń",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_LOCATION" : "Lokalizacja:",
+        "FIELD_HEADER_NAME"     : "Nazwa:",
+        "FIELD_HEADER_TYPE"     : "Typ:",
+
+        "NAME_TYPE_BALANCING"       : "Równoważący obciążenie",
+        "NAME_TYPE_ORGANIZATIONAL"  : "Organizacyjny",
+
+        "SECTION_HEADER_EDIT_CONNECTION_GROUP" : "Edytuj Grupę Połączeń",
+
+        "TEXT_CONFIRM_DELETE" : "Grupy połączeń nie mogą zostać przywrócone po tym jak zostaną usunięte. Czy na pewno chcesz usunąć tę grupę połączeń?"
+
+    },
+
+    "MANAGE_SHARING_PROFILE" : {
+
+        "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"      : "@:APP.ACTION_CANCEL",
+        "ACTION_CLONE"       : "@:APP.ACTION_CLONE",
+        "ACTION_DELETE"      : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"        : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Usuń Profil Udostępniania",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_NAME"               : "Nazwa:",
+        "FIELD_HEADER_PRIMARY_CONNECTION" : "Podstawowe Połączenie:",
+
+        "SECTION_HEADER_EDIT_SHARING_PROFILE" : "Edytuj Profil Udostępniania",
+        "SECTION_HEADER_PARAMETERS"           : "Parametry",
+
+        "TEXT_CONFIRM_DELETE" : "Profile udostępniania nie mogą zostać przywrócone po tym jak zostaną usunięte. Czy na pewno chcesz usunąć ten profil udostępniania?"
+
+    },
+
+    "MANAGE_USER" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_CLONE"         : "@:APP.ACTION_CLONE",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Usuń Użytkownika",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+
+        "FIELD_HEADER_ADMINISTER_SYSTEM"             : "Administracja systemem:",
+        "FIELD_HEADER_CHANGE_OWN_PASSWORD"           : "Zmiana własnego hasła:",
+        "FIELD_HEADER_CREATE_NEW_USERS"              : "Tworzenie nowych użytkowników:",
+        "FIELD_HEADER_CREATE_NEW_USER_GROUPS"        : "Tworzenie nowych grup użytkowników:",
+        "FIELD_HEADER_CREATE_NEW_CONNECTIONS"        : "Tworzenie nowych połączeń:",
+        "FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS"  : "Tworzenie nowych grup połączeń:",
+        "FIELD_HEADER_CREATE_NEW_SHARING_PROFILES"   : "Tworzenie nowych profili udostępniania:",
+        "FIELD_HEADER_PASSWORD"                      : "@:APP.FIELD_HEADER_PASSWORD",
+        "FIELD_HEADER_PASSWORD_AGAIN"                : "@:APP.FIELD_HEADER_PASSWORD_AGAIN",
+        "FIELD_HEADER_USERNAME"                      : "Nazwa użytkownika:",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_NO_USER_GROUPS" : "Ten użytkownik nie należy obecnie do żadnej grupy. Rozwiń tę sekcje aby dodać do grupy.",
+
+        "INFO_READ_ONLY"                : "Ten użytkownik nie może być edytowany",
+        "INFO_NO_USER_GROUPS_AVAILABLE" : "Brak dostępnych grup.",
+
+        "SECTION_HEADER_ALL_CONNECTIONS"     : "Wszystkie Połączenia",
+        "SECTION_HEADER_CONNECTIONS"         : "Połączenia",
+        "SECTION_HEADER_CURRENT_CONNECTIONS" : "Obecne Połączenia",
+        "SECTION_HEADER_EDIT_USER"           : "Edytuj Użytkownika",
+        "SECTION_HEADER_PERMISSIONS"         : "Uprawnienia",
+        "SECTION_HEADER_USER_GROUPS"         : "Grupy",
+
+        "TEXT_CONFIRM_DELETE" : "Użytkownicy nie mogą zostać przywróceni po tym jak zostaną usunięci. Czy na pewno chcesz usunąć tego użytkownika?"
+
+    },
+
+    "MANAGE_USER_GROUP" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_CLONE"         : "@:APP.ACTION_CLONE",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Usuń grupę",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_ADMINISTER_SYSTEM"             : "@:MANAGE_USER.FIELD_HEADER_ADMINISTER_SYSTEM",
+        "FIELD_HEADER_CHANGE_OWN_PASSWORD"           : "@:MANAGE_USER.FIELD_HEADER_CHANGE_OWN_PASSWORD",
+        "FIELD_HEADER_CREATE_NEW_USERS"              : "@:MANAGE_USER.FIELD_HEADER_CREATE_NEW_USERS",
+        "FIELD_HEADER_CREATE_NEW_USER_GROUPS"        : "@:MANAGE_USER.FIELD_HEADER_CREATE_NEW_USER_GROUPS",
+        "FIELD_HEADER_CREATE_NEW_CONNECTIONS"        : "@:MANAGE_USER.FIELD_HEADER_CREATE_NEW_CONNECTIONS",
+        "FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS"  : "@:MANAGE_USER.FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS",
+        "FIELD_HEADER_CREATE_NEW_SHARING_PROFILES"   : "@:MANAGE_USER.FIELD_HEADER_CREATE_NEW_SHARING_PROFILES",
+        "FIELD_HEADER_USER_GROUP_NAME"               : "Nazwa grupy:",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_NO_USER_GROUPS"        : "To grupa nie należy do żadnej grupy. Rozwiń tę sekcje aby dodać do grupy.",
+        "HELP_NO_MEMBER_USER_GROUPS" : "Ta grupa nie zawiera żadnych grup. Rozwiń tę sekcje aby dodać grupy.",
+        "HELP_NO_MEMBER_USERS"       : "Ta grupa nie zawiera żadnych użytkowników. Rozwiń tę sekcje aby dodać użytkowników do grupy.",
+
+        "INFO_READ_ONLY"                : "Ta grupa nie może być edytowana",
+        "INFO_NO_USER_GROUPS_AVAILABLE" : "@:MANAGE_USER.INFO_NO_USER_GROUPS_AVAILABLE",
+        "INFO_NO_USERS_AVAILABLE"       : "Brak dostępnych użytkowników.",
+
+        "SECTION_HEADER_ALL_CONNECTIONS"     : "@:MANAGE_USER.SECTION_HEADER_ALL_CONNECTIONS",
+        "SECTION_HEADER_CONNECTIONS"         : "@:MANAGE_USER.SECTION_HEADER_CONNECTIONS",
+        "SECTION_HEADER_CURRENT_CONNECTIONS" : "@:MANAGE_USER.SECTION_HEADER_CURRENT_CONNECTIONS",
+        "SECTION_HEADER_EDIT_USER_GROUP"     : "Edytuj Grupę",
+        "SECTION_HEADER_MEMBER_USERS"        : "Należący użytkownicy",
+        "SECTION_HEADER_MEMBER_USER_GROUPS"  : "Należące grupy",
+        "SECTION_HEADER_PERMISSIONS"         : "@:MANAGE_USER.SECTION_HEADER_PERMISSIONS",
+        "SECTION_HEADER_USER_GROUPS"         : "Grupy nadrzędne",
+
+        "TEXT_CONFIRM_DELETE" : "Grupy nie mogą zostać przywrócone po tym jak zostaną usunięte. Czy na pewno chcesz usunąć tę grupę?"
+
+    },
+
+    "PLAYER" : {
+
+        "ACTION_CANCEL" : "@:APP.ACTION_CANCEL",
+        "ACTION_PAUSE"  : "@:APP.ACTION_PAUSE",
+        "ACTION_PLAY"   : "@:APP.ACTION_PLAY",
+
+        "INFO_LOADING_RECORDING" : "Twoje nagranie jest właśnie ładowane. Proszę czekać...",
+        "INFO_SEEK_IN_PROGRESS"  : "Szukanie żądanej pozycji. Proszę czekać..."
+
+    },
+
+    "PROTOCOL_KUBERNETES" : {
+
+        "FIELD_HEADER_BACKSPACE"       : "Klawisz Backspace wysyła:",
+        "FIELD_HEADER_CA_CERT"         : "Certyfikat CA:",
+        "FIELD_HEADER_CLIENT_CERT"     : "Certyfikat klienta:",
+        "FIELD_HEADER_CLIENT_KEY"      : "Klucz klienta:",
+        "FIELD_HEADER_COLOR_SCHEME"    : "Schemat kolorów:",
+        "FIELD_HEADER_CONTAINER"       : "Nazwa kontenera:",
+        "FIELD_HEADER_CREATE_RECORDING_PATH"  : "Automatycznie utwórz ścieżkę nagrywania:",
+        "FIELD_HEADER_CREATE_TYPESCRIPT_PATH" : "Automatycznie utwórz ścieżkę maszynopisu:",
+        "FIELD_HEADER_EXEC_COMMAND"    : "Komenda (exec):",
+        "FIELD_HEADER_FONT_NAME"       : "Nazwa czcionki:",
+        "FIELD_HEADER_FONT_SIZE"       : "Rozmiar czcionki:",
+        "FIELD_HEADER_HOSTNAME"        : "Nazwa Hosta:",
+        "FIELD_HEADER_IGNORE_CERT"     : "Ignoruj certyfikat serwera:",
+        "FIELD_HEADER_NAMESPACE"       : "Przestrzeń nazw:",
+        "FIELD_HEADER_POD"             : "Nazwa pod'a:",
+        "FIELD_HEADER_PORT"            : "Port:",
+        "FIELD_HEADER_READ_ONLY"       : "Tylko do odczytu:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "Wyklucz mysz:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Wyklucz grafiki/strumienie:",
+        "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "Dołącz zdarzenia klawiszy:",
+        "FIELD_HEADER_RECORDING_NAME"  : "Nazwa nagrania:",
+        "FIELD_HEADER_RECORDING_PATH"  : "Ścieżka nagrania:",
+        "FIELD_HEADER_SCROLLBACK"      : "Maksymalny rozmiar przewijania:",
+        "FIELD_HEADER_TYPESCRIPT_NAME" : "Nazwa maszynopisu:",
+        "FIELD_HEADER_TYPESCRIPT_PATH" : "Ścieżka maszynopisu:",
+        "FIELD_HEADER_USE_SSL"         : "Użyj SSL/TLS",
+
+        "FIELD_OPTION_BACKSPACE_EMPTY" : "",
+        "FIELD_OPTION_BACKSPACE_8"     : "Backspace (Ctrl-H)",
+        "FIELD_OPTION_BACKSPACE_127"   : "Delete (Ctrl-?)",
+
+        "FIELD_OPTION_COLOR_SCHEME_BLACK_WHITE" : "Czarny na białym",
+        "FIELD_OPTION_COLOR_SCHEME_EMPTY"       : "",
+        "FIELD_OPTION_COLOR_SCHEME_GRAY_BLACK"  : "Szary na czarnym",
+        "FIELD_OPTION_COLOR_SCHEME_GREEN_BLACK" : "Zielony na czarnym",
+        "FIELD_OPTION_COLOR_SCHEME_WHITE_BLACK" : "Biały na czarnym",
+
+        "FIELD_OPTION_FONT_SIZE_8"     : "8",
+        "FIELD_OPTION_FONT_SIZE_9"     : "9",
+        "FIELD_OPTION_FONT_SIZE_10"    : "10",
+        "FIELD_OPTION_FONT_SIZE_11"    : "11",
+        "FIELD_OPTION_FONT_SIZE_12"    : "12",
+        "FIELD_OPTION_FONT_SIZE_14"    : "14",
+        "FIELD_OPTION_FONT_SIZE_18"    : "18",
+        "FIELD_OPTION_FONT_SIZE_24"    : "24",
+        "FIELD_OPTION_FONT_SIZE_30"    : "30",
+        "FIELD_OPTION_FONT_SIZE_36"    : "36",
+        "FIELD_OPTION_FONT_SIZE_48"    : "48",
+        "FIELD_OPTION_FONT_SIZE_60"    : "60",
+        "FIELD_OPTION_FONT_SIZE_72"    : "72",
+        "FIELD_OPTION_FONT_SIZE_96"    : "96",
+        "FIELD_OPTION_FONT_SIZE_EMPTY" : "",
+
+        "NAME" : "Kubernetes",
+
+        "SECTION_HEADER_AUTHENTICATION" : "Uwierzytelnianie",
+        "SECTION_HEADER_BEHAVIOR"       : "Zachowanie terminala",
+        "SECTION_HEADER_CONTAINER"      : "Kontener",
+        "SECTION_HEADER_DISPLAY"        : "Wyświetlanie",
+        "SECTION_HEADER_RECORDING"      : "Nagrywanie Ekranu",
+        "SECTION_HEADER_TYPESCRIPT"     : "Maszynopis (Tekstowe nagranie sesji)",
+        "SECTION_HEADER_NETWORK"        : "Sieć"
+
+    },
+
+    "PROTOCOL_RDP" : {
+
+        "FIELD_HEADER_CLIENT_NAME"     : "Nazwa klienta:",
+        "FIELD_HEADER_COLOR_DEPTH"     : "Głębia kolorów:",
+        "FIELD_HEADER_CONSOLE"         : "Konsola administratora:",
+        "FIELD_HEADER_CONSOLE_AUDIO"   : "Wspieraj dźwięk w konsoli:",
+        "FIELD_HEADER_CREATE_DRIVE_PATH" : "Automatycznie utwórz dysk:",
+        "FIELD_HEADER_CREATE_RECORDING_PATH" : "Automatycznie utwórz ścieżkę nagrywania:",
+        "FIELD_HEADER_DISABLE_AUDIO"   : "Wyłącz dźwięk:",
+        "FIELD_HEADER_DISABLE_AUTH"    : "Wyłącz uwierzytelnianie:",
+        "FIELD_HEADER_DISABLE_COPY"    : "Wyłącz kopiowanie ze zdalnego pulpitu:",
+        "FIELD_HEADER_DISABLE_DOWNLOAD" : "Wyłącz pobieranie plików:",
+        "FIELD_HEADER_DISABLE_PASTE"   : "Wyłącz wklejanie z klienta:",
+        "FIELD_HEADER_DISABLE_UPLOAD"   : "Wyłącz przesyłanie plików:",
+        "FIELD_HEADER_DOMAIN"          : "Domena:",
+        "FIELD_HEADER_DPI"             : "Rozdzielczość (DPI):",
+        "FIELD_HEADER_DRIVE_NAME"       : "Nazwa dysku:",
+        "FIELD_HEADER_DRIVE_PATH"       : "Ścieżka dysku:",
+        "FIELD_HEADER_ENABLE_AUDIO_INPUT"         : "Włącz wejście dźwięku (mikrofon):",
+        "FIELD_HEADER_ENABLE_DESKTOP_COMPOSITION" : "Włącz kompozycje pulpitu (Aero):",
+        "FIELD_HEADER_ENABLE_DRIVE"               : "Włącz dysk:",
+        "FIELD_HEADER_ENABLE_FONT_SMOOTHING"      : "Włącz wygładzanie czcionek (ClearType):",
+        "FIELD_HEADER_ENABLE_FULL_WINDOW_DRAG"    : "Włącz przeciąganie w pełnym oknie:",
+        "FIELD_HEADER_ENABLE_MENU_ANIMATIONS"     : "Włącz animacje menu:",
+        "FIELD_HEADER_DISABLE_BITMAP_CACHING"     : "Wyłącz bitmap caching:",
+        "FIELD_HEADER_DISABLE_OFFSCREEN_CACHING"  : "Wyłącz off-screen caching:",
+        "FIELD_HEADER_DISABLE_GLYPH_CACHING"      : "Wyłącz glyph caching:",
+        "FIELD_HEADER_DISABLE_GFX"                : "Wyłącz Rozszerzenie Potoku Graficznego:",
+        "FIELD_HEADER_ENABLE_PRINTING"            : "Włącz drukowanie:",
+        "FIELD_HEADER_ENABLE_SFTP"     : "Włącz SFTP:",
+        "FIELD_HEADER_ENABLE_THEMING"             : "Włącz motyw:",
+        "FIELD_HEADER_ENABLE_TOUCH"               : "Włącz multi-touch:",
+        "FIELD_HEADER_ENABLE_WALLPAPER"           : "Włącz tapetę:",
+        "FIELD_HEADER_FORCE_LOSSLESS"             : "Wymuś bezstratną kompresję:",
+        "FIELD_HEADER_GATEWAY_DOMAIN"   : "Domena:",
+        "FIELD_HEADER_GATEWAY_HOSTNAME" : "Nazwa hosta:",
+        "FIELD_HEADER_GATEWAY_PASSWORD" : "Hasło:",
+        "FIELD_HEADER_GATEWAY_PORT"     : "Port:",
+        "FIELD_HEADER_GATEWAY_USERNAME" : "Użytkownik:",
+        "FIELD_HEADER_HEIGHT"          : "Wysokość:",
+        "FIELD_HEADER_HOSTNAME"        : "Nazwa hosta:",
+        "FIELD_HEADER_IGNORE_CERT"     : "Ignoruj certyfikat serwera:",
+        "FIELD_HEADER_INITIAL_PROGRAM" : "Program początkowy:",
+        "FIELD_HEADER_LOAD_BALANCE_INFO" : "Load balance info/cookie:",
+        "FIELD_HEADER_NORMALIZE_CLIPBOARD" : "Zakończenie linii:",
+        "FIELD_HEADER_PASSWORD"        : "Hasło:",
+        "FIELD_HEADER_PORT"            : "Port:",
+        "FIELD_HEADER_PRINTER_NAME"    : "Nazwa przekierowanej drukarki:",
+        "FIELD_HEADER_PRECONNECTION_BLOB" : "Preconnection BLOB (VM ID):",
+        "FIELD_HEADER_PRECONNECTION_ID"   : "Źródłowe ID RDP:",
+        "FIELD_HEADER_READ_ONLY"      : "Tylko do odczytu:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "Wyklucz mysz:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Wyklucz grafiki/strumienie:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_TOUCH"  : "Wyklucz zdarzenia dotykowe:",
+        "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "Dołącz zdarzenia klawiszy:",
+        "FIELD_HEADER_RECORDING_NAME" : "Nazwa nagrania:",
+        "FIELD_HEADER_RECORDING_PATH" : "Ścieżka nagrania:",
+        "FIELD_HEADER_RESIZE_METHOD" : "Metoda zmiany rozmiaru:",
+        "FIELD_HEADER_REMOTE_APP_ARGS" : "Parametry:",
+        "FIELD_HEADER_REMOTE_APP_DIR"  : "Katalog roboczy:",
+        "FIELD_HEADER_REMOTE_APP"      : "Program:",
+        "FIELD_HEADER_SECURITY"        : "Tryb bezpieczeństwa:",
+        "FIELD_HEADER_SERVER_LAYOUT"   : "Układ klawiatury:",
+        "FIELD_HEADER_SFTP_DIRECTORY"             : "Domyślny katalog dla przesyłania:",
+        "FIELD_HEADER_SFTP_DISABLE_DOWNLOAD"      : "Wyłącz pobieranie plików:",
+        "FIELD_HEADER_SFTP_HOST_KEY"              : "Publiczny klucz hosta (Base64):",
+        "FIELD_HEADER_SFTP_HOSTNAME"              : "Nazwa Hosta:",
+        "FIELD_HEADER_SFTP_SERVER_ALIVE_INTERVAL" : "Interwał utrzymania aktywności (keepalive) SFTP:",
+        "FIELD_HEADER_SFTP_PASSPHRASE"            : "Hasło klucza:",
+        "FIELD_HEADER_SFTP_PASSWORD"              : "Hasło:",
+        "FIELD_HEADER_SFTP_PORT"                  : "Port:",
+        "FIELD_HEADER_SFTP_PRIVATE_KEY"           : "Klucz prywatny:",
+        "FIELD_HEADER_SFTP_ROOT_DIRECTORY"        : "Katalog nadrzędny przeglądarki plików:",
+        "FIELD_HEADER_SFTP_DISABLE_UPLOAD"        : "Wyłącz przesyłanie plików:",
+        "FIELD_HEADER_SFTP_USERNAME"              : "Użytkownik:",
+        "FIELD_HEADER_STATIC_CHANNELS" : "Nazwy statycznych kanałów:",
+        "FIELD_HEADER_TIMEZONE"        : "Strefa czasowa:",
+        "FIELD_HEADER_USERNAME"        : "Użytkownik:",
+        "FIELD_HEADER_WIDTH"           : "Szerokość:",
+        "FIELD_HEADER_WOL_BROADCAST_ADDR" : "Adres rozgłoszeniowy (broadcast) dla pakietu WoL:",
+        "FIELD_HEADER_WOL_MAC_ADDR"       : "Adres MAC zdalnego hosta:",
+        "FIELD_HEADER_WOL_SEND_PACKET"    : "Wyślij pakiet WoL:",
+        "FIELD_HEADER_WOL_UDP_PORT"       : "Port UDP dla pakietu WoL:",
+        "FIELD_HEADER_WOL_WAIT_TIME"      : "Czas oczekiwania na rozruch hosta:",
+
+        "FIELD_OPTION_NORMALIZE_CLIPBOARD_EMPTY"    : "",
+        "FIELD_OPTION_NORMALIZE_CLIPBOARD_PRESERVE" : "Zachowaj tak, jak jest",
+        "FIELD_OPTION_NORMALIZE_CLIPBOARD_UNIX"     : "Linux/Mac/Unix (LF)",
+        "FIELD_OPTION_NORMALIZE_CLIPBOARD_WINDOWS"  : "Windows (CRLF)",
+
+        "FIELD_OPTION_COLOR_DEPTH_16"    : "Low color (16-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_24"    : "True color (24-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_32"    : "True color (32-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_8"     : "256 color",
+        "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "",
+
+        "FIELD_OPTION_RESIZE_METHOD_DISPLAY_UPDATE" : "\"Aktualizacja Wyświetlania\" kanał wirtualny (RDP 8.1+)",
+        "FIELD_OPTION_RESIZE_METHOD_EMPTY"          : "",
+        "FIELD_OPTION_RESIZE_METHOD_RECONNECT"      : "Połącz Ponownie",
+
+        "FIELD_OPTION_SECURITY_ANY"   : "Dowolny",
+        "FIELD_OPTION_SECURITY_EMPTY" : "",
+        "FIELD_OPTION_SECURITY_NLA"   : "NLA (Uwierzytelnianie Na Poziomie Sieci)",
+        "FIELD_OPTION_SECURITY_RDP"   : "szyfrowanie RDP",
+        "FIELD_OPTION_SECURITY_TLS"   : "szyfrowanie TLS",
+        "FIELD_OPTION_SECURITY_VMCONNECT" : "Hyper-V / VMConnect",
+
+        "FIELD_OPTION_SERVER_LAYOUT_DE_CH_QWERTZ" : "Szwajcarski Niemiecki (Qwertz)",
+        "FIELD_OPTION_SERVER_LAYOUT_DE_DE_QWERTZ" : "Niemiecki (Qwertz)",
+        "FIELD_OPTION_SERVER_LAYOUT_EMPTY"        : "",
+        "FIELD_OPTION_SERVER_LAYOUT_EN_GB_QWERTY" : "UK Angielski (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_EN_US_QWERTY" : "US Angielski (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_ES_ES_QWERTY" : "Hiszpański (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_ES_LATAM_QWERTY" : "Latynoamerykański (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE"     : "Unicode",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_BE_AZERTY" : "Belgijski Francuski (Azerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_CA_QWERTY" : "Kanadyjski Francuski (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_CH_QWERTZ" : "Szwajcarski Francuski (Qwertz)",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "Francuski (Azerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_HU_HU_QWERTZ" : "Węgierski (Qwertz)",
+        "FIELD_OPTION_SERVER_LAYOUT_IT_IT_QWERTY" : "Włoski (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_JA_JP_QWERTY" : "Japoński (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_NO_NO_QWERTY" : "Norweski (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_PL_PL_QWERTY" : "Polski (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_PT_BR_QWERTY" : "Portugalski Brazylijski (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_PT_PT_QWERTY" : "Portugalski (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Szwecki (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_DA_DK_QWERTY" : "Duński (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_TR_TR_QWERTY" : "Turecki-Q (Qwerty)",
+
+        "NAME" : "RDP",
+
+        "SECTION_HEADER_AUTHENTICATION"     : "Uwierzytelnianie",
+        "SECTION_HEADER_BASIC_PARAMETERS"   : "Ustawienia Podstawowe",
+        "SECTION_HEADER_CLIPBOARD"          : "Schowek",
+        "SECTION_HEADER_DEVICE_REDIRECTION" : "Przekierowanie Urządzeń",
+        "SECTION_HEADER_DISPLAY"            : "Wyświetlanie",
+        "SECTION_HEADER_GATEWAY"            : "Remote Desktop Gateway",
+        "SECTION_HEADER_LOAD_BALANCING"     : "Load Balancing",
+        "SECTION_HEADER_NETWORK"            : "Sieć",
+        "SECTION_HEADER_PERFORMANCE"        : "Wydajność",
+        "SECTION_HEADER_PRECONNECTION_PDU"  : "Wstępne połączenie PDU / Hyper-V",
+        "SECTION_HEADER_RECORDING"          : "Nagrywanie Ekranu",
+        "SECTION_HEADER_REMOTEAPP"          : "RemoteApp",
+        "SECTION_HEADER_SFTP"               : "SFTP",
+        "SECTION_HEADER_WOL"                : "Wake-on-LAN (WoL)"
+
+    },
+
+    "PROTOCOL_SSH" : {
+
+        "FIELD_HEADER_BACKSPACE"    : "Klawisz Backspace wysyła:",
+        "FIELD_HEADER_COLOR_SCHEME" : "Schemat kolorów:",
+        "FIELD_HEADER_COMMAND"      : "Wykonaj polecenie:",
+        "FIELD_HEADER_CREATE_RECORDING_PATH" : "Automatycznie utwórz ścieżkę nagrywania:",
+        "FIELD_HEADER_CREATE_TYPESCRIPT_PATH" : "Automatycznie utwórz ścieżkę maszynopisu:",
+        "FIELD_HEADER_DISABLE_COPY"  : "Wyłącz kopiowanie z terminala:",
+        "FIELD_HEADER_DISABLE_PASTE" : "Wyłącz wklejanie z klienta:",
+        "FIELD_HEADER_FONT_NAME"     : "Nazwa czcionki:",
+        "FIELD_HEADER_FONT_SIZE"     : "Rozmiar czcionki:",
+        "FIELD_HEADER_ENABLE_SFTP"   : "Włącz SFTP:",
+        "FIELD_HEADER_HOST_KEY"      : "Publiczny klucz hosta (Base64):",
+        "FIELD_HEADER_HOSTNAME"      : "Nazwa Hosta:",
+        "FIELD_HEADER_LOCALE"        : "Język/Ustawienia regionalne ($LANG):",
+        "FIELD_HEADER_USERNAME"      : "Użytkownik:",
+        "FIELD_HEADER_PASSWORD"      : "Hasło:",
+        "FIELD_HEADER_PASSPHRASE"    : "Hasło klucza:",
+        "FIELD_HEADER_PORT"          : "Port:",
+        "FIELD_HEADER_PRIVATE_KEY"   : "Klucz prywatny:",
+        "FIELD_HEADER_SCROLLBACK"    : "Maksymalny rozmiar przewijania:",
+        "FIELD_HEADER_READ_ONLY"     : "Tylko do odczytu:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "Wyklucz mysz:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Wyklucz grafiki/strumienie:",
+        "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "Dołącz zdarzenia klawiszy:",
+        "FIELD_HEADER_RECORDING_NAME" : "Nazwa nagrania:",
+        "FIELD_HEADER_RECORDING_PATH" : "Ścieżka nagrania:",
+        "FIELD_HEADER_SERVER_ALIVE_INTERVAL" : "Interwał utrzymywania aktywności serwera (keepalive):",
+        "FIELD_HEADER_SFTP_DISABLE_DOWNLOAD" : "Wyłącz pobieranie plików:",
+        "FIELD_HEADER_SFTP_ROOT_DIRECTORY"   : "Katalog nadrzędny przeglądarki plików:",
+        "FIELD_HEADER_SFTP_DISABLE_UPLOAD"   : "Wyłącz przesyłanie plików:",
+        "FIELD_HEADER_TERMINAL_TYPE"   : "Typ terminala:",
+        "FIELD_HEADER_TIMEZONE"        : "Strefa czasowa ($TZ):",
+        "FIELD_HEADER_TYPESCRIPT_NAME" : "Nazwa maszynopisu:",
+        "FIELD_HEADER_TYPESCRIPT_PATH" : "Ścieżka maszynopisu:",
+        "FIELD_HEADER_WOL_BROADCAST_ADDR" : "Adres rozgłoszeniowy (broadcast) dla pakietu WoL:",
+        "FIELD_HEADER_WOL_MAC_ADDR"       : "Adres MAC zdalnego hosta:",
+        "FIELD_HEADER_WOL_SEND_PACKET"    : "Wyślij pakiet WoL:",
+        "FIELD_HEADER_WOL_UDP_PORT"       : "Port UDP dla pakietu WoL:",
+        "FIELD_HEADER_WOL_WAIT_TIME"      : "Czas oczekiwania na rozruch hosta:",
+
+        "FIELD_OPTION_BACKSPACE_EMPTY" : "",
+        "FIELD_OPTION_BACKSPACE_8"     : "Backspace (Ctrl-H)",
+        "FIELD_OPTION_BACKSPACE_127"   : "Delete (Ctrl-?)",
+
+        "FIELD_OPTION_COLOR_SCHEME_BLACK_WHITE" : "Czarny na białym",
+        "FIELD_OPTION_COLOR_SCHEME_EMPTY"       : "",
+        "FIELD_OPTION_COLOR_SCHEME_GRAY_BLACK"  : "Szary na czarnym",
+        "FIELD_OPTION_COLOR_SCHEME_GREEN_BLACK" : "Zielony na czarnym",
+        "FIELD_OPTION_COLOR_SCHEME_WHITE_BLACK" : "Biały na czarnym",
+
+        "FIELD_OPTION_FONT_SIZE_8"     : "8",
+        "FIELD_OPTION_FONT_SIZE_9"     : "9",
+        "FIELD_OPTION_FONT_SIZE_10"    : "10",
+        "FIELD_OPTION_FONT_SIZE_11"    : "11",
+        "FIELD_OPTION_FONT_SIZE_12"    : "12",
+        "FIELD_OPTION_FONT_SIZE_14"    : "14",
+        "FIELD_OPTION_FONT_SIZE_18"    : "18",
+        "FIELD_OPTION_FONT_SIZE_24"    : "24",
+        "FIELD_OPTION_FONT_SIZE_30"    : "30",
+        "FIELD_OPTION_FONT_SIZE_36"    : "36",
+        "FIELD_OPTION_FONT_SIZE_48"    : "48",
+        "FIELD_OPTION_FONT_SIZE_60"    : "60",
+        "FIELD_OPTION_FONT_SIZE_72"    : "72",
+        "FIELD_OPTION_FONT_SIZE_96"    : "96",
+        "FIELD_OPTION_FONT_SIZE_EMPTY" : "",
+
+        "FIELD_OPTION_TERMINAL_TYPE_ANSI"           : "ansi",
+        "FIELD_OPTION_TERMINAL_TYPE_EMPTY"          : "",
+        "FIELD_OPTION_TERMINAL_TYPE_LINUX"          : "linux",
+        "FIELD_OPTION_TERMINAL_TYPE_VT100"          : "vt100",
+        "FIELD_OPTION_TERMINAL_TYPE_VT220"          : "vt220",
+        "FIELD_OPTION_TERMINAL_TYPE_XTERM"          : "xterm",
+        "FIELD_OPTION_TERMINAL_TYPE_XTERM_256COLOR" : "xterm-256color",
+
+        "NAME" : "SSH",
+
+        "SECTION_HEADER_AUTHENTICATION" : "Uwierzytelnianie",
+        "SECTION_HEADER_BEHAVIOR"       : "Zachowanie Terminala",
+        "SECTION_HEADER_CLIPBOARD"      : "Schowek",
+        "SECTION_HEADER_DISPLAY"        : "Wyświetlanie",
+        "SECTION_HEADER_NETWORK"        : "Sieć",
+        "SECTION_HEADER_RECORDING"      : "Nagrywanie Ekranu",
+        "SECTION_HEADER_SESSION"        : "Sesja / Środowisko",
+        "SECTION_HEADER_TYPESCRIPT"     : "Maszynopis (Tekstowe Nagranie Sesji)",
+        "SECTION_HEADER_SFTP"           : "SFTP",
+        "SECTION_HEADER_WOL"            : "Wake-on-LAN (WoL)"
+
+    },
+
+    "PROTOCOL_TELNET" : {
+
+        "FIELD_HEADER_BACKSPACE"      : "Klawisz Backspace wysyła:",
+        "FIELD_HEADER_COLOR_SCHEME"   : "Schemat kolorów:",
+        "FIELD_HEADER_CREATE_RECORDING_PATH" : "Automatycznie utwórz ścieżkę nagrywania:",
+        "FIELD_HEADER_CREATE_TYPESCRIPT_PATH" : "Automatycznie utwórz ścieżkę maszynopisu:",
+        "FIELD_HEADER_DISABLE_COPY"   : "Wyłącz kopiowanie z terminala:",
+        "FIELD_HEADER_DISABLE_PASTE"  : "Wyłącz wklejanie z klienta:",
+        "FIELD_HEADER_FONT_NAME"      : "Nazwa czcionki:",
+        "FIELD_HEADER_FONT_SIZE"      : "Rozmiar czcionki:",
+        "FIELD_HEADER_HOSTNAME"       : "Nazwa Hosta:",
+        "FIELD_HEADER_LOGIN_FAILURE_REGEX" : "Wyrażenie regularne nieudanego logowania:",
+        "FIELD_HEADER_LOGIN_SUCCESS_REGEX" : "Wyrażenie regularne udanego logowania:",
+        "FIELD_HEADER_USERNAME"       : "Użytkownik:",
+        "FIELD_HEADER_USERNAME_REGEX" : "Wyrażenie regularne użytkownika:",
+        "FIELD_HEADER_PASSWORD"       : "Hasło:",
+        "FIELD_HEADER_PASSWORD_REGEX" : "Wyrażenie regularne hasła",
+        "FIELD_HEADER_PORT"           : "Port:",
+        "FIELD_HEADER_READ_ONLY"      : "Tylko do odczytu:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "Wyklucz mysz:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Wyklucz grafiki/strumienie:",
+        "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "Dołącz wciśnięcia klawiszy:",
+        "FIELD_HEADER_RECORDING_NAME" : "Nazwa nagrania:",
+        "FIELD_HEADER_RECORDING_PATH" : "Ścieżka nagrania:",
+        "FIELD_HEADER_SCROLLBACK"     : "Maksymalny rozmiar przewijania:",
+        "FIELD_HEADER_TERMINAL_TYPE"   : "Typ terminala:",
+        "FIELD_HEADER_TYPESCRIPT_NAME" : "Nazwa maszynopisu:",
+        "FIELD_HEADER_TYPESCRIPT_PATH" : "Ścieżka maszynopisu:",
+        "FIELD_HEADER_WOL_BROADCAST_ADDR" : "Adres rozgłoszeniowy (broadcast) dla pakietu WoL:",
+        "FIELD_HEADER_WOL_MAC_ADDR"       : "Adres MAC zdalnego hosta:",
+        "FIELD_HEADER_WOL_SEND_PACKET"    : "Wyślij pakiet WoL:",
+        "FIELD_HEADER_WOL_UDP_PORT"       : "Port UDP dla pakietu WoL:",
+        "FIELD_HEADER_WOL_WAIT_TIME"      : "Czas oczekiwania na rozruch hosta:",
+
+        "FIELD_OPTION_BACKSPACE_EMPTY" : "",
+        "FIELD_OPTION_BACKSPACE_8"     : "Backspace (Ctrl-H)",
+        "FIELD_OPTION_BACKSPACE_127"   : "Delete (Ctrl-?)",
+
+        "FIELD_OPTION_COLOR_SCHEME_BLACK_WHITE" : "Czarny na białym",
+        "FIELD_OPTION_COLOR_SCHEME_EMPTY"       : "",
+        "FIELD_OPTION_COLOR_SCHEME_GRAY_BLACK"  : "Szary na czarnym",
+        "FIELD_OPTION_COLOR_SCHEME_GREEN_BLACK" : "Zielony na czarnym",
+        "FIELD_OPTION_COLOR_SCHEME_WHITE_BLACK" : "Biały na czarnym",
+
+        "FIELD_OPTION_FONT_SIZE_8"     : "8",
+        "FIELD_OPTION_FONT_SIZE_9"     : "9",
+        "FIELD_OPTION_FONT_SIZE_10"    : "10",
+        "FIELD_OPTION_FONT_SIZE_11"    : "11",
+        "FIELD_OPTION_FONT_SIZE_12"    : "12",
+        "FIELD_OPTION_FONT_SIZE_14"    : "14",
+        "FIELD_OPTION_FONT_SIZE_18"    : "18",
+        "FIELD_OPTION_FONT_SIZE_24"    : "24",
+        "FIELD_OPTION_FONT_SIZE_30"    : "30",
+        "FIELD_OPTION_FONT_SIZE_36"    : "36",
+        "FIELD_OPTION_FONT_SIZE_48"    : "48",
+        "FIELD_OPTION_FONT_SIZE_60"    : "60",
+        "FIELD_OPTION_FONT_SIZE_72"    : "72",
+        "FIELD_OPTION_FONT_SIZE_96"    : "96",
+        "FIELD_OPTION_FONT_SIZE_EMPTY" : "",
+
+        "FIELD_OPTION_TERMINAL_TYPE_ANSI"           : "ansi",
+        "FIELD_OPTION_TERMINAL_TYPE_EMPTY"          : "",
+        "FIELD_OPTION_TERMINAL_TYPE_LINUX"          : "linux",
+        "FIELD_OPTION_TERMINAL_TYPE_VT100"          : "vt100",
+        "FIELD_OPTION_TERMINAL_TYPE_VT220"          : "vt220",
+        "FIELD_OPTION_TERMINAL_TYPE_XTERM"          : "xterm",
+        "FIELD_OPTION_TERMINAL_TYPE_XTERM_256COLOR" : "xterm-256color",
+
+        "NAME" : "Telnet",
+
+        "SECTION_HEADER_AUTHENTICATION" : "Uwierzytelnianie",
+        "SECTION_HEADER_BEHAVIOR"       : "Zachowanie Terminala",
+        "SECTION_HEADER_CLIPBOARD"      : "Schowek",
+        "SECTION_HEADER_DISPLAY"        : "Wyświetlanie",
+        "SECTION_HEADER_RECORDING"      : "Nagrywanie Ekranu",
+        "SECTION_HEADER_TYPESCRIPT"     : "Maszynopis (Tekstowe Nagranie Sesji)",
+        "SECTION_HEADER_NETWORK"        : "Sieć",
+        "SECTION_HEADER_WOL"            : "Wake-on-LAN (WoL)"
+
+    },
+
+    "PROTOCOL_VNC" : {
+
+        "FIELD_HEADER_AUDIO_SERVERNAME" : "Nazwa serwera audio:",
+        "FIELD_HEADER_CLIPBOARD_ENCODING" : "Kodowanie:",
+        "FIELD_HEADER_COLOR_DEPTH"      : "Głębia kolorów:",
+        "FIELD_HEADER_CREATE_RECORDING_PATH" : "Automatycznie utwórz ścieżkę nagrywania:",
+        "FIELD_HEADER_CURSOR"           : "Kursor:",
+        "FIELD_HEADER_DEST_HOST"        : "Docelowy host:",
+        "FIELD_HEADER_DEST_PORT"        : "Docelowy port:",
+        "FIELD_HEADER_DISABLE_COPY"     : "Wyłącz kopiowanie ze zdalnego pulpitu:",
+        "FIELD_HEADER_DISABLE_PASTE"    : "Wyłącz wklejanie z klienta:",
+        "FIELD_HEADER_ENABLE_AUDIO"     : "Włącz dźwięk:",
+        "FIELD_HEADER_ENABLE_SFTP"      : "Włącz SFTP:",
+        "FIELD_HEADER_FORCE_LOSSLESS"   : "Wymuś bezstratną kompresję:",
+        "FIELD_HEADER_HOSTNAME"         : "Nazwa Hosta:",
+        "FIELD_HEADER_USERNAME"         : "Użytkownik:",
+        "FIELD_HEADER_PASSWORD"         : "Hasło:",
+        "FIELD_HEADER_PORT"             : "Port:",
+        "FIELD_HEADER_READ_ONLY"        : "Tylko do odczytu:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "Wyklucz mysz:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "Wyklucz grafiki/strumienie:",
+        "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "Dołącz zdarzenia klawiszy:",
+        "FIELD_HEADER_RECORDING_NAME" : "Nazwa nagrania:",
+        "FIELD_HEADER_RECORDING_PATH" : "Ścieżka nagrania:",
+        "FIELD_HEADER_SFTP_DIRECTORY"             : "Domyślny katalog przesyłania:",
+        "FIELD_HEADER_SFTP_DISABLE_DOWNLOAD"      : "Wyłącz pobieranie plików:",
+        "FIELD_HEADER_SFTP_HOST_KEY"              : "Publiczny klucz hosta (Base64):",
+        "FIELD_HEADER_SFTP_HOSTNAME"              : "Nazwa Hosta:",
+        "FIELD_HEADER_SFTP_SERVER_ALIVE_INTERVAL" : "Interwał utrzymania aktywności (keepalive) SFTP:",
+        "FIELD_HEADER_SFTP_PASSPHRASE"            : "Hasło klucza:",
+        "FIELD_HEADER_SFTP_PASSWORD"              : "Hasło:",
+        "FIELD_HEADER_SFTP_PORT"                  : "Port:",
+        "FIELD_HEADER_SFTP_PRIVATE_KEY"           : "Klucz prywatny:",
+        "FIELD_HEADER_SFTP_ROOT_DIRECTORY"        : "Katalog nadrzędny przeglądarki plików:",
+        "FIELD_HEADER_SFTP_DISABLE_UPLOAD"        : "Wyłącz przesyłanie plików:",
+        "FIELD_HEADER_SFTP_USERNAME"              : "Użytkownik:",
+        "FIELD_HEADER_SWAP_RED_BLUE"    : "Zamień czerwone/niebieskie komponenty:",
+        "FIELD_HEADER_WOL_BROADCAST_ADDR" : "Adres rozgłoszeniowy (broadcast) dla pakietu WoL:",
+        "FIELD_HEADER_WOL_MAC_ADDR"       : "Adres MAC zdalnego hosta:",
+        "FIELD_HEADER_WOL_SEND_PACKET"    : "Wyślij pakiet WoL:",
+        "FIELD_HEADER_WOL_UDP_PORT"       : "Port UDP dla pakietu WoL:",
+        "FIELD_HEADER_WOL_WAIT_TIME"      : "Czas oczekiwania na rozruch hosta:",
+
+        "FIELD_OPTION_COLOR_DEPTH_8"     : "256 color",
+        "FIELD_OPTION_COLOR_DEPTH_16"    : "Low color (16-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_24"    : "True color (24-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_32"    : "True color (32-bit)",
+        "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "",
+
+        "FIELD_OPTION_CURSOR_EMPTY"  : "",
+        "FIELD_OPTION_CURSOR_LOCAL"  : "Lokalny",
+        "FIELD_OPTION_CURSOR_REMOTE" : "Zdalny",
+
+        "FIELD_OPTION_CLIPBOARD_ENCODING_CP1252"    : "CP1252",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_EMPTY"     : "",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_ISO8859_1" : "ISO 8859-1",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_UTF_8"     : "UTF-8",
+        "FIELD_OPTION_CLIPBOARD_ENCODING_UTF_16"    : "UTF-16",
+
+        "NAME" : "VNC",
+
+        "SECTION_HEADER_AUDIO"          : "Dźwięk",
+        "SECTION_HEADER_AUTHENTICATION" : "Uwierzytelnianie",
+        "SECTION_HEADER_CLIPBOARD"      : "Schowek",
+        "SECTION_HEADER_DISPLAY"        : "Wyświetlanie",
+        "SECTION_HEADER_NETWORK"        : "Sieć",
+        "SECTION_HEADER_RECORDING"      : "Nagrywanie Ekranu",
+        "SECTION_HEADER_REPEATER"       : "Przekaźnik VNC",
+        "SECTION_HEADER_SFTP"           : "SFTP",
+        "SECTION_HEADER_WOL"            : "Wake-on-LAN (WoL)"
+
+    },
+
+    "SETTINGS" : {
+
+        "SECTION_HEADER_SETTINGS" : "Ustawienia"
+
+    },
+
+    "SETTINGS_CONNECTION_HISTORY" : {
+
+        "ACTION_DOWNLOAD"       : "@:APP.ACTION_DOWNLOAD",
+        "ACTION_SEARCH"         : "@:APP.ACTION_SEARCH",
+        "ACTION_VIEW_RECORDING" : "@:APP.ACTION_VIEW_RECORDING",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "FILENAME_HISTORY_CSV" : "history.csv",
+
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "HELP_CONNECTION_HISTORY" : "Rekordy historii poprzednich połączeń są wylistowane tutaj i można je sortować klikając nagłówki kolumn. Aby wyszukać określone rekordy wprowadź ciąg filtru i kliknij \"Szukaj\". Tylko rekordy pasujące do podanego filtru zostaną wyświetlone.",
+
+        "INFO_CONNECTION_DURATION_UNKNOWN" : "--",
+        "INFO_NO_HISTORY"                  : "Brak pasujących rekordów",
+
+        "TABLE_HEADER_SESSION_CONNECTION_NAME" : "Nazwa połączenia",
+        "TABLE_HEADER_SESSION_DURATION"        : "Czas trwania",
+        "TABLE_HEADER_SESSION_LOGS"            : "Logi",
+        "TABLE_HEADER_SESSION_REMOTEHOST"      : "Zdalny host",
+        "TABLE_HEADER_SESSION_STARTDATE"       : "Czas rozpoczęcia",
+        "TABLE_HEADER_SESSION_USERNAME"        : "Użytkownik",
+
+        "TEXT_HISTORY_DURATION" : "@:APP.TEXT_HISTORY_DURATION"
+
+    },
+
+    "SETTINGS_CONNECTIONS" : {
+
+        "ACTION_ACKNOWLEDGE"          : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_CONNECTION"       : "Nowe Połączenie",
+        "ACTION_NEW_CONNECTION_GROUP" : "Nowa Grupa",
+        "ACTION_NEW_SHARING_PROFILE"  : "Nowy Profil Udostępniania",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_CONNECTIONS"   : "Kliknij lub naciśnij połączenie poniżej, aby nim zarządzać. W zależności od poziomu dostępu, połączenia mogą być dodawane i usuwane, a ich właściwości (protokół, nazwa hosta, port itp.) mogą być zmieniane.",
+
+        "INFO_ACTIVE_USER_COUNT" : "@:APP.INFO_ACTIVE_USER_COUNT",
+
+        "SECTION_HEADER_CONNECTIONS"     : "Połączenia"
+
+    },
+
+    "SETTINGS_PREFERENCES" : {
+
+        "ACTION_ACKNOWLEDGE"        : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"             : "@:APP.ACTION_CANCEL",
+        "ACTION_SAVE"               : "@:APP.ACTION_SAVE",
+        "ACTION_UPDATE_PASSWORD"    : "@:APP.ACTION_UPDATE_PASSWORD",
+
+        "DIALOG_HEADER_ERROR"    : "@:APP.DIALOG_HEADER_ERROR",
+
+        "ERROR_PASSWORD_BLANK"    : "@:APP.ERROR_PASSWORD_BLANK",
+        "ERROR_PASSWORD_MISMATCH" : "@:APP.ERROR_PASSWORD_MISMATCH",
+
+        "FIELD_HEADER_LANGUAGE"           : "Język:",
+        "FIELD_HEADER_PASSWORD"           : "Hasło:",
+        "FIELD_HEADER_PASSWORD_OLD"       : "Obecne Hasło:",
+        "FIELD_HEADER_PASSWORD_NEW"       : "Nowe Hasło:",
+        "FIELD_HEADER_PASSWORD_NEW_AGAIN" : "Powtórz Nowe Hasło:",
+        "FIELD_HEADER_TIMEZONE"           : "Strefa Czasowa:",
+        "FIELD_HEADER_USERNAME"           : "Użytkownik:",
+
+        "HELP_DEFAULT_INPUT_METHOD" : "Domyślna metoda wprowadzania określa sposób odbierania zdarzeń klawiatury przez Guacamole. Zmiana tego ustawienia może być konieczna w przypadku korzystania z urządzenia mobilnego lub pisania w edytorze IME. To ustawienie można zmienić dla każdego połączenia w menu Guacamole.",
+        "HELP_DEFAULT_MOUSE_MODE"   : "Domyślny tryb emulacji myszy określa, jak zdalna mysz będzie się zachowywać w nowych połączeniach w odniesieniu do dotknięć. To ustawienie można zmienić dla każdego połączenia w menu Guacamole.",
+        "HELP_INPUT_METHOD_NONE"    : "@:CLIENT.HELP_INPUT_METHOD_NONE",
+        "HELP_INPUT_METHOD_OSK"     : "@:CLIENT.HELP_INPUT_METHOD_OSK",
+        "HELP_INPUT_METHOD_TEXT"    : "@:CLIENT.HELP_INPUT_METHOD_TEXT",
+        "HELP_LOCALE"               : "Poniższe opcje są związane z ustawieniami regionalnymi użytkownika i wpłyną na sposób wyświetlania różnych części interfejsu",
+        "HELP_MOUSE_MODE_ABSOLUTE"  : "@:CLIENT.HELP_MOUSE_MODE_ABSOLUTE",
+        "HELP_MOUSE_MODE_RELATIVE"  : "@:CLIENT.HELP_MOUSE_MODE_RELATIVE",
+        "HELP_UPDATE_PASSWORD"      : "Jeśli chcesz zmienić swoje hasło, wprowadź poniżej obecne hasło i żądane nowe hasło, a następnie kliknij \"Zmień Hasło\". Zmiana będzie miała efekt natychmiastowy.",
+
+        "INFO_PASSWORD_CHANGED" : "Hasło zmienione.",
+        "INFO_PREFERENCE_ATTRIBUTES_CHANGED" : "Ustawienia użytkownika zapisane.",
+
+        "NAME_INPUT_METHOD_NONE" : "@:CLIENT.NAME_INPUT_METHOD_NONE",
+        "NAME_INPUT_METHOD_OSK"  : "@:CLIENT.NAME_INPUT_METHOD_OSK",
+        "NAME_INPUT_METHOD_TEXT" : "@:CLIENT.NAME_INPUT_METHOD_TEXT",
+
+        "SECTION_HEADER_DEFAULT_INPUT_METHOD" : "Domyślna Metoda Wprowadzania",
+        "SECTION_HEADER_DEFAULT_MOUSE_MODE"   : "Domyślny Tryb Emulacji Myszy",
+        "SECTION_HEADER_UPDATE_PASSWORD"      : "Zmień Hasło"
+
+    },
+
+    "SETTINGS_USERS" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_USER"      : "Nowy Użytkownik",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "HELP_USERS" : "Kliknij lub dotknij użytkownika poniżej, aby nim zarządzać. W zależności od Twojego poziomu dostępu, użytkowników można dodawać i usuwać, a także zmieniać ich hasła.",
+
+        "SECTION_HEADER_USERS"       : "Użytkownicy",
+
+        "TABLE_HEADER_FULL_NAME"   : "Imię i Nazwisko",
+        "TABLE_HEADER_LAST_ACTIVE" : "Ostatnio aktywny",
+        "TABLE_HEADER_ORGANIZATION" : "Organizacja",
+        "TABLE_HEADER_USERNAME"    : "Nazwa użytkownika"
+
+    },
+
+    "SETTINGS_USER_GROUPS" : {
+
+        "ACTION_ACKNOWLEDGE"    : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_USER_GROUP" : "Nowa Grupa",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "HELP_USER_GROUPS" : "Kliknij lub naciśnij grupę poniżej, aby nią zarządzać. W zależności od Twojego poziomu dostępu, grupy mogą być dodawane i usuwane, a ich użytkownicy i grupy mogą być edytowane.",
+
+        "SECTION_HEADER_USER_GROUPS" : "Grupy",
+
+        "TABLE_HEADER_USER_GROUP_NAME" : "Nazwa Grupy"
+
+    },
+
+    "SETTINGS_SESSIONS" : {
+
+        "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"      : "@:APP.ACTION_CANCEL",
+        "ACTION_DELETE"      : "Zakończ sesję",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Zakończ Sesję",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "FORMAT_STARTDATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "HELP_SESSIONS" : "Ta strona zostanie wypełniona aktualnie aktywnymi połączeniami. Lista połączeń i możliwość ich zakończenia zależy od Twojego poziomu dostępu. Jeśli chcesz zakończyć jedną lub więcej sesji, zaznacz pola obok tych sesji i kliknij \"Zakończ sesję\". Zakończenie sesji spowoduje natychmiastowe rozłączenie użytkownika z powiązanym połączeniem.",
+
+
+        "INFO_NO_SESSIONS" : "Brak aktywnych połączeń",
+
+        "SECTION_HEADER_SESSIONS" : "Aktywne Połączenia",
+
+        "TABLE_HEADER_SESSION_CONNECTION_NAME" : "Nazwa połączenia",
+        "TABLE_HEADER_SESSION_REMOTEHOST"      : "Zdalny host",
+        "TABLE_HEADER_SESSION_STARTDATE"       : "Aktywne od",
+        "TABLE_HEADER_SESSION_USERNAME"        : "Użytkownik",
+
+        "TEXT_CONFIRM_DELETE" : "Czy na pewno chcesz zakończyć wszystkie wybrane sesje? Użytkownicy korzystający z tych sesji zostaną natychmiast rozłączeni."
+
+    },
+
+    "USER_ATTRIBUTES" : {
+
+        "FIELD_HEADER_GUAC_EMAIL_ADDRESS"       : "Adres email:",
+        "FIELD_HEADER_GUAC_FULL_NAME"           : "Imię i Nazwisko:",
+        "FIELD_HEADER_GUAC_ORGANIZATION"        : "Organizacja:",
+        "FIELD_HEADER_GUAC_ORGANIZATIONAL_ROLE" : "Rola:"
+
+    },
+
+    "USER_MENU" : {
+
+        "ACTION_LOGOUT"             : "@:APP.ACTION_LOGOUT",
+        "ACTION_MANAGE_CONNECTIONS" : "@:APP.ACTION_MANAGE_CONNECTIONS",
+        "ACTION_MANAGE_PREFERENCES" : "@:APP.ACTION_MANAGE_PREFERENCES",
+        "ACTION_MANAGE_SESSIONS"    : "@:APP.ACTION_MANAGE_SESSIONS",
+        "ACTION_MANAGE_SETTINGS"    : "@:APP.ACTION_MANAGE_SETTINGS",
+        "ACTION_MANAGE_USERS"       : "@:APP.ACTION_MANAGE_USERS",
+        "ACTION_MANAGE_USER_GROUPS" : "@:APP.ACTION_MANAGE_USER_GROUPS",
+        "ACTION_NAVIGATE_HOME"      : "@:APP.ACTION_NAVIGATE_HOME",
+        "ACTION_VIEW_HISTORY"       : "@:APP.ACTION_VIEW_HISTORY"
+
+    }
+
+}
diff --git a/guacamole/src/main/frontend/src/translations/pt.json b/guacamole/src/main/frontend/src/translations/pt.json
index e7f09d8..77b9564 100644
--- a/guacamole/src/main/frontend/src/translations/pt.json
+++ b/guacamole/src/main/frontend/src/translations/pt.json
@@ -560,6 +560,7 @@
         "FIELD_OPTION_SERVER_LAYOUT_JA_JP_QWERTY" : "Japonês (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_PL_PL_QWERTY" : "Polonês (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_PT_BR_QWERTY" : "Português Brasileiro (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_PT_PT_QWERTY" : "Português (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Sueco (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_DA_DK_QWERTY" : "Dinamarquês (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_TR_TR_QWERTY" : "Turco-Q (Qwerty)",
diff --git a/guacamole/src/main/frontend/src/translations/zh.json b/guacamole/src/main/frontend/src/translations/zh.json
index a56f393..1431ed9 100644
--- a/guacamole/src/main/frontend/src/translations/zh.json
+++ b/guacamole/src/main/frontend/src/translations/zh.json
@@ -4,14 +4,19 @@
     
     "APP" : {
 
+        "NAME"    : "Apache Guacamole",
+
         "ACTION_ACKNOWLEDGE"        : "确定",
         "ACTION_CANCEL"             : "取消",
+        "ACTION_CLEAR"              : "清除",
         "ACTION_CLONE"              : "克隆",
         "ACTION_CONTINUE"           : "继续",
         "ACTION_DELETE"             : "删除",
         "ACTION_DELETE_SESSIONS"    : "删除会话",
         "ACTION_DOWNLOAD"           : "下载",
+        "ACTION_IMPORT"             : "导入",
         "ACTION_LOGIN"              : "登录",
+        "ACTION_LOGIN_AGAIN"        : "重新登录",
         "ACTION_LOGOUT"             : "登出",
         "ACTION_MANAGE_CONNECTIONS" : "连接",
         "ACTION_MANAGE_PREFERENCES" : "偏好",
@@ -21,18 +26,22 @@
         "ACTION_MANAGE_USER_GROUPS" : "用户组",
         "ACTION_NAVIGATE_BACK"      : "返回",
         "ACTION_NAVIGATE_HOME"      : "首页",
+        "ACTION_PAUSE"              : "暂停",
+        "ACTION_PLAY"               : "播放",
         "ACTION_SAVE"               : "保存",
         "ACTION_SEARCH"             : "搜索",
         "ACTION_SHARE"              : "共享",
         "ACTION_UPDATE_PASSWORD"    : "更新密码",
         "ACTION_VIEW_HISTORY"       : "历史",
+        "ACTION_VIEW_RECORDING"     : "查看",
 
-        "DIALOG_HEADER_ERROR" : "出错",
+        "DIALOG_HEADER_ERROR" : "错误",
 
         "ERROR_PAGE_UNAVAILABLE"  : "发生错误,此操作无法完成。 如果问题仍然存在,请通知系统管理员或检查系统日志。",
-        "ERROR_PASSWORD_BLANK"    : "密码不能留空。",
-        "ERROR_PASSWORD_MISMATCH" : "输入的密码不吻合。",
-        
+        "ERROR_PASSWORD_BLANK"    : "密码不能为空。",
+        "ERROR_PASSWORD_MISMATCH" : "输入的密码不匹配。",
+        "ERROR_SINGLE_FILE_ONLY"  : "请一次只上传一个文件",
+
         "FIELD_HEADER_PASSWORD"       : "密码:",
         "FIELD_HEADER_PASSWORD_AGAIN" : "重输密码:",
 
@@ -40,17 +49,22 @@
 
         "FORMAT_DATE_TIME_PRECISE" : "yyyy-MM-dd HH:mm:ss",
 
-        "INFO_ACTIVE_USER_COUNT" : "正在被{USERS}用户使用。",
+        "INFO_ACTIVE_USER_COUNT" : "正在被 {USERS} 个用户使用。",
+        "INFO_LOGGED_OUT"        : "您已登出。",
 
-        "TEXT_ANONYMOUS_USER"   : "匿名",
-        "TEXT_HISTORY_DURATION" : "{VALUE} {UNIT, select, second{秒} minute{分} hour{小时} day{天} other{}}"
+        "TEXT_ANONYMOUS_USER"   : "匿名用户",
+        "TEXT_HISTORY_DURATION" : "{VALUE} {UNIT, select, second{秒} minute{分} hour{小时} day{天} other{}}",
+        "TEXT_UNTRANSLATED" : "{MESSAGE}"
 
     },
 
     "CLIENT" : {
 
         "ACTION_ACKNOWLEDGE"               : "@:APP.ACTION_ACKNOWLEDGE",
-        "ACTION_CLEAR_COMPLETED_TRANSFERS" : "清除",
+        "ACTION_CANCEL"                    : "@:APP.ACTION_CANCEL",
+        "ACTION_CLEAR_CLIENT_MESSAGES"     : "@:APP.ACTION_CLEAR",
+        "ACTION_CLEAR_COMPLETED_TRANSFERS" : "@:APP.ACTION_CLEAR",
+        "ACTION_CONTINUE"                  : "@:APP.ACTION_CONTINUE",
         "ACTION_DISCONNECT"                : "断开连接",
         "ACTION_LOGOUT"                    : "@:APP.ACTION_LOGOUT",
         "ACTION_NAVIGATE_BACK"             : "@:APP.ACTION_NAVIGATE_BACK",
@@ -64,32 +78,32 @@
         "DIALOG_HEADER_CONNECTION_ERROR" : "连接出错",
         "DIALOG_HEADER_DISCONNECTED"     : "已断开连接",
 
-        "ERROR_CLIENT_201"     : "因服务器繁忙,本连接已被关闭。请稍候几分钟再重试。",
-        "ERROR_CLIENT_202"     : "因远程桌面太久没有应答,Guacamole服务器关闭了本连接。请重试或联系您的系统管理员。",
-        "ERROR_CLIENT_203"     : "远程桌面服务器因为出错而关闭了本连接。请重试或联系您的系统管理员。",
+        "ERROR_CLIENT_201"     : "因服务器繁忙,本连接已被关闭。请稍等几分钟后再尝试。",
+        "ERROR_CLIENT_202"     : "因远程桌面太久没有应答,Guacamole 服务器关闭了本连接。请重试或联系您的系统管理员。",
+        "ERROR_CLIENT_203"     : "远程桌面服务器因为发生错误而关闭了本连接。请重试或联系您的系统管理员。",
         "ERROR_CLIENT_207"     : "联系不上远程桌面服务器。如果问题持续,请通知您的系统管理员,或检查您的系统日志。",
         "ERROR_CLIENT_208"     : "远程桌面服务器不在线。如果问题持续,请通知您的系统管理员,或检查您的系统日志。",
         "ERROR_CLIENT_209"     : "因与另一个连接冲突,远程桌面服务器关闭了本连接。请稍后重试。",
         "ERROR_CLIENT_20A"     : "因长时间没有活动,远程桌面服务器关闭了本连接。如果这不是期望的设置,请通知您的系统管理员,或检查您的系统设置。",
-        "ERROR_CLIENT_20B"     : "远程桌面服务器强制关闭了本连接。如果这不是期望的配置,请通知您的系统管理员,或检查您的系统日志。",
-        "ERROR_CLIENT_301"     : "登录失败。请先重新连接再重试。",
+        "ERROR_CLIENT_20B"     : "远程桌面服务器强制关闭了本连接。如果这不是预期的情况,请通知您的系统管理员,或检查您的系统日志。",
+        "ERROR_CLIENT_301"     : "登录失败。请重新连接后再重试。",
         "ERROR_CLIENT_303"     : "远程桌面服务器拒绝了本连接。如果需要使用本连接,请联系您的系统管理员开放权限,或者检查您的系统设置。",
-        "ERROR_CLIENT_308"     : "因为您的浏览器长时间没有应答,Guacamole服务器关闭了本连接。这通常是因为网络问题(如不稳定的无线连接或网速太慢等)而导致的。请先检查您的网络连接再重试。",
-        "ERROR_CLIENT_31D"     : "因为您已超出了单一用户可同时使用的连接数量,Guacamole服务器拒绝了本连接。请先关闭至少一个连接再重试。",
-        "ERROR_CLIENT_DEFAULT" : "本连接因为Guacamole服务器出现了内部错误而被终止。如果问题持续,请通知您的系统管理员,或检查您的系统日志。",
+        "ERROR_CLIENT_308"     : "因为您的浏览器长时间没有应答,Guacamole 服务器关闭了本连接。这通常是因为网络问题(如不稳定的无线连接或网速太慢等)而导致的。请先检查您的网络连接再重试。",
+        "ERROR_CLIENT_31D"     : "因为您已超出了单一用户可同时使用的连接数量,Guacamole 服务器拒绝了本连接。请先关闭至少一个连接再重试。",
+        "ERROR_CLIENT_DEFAULT" : "本连接因为 Guacamole 服务器出现了内部错误而被终止。如果问题持续,请通知您的系统管理员,或检查您的系统日志。",
 
-        "ERROR_TUNNEL_201"     : "因为正在使用的活动连接太多,Guacamole服务器拒绝了本连接。请稍后再重试。",
+        "ERROR_TUNNEL_201"     : "因为正在使用的活动连接太多,Guacamole 服务器拒绝了本连接。请稍后再重试。",
         "ERROR_TUNNEL_202"     : "因服务器太久没有应答,本连接已被关闭。这通常是因为网络问题(如不稳定的无线连接或网速太慢等)而导致的。请先检查您的网络连接再重试,或者联系您的系统管理员。",
         "ERROR_TUNNEL_203"     : "服务器出错并关闭了本连接。请重试,或联系您的系统管理员。",
         "ERROR_TUNNEL_204"     : "请求的连接不存在。请先检查连接的名字再重试。",
         "ERROR_TUNNEL_205"     : "本连接正在使用中,并且不允许共享连接。请稍后重试。",
-        "ERROR_TUNNEL_207"     : "联系不上Guacamole服务器。请先检查您的网络连接再重试。",
-        "ERROR_TUNNEL_208"     : "Guacamole服务器不接受连接请求。请先检查您的网络连接再重试。",
+        "ERROR_TUNNEL_207"     : "联系不上 Guacamole 服务器。请先检查您的网络连接再重试。",
+        "ERROR_TUNNEL_208"     : "Guacamole 服务器不接受连接请求。请先检查您的网络连接再重试。",
         "ERROR_TUNNEL_301"     : "您还未登录,所以没有使用此连接的权限。请先登录再重试。",
         "ERROR_TUNNEL_303"     : "您没有使用此连接的权限。如果您的确需要使用此连接,请联系您的系统管理员开通权限,或检查您的系统设置。",
-        "ERROR_TUNNEL_308"     : "因为您的浏览器长时间没有应答,Guacamole服务器关闭了本连接。这通常是因为网络问题(如不稳定的无线连接或网速太慢等)而导致的。请先检查您的网络连接再重试。",
-        "ERROR_TUNNEL_31D"     : "因为您已超出了单一用户可同时使用的连接数量,Guacamole服务器拒绝了本连接。请先关闭至少一个连接再重试。",
-        "ERROR_TUNNEL_DEFAULT" : "本连接因为Guacamole服务器出现了内部错误而被终止。如果问题持续,请通知您的系统管理员,或检查您的系统日志。",
+        "ERROR_TUNNEL_308"     : "因为您的浏览器长时间没有应答,Guacamole 服务器关闭了本连接。这通常是因为网络问题(如不稳定的无线连接或网速太慢等)而导致的。请先检查您的网络连接再重试。",
+        "ERROR_TUNNEL_31D"     : "因为您已超出了单一用户可同时使用的连接数量,Guacamole 服务器拒绝了本连接。请先关闭至少一个连接再重试。",
+        "ERROR_TUNNEL_DEFAULT" : "本连接因为 Guacamole 服务器出现了内部错误而被终止。如果问题持续,请通知您的系统管理员,或检查您的系统日志。",
 
         "ERROR_UPLOAD_100"     : "不支持或不允许使用文件传输。请联系您的系统管理员,或检查您的系统日志。",
         "ERROR_UPLOAD_201"     : "正在同时传输太多文件。请等待当前的文件传输任务完成后,再重试。",
@@ -101,47 +115,56 @@
         "ERROR_UPLOAD_303"     : "您没有上传此文件的权限。如果您需要权限,请检查您的系统设置,或联系您的系统管理员。",
         "ERROR_UPLOAD_308"     : "文件传输已停止。这通常是因为网络问题(如不稳定的无线连接或网速太慢等)而导致的。请先检查您的网络连接再重试。",
         "ERROR_UPLOAD_31D"     : "正在同时传输太多文件。请等待当前的传输任务完成后,再重试。",
-        "ERROR_UPLOAD_DEFAULT" : "本连接因为Guacamole服务器出现了内部错误而被终止。如果问题持续,请通知您的系统管理员,或检查您的系统日志。",
+        "ERROR_UPLOAD_DEFAULT" : "本连接因为 Guacamole 服务器出现了内部错误而被终止。如果问题持续,请通知您的系统管理员,或检查您的系统日志。",
 
         "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
 
         "HELP_CLIPBOARD"           : "复制/剪切的文本将出现在这里。对下面文本内容所作的修改将会影响远程电脑上的剪贴板。",
-        "HELP_INPUT_METHOD_NONE"   : "没有选择任何输入法。将从连接的物理键盘接受键盘输入。",
-        "HELP_INPUT_METHOD_OSK"    : "显示并从内建的Guacamole屏幕键盘接受输入。屏幕键盘可以输入平常无法输入的按键组合(如Ctrl-Alt-Del等)。",
+        "HELP_INPUT_METHOD_NONE"   : "没有选择任何输入方式。将从连接的物理键盘接受键盘输入。",
+        "HELP_INPUT_METHOD_OSK"    : "显示并从内建的 Guacamole 屏幕键盘接受输入。屏幕键盘可以输入平常无法输入的按键组合(如 Ctrl-Alt-Del 等)。",
         "HELP_INPUT_METHOD_TEXT"   : "允许输入文本,并根据所输入的文本模拟键盘事件。可用于没有物理键盘的设备,如手机等。",
         "HELP_MOUSE_MODE"          : "设置远程电脑上的鼠标对触控行为的反应。",
         "HELP_MOUSE_MODE_ABSOLUTE" : "点击时立即触发按键。在点击的位置触发鼠标按键事件。",
         "HELP_MOUSE_MODE_RELATIVE" : "拖拽时移动鼠标,再点击时触发按键。在鼠标当前所在的位置触发按键事件。",
         "HELP_SHARE_LINK"          : "正在共享当前连接,并可被使用以下链接的任何人使用:",
 
-        "INFO_CONNECTION_SHARED" : "此连接已被共享。",
-        "INFO_NO_FILE_TRANSFERS" : "无文件传输任务。",
+        "INFO_ANONYMOUS_USER_COUNT" : "匿名用户{COUNT, plural, one{} other{ (#)}}",
+        "INFO_CONNECTION_SHARED"    : "此连接已被共享。",
+        "INFO_NO_FILE_TRANSFERS"    : "无文件传输任务。",
+        "INFO_USER_COUNT"           : "{USERNAME}{COUNT, plural, one{} other{ (#)}}",
 
-        "NAME_INPUT_METHOD_NONE"   : "无输入法",
+        "NAME_INPUT_METHOD_NONE"   : "无",
         "NAME_INPUT_METHOD_OSK"    : "屏幕键盘",
         "NAME_INPUT_METHOD_TEXT"   : "文本输入",
         "NAME_KEY_CTRL"            : "Ctrl",
         "NAME_KEY_ALT"             : "Alt",
         "NAME_KEY_ESC"             : "Esc",
         "NAME_KEY_TAB"             : "Tab",
-        "NAME_MOUSE_MODE_ABSOLUTE" : "触控屏",
+        "NAME_MOUSE_MODE_ABSOLUTE" : "触摸屏",
         "NAME_MOUSE_MODE_RELATIVE" : "触控板",
 
-        "SECTION_HEADER_CLIPBOARD"      : "剪贴板",
-        "SECTION_HEADER_DEVICES"        : "设备",
-        "SECTION_HEADER_DISPLAY"        : "显示",
-        "SECTION_HEADER_FILE_TRANSFERS" : "文件传输",
-        "SECTION_HEADER_INPUT_METHOD"   : "输入法",
+        "SECTION_HEADER_CLIENT_MESSAGES" : "消息",
+        "SECTION_HEADER_CLIPBOARD"       : "剪贴板",
+        "SECTION_HEADER_DEVICES"         : "设备",
+        "SECTION_HEADER_DISPLAY"         : "显示",
+        "SECTION_HEADER_FILE_TRANSFERS"  : "文件传输",
+        "SECTION_HEADER_INPUT_METHOD"    : "输入方式",
+
         "SECTION_HEADER_MOUSE_MODE"     : "模拟鼠标模式",
 
+        "TEXT_ANONYMOUS_USER_JOINED"      : "有一名匿名用户已加入连接。",
+        "TEXT_ANONYMOUS_USER_LEFT"        : "有一名匿名用户断开了连接。",
         "TEXT_ZOOM_AUTO_FIT"              : "自适应浏览器窗口大小",
         "TEXT_CLIENT_STATUS_IDLE"         : "空闲。",
-        "TEXT_CLIENT_STATUS_CONNECTING"   : "正在连接Guacamole……",
+        "TEXT_CLIENT_STATUS_CONNECTING"   : "正在连接 Guacamole...",
         "TEXT_CLIENT_STATUS_DISCONNECTED" : "您的连接已断开。",
-        "TEXT_CLIENT_STATUS_UNSTABLE"     : "到Guacamole服务器的网络连接似乎不太稳定。",
-        "TEXT_CLIENT_STATUS_WAITING"      : "已连接到Guacamole。正在等候应答……",
-        "TEXT_RECONNECT_COUNTDOWN"        : "在{REMAINING}秒后重连……",
+        "TEXT_CLIENT_STATUS_UNSTABLE"     : "到 Guacamole 服务器的网络连接似乎不太稳定。",
+        "TEXT_CLIENT_STATUS_WAITING"      : "已连接到 Guacamole。正在等待应答...",
+        "TEXT_USER_JOINED"                : "用户 {USERNAME} 已加入连接。",
+        "TEXT_USER_LEFT"                  : "用户 {USERNAME} 断开了连接。",
+        "TEXT_RECONNECT_COUNTDOWN"        : "在 {REMAINING} 秒后重连...",
         "TEXT_FILE_TRANSFER_PROGRESS"     : "{PROGRESS} {UNIT, select, b{B} kb{KB} mb{MB} gb{GB} other{}}",
+        "TEXT_CLIPBOARD_AWAITING_FOCUS"   : "点击以查看剪贴板内容...",
 
         "URL_OSK_LAYOUT" : "layouts/en-us-qwerty.json"
 
@@ -154,17 +177,85 @@
         "ACTION_SAVE"         : "@:APP.ACTION_SAVE",
         "ACTION_SHOW_DETAILS" : "显示",
 
-        "FIELD_HEADER_BACKGROUND" : "背景",
-        "FIELD_HEADER_FOREGROUND" : "前景",
+        "FIELD_HEADER_BACKGROUND" : "背景色",
+        "FIELD_HEADER_FOREGROUND" : "前景色",
 
         "FIELD_OPTION_CUSTOM" : "自定义...",
 
-        "SECTION_HEADER_DETAILS" : "详情:"
+        "SECTION_HEADER_DETAILS" : "详情:"
 
     },
 
+    "IMPORT": {
+
+        "ACTION_ACKNOWLEDGE"        : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_BROWSE"             : "浏览文件",
+        "ACTION_CANCEL"             : "@:APP.ACTION_CANCEL",
+        "ACTION_CLEAR"              : "@:APP.ACTION_CLEAR",
+        "ACTION_VIEW_FORMAT_HELP"   : "查看帮助",
+        "ACTION_IMPORT"             : "@:APP.ACTION_IMPORT",
+        "ACTION_IMPORT_CONNECTIONS" : "导入连接",
+
+        "DIALOG_HEADER_ERROR"   : "@:APP.DIALOG_HEADER_ERROR",
+        "DIALOG_HEADER_SUCCESS" : "成功",
+
+        "ERROR_AMBIGUOUS_CSV_HEADER"         : "存在冲突的 CSV 列名: \"{HEADER}\" ,可能是一个连接属性或者参数",
+        "ERROR_AMBIGUOUS_PARENT_GROUP"       : "连接组以其父连接组不能同时设置",
+        "ERROR_ARRAY_REQUIRED"               : "上传的文件必须包含有效的连接数据",
+        "ERROR_DETECTED_INVALID_TYPE"        : "不支持的文件类型。请确认您上传的文件是合法的 CSV,JSON 或者 YAML 文件。",
+        "ERROR_DUPLICATE_CONNECTION_IN_FILE" : "在导入的文件中发现了重复的连接: \"{NAME}\" 位于 \"{PATH}\"",
+        "ERROR_DUPLICATE_CSV_HEADER"         : "重复的 CSV 列名: {HEADER}",
+        "ERROR_EMPTY_FILE"                   : "上传的文件内容为空",
+        "ERROR_INVALID_CSV_HEADER"           : "非法的 CSV 列名: \"{HEADER}\" 不是一个可识别的属性或者参数。",
+        "ERROR_INVALID_MIME_TYPE"            : "不支持的文件类型: \"{TYPE}\"",
+        "ERROR_INVALID_GROUP"                : "未找到名称为: \"{GROUP}\" 的连接组",
+        "ERROR_INVALID_GROUP_IDENTIFIER"     : "未找到能识别为: \"{IDENTIFIER}\" 的连接组",
+        "ERROR_NO_FILE_SUPPLIED"             : "请选择一个文件",
+        "ERROR_PARSE_FAILURE_CSV"            : "请确保您上传的是合法的 CSV 文件。解析错误: \"{ERROR}\" 。",
+        "ERROR_PARSE_FAILURE_JSON"           : "请确保您上传的是合法的 JSON 文件。解析错误: \"{ERROR}\" 。",
+        "ERROR_PARSE_FAILURE_YAML"           : "请确保您上传的是合法的 YAML 文件。解析错误: \"{ERROR}\" 。",
+        "ERROR_REJECT_UPDATE_CONNECTION"     : "连接 \"{NAME}\" 已存在于 \"{PATH}\"",
+        "ERROR_REQUIRED_NAME"                : "在提供的文件中未找到连接名称",
+        "ERROR_REQUIRED_PROTOCOL"            : "在提供的文件中未找到协议",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "FIELD_HEADER_EXISTING_CONNECTION_MODE" : "替换/更新已存在的连接",
+        "FIELD_HEADER_EXISTING_PERMISSION_MODE" : "重置权限",
+
+        "HELP_CSV_DESCRIPTION"              : "当导入的文件格式为 CSV 时,一行记录为一条连接数据。每列为一个配置条目。每条记录至少需要提供连接名称以及协议。",
+        "HELP_CSV_EXAMPLE"                  : "name,protocol,hostname,group,users,groups,guacd-encryption (attribute)\nconn1,vnc,conn1.web.com,ROOT,guac user 1;guac user 2,Connection 1 Users,none\nconn2,rdp,conn2.web.com,ROOT/Parent Group,guac user 1,,ssl\nconn3,ssh,conn3.web.com,ROOT/Parent Group/Child Group,guac user 2;guac user 3,,\nconn4,kubernetes,,,,,",
+        "HELP_CSV_MORE_DETAILS"             : "CSV 列名与连接中的属性一一对应。需要导入的连接组信息可以通过 \"parentIdentifier\" 属性直接设定,或者通过 \"group\" 属性进行指定。在大多数场合下,属性之间不应当存在冲突。但如过这种情况发生时,在列名后加入 \" (attribute)\" 或者 \" (parameter)\" 即可避免混淆。用户或者用户组必须使用分号进行分隔。¹",
+        "HELP_FILE_TYPE_DESCRIPTION"        : "目前系统支持三种文件格式:CSV,JSON 或者 YAML。文件格式之间支持的属性相同,即至少需要连接名称以及协议。当然,连接组,哪些用户或者用户组有权限使用,连接参数等属性均可以被指定。不存在的用户或者用户组会被自动创建。注意,任何已存在的连接权限并不会根据导入的数据被清除,除非您勾选了“重置权限”选框。",
+        "HELP_FILE_TYPE_HEADER"             : "文件格式",
+        "HELP_JSON_DESCRIPTION"             : "当导入的文件格式为 JSON 时,每条记录至少需要提供连接名称以及协议。",
+        "HELP_JSON_EXAMPLE"                 : "[\n  \\{\n    \"name\": \"conn1\",\n    \"protocol\": \"vnc\",\n    \"parameters\": \\{ \"hostname\": \"conn1.web.com\" \\},\n    \"parentIdentifier\": \"ROOT\",\n    \"users\": [ \"guac user 1\", \"guac user 2\" ],\n    \"groups\": [ \"Connection 1 Users\" ],\n    \"attributes\": \\{ \"guacd-encryption\": \"none\" \\}\n  \\},\n  \\{\n    \"name\": \"conn2\",\n    \"protocol\": \"rdp\",\n    \"parameters\": \\{ \"hostname\": \"conn2.web.com\" \\},\n    \"group\": \"ROOT/Parent Group\",\n    \"users\": [ \"guac user 1\" ],\n    \"attributes\": \\{ \"guacd-encryption\": \"none\" \\}\n  \\},\n  \\{\n    \"name\": \"conn3\",\n    \"protocol\": \"ssh\",\n    \"parameters\": \\{ \"hostname\": \"conn3.web.com\" \\},\n    \"group\": \"ROOT/Parent Group/Child Group\",\n    \"users\": [ \"guac user 2\", \"guac user 3\" ]\n  \\},\n  \\{\n    \"name\": \"conn4\",\n    \"protocol\": \"kubernetes\"\n  \\}\n]",
+        "HELP_JSON_MORE_DETAILS"            : "需要被导入的连接组信息可以通过 \"parentIdentifier\" 属性直接指定,或者通过 \"group\" 属性进行指定。每条连接信息均可单独设置用户以及用户组的访问权限",
+        "HELP_EXISTING_CONNECTION_MODE"     : "如果提供文件中的信息匹配,将完全替换/更新已存在的连接信息。如果未勾选,尝试导入已存在的连接将会导致导入时发生错误。",
+        "HELP_EXISTING_PERMISSION_MODE"     : "根据提供文件中的设定完全重置权限。如果没有权限设定信息,所有已设定的连接权限将会被撤销。如果未勾选,已存在的权限会被保留,任何新权限设定会被自动添加。",
+        "HELP_SEMICOLON_FOOTNOTE"           : "如果需要,您可以通过反斜杠来转义分号,比如: \"first\\\\;last\"",
+        "HELP_UPLOAD_DROP_TITLE"            : "拖拽文件",
+        "HELP_UPLOAD_FILE_TYPES"            : "CSV,JSON 或者 YAML",
+        "HELP_YAML_DESCRIPTION"             : "当导入的文件格式为 YAML 时,其数据结构必须与使用 JSON 格式时保持一致。",
+        "HELP_YAML_EXAMPLE"                 : "---\n  - name: conn1\n    protocol: vnc\n    parameters:\n      hostname: conn1.web.com\n    group: ROOT\n    users:\n    - guac user 1\n    - guac user 2\n    groups:\n    - Connection 1 Users\n    attributes:\n      guacd-encryption: none\n  - name: conn2\n    protocol: rdp\n    parameters:\n      hostname: conn2.web.com\n    group: ROOT/Parent Group\n    users:\n    - guac user 1\n    attributes:\n      guacd-encryption: none\n  - name: conn3\n    protocol: ssh\n    parameters:\n      hostname: conn3.web.com\n    group: ROOT/Parent Group/Child Group\n    users:\n    - guac user 2\n    - guac user 3\n  - name: conn4\n    protocol: kubernetes",
+
+        "INFO_CONNECTIONS_IMPORTED_SUCCESS" : "{NUMBER} 条连接已成功导入。",
+        
+        "SECTION_HEADER_CONNECTION_IMPORT"           : "导入连接",
+        "SECTION_HEADER_HELP_CONNECTION_IMPORT_FILE" : "导入连接文件格式",
+        "SECTION_HEADER_CSV"                         : "CSV 格式",
+        "SECTION_HEADER_JSON"                        : "JSON 格式",
+        "SECTION_HEADER_YAML"                        : "YAML 格式",
+
+        "TABLE_HEADER_ERRORS"     : "错误",
+        "TABLE_HEADER_GROUP"      : "分组",
+        "TABLE_HEADER_NAME"       : "名称",
+        "TABLE_HEADER_PROTOCOL"   : "协议",
+        "TABLE_HEADER_ROW_NUMBER" : "行 #"
+    },
+
     "DATA_SOURCE_DEFAULT" : {
-        "NAME" : "缺省(XML)"
+        "NAME" : "默认(XML)"
     },
 
     "FORM" : {
@@ -194,7 +285,7 @@
 
     "LIST" : {
 
-        "TEXT_ANONYMOUS_USER" : "匿名"
+        "TEXT_ANONYMOUS_USER" : "匿名用户"
 
     },
 
@@ -243,7 +334,7 @@
         "TABLE_HEADER_HISTORY_DURATION"   : "持续时间",
         "TABLE_HEADER_HISTORY_REMOTEHOST" : "远程主机",
 
-        "TEXT_CONFIRM_DELETE"   : "将无法恢复已被删除的连接。确定要删除这个连接吗?",
+        "TEXT_CONFIRM_DELETE"   : "连接被删除后将无法恢复。确定要删除这个连接吗?",
         "TEXT_HISTORY_DURATION" : "@:APP.TEXT_HISTORY_DURATION"
 
     },
@@ -321,7 +412,7 @@
 
         "HELP_NO_USER_GROUPS" : "该用户当前不属于任何组。 展开此部分以添加组。",
 	
-        "INFO_READ_ONLY" : "对不起,不能编辑此用户的账户。",
+        "INFO_READ_ONLY"                : "对不起,不能编辑此用户的账户。",
         "INFO_NO_USER_GROUPS_AVAILABLE" : "没用可用的用户组.",
 
         "SECTION_HEADER_ALL_CONNECTIONS"     : "全部连接",
@@ -353,15 +444,15 @@
         "FIELD_HEADER_CREATE_NEW_CONNECTIONS"        : "@:MANAGE_USER.FIELD_HEADER_CREATE_NEW_CONNECTIONS",
         "FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS"  : "@:MANAGE_USER.FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS",
         "FIELD_HEADER_CREATE_NEW_SHARING_PROFILES"   : "@:MANAGE_USER.FIELD_HEADER_CREATE_NEW_SHARING_PROFILES",
-        "FIELD_HEADER_USER_GROUP_NAME"               : "用户组名称:",
+        "FIELD_HEADER_USER_GROUP_NAME"               : "用户组名称:",
 
         "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
 
-        "HELP_NO_USER_GROUPS"        : "该组当前不属于任何组。 展开此部分以添加组。",
-        "HELP_NO_MEMBER_USER_GROUPS" : "该组当前不包含任何组。 展开此部分以添加组。",
-        "HELP_NO_MEMBER_USERS"       : "该组当前不包含任何用户。 展开此部分以添加用户。",
+        "HELP_NO_USER_GROUPS"        : "该组当前不属于任何组。展开此部分以添加组。",
+        "HELP_NO_MEMBER_USER_GROUPS" : "该组当前不包含任何组。展开此部分以添加组。",
+        "HELP_NO_MEMBER_USERS"       : "该组当前不包含任何用户。展开此部分以添加用户。",
 
-        "INFO_READ_ONLY"                : "抱歉,无法编辑此组。",
+        "INFO_READ_ONLY"                : "抱歉,无法编辑此用户组。",
         "INFO_NO_USER_GROUPS_AVAILABLE" : "@:MANAGE_USER.INFO_NO_USER_GROUPS_AVAILABLE",
         "INFO_NO_USERS_AVAILABLE"       : "没有可用的用户。",
 
@@ -369,12 +460,23 @@
         "SECTION_HEADER_CONNECTIONS"         : "@:MANAGE_USER.SECTION_HEADER_CONNECTIONS",
         "SECTION_HEADER_CURRENT_CONNECTIONS" : "@:MANAGE_USER.SECTION_HEADER_CURRENT_CONNECTIONS",
         "SECTION_HEADER_EDIT_USER_GROUP"     : "编辑用户组",
-        "SECTION_HEADER_MEMBER_USERS"        : "会员用户",
-        "SECTION_HEADER_MEMBER_USER_GROUPS"  : "会员用户组",
+        "SECTION_HEADER_MEMBER_USERS"        : "成员用户",
+        "SECTION_HEADER_MEMBER_USER_GROUPS"  : "成员用户组",
         "SECTION_HEADER_PERMISSIONS"         : "@:MANAGE_USER.SECTION_HEADER_PERMISSIONS",
         "SECTION_HEADER_USER_GROUPS"         : "父用户组",
 
-        "TEXT_CONFIRM_DELETE" : "删除组后将无法还原。 您确定要删除该组吗?"
+        "TEXT_CONFIRM_DELETE" : "删除用户组后将无法还原。确定要删除该用户组吗?"
+
+    },
+
+    "PLAYER" : {
+
+        "ACTION_CANCEL" : "@:APP.ACTION_CANCEL",
+        "ACTION_PAUSE"  : "@:APP.ACTION_PAUSE",
+        "ACTION_PLAY"   : "@:APP.ACTION_PLAY",
+
+        "INFO_LOADING_RECORDING" : "录像已载入。请稍候...",
+        "INFO_SEEK_IN_PROGRESS"  : "正在跳转到指定位置。请稍候..."
 
     },
     
@@ -382,18 +484,19 @@
 
         "FIELD_HEADER_BACKSPACE"      : "发送退格键:",
         "FIELD_HEADER_CA_CERT"         : "证书颁发机构证书:",
-        "FIELD_HEADER_CLIENT_CERT"     : "客户证书:",
-        "FIELD_HEADER_CLIENT_KEY"      : "客户端密钥:",
+        "FIELD_HEADER_CLIENT_CERT"     : "客户证书:",
+        "FIELD_HEADER_CLIENT_KEY"      : "客户端密钥:",
         "FIELD_HEADER_COLOR_SCHEME"   : "配色方案:",
-        "FIELD_HEADER_CONTAINER"       : "容器名称:",
+        "FIELD_HEADER_CONTAINER"       : "容器名称:",
         "FIELD_HEADER_CREATE_RECORDING_PATH" : "自动建立录像目录:",
-        "FIELD_HEADER_CREATE_TYPESCRIPT_PATH" : "自动建立打字稿目录:",
+        "FIELD_HEADER_CREATE_TYPESCRIPT_PATH" : "自动建立 Typescript 目录:",
+        "FIELD_HEADER_EXEC_COMMAND"    : "命令(exec):",
         "FIELD_HEADER_FONT_NAME"      : "字体名:",
         "FIELD_HEADER_FONT_SIZE"      : "字体大小:",
         "FIELD_HEADER_HOSTNAME"       : "主机名:",
-        "FIELD_HEADER_IGNORE_CERT"     : "忽略服务器证书:",
-        "FIELD_HEADER_NAMESPACE"       : "命名空间:",
-        "FIELD_HEADER_POD"             : "Pod名称:",
+        "FIELD_HEADER_IGNORE_CERT"     : "忽略服务器证书:",
+        "FIELD_HEADER_NAMESPACE"       : "命名空间:",
+        "FIELD_HEADER_POD"             : "Pod 名称:",
         "FIELD_HEADER_PORT"           : "端口:",
         "FIELD_HEADER_READ_ONLY"      : "只读:",
         "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "排除鼠标:",
@@ -401,10 +504,10 @@
         "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "包含按键事件:",
         "FIELD_HEADER_RECORDING_NAME" : "录像名:",
         "FIELD_HEADER_RECORDING_PATH" : "录像路径:",
-        "FIELD_HEADER_SCROLLBACK"      : "最大回滚尺寸:",
-        "FIELD_HEADER_TYPESCRIPT_NAME" : "打字稿名称:",
-        "FIELD_HEADER_TYPESCRIPT_PATH" : "打字稿路径:",
-        "FIELD_HEADER_USE_SSL"         : "使用SSL/TLS",
+        "FIELD_HEADER_SCROLLBACK"      : "最大回滚尺寸:",
+        "FIELD_HEADER_TYPESCRIPT_NAME" : "Typescript 名称:",
+        "FIELD_HEADER_TYPESCRIPT_PATH" : "Typescript 路径:",
+        "FIELD_HEADER_USE_SSL"         : "使用 SSL/TLS",
 
         "FIELD_OPTION_BACKSPACE_EMPTY" : "",
         "FIELD_OPTION_BACKSPACE_8"     : "退格键(Ctrl-H)",
@@ -439,7 +542,7 @@
         "SECTION_HEADER_CONTAINER"      : "容器",
         "SECTION_HEADER_DISPLAY"        : "显示",
         "SECTION_HEADER_RECORDING"      : "屏幕录制",
-        "SECTION_HEADER_TYPESCRIPT"     : "打字稿(文本会话录制)",
+        "SECTION_HEADER_TYPESCRIPT"     : "Typescript(文本会话录制)",
         "SECTION_HEADER_NETWORK"        : "网络"
 
     },
@@ -454,25 +557,30 @@
         "FIELD_HEADER_CREATE_RECORDING_PATH" : "自动建立录像目录:",
         "FIELD_HEADER_DISABLE_AUDIO"   : "禁用音频:",
         "FIELD_HEADER_DISABLE_AUTH"    : "禁用认证:",
-        "FIELD_HEADER_DISABLE_COPY"    : "禁用从远程桌面复制:",
-        "FIELD_HEADER_DISABLE_PASTE"   : "禁用从客户端粘贴:",
+        "FIELD_HEADER_DISABLE_COPY"    : "禁用从远程桌面复制:",
+        "FIELD_HEADER_DISABLE_DOWNLOAD" : "禁用文件下载:",
+        "FIELD_HEADER_DISABLE_PASTE"   : "禁用从客户端粘贴:",
+        "FIELD_HEADER_DISABLE_UPLOAD"   : "禁用文件上传:",
         "FIELD_HEADER_DOMAIN"          : "域:",
         "FIELD_HEADER_DPI"             : "分辨率(DPI):",
-        "FIELD_HEADER_DRIVE_NAME"      : "驱动器名称:",
+        "FIELD_HEADER_DRIVE_NAME"      : "驱动器名称:",
         "FIELD_HEADER_DRIVE_PATH"      : "虚拟盘路径:",
         "FIELD_HEADER_ENABLE_AUDIO_INPUT"         : "启用音频输入(话筒):",
         "FIELD_HEADER_ENABLE_DESKTOP_COMPOSITION" : "启用桌面合成效果(Aero):",
-        "FIELD_HEADER_ENABLE_DRIVE"               : "启用虚拟盘:",
+        "FIELD_HEADER_ENABLE_DRIVE"               : "启用虚拟驱动器:",
         "FIELD_HEADER_ENABLE_FONT_SMOOTHING"      : "启用字体平滑(ClearType):",
         "FIELD_HEADER_ENABLE_FULL_WINDOW_DRAG"    : "启用全窗口拖拽:",
         "FIELD_HEADER_ENABLE_MENU_ANIMATIONS"     : "启用菜单动画:",
-        "FIELD_HEADER_DISABLE_BITMAP_CACHING"     : "启用位图缓存:",
-        "FIELD_HEADER_DISABLE_OFFSCREEN_CACHING"  : "启用离屏缓存:",
+        "FIELD_HEADER_DISABLE_BITMAP_CACHING"     : "禁用位图缓存:",
+        "FIELD_HEADER_DISABLE_OFFSCREEN_CACHING"  : "禁用离屏缓存:",
         "FIELD_HEADER_DISABLE_GLYPH_CACHING"      : "禁用字形缓存:",
+        "FIELD_HEADER_DISABLE_GFX"                : "禁用图形管线拓展:",
         "FIELD_HEADER_ENABLE_PRINTING"            : "启用打印功能:",
         "FIELD_HEADER_ENABLE_SFTP"     : "启用SFTP:",
         "FIELD_HEADER_ENABLE_THEMING"             : "启用桌面主题:",
+        "FIELD_HEADER_ENABLE_TOUCH"               : "启用多点触控:",
         "FIELD_HEADER_ENABLE_WALLPAPER"           : "启用桌面墙纸:",
+        "FIELD_HEADER_FORCE_LOSSLESS"             : "强制无损压缩:",
         "FIELD_HEADER_GATEWAY_DOMAIN"   : "域:",
         "FIELD_HEADER_GATEWAY_HOSTNAME" : "主机名:",
         "FIELD_HEADER_GATEWAY_PASSWORD" : "密码:",
@@ -482,15 +590,17 @@
         "FIELD_HEADER_HOSTNAME"        : "主机名:",
         "FIELD_HEADER_IGNORE_CERT"     : "忽略服务器证书:",
         "FIELD_HEADER_INITIAL_PROGRAM" : "初始程序:",
-        "FIELD_HEADER_LOAD_BALANCE_INFO" : "负载平衡信息/cookie:",
+        "FIELD_HEADER_LOAD_BALANCE_INFO" : "负载平衡信息/Cookie:",
+        "FIELD_HEADER_NORMALIZE_CLIPBOARD" : "换行符:",
         "FIELD_HEADER_PASSWORD"        : "密码:",
         "FIELD_HEADER_PORT"            : "端口:",
-        "FIELD_HEADER_PRINTER_NAME"    : "重定向的打印机名称:",
-        "FIELD_HEADER_PRECONNECTION_BLOB" : "预连接BLOB(VM标识):",
-        "FIELD_HEADER_PRECONNECTION_ID"   : "RDP源标识:",
+        "FIELD_HEADER_PRINTER_NAME"    : "重定向的打印机名称:",
+        "FIELD_HEADER_PRECONNECTION_BLOB" : "预连接 BLOB(VM 标识):",
+        "FIELD_HEADER_PRECONNECTION_ID"   : "RDP 源标识:",
         "FIELD_HEADER_READ_ONLY"      : "只读:",
         "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "排除鼠标:",
         "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "排除图像/数据流:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_TOUCH"  : "排除触摸事件:",
         "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "包含按键事件:",
         "FIELD_HEADER_RECORDING_NAME" : "录像名:",
         "FIELD_HEADER_RECORDING_PATH" : "录像路径:",
@@ -501,24 +611,36 @@
         "FIELD_HEADER_SECURITY"        : "安全模式:",
         "FIELD_HEADER_SERVER_LAYOUT"   : "键盘布局:",
         "FIELD_HEADER_SFTP_DIRECTORY"             : "缺省文件上传目录:",
-        "FIELD_HEADER_SFTP_HOST_KEY"              : "公钥(Base64):",
+        "FIELD_HEADER_SFTP_DISABLE_DOWNLOAD"      : "禁用文件下载:",
+        "FIELD_HEADER_SFTP_HOST_KEY"              : "公钥(Base64):",
         "FIELD_HEADER_SFTP_HOSTNAME"              : "主机名:",
-        "FIELD_HEADER_SFTP_SERVER_ALIVE_INTERVAL" : "SFTP keepalive时间间隔:",
+        "FIELD_HEADER_SFTP_SERVER_ALIVE_INTERVAL" : "SFTP keepalive 时间间隔:",
         "FIELD_HEADER_SFTP_PASSPHRASE"            : "口令:",
         "FIELD_HEADER_SFTP_PASSWORD"              : "密码:",
         "FIELD_HEADER_SFTP_PORT"                  : "端口:",
         "FIELD_HEADER_SFTP_PRIVATE_KEY"           : "私钥:",
         "FIELD_HEADER_SFTP_ROOT_DIRECTORY"        : "文件浏览器根目录:",
+        "FIELD_HEADER_SFTP_DISABLE_UPLOAD"        : "禁用文件上传:",
         "FIELD_HEADER_SFTP_USERNAME"              : "用户名:",
         "FIELD_HEADER_STATIC_CHANNELS" : "静态通道名:",
-        "FIELD_HEADER_TIMEZONE"        : "时区:",
+        "FIELD_HEADER_TIMEZONE"        : "时区:",
         "FIELD_HEADER_USERNAME"        : "用户名:",
         "FIELD_HEADER_WIDTH"           : "宽度:",
+        "FIELD_HEADER_WOL_BROADCAST_ADDR" : "WoL 数据包广播地址:",
+        "FIELD_HEADER_WOL_MAC_ADDR"       : "远程主机 MAC 地址:",
+        "FIELD_HEADER_WOL_SEND_PACKET"    : "发送 WoL 数据包:",
+        "FIELD_HEADER_WOL_UDP_PORT"       : "WoL 数据包 UDP 端口: ",
+        "FIELD_HEADER_WOL_WAIT_TIME"      : "主机启动等待时间:",
 
-        "FIELD_OPTION_COLOR_DEPTH_16"    : "低色(16位)",
-        "FIELD_OPTION_COLOR_DEPTH_24"    : "真彩(24位)",
-        "FIELD_OPTION_COLOR_DEPTH_32"    : "真彩(32位)",
-        "FIELD_OPTION_COLOR_DEPTH_8"     : "256色",
+        "FIELD_OPTION_NORMALIZE_CLIPBOARD_EMPTY"    : "",
+        "FIELD_OPTION_NORMALIZE_CLIPBOARD_PRESERVE" : "保持原样",
+        "FIELD_OPTION_NORMALIZE_CLIPBOARD_UNIX"     : "Linux/Mac/Unix (LF)",
+        "FIELD_OPTION_NORMALIZE_CLIPBOARD_WINDOWS"  : "Windows (CRLF)",   
+
+        "FIELD_OPTION_COLOR_DEPTH_16"    : "低彩色(16 位)",
+        "FIELD_OPTION_COLOR_DEPTH_24"    : "真彩色(24 位)",
+        "FIELD_OPTION_COLOR_DEPTH_32"    : "真彩色(32 位)",
+        "FIELD_OPTION_COLOR_DEPTH_8"     : "256 色",
         "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "",
 
         "FIELD_OPTION_RESIZE_METHOD_DISPLAY_UPDATE" : "“显示更新”虚拟通道(RDP 8.1+)",
@@ -528,8 +650,9 @@
         "FIELD_OPTION_SECURITY_ANY"   : "任意",
         "FIELD_OPTION_SECURITY_EMPTY" : "",
         "FIELD_OPTION_SECURITY_NLA"   : "NLA(网络级别认证)",
-        "FIELD_OPTION_SECURITY_RDP"   : "RDP加密",
-        "FIELD_OPTION_SECURITY_TLS"   : "TLS加密",
+        "FIELD_OPTION_SECURITY_RDP"   : "RDP 加密",
+        "FIELD_OPTION_SECURITY_TLS"   : "TLS 加密",
+        "FIELD_OPTION_SECURITY_VMCONNECT" : "Hyper-V / VMConnect",
 
         "FIELD_OPTION_SERVER_LAYOUT_DE_CH_QWERTZ" : "Swiss German (Qwertz)",
         "FIELD_OPTION_SERVER_LAYOUT_DE_DE_QWERTZ" : "German (Qwertz)",
@@ -537,14 +660,20 @@
         "FIELD_OPTION_SERVER_LAYOUT_EN_GB_QWERTY" : "UK English (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_EN_US_QWERTY" : "US English (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_ES_ES_QWERTY" : "Spanish (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_ES_LATAM_QWERTY" : "Latin American (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_FAILSAFE"     : "Unicode",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_BE_AZERTY" : "Belgian French (Azerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_FR_CA_QWERTY" : "Canadian French (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_FR_CH_QWERTZ" : "Swiss French (Qwertz)",
         "FIELD_OPTION_SERVER_LAYOUT_FR_FR_AZERTY" : "French (Azerty)",
         "FIELD_OPTION_SERVER_LAYOUT_HU_HU_QWERTZ" : "Hungarian (Qwertz)",
         "FIELD_OPTION_SERVER_LAYOUT_IT_IT_QWERTY" : "Italian (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_JA_JP_QWERTY" : "Japanese (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_NO_NO_QWERTY" : "Norwegian (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_PL_PL_QWERTY" : "Polish (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_PT_BR_QWERTY" : "Portuguese Brazilian (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_PT_PT_QWERTY" : "Portuguese (Qwerty)",
+        "FIELD_OPTION_SERVER_LAYOUT_RO_RO_QWERTY" : "Romanian (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_SV_SE_QWERTY" : "Swedish (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_DA_DK_QWERTY" : "Danish (Qwerty)",
         "FIELD_OPTION_SERVER_LAYOUT_TR_TR_QWERTY" : "Turkish-Q (Qwerty)",
@@ -560,10 +689,11 @@
         "SECTION_HEADER_LOAD_BALANCING"     : "负载平衡",
         "SECTION_HEADER_NETWORK"            : "网络",
         "SECTION_HEADER_PERFORMANCE"        : "性能",
-        "SECTION_HEADER_PRECONNECTION_PDU"  : "预连接PDU / Hyper-V",
+        "SECTION_HEADER_PRECONNECTION_PDU"  : "预连接 PDU / Hyper-V",
         "SECTION_HEADER_RECORDING"          : "屏幕录像",
         "SECTION_HEADER_REMOTEAPP"          : "RemoteApp",
-        "SECTION_HEADER_SFTP"               : "SFTP"
+        "SECTION_HEADER_SFTP"               : "SFTP",
+        "SECTION_HEADER_WOL"                : "网络唤醒(WoL)"
 
     },
 
@@ -573,34 +703,40 @@
         "FIELD_HEADER_COLOR_SCHEME" : "配色方案:",
         "FIELD_HEADER_COMMAND"      : "运行命令:",
         "FIELD_HEADER_CREATE_RECORDING_PATH" : "自动建立录像目录:",
-        "FIELD_HEADER_CREATE_TYPESCRIPT_PATH" : "自动建立打字稿目录:",
-        "FIELD_HEADER_DISABLE_COPY"  : "禁用从终端复制:",
-        "FIELD_HEADER_DISABLE_PASTE" : "禁用从客户端粘贴:",
+        "FIELD_HEADER_CREATE_TYPESCRIPT_PATH" : "自动建立 Typescript 目录:",
+        "FIELD_HEADER_DISABLE_COPY"  : "禁用从终端复制:",
+        "FIELD_HEADER_DISABLE_PASTE" : "禁用从客户端粘贴:",
         "FIELD_HEADER_FONT_NAME"   : "字体名:",
         "FIELD_HEADER_FONT_SIZE"   : "字体大小:",
         "FIELD_HEADER_ENABLE_SFTP" : "启用SFTP:",
-        "FIELD_HEADER_HOST_KEY"      : "公钥(Base64):",
+        "FIELD_HEADER_HOST_KEY"      : "公钥(Base64):",
         "FIELD_HEADER_HOSTNAME"    : "主机名:",
-        "FIELD_HEADER_LOCALE"        : "语言/地区($LANG):",
+        "FIELD_HEADER_LOCALE"        : "语言/地区($LANG):",
         "FIELD_HEADER_USERNAME"    : "用户名:",
         "FIELD_HEADER_PASSWORD"    : "密码:",
         "FIELD_HEADER_PASSPHRASE"  : "口令:",
         "FIELD_HEADER_PORT"        : "端口:",
         "FIELD_HEADER_PRIVATE_KEY" : "私钥:",
-        "FIELD_HEADER_SCROLLBACK"    : "最大回滚尺寸:",
+        "FIELD_HEADER_SCROLLBACK"    : "最大回滚尺寸:",
         "FIELD_HEADER_READ_ONLY"   : "只读:",
         "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "排除鼠标:",
         "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "排除图像/数据流:",
         "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "包含按键事件:",
         "FIELD_HEADER_RECORDING_NAME" : "录像名:",
         "FIELD_HEADER_RECORDING_PATH" : "录像路径:",
-        "FIELD_HEADER_SERVER_ALIVE_INTERVAL" : "服务器keepalive时间间隔:",
-
+        "FIELD_HEADER_SERVER_ALIVE_INTERVAL" : "服务器 keepalive 时间间隔:",
+        "FIELD_HEADER_SFTP_DISABLE_DOWNLOAD" : "禁用文件下载:",
         "FIELD_HEADER_SFTP_ROOT_DIRECTORY"   : "文件浏览器根目录:",
-        "FIELD_HEADER_TERMINAL_TYPE"   : "终端类型:",
-        "FIELD_HEADER_TIMEZONE"        : "时区($TZ):",
-        "FIELD_HEADER_TYPESCRIPT_NAME" : "打字稿名称:",
-        "FIELD_HEADER_TYPESCRIPT_PATH" : "打字稿路径:",
+        "FIELD_HEADER_SFTP_DISABLE_UPLOAD"   : "禁用文件上传:",
+        "FIELD_HEADER_TERMINAL_TYPE"   : "终端类型:",
+        "FIELD_HEADER_TIMEZONE"        : "时区($TZ):",
+        "FIELD_HEADER_TYPESCRIPT_NAME" : "Typescript 名称:",
+        "FIELD_HEADER_TYPESCRIPT_PATH" : "Typescript 路径:",
+        "FIELD_HEADER_WOL_BROADCAST_ADDR" : "WoL 数据包广播地址:",
+        "FIELD_HEADER_WOL_MAC_ADDR"       : "远程主机 MAC 地址:",
+        "FIELD_HEADER_WOL_SEND_PACKET"    : "发送 WoL 数据包:",
+        "FIELD_HEADER_WOL_UDP_PORT"       : "WoL 数据包 UDP 端口: ",
+        "FIELD_HEADER_WOL_WAIT_TIME"      : "主机启动等待时间:",
 
         "FIELD_OPTION_BACKSPACE_EMPTY" : "",
         "FIELD_OPTION_BACKSPACE_8"     : "退格键(Ctrl-H)",
@@ -645,39 +781,45 @@
         "SECTION_HEADER_NETWORK"        : "网络",
         "SECTION_HEADER_RECORDING"      : "屏幕录像",
         "SECTION_HEADER_SESSION"        : "会话 / 环境",
-        "SECTION_HEADER_TYPESCRIPT"     : "打字稿(文本会话录像)",
-        "SECTION_HEADER_SFTP"           : "SFTP"
+        "SECTION_HEADER_TYPESCRIPT"     : "Typescript(文本会话录像)",
+        "SECTION_HEADER_SFTP"           : "SFTP",
+        "SECTION_HEADER_WOL"            : "网络唤醒(WoL)"
 
     },
 
     "PROTOCOL_TELNET" : {
 
-        "FIELD_HEADER_BACKSPACE"      : "发送退格键:",
-        "FIELD_HEADER_COLOR_SCHEME"   : "配色方案:",
-        "FIELD_HEADER_CREATE_RECORDING_PATH" : "自动创建记录路径:",
-        "FIELD_HEADER_CREATE_TYPESCRIPT_PATH" : "自动创建typescript路径:",
+        "FIELD_HEADER_BACKSPACE"      : "发送退格键:",
+        "FIELD_HEADER_COLOR_SCHEME"   : "配色方案:",
+        "FIELD_HEADER_CREATE_RECORDING_PATH" : "自动创建记录路径:",
+        "FIELD_HEADER_CREATE_TYPESCRIPT_PATH" : "自动创建 Typescript 路径:",
         "FIELD_HEADER_DISABLE_COPY"   : "禁止从终端复制:",
         "FIELD_HEADER_DISABLE_PASTE"  : "禁用从客户端粘贴:",
-        "FIELD_HEADER_FONT_NAME"      : "字体名称:",
-        "FIELD_HEADER_FONT_SIZE"      : "字体大小:",
-        "FIELD_HEADER_HOSTNAME"       : "主机名:",
-        "FIELD_HEADER_LOGIN_FAILURE_REGEX" : "登录失败正则表达式:",
-        "FIELD_HEADER_LOGIN_SUCCESS_REGEX" : "登录成功正则表达式:",
-        "FIELD_HEADER_USERNAME"       : "用户名:",
-        "FIELD_HEADER_USERNAME_REGEX" : "用户名正则表达式:",
-        "FIELD_HEADER_PASSWORD"       : "密码:",
-        "FIELD_HEADER_PASSWORD_REGEX" : "密码正则表达式:",
-        "FIELD_HEADER_PORT"           : "端口:",
-        "FIELD_HEADER_READ_ONLY"      : "只读:",
-        "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "排除鼠标:",
-        "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "排除图形/流:",
-        "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "包含关键事件:",
-        "FIELD_HEADER_RECORDING_NAME" : "记录名称:",
-        "FIELD_HEADER_RECORDING_PATH" : "记录路径:",
-        "FIELD_HEADER_SCROLLBACK"     : "最大回滚尺寸:",
-        "FIELD_HEADER_TERMINAL_TYPE"   : "终端类型:",
-        "FIELD_HEADER_TYPESCRIPT_NAME" : "打字稿名称:",
-        "FIELD_HEADER_TYPESCRIPT_PATH" : "打字稿路径:",
+        "FIELD_HEADER_FONT_NAME"      : "字体名称:",
+        "FIELD_HEADER_FONT_SIZE"      : "字体大小:",
+        "FIELD_HEADER_HOSTNAME"       : "主机名:",
+        "FIELD_HEADER_LOGIN_FAILURE_REGEX" : "登录失败的正则表达式:",
+        "FIELD_HEADER_LOGIN_SUCCESS_REGEX" : "登录成功的正则表达式:",
+        "FIELD_HEADER_USERNAME"       : "用户名:",
+        "FIELD_HEADER_USERNAME_REGEX" : "用户名正则表达式:",
+        "FIELD_HEADER_PASSWORD"       : "密码:",
+        "FIELD_HEADER_PASSWORD_REGEX" : "密码正则表达式:",
+        "FIELD_HEADER_PORT"           : "端口:",
+        "FIELD_HEADER_READ_ONLY"      : "只读:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_MOUSE"  : "排除鼠标:",
+        "FIELD_HEADER_RECORDING_EXCLUDE_OUTPUT" : "排除图形/流:",
+        "FIELD_HEADER_RECORDING_INCLUDE_KEYS"   : "包含关键事件:",
+        "FIELD_HEADER_RECORDING_NAME" : "记录名称:",
+        "FIELD_HEADER_RECORDING_PATH" : "记录路径:",
+        "FIELD_HEADER_SCROLLBACK"     : "最大回滚尺寸:",
+        "FIELD_HEADER_TERMINAL_TYPE"   : "终端类型:",
+        "FIELD_HEADER_TYPESCRIPT_NAME" : "Typescript 名称:",
+        "FIELD_HEADER_TYPESCRIPT_PATH" : "Typescript 路径:",
+        "FIELD_HEADER_WOL_BROADCAST_ADDR" : "WoL 数据包广播地址:",
+        "FIELD_HEADER_WOL_MAC_ADDR"       : "远程主机 MAC 地址:",
+        "FIELD_HEADER_WOL_SEND_PACKET"    : "发送 WoL 数据包:",
+        "FIELD_HEADER_WOL_UDP_PORT"       : "WoL 数据包 UDP 端口:",
+        "FIELD_HEADER_WOL_WAIT_TIME"      : "主机启动等待时间:",
 
         "FIELD_OPTION_BACKSPACE_EMPTY" : "",
         "FIELD_OPTION_BACKSPACE_8"     : "退格键(Ctrl-H)",
@@ -720,8 +862,9 @@
         "SECTION_HEADER_CLIPBOARD"      : "剪贴板",
         "SECTION_HEADER_DISPLAY"        : "显示",
         "SECTION_HEADER_RECORDING"      : "屏幕录像",
-        "SECTION_HEADER_TYPESCRIPT"     : "打字稿(文本会话录像)",
-        "SECTION_HEADER_NETWORK"        : "网络"
+        "SECTION_HEADER_TYPESCRIPT"     : "Typescript(文本会话录像)",
+        "SECTION_HEADER_NETWORK"        : "网络",
+        "SECTION_HEADER_WOL"            : "网络唤醒(WoL)"
 
     },
 
@@ -734,10 +877,11 @@
         "FIELD_HEADER_CURSOR"           : "光标:",
         "FIELD_HEADER_DEST_HOST"        : "目标主机:",
         "FIELD_HEADER_DEST_PORT"        : "目标端口:",
-        "FIELD_HEADER_DISABLE_COPY"     : "禁用从远程桌面复制:",
-        "FIELD_HEADER_DISABLE_PASTE"    : "禁用从客户端粘贴:",
+        "FIELD_HEADER_DISABLE_COPY"     : "禁用从远程桌面复制:",
+        "FIELD_HEADER_DISABLE_PASTE"    : "禁用从客户端粘贴:",
         "FIELD_HEADER_ENABLE_AUDIO"     : "启用音频:",
-        "FIELD_HEADER_ENABLE_SFTP"      : "启用SFTP:",
+        "FIELD_HEADER_ENABLE_SFTP"      : "启用 SFTP:",
+        "FIELD_HEADER_FORCE_LOSSLESS"   : "强制无损压缩:",
         "FIELD_HEADER_HOSTNAME"         : "主机名:",
         "FIELD_HEADER_USERNAME"         : "用户名:",
         "FIELD_HEADER_PASSWORD"         : "密码:",
@@ -749,21 +893,28 @@
         "FIELD_HEADER_RECORDING_NAME" : "录像名:",
         "FIELD_HEADER_RECORDING_PATH" : "录像路径:",
         "FIELD_HEADER_SFTP_DIRECTORY"             : "缺省文件上传目录:",
-        "FIELD_HEADER_SFTP_HOST_KEY"              : "公钥(Base64):",
+        "FIELD_HEADER_SFTP_DISABLE_DOWNLOAD"      : "禁用文件下载:",
+        "FIELD_HEADER_SFTP_HOST_KEY"              : "公钥(Base64):",
         "FIELD_HEADER_SFTP_HOSTNAME"              : "主机名:",
-        "FIELD_HEADER_SFTP_SERVER_ALIVE_INTERVAL" : "SFTP keepalive时间间隔:",
+        "FIELD_HEADER_SFTP_SERVER_ALIVE_INTERVAL" : "SFTP keepalive 时间间隔:",
         "FIELD_HEADER_SFTP_PASSPHRASE"            : "口令:",
         "FIELD_HEADER_SFTP_PASSWORD"              : "密码:",
         "FIELD_HEADER_SFTP_PORT"                  : "端口:",
         "FIELD_HEADER_SFTP_PRIVATE_KEY"           : "私钥:",
         "FIELD_HEADER_SFTP_ROOT_DIRECTORY"        : "文件浏览器根目录:",
+        "FIELD_HEADER_SFTP_DISABLE_UPLOAD"        : "禁用文件上传:",
         "FIELD_HEADER_SFTP_USERNAME"              : "用户名:",
         "FIELD_HEADER_SWAP_RED_BLUE"    : "交换红/蓝成分:",
+        "FIELD_HEADER_WOL_BROADCAST_ADDR" : "WoL 数据包广播地址:",
+        "FIELD_HEADER_WOL_MAC_ADDR"       : "远程主机 MAC 地址:",
+        "FIELD_HEADER_WOL_SEND_PACKET"    : "发送 WoL 数据包:",
+        "FIELD_HEADER_WOL_UDP_PORT"       : "WoL 数据包 UDP 端口: ",
+        "FIELD_HEADER_WOL_WAIT_TIME"      : "主机启动等待时间:",
 
-        "FIELD_OPTION_COLOR_DEPTH_8"     : "256色",
-        "FIELD_OPTION_COLOR_DEPTH_16"    : "低色(16位)",
-        "FIELD_OPTION_COLOR_DEPTH_24"    : "真彩(24位)",
-        "FIELD_OPTION_COLOR_DEPTH_32"    : "真彩(32位)",
+        "FIELD_OPTION_COLOR_DEPTH_8"     : "256 色",
+        "FIELD_OPTION_COLOR_DEPTH_16"    : "低彩色(16 位)",
+        "FIELD_OPTION_COLOR_DEPTH_24"    : "真彩色(24 位)",
+        "FIELD_OPTION_COLOR_DEPTH_32"    : "真彩色(32 位)",
         "FIELD_OPTION_COLOR_DEPTH_EMPTY" : "",
 
         "FIELD_OPTION_CURSOR_EMPTY"  : "",
@@ -784,8 +935,9 @@
         "SECTION_HEADER_DISPLAY"        : "显示",
         "SECTION_HEADER_NETWORK"        : "网络",
         "SECTION_HEADER_RECORDING"      : "屏幕录像",
-        "SECTION_HEADER_REPEATER"       : "VNC中继",
-        "SECTION_HEADER_SFTP"           : "SFTP"
+        "SECTION_HEADER_REPEATER"       : "VNC 中继",
+        "SECTION_HEADER_SFTP"           : "SFTP",
+        "SECTION_HEADER_WOL"            : "网络唤醒(WoL)"
 
     },
 
@@ -799,20 +951,22 @@
 
         "ACTION_DOWNLOAD" : "@:APP.ACTION_DOWNLOAD",
         "ACTION_SEARCH"   : "@:APP.ACTION_SEARCH",
+        "ACTION_VIEW_RECORDING" : "@:APP.ACTION_VIEW_RECORDING",
 
         "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
 
-        "FILENAME_HISTORY_CSV" : "历史.csv",
+        "FILENAME_HISTORY_CSV" : "连接历史.csv",
 
         "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
 
-        "HELP_CONNECTION_HISTORY" : "下表中是过往的连接历史,可以点击列头来进行排序。如需搜索特定的记录,输入一个过滤字符串并点击”搜索“。列表中将只显示符合过滤条件的记录。",
+        "HELP_CONNECTION_HISTORY" : "下表中是过往的连接历史,可以点击列头来进行排序。如需搜索特定的记录,输入一个过滤字符串并点击“搜索”。列表中将只显示符合过滤条件的记录。",
 
         "INFO_CONNECTION_DURATION_UNKNOWN" : "--",
         "INFO_NO_HISTORY"                  : "无符合条件的记录",
 
         "TABLE_HEADER_SESSION_CONNECTION_NAME" : "连接名",
         "TABLE_HEADER_SESSION_DURATION"        : "持续时间",
+        "TABLE_HEADER_SESSION_LOGS"            : "日志",
         "TABLE_HEADER_SESSION_REMOTEHOST"      : "远程主机",
         "TABLE_HEADER_SESSION_STARTDATE"       : "起始时间",
         "TABLE_HEADER_SESSION_USERNAME"        : "用户名",
@@ -824,6 +978,7 @@
     "SETTINGS_CONNECTIONS" : {
 
         "ACTION_ACKNOWLEDGE"          : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_IMPORT"               : "@:APP.ACTION_IMPORT",
         "ACTION_NEW_CONNECTION"       : "新建连接",
         "ACTION_NEW_CONNECTION_GROUP" : "新建连接组",
         "ACTION_NEW_SHARING_PROFILE"  : "新建共享设定",
@@ -844,6 +999,7 @@
 
         "ACTION_ACKNOWLEDGE"        : "@:APP.ACTION_ACKNOWLEDGE",
         "ACTION_CANCEL"             : "@:APP.ACTION_CANCEL",
+        "ACTION_SAVE"               : "@:APP.ACTION_SAVE",
         "ACTION_UPDATE_PASSWORD"    : "@:APP.ACTION_UPDATE_PASSWORD",
 
         "DIALOG_HEADER_ERROR"    : "@:APP.DIALOG_HEADER_ERROR",
@@ -856,20 +1012,21 @@
         "FIELD_HEADER_PASSWORD_OLD"       : "当前密码:",
         "FIELD_HEADER_PASSWORD_NEW"       : "新密码:",
         "FIELD_HEADER_PASSWORD_NEW_AGAIN" : "确认新密码:",
-        "FIELD_HEADER_TIMEZONE"           : "时区:",
+        "FIELD_HEADER_TIMEZONE"           : "时区:",
         "FIELD_HEADER_USERNAME"           : "用户名:",
         
-        "HELP_DEFAULT_INPUT_METHOD" : "缺省输入法决定了Guacamole如何接收键盘事件。当使用移动设备或使用IME输入时,有可能需要更改设置。本设置可在Guacamole菜单内被单个连接的设定覆盖。",
-        "HELP_DEFAULT_MOUSE_MODE"   : "缺省鼠标模拟方式决定了新连接内的远程鼠标如何响应屏幕触控。本设置可在Guacamole菜单内被单个连接的设定覆盖。",
+        "HELP_DEFAULT_INPUT_METHOD" : "默认输入法决定了 Guacamole 如何接收键盘事件。当使用移动设备或使用 IME 输入时,有可能需要更改设置。本设置可在 Guacamole 菜单内被单个连接的设定覆盖。",
+        "HELP_DEFAULT_MOUSE_MODE"   : "默认鼠标模拟方式决定了新连接内的远程鼠标如何响应屏幕触控。本设置可在 Guacamole 菜单内被单个连接的设定覆盖。",
         "HELP_INPUT_METHOD_NONE"    : "@:CLIENT.HELP_INPUT_METHOD_NONE",
         "HELP_INPUT_METHOD_OSK"     : "@:CLIENT.HELP_INPUT_METHOD_OSK",
         "HELP_INPUT_METHOD_TEXT"    : "@:CLIENT.HELP_INPUT_METHOD_TEXT",
-        "HELP_LOCALE"             : "以下选项与用户的语言环境有关,并将影响界面各部分的显示方式。",
+        "HELP_LOCALE"               : "以下选项与用户的语言环境有关,并将影响界面各部分的显示方式。",
         "HELP_MOUSE_MODE_ABSOLUTE"  : "@:CLIENT.HELP_MOUSE_MODE_ABSOLUTE",
         "HELP_MOUSE_MODE_RELATIVE"  : "@:CLIENT.HELP_MOUSE_MODE_RELATIVE",
         "HELP_UPDATE_PASSWORD"      : "如需改变密码,请在下面输入您的当前密码与希望使用的新密码,并点击“更新密码” 。密码的改动会立即生效。",
 
         "INFO_PASSWORD_CHANGED" : "密码已更改。",
+        "INFO_PREFERENCE_ATTRIBUTES_CHANGED" : "用户设定已保存。",
 
         "NAME_INPUT_METHOD_NONE" : "@:CLIENT.NAME_INPUT_METHOD_NONE",
         "NAME_INPUT_METHOD_OSK"  : "@:CLIENT.NAME_INPUT_METHOD_OSK",
@@ -973,4 +1130,4 @@
 
     }
 
-}
+}
\ No newline at end of file
diff --git a/guacamole/src/main/frontend/webpack.config.js b/guacamole/src/main/frontend/webpack.config.js
index 29bb8dd..dc6ad08 100644
--- a/guacamole/src/main/frontend/webpack.config.js
+++ b/guacamole/src/main/frontend/webpack.config.js
@@ -47,6 +47,22 @@
     module: {
         rules: [
 
+            // NOTE: This is required in order to parse ES2020 language features,
+            // like the optional chaining and nullish coalescing operators. It
+            // specifically needs to operate on the node-modules directory since
+            // Webpack 4 cannot handle such language features.
+            {
+                test: /\.js$/i,
+                use: {
+                    loader: 'babel-loader',
+                    options: {
+                      presets: [
+                        ['@babel/preset-env']
+                      ]
+                    }
+                }
+            },
+
             // Automatically extract imported CSS for later reference within separate CSS file
             {
                 test: /\.css$/i,
diff --git a/guacamole/src/main/java/org/apache/guacamole/GuacamoleServletContextListener.java b/guacamole/src/main/java/org/apache/guacamole/GuacamoleServletContextListener.java
index 2b3f155..1068477 100644
--- a/guacamole/src/main/java/org/apache/guacamole/GuacamoleServletContextListener.java
+++ b/guacamole/src/main/java/org/apache/guacamole/GuacamoleServletContextListener.java
@@ -35,11 +35,14 @@
 import org.apache.guacamole.extension.ExtensionModule;
 import org.apache.guacamole.log.LogModule;
 import org.apache.guacamole.net.auth.AuthenticationProvider;
+import org.apache.guacamole.net.event.ApplicationShutdownEvent;
+import org.apache.guacamole.net.event.ApplicationStartedEvent;
 import org.apache.guacamole.properties.BooleanGuacamoleProperty;
 import org.apache.guacamole.properties.FileGuacamoleProperties;
 import org.apache.guacamole.rest.RESTServiceModule;
 import org.apache.guacamole.rest.auth.HashTokenSessionMap;
 import org.apache.guacamole.rest.auth.TokenSessionMap;
+import org.apache.guacamole.rest.event.ListenerService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -125,7 +128,13 @@
      */
     @Inject
     private List<File> temporaryFiles;
-    
+
+    /**
+     * Service for dispatching events to registered listeners.
+     */
+    @Inject
+    private ListenerService listenerService;
+
     /**
      * Internal reference to the Guice injector that was lazily created when
      * getInjector() was first invoked.
@@ -179,6 +188,17 @@
         // Store reference to injector for use by Jersey and HK2 bridge
         servletContextEvent.getServletContext().setAttribute(GUICE_INJECTOR, injector);
 
+        // Inform any listeners that application startup has completed
+        try {
+            listenerService.handleEvent(new ApplicationStartedEvent() {
+                // The application startup event currently has no content
+            });
+        }
+        catch (GuacamoleException e) {
+            logger.error("An extension listening for application startup failed: {}", e.getMessage());
+            logger.debug("Extension failed internally while handling the application startup event.", e);
+        }
+
     }
 
     @Override
@@ -228,19 +248,36 @@
             // Clean up reference to Guice injector
             servletContextEvent.getServletContext().removeAttribute(GUICE_INJECTOR);
 
-            // Shutdown TokenSessionMap
+            // Shutdown TokenSessionMap, invalidating all sessions (logging all
+            // users out)
             if (sessionMap != null)
                 sessionMap.shutdown();
 
-            // Unload all extensions
+            // Unload authentication for all extensions
             if (authProviders != null) {
                 for (AuthenticationProvider authProvider : authProviders)
                     authProvider.shutdown();
             }
 
+            // Inform any listeners that application shutdown has completed
+            try {
+                listenerService.handleEvent(new ApplicationShutdownEvent() {
+                    // The application shutdown event currently has no content
+                });
+            }
+            catch (GuacamoleException e) {
+                logger.error("An extension listening for application shutdown failed: {}", e.getMessage());
+                logger.debug("Extension failed internally while handling the application shutdown event.", e);
+            }
+
         }
         finally {
 
+            // NOTE: This temporary file cleanup must happen AFTER firing the
+            // ApplicationShutdownEvent, or an extension that relies on a .jar
+            // file among those temporary files might fail internally when
+            // attempting to process the event.
+
             // Regardless of what may succeed/fail here, always attempt to
             // clean up ALL temporary files
             if (temporaryFiles != null)
diff --git a/guacamole/src/main/java/org/apache/guacamole/GuacamoleSession.java b/guacamole/src/main/java/org/apache/guacamole/GuacamoleSession.java
index 24ea196..5bf0371 100644
--- a/guacamole/src/main/java/org/apache/guacamole/GuacamoleSession.java
+++ b/guacamole/src/main/java/org/apache/guacamole/GuacamoleSession.java
@@ -23,12 +23,13 @@
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
-import org.apache.guacamole.environment.Environment;
 import org.apache.guacamole.net.GuacamoleTunnel;
 import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.AuthenticationProvider;
 import org.apache.guacamole.net.auth.UserContext;
+import org.apache.guacamole.net.event.UserSessionInvalidatedEvent;
 import org.apache.guacamole.rest.auth.DecoratedUserContext;
+import org.apache.guacamole.rest.event.ListenerService;
 import org.apache.guacamole.tunnel.UserTunnel;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -58,8 +59,12 @@
     /**
      * All currently-active tunnels, indexed by tunnel UUID.
      */
-    private final Map<String, UserTunnel> tunnels =
-            new ConcurrentHashMap<String, UserTunnel>();
+    private final Map<String, UserTunnel> tunnels = new ConcurrentHashMap<>();
+
+    /**
+     * Service for dispatching events to registered event listeners.
+     */
+    private final ListenerService listenerService;
 
     /**
      * The last time this session was accessed.
@@ -70,9 +75,9 @@
      * Creates a new Guacamole session associated with the given
      * AuthenticatedUser and UserContexts.
      *
-     * @param environment
-     *     The environment of the Guacamole server associated with this new
-     *     session.
+     * @param listenerService
+     *     The service to use to notify registered event listeners when this
+     *     session is invalidated.
      *
      * @param authenticatedUser
      *     The authenticated user to associate this session with.
@@ -83,53 +88,79 @@
      * @throws GuacamoleException
      *     If an error prevents the session from being created.
      */
-    public GuacamoleSession(Environment environment,
+    public GuacamoleSession(ListenerService listenerService,
             AuthenticatedUser authenticatedUser,
             List<DecoratedUserContext> userContexts)
             throws GuacamoleException {
         this.lastAccessedTime = System.currentTimeMillis();
+        this.listenerService = listenerService;
         this.authenticatedUser = authenticatedUser;
         this.userContexts = userContexts;
     }
 
     /**
-     * Returns the authenticated user associated with this session.
+     * Returns the authenticated user associated with this session. Invoking
+     * this function automatically updates this session's last access time.
      *
      * @return
      *     The authenticated user associated with this session.
      */
     public AuthenticatedUser getAuthenticatedUser() {
+        this.access();
         return authenticatedUser;
     }
 
     /**
      * Replaces the authenticated user associated with this session with the
-     * given authenticated user.
+     * given authenticated user. Invoking this function automatically updates
+     * this session's last access time.
      *
      * @param authenticatedUser
      *     The authenticated user to associated with this session.
      */
     public void setAuthenticatedUser(AuthenticatedUser authenticatedUser) {
+        this.access();
         this.authenticatedUser = authenticatedUser;
     }
 
     /**
      * Returns a list of all UserContexts associated with this session. Each
      * AuthenticationProvider currently loaded by Guacamole may provide its own
-     * UserContext for any successfully-authenticated user.
+     * UserContext for any successfully-authenticated user. Invoking this
+     * function automatically updates this session's last access time.
      *
      * @return
      *     An unmodifiable list of all UserContexts associated with this
      *     session.
      */
     public List<DecoratedUserContext> getUserContexts() {
+        this.access();
         return Collections.unmodifiableList(userContexts);
     }
 
     /**
+     * Returns true if all user contexts associated with this session are
+     * valid, or false if any user context is not valid. If a session is not
+     * valid, it may no longer be used, and invalidate() should be invoked.
+     * Invoking this function does not affect the last access time of this
+     * session.
+     *
+     * @return
+     *     true if all user contexts associated with this session are
+     *     valid, or false if any user context is not valid.
+     */
+    public boolean isValid() {
+
+        // Immediately return false if any user context is not valid
+        return !userContexts.stream().anyMatch(
+                userContext -> !userContext.isValid());
+    }
+
+    /**
      * Returns the UserContext associated with this session that originated
      * from the AuthenticationProvider with the given identifier. If no such
-     * UserContext exists, an exception is thrown.
+     * UserContext exists, an exception is thrown. Invoking this function
+     * automatically updates this session's last access time.
      *
      * @param authProviderIdentifier
      *     The unique identifier of the AuthenticationProvider that created the
@@ -166,20 +197,24 @@
 
     /**
      * Replaces all UserContexts associated with this session with the given
-     * List of UserContexts.
+     * List of UserContexts. Invoking this function automatically updates this
+     * session's last access time.
      *
      * @param userContexts
      *     The List of UserContexts to associate with this session.
      */
     public void setUserContexts(List<DecoratedUserContext> userContexts) {
+        this.access();
         this.userContexts = userContexts;
     }
     
     /**
-     * Returns whether this session has any associated active tunnels.
+     * Returns whether this session has any associated active tunnels. Invoking
+     * this function does not affect the last access time of this session.
      *
-     * @return true if this session has any associated active tunnels,
-     *         false otherwise.
+     * @return
+     *     true if this session has any associated active tunnels, false
+     *     otherwise.
      */
     public boolean hasTunnels() {
         return !tunnels.isEmpty();
@@ -192,10 +227,14 @@
      * session. A tunnel need not be present here to be used by the user
      * associated with this session, but tunnels not in this set will not
      * be taken into account when determining whether a session is in use.
+     * Invoking this function automatically updates this session's last access
+     * time.
      *
-     * @return A map of all active tunnels associated with this session.
+     * @return
+     *     A map of all active tunnels associated with this session.
      */
     public Map<String, UserTunnel> getTunnels() {
+        this.access();
         return tunnels;
     }
 
@@ -206,16 +245,23 @@
      * @param tunnel The tunnel to associate with this session.
      */
     public void addTunnel(UserTunnel tunnel) {
+        this.access();
         tunnels.put(tunnel.getUUID().toString(), tunnel);
     }
 
     /**
      * Disassociates the tunnel having the given UUID from this session.
+     * Invoking this function automatically updates this session's last access
+     * time.
      *
-     * @param uuid The UUID of the tunnel to disassociate from this session.
-     * @return true if the tunnel existed and was removed, false otherwise.
+     * @param uuid
+     *     The UUID of the tunnel to disassociate from this session.
+     *
+     * @return
+     *     true if the tunnel existed and was removed, false otherwise.
      */
     public boolean removeTunnel(String uuid) {
+        this.access();
         return tunnels.remove(uuid) != null;
     }
 
@@ -229,7 +275,8 @@
     /**
      * Returns the time this session was last accessed, as the number of
      * milliseconds since midnight January 1, 1970 GMT. Session access must
-     * be explicitly marked through calls to the access() function.
+     * be explicitly marked through calls to the access() function. Invoking
+     * this function does not affect the last access time of this session.
      *
      * @return The time this session was last accessed.
      */
@@ -260,6 +307,23 @@
         // Invalidate the authenticated user object
         authenticatedUser.invalidate();
 
+        // Advise any registered listeners that the user's session is now
+        // invalidated
+        try {
+            listenerService.handleEvent(new UserSessionInvalidatedEvent() {
+
+                @Override
+                public AuthenticatedUser getAuthenticatedUser() {
+                    return authenticatedUser;
+                }
+
+            });
+        }
+        catch (GuacamoleException e) {
+            logger.error("An extension listening for session invalidation failed: {}", e.getMessage());
+            logger.debug("Extension failed internally while handling the session invalidation event.", e);
+        }
+
     }
     
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/event/AffectedObject.java b/guacamole/src/main/java/org/apache/guacamole/event/AffectedObject.java
new file mode 100644
index 0000000..c08b4ed
--- /dev/null
+++ b/guacamole/src/main/java/org/apache/guacamole/event/AffectedObject.java
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.event;
+
+import org.apache.guacamole.net.auth.Nameable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.apache.guacamole.net.event.IdentifiableObjectEvent;
+
+/**
+ * Loggable representation of the object affected by an operation.
+ */
+public class AffectedObject implements LoggableDetail {
+
+    /**
+     * Logger for this class.
+     */
+    private static final Logger logger = LoggerFactory.getLogger(AffectedObject.class);
+    
+    /**
+     * The event representing the requested operation.
+     */
+    private final IdentifiableObjectEvent<?> event;
+
+    /**
+     * Creates a new AffectedObject representing the object affected by the
+     * operation described by the given event.
+     *
+     * @param event
+     *     The event representing the operation.
+     */
+    public AffectedObject(IdentifiableObjectEvent<?> event) {
+        this.event = event;
+    }
+
+    @Override
+    public String toString() {
+
+        Object object = event.getObject();
+        String identifier = event.getObjectIdentifier();
+        String dataSource = event.getAuthenticationProvider().getIdentifier();
+
+        String objectType;
+        String name = null; // Not all objects have names
+
+        // Obtain name of object (if applicable and available)
+        if (object instanceof Nameable) {
+            try {
+                name = ((Nameable) object).getName();
+            }
+            catch (RuntimeException | Error e) {
+                logger.debug("Name of object \"{}\" could not be retrieved.", identifier, e);
+            }
+        }
+
+        // Determine type of object
+        switch (event.getDirectoryType()) {
+
+            // Active connections
+            case ACTIVE_CONNECTION:
+                objectType = "active connection";
+                break;
+
+            // Connections
+            case CONNECTION:
+                objectType = "connection";
+                break;
+
+            // Connection groups
+            case CONNECTION_GROUP:
+                objectType = "connection group";
+                break;
+
+            // Sharing profiles
+            case SHARING_PROFILE:
+                objectType = "sharing profile";
+                break;
+
+            // Users
+            case USER:
+
+                if (identifier != null && identifier.equals(event.getAuthenticatedUser().getIdentifier()))
+                    return "their own user account within \"" + dataSource + "\"";
+
+                objectType = "user";
+                break;
+
+            // User groups
+            case USER_GROUP:
+                objectType = "user group";
+                break;
+
+            // Unknown
+            default:
+                objectType = (object != null) ? object.getClass().toString() : "an unknown object";
+                
+        }
+
+        // Describe at least the type of the object and its identifier,
+        // including the name of the object, as well, if available
+        if (identifier != null) {
+            if (name != null)
+                return objectType + " \"" + identifier + "\" within \"" + dataSource + "\" (currently named \"" + name + "\")";
+            else
+                return objectType + " \"" + identifier + "\" within \"" + dataSource + "\"";
+        }
+        else
+            return objectType + " within \"" + dataSource + "\"";
+
+    }
+    
+}
diff --git a/guacamole/src/main/java/org/apache/guacamole/event/EventLoggingListener.java b/guacamole/src/main/java/org/apache/guacamole/event/EventLoggingListener.java
new file mode 100644
index 0000000..16bee6a
--- /dev/null
+++ b/guacamole/src/main/java/org/apache/guacamole/event/EventLoggingListener.java
@@ -0,0 +1,248 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.event;
+
+import javax.annotation.Nonnull;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.GuacamoleResourceNotFoundException;
+import org.apache.guacamole.net.auth.AuthenticationProvider;
+import org.apache.guacamole.net.auth.Credentials;
+import org.apache.guacamole.net.auth.Identifiable;
+import org.apache.guacamole.net.auth.User;
+import org.apache.guacamole.net.auth.credentials.GuacamoleInsufficientCredentialsException;
+import org.apache.guacamole.net.event.ApplicationShutdownEvent;
+import org.apache.guacamole.net.event.ApplicationStartedEvent;
+import org.apache.guacamole.net.event.AuthenticationFailureEvent;
+import org.apache.guacamole.net.event.AuthenticationRequestReceivedEvent;
+import org.apache.guacamole.net.event.AuthenticationSuccessEvent;
+import org.apache.guacamole.net.event.DirectoryEvent;
+import org.apache.guacamole.net.event.DirectoryFailureEvent;
+import org.apache.guacamole.net.event.DirectorySuccessEvent;
+import org.apache.guacamole.net.event.IdentifiableObjectEvent;
+import org.apache.guacamole.net.event.UserSessionInvalidatedEvent;
+import org.apache.guacamole.net.event.listener.Listener;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Listener that records each event that occurs in the logs, such as changes
+ * made to objects via the REST API.
+ */
+public class EventLoggingListener implements Listener {
+
+    /**
+     * Logger for this class.
+     */
+    private final Logger logger = LoggerFactory.getLogger(EventLoggingListener.class);
+
+    /**
+     * Returns whether the given event affects the password of a User object.
+     *
+     * @param event
+     *     The event to check.
+     *
+     * @return
+     *     true if a user's password is specifically set or modified by the
+     *     given event, false otherwise.
+     */
+    private boolean isPasswordAffected(IdentifiableObjectEvent<?> event) {
+
+        Identifiable object = event.getObject();
+        if (!(object instanceof User))
+            return false;
+
+        return ((User) object).getPassword() != null;
+        
+    }
+
+    /**
+     * Logs that an operation was performed on an object within a Directory
+     * successfully.
+     *
+     * @param event
+     *     The event describing the operation successfully performed on the
+     *     object.
+     */
+    private void logSuccess(DirectorySuccessEvent<?> event) {
+        DirectoryEvent.Operation op = event.getOperation();
+        switch (op) {
+
+            case GET:
+                logger.debug("{} successfully accessed/retrieved {}", new RequestingUser(event), new AffectedObject(event));
+                break;
+
+            case ADD:
+                if (isPasswordAffected(event))
+                    logger.info("{} successfully created {}, setting their password", new RequestingUser(event), new AffectedObject(event));
+                else
+                    logger.info("{} successfully created {}", new RequestingUser(event), new AffectedObject(event));
+                break;
+
+            case UPDATE:
+                if (isPasswordAffected(event))
+                    logger.info("{} successfully updated {}, changing their password", new RequestingUser(event), new AffectedObject(event));
+                else
+                    logger.info("{} successfully updated {}", new RequestingUser(event), new AffectedObject(event));
+                break;
+
+            case REMOVE:
+                logger.info("{} successfully deleted {}", new RequestingUser(event), new AffectedObject(event));
+                break;
+
+            default:
+                logger.warn("DirectoryEvent operation type has no corresponding log message implemented: {}", op);
+                logger.info("{} successfully performed an unknown action on {} {}", new RequestingUser(event), new AffectedObject(event));
+
+        }
+    }
+
+    /**
+     * Logs that an operation failed to be performed on an object within a
+     * Directory.
+     *
+     * @param event
+     *     The event describing the operation that failed.
+     */
+    private void logFailure(DirectoryFailureEvent<?> event) {
+        DirectoryEvent.Operation op = event.getOperation();
+        switch (op) {
+
+            case GET:
+                if (event.getFailure() instanceof GuacamoleResourceNotFoundException)
+                    logger.debug("{} failed to access/retrieve {}: {}", new RequestingUser(event), new AffectedObject(event), new Failure(event));
+                else
+                    logger.info("{} failed to access/retrieve {}: {}", new RequestingUser(event), new AffectedObject(event), new Failure(event));
+                break;
+
+            case ADD:
+                logger.info("{} failed to create {}: {}", new RequestingUser(event), new AffectedObject(event), new Failure(event));
+                break;
+
+            case UPDATE:
+                logger.info("{} failed to update {}: {}", new RequestingUser(event), new AffectedObject(event), new Failure(event));
+                break;
+
+            case REMOVE:
+                logger.info("{} failed to delete {}: {}", new RequestingUser(event), new AffectedObject(event), new Failure(event));
+                break;
+
+            default:
+                logger.warn("DirectoryEvent operation type has no corresponding log message implemented: {}", op);
+                logger.info("{} failed to perform an unknown action on {}: {}", new RequestingUser(event), new AffectedObject(event), new Failure(event));
+
+        }
+    }
+
+    /**
+     * Logs that authentication succeeded for a user.
+     *
+     * @param event
+     *     The event describing the authentication attempt that succeeded.
+     */
+    private void logSuccess(AuthenticationSuccessEvent event) {
+        if (!event.isExistingSession())
+            logger.info("{} successfully authenticated from {}",
+                    new RequestingUser(event),
+                    new RemoteAddress(event.getCredentials()));
+        else
+            logger.debug("{} successfully re-authenticated their existing "
+                    + "session from {}", new RequestingUser(event),
+                    new RemoteAddress(event.getCredentials()));
+    }
+
+    /**
+     * Logs that authentication failed for a user.
+     *
+     * @param event
+     *     The event describing the authentication attempt that failed.
+     */
+    private void logFailure(AuthenticationFailureEvent event) {
+
+        AuthenticationProvider authProvider = event.getAuthenticationProvider();
+
+        Credentials creds = event.getCredentials();
+        String username = creds.getUsername();
+
+        if (creds.isEmpty())
+            logger.debug("Empty authentication attempt (login screen "
+                    + "initialization) from {} failed: {}",
+                    new RemoteAddress(creds), new Failure(event));
+        else if (username == null || username.isEmpty())
+            logger.debug("Anonymous authentication attempt from {} failed: {}",
+                    new RemoteAddress(creds), new Failure(event));
+        else if (event.getFailure() instanceof GuacamoleInsufficientCredentialsException) {
+            if (authProvider != null)
+                logger.debug("Authentication attempt from {} for user \"{}\" "
+                        + "requires additional credentials to continue: {} "
+                        + "(requested by \"{}\")", new RemoteAddress(creds),
+                        username, new Failure(event), authProvider.getIdentifier());
+            else
+                logger.debug("Authentication attempt from {} for user \"{}\" "
+                        + "requires additional credentials to continue: {}",
+                        new RemoteAddress(creds), username, new Failure(event));
+        }
+        else {
+            if (authProvider != null)
+                logger.warn("Authentication attempt from {} for user \"{}\" "
+                        + "failed: {} (rejected by \"{}\")", new RemoteAddress(creds),
+                        username, new Failure(event), authProvider.getIdentifier());
+            else
+                logger.warn("Authentication attempt from {} for user \"{}\" "
+                        + "failed: {}", new RemoteAddress(creds), username,
+                        new Failure(event));
+        }
+
+    }
+
+    @Override
+    public void handleEvent(@Nonnull Object event) throws GuacamoleException {
+
+        // General object creation/modification/deletion
+        if (event instanceof DirectorySuccessEvent)
+            logSuccess((DirectorySuccessEvent<?>) event);
+        else if (event instanceof DirectoryFailureEvent)
+            logFailure((DirectoryFailureEvent<?>) event);
+
+        // Login / logout / session expiration
+        else if (event instanceof AuthenticationSuccessEvent)
+            logSuccess((AuthenticationSuccessEvent) event);
+        else if (event instanceof AuthenticationFailureEvent)
+            logFailure((AuthenticationFailureEvent) event);
+        else if (event instanceof UserSessionInvalidatedEvent)
+            logger.info("{} has logged out, or their session has expired or "
+                    + "been terminated.", new RequestingUser((UserSessionInvalidatedEvent) event));
+        else if (event instanceof AuthenticationRequestReceivedEvent)
+            logger.trace("Authentication request received from {}",
+                    new RemoteAddress(((AuthenticationRequestReceivedEvent) event).getCredentials()));
+
+        // Application startup/shutdown
+        else if (event instanceof ApplicationStartedEvent)
+            logger.info("The Apache Guacamole web application has started.");
+        else if (event instanceof ApplicationShutdownEvent)
+            logger.info("The Apache Guacamole web application has shut down.");
+
+        // Unknown events
+        else
+            logger.debug("Ignoring unknown/unimplemented event type: {}",
+                    event.getClass());
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/apache/guacamole/event/Failure.java b/guacamole/src/main/java/org/apache/guacamole/event/Failure.java
new file mode 100644
index 0000000..10b3c96
--- /dev/null
+++ b/guacamole/src/main/java/org/apache/guacamole/event/Failure.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.event;
+
+import org.apache.guacamole.net.event.FailureEvent;
+
+/**
+ * Loggable representation of a failure that occurred.
+ */
+public class Failure implements LoggableDetail {
+
+    /**
+     * The event representing the failure.
+     */
+    private final FailureEvent event;
+
+    /**
+     * Creates a new Failure representing the failure described by the given
+     * event.
+     *
+     * @param event 
+     *     The event representing the failure.
+     */
+    public Failure(FailureEvent event) {
+        this.event = event;
+    }
+
+    @Override
+    public String toString() {
+
+        Throwable failure = event.getFailure();
+        if (failure == null)
+            return "unknown error (no specific failure recorded)";
+
+        return failure.getMessage();
+        
+    }
+    
+}
diff --git a/guacamole/src/main/java/org/apache/guacamole/event/LoggableDetail.java b/guacamole/src/main/java/org/apache/guacamole/event/LoggableDetail.java
new file mode 100644
index 0000000..909412e
--- /dev/null
+++ b/guacamole/src/main/java/org/apache/guacamole/event/LoggableDetail.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.event;
+
+/**
+ * Provides a {@link #toString()} implementation that returns a human-readable
+ * string that is intended to be logged and which describes a particular detail
+ * of an event.
+ */
+public interface LoggableDetail {
+
+    /**
+     * {@inheritDoc}
+     * <p>
+     * A LoggableDetail implementation of toString() is required to return a
+     * string that is human-readable, describes a detail of a provided event,
+     * and that is intended to be logged.
+     */
+    @Override
+    String toString();
+
+}
diff --git a/guacamole/src/main/java/org/apache/guacamole/event/RemoteAddress.java b/guacamole/src/main/java/org/apache/guacamole/event/RemoteAddress.java
new file mode 100644
index 0000000..f55602f
--- /dev/null
+++ b/guacamole/src/main/java/org/apache/guacamole/event/RemoteAddress.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.event;
+
+import java.util.regex.Pattern;
+import javax.servlet.http.HttpServletRequest;
+import org.apache.guacamole.net.auth.Credentials;
+
+/**
+ * Loggable representation of the remote address of a user, including any
+ * intervening proxies noted by "X-Forwarded-For". This representation takes
+ * into account the fact that "X-Forwarded-For" may come from an untrusted
+ * source, logging such addresses within square brackets alongside the trusted
+ * source IP.
+ */
+public class RemoteAddress implements LoggableDetail {
+
+    /**
+     * Regular expression which matches any IPv4 address.
+     */
+    private static final String IPV4_ADDRESS_REGEX = "([0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3})";
+
+    /**
+     * Regular expression which matches any IPv6 address.
+     */
+    private static final String IPV6_ADDRESS_REGEX = "([0-9a-fA-F]*(:[0-9a-fA-F]*){0,7})";
+
+    /**
+     * Regular expression which matches any IP address, regardless of version.
+     */
+    private static final String IP_ADDRESS_REGEX = "(" + IPV4_ADDRESS_REGEX + "|" + IPV6_ADDRESS_REGEX + ")";
+
+    /**
+     * Regular expression which matches any Port Number.
+     */
+    private static final String PORT_NUMBER_REGEX = "(:[0-9]{1,5})?";
+
+    /**
+     * Pattern which matches valid values of the de-facto standard
+     * "X-Forwarded-For" header.
+     */
+    private static final Pattern X_FORWARDED_FOR = Pattern.compile("^" + IP_ADDRESS_REGEX + PORT_NUMBER_REGEX + "(, " + IP_ADDRESS_REGEX + PORT_NUMBER_REGEX + ")*$");
+
+    /**
+     * The credentials supplied by the user when they authenticated.
+     */
+    private final Credentials creds;
+
+    /**
+     * Creates a new RemoteAddress representing the source address of the HTTP
+     * request that provided the given Credentials.
+     *
+     * @param creds
+     *     The Credentials associated with the request whose source address
+     *     should be represented by this RemoteAddress.
+     */
+    public RemoteAddress(Credentials creds) {
+        this.creds = creds;
+    }
+
+    @Override
+    public String toString() {
+
+        HttpServletRequest request = creds.getRequest();
+        if (request == null)
+            return creds.getRemoteAddress();
+
+        // Log X-Forwarded-For, if present and valid
+        String header = request.getHeader("X-Forwarded-For");
+        if (header != null && X_FORWARDED_FOR.matcher(header).matches())
+            return "[" + header + ", " + request.getRemoteAddr() + "]";
+
+        // If header absent or invalid, just use source IP
+        return request.getRemoteAddr();
+
+    }
+
+}
diff --git a/guacamole/src/main/java/org/apache/guacamole/event/RequestingUser.java b/guacamole/src/main/java/org/apache/guacamole/event/RequestingUser.java
new file mode 100644
index 0000000..bcc14f4
--- /dev/null
+++ b/guacamole/src/main/java/org/apache/guacamole/event/RequestingUser.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.event;
+
+import org.apache.guacamole.net.auth.AuthenticatedUser;
+import org.apache.guacamole.net.event.UserEvent;
+
+/**
+ * Loggable representation of the user that requested an operation.
+ */
+public class RequestingUser implements LoggableDetail {
+
+    /**
+     * The event representing the requested operation.
+     */
+    private final UserEvent event;
+
+    /**
+     * Creates a new RequestingUser that represents the user that requested the
+     * operation described by the given event.
+     *
+     * @param event
+     *     The event representing the requested operation.
+     */
+    public RequestingUser(UserEvent event) {
+        this.event = event;
+    }
+
+    @Override
+    public String toString() {
+
+        AuthenticatedUser user = event.getAuthenticatedUser();
+        String identifier = user.getIdentifier();
+
+        if (AuthenticatedUser.ANONYMOUS_IDENTIFIER.equals(identifier))
+            return "Anonymous user (authenticated by \"" + user.getAuthenticationProvider().getIdentifier() + "\")";
+
+        return "User \"" + identifier + "\" (authenticated by \"" + user.getAuthenticationProvider().getIdentifier() + "\")";
+        
+    }
+
+}
diff --git a/guacamole/src/main/java/org/apache/guacamole/extension/DirectoryClassLoader.java b/guacamole/src/main/java/org/apache/guacamole/extension/DirectoryClassLoader.java
index 215c3c0..83ad9be 100644
--- a/guacamole/src/main/java/org/apache/guacamole/extension/DirectoryClassLoader.java
+++ b/guacamole/src/main/java/org/apache/guacamole/extension/DirectoryClassLoader.java
@@ -24,9 +24,6 @@
 import java.net.MalformedURLException;
 import java.net.URL;
 import java.net.URLClassLoader;
-import java.security.AccessController;
-import java.security.PrivilegedActionException;
-import java.security.PrivilegedExceptionAction;
 import java.util.ArrayList;
 import java.util.Collection;
 import org.apache.guacamole.GuacamoleException;
@@ -38,44 +35,6 @@
 public class DirectoryClassLoader extends URLClassLoader {
 
     /**
-     * Returns an instance of DirectoryClassLoader configured to load .jar
-     * files from the given directory. Calling this function multiple times
-     * will not affect previously-returned instances of DirectoryClassLoader.
-     *
-     * @param dir
-     *     The directory from which .jar files should be read.
-     *
-     * @return
-     *     A DirectoryClassLoader instance which loads classes from the .jar
-     *     files in the given directory.
-     *
-     * @throws GuacamoleException
-     *     If the given file is not a directory, or the contents of the given
-     *     directory cannot be read.
-     */
-    public static DirectoryClassLoader getInstance(final File dir)
-            throws GuacamoleException {
-
-        try {
-            // Attempt to create singleton classloader which loads classes from
-            // all .jar's in the lib directory defined in guacamole.properties
-            return AccessController.doPrivileged(new PrivilegedExceptionAction<DirectoryClassLoader>() {
-
-                @Override
-                public DirectoryClassLoader run() throws GuacamoleException {
-                    return new DirectoryClassLoader(dir);
-                }
-
-            });
-        }
-
-        catch (PrivilegedActionException e) {
-            throw (GuacamoleException) e.getException();
-        }
-
-    }
-
-    /**
      * Returns all .jar files within the given directory as an array of URLs.
      *
      * @param dir
@@ -142,7 +101,7 @@
      *     directory cannot be read.
      */
 
-    private DirectoryClassLoader(File dir) throws GuacamoleException {
+    public DirectoryClassLoader(File dir) throws GuacamoleException {
         super(getJarURLs(dir), DirectoryClassLoader.class.getClassLoader());
     }
 
diff --git a/guacamole/src/main/java/org/apache/guacamole/extension/Extension.java b/guacamole/src/main/java/org/apache/guacamole/extension/Extension.java
index f6cf80d..be67a38 100644
--- a/guacamole/src/main/java/org/apache/guacamole/extension/Extension.java
+++ b/guacamole/src/main/java/org/apache/guacamole/extension/Extension.java
@@ -397,7 +397,7 @@
             }
 
             // Create isolated classloader for this extension
-            classLoader = ExtensionClassLoader.getInstance(file, temporaryFiles, parent);
+            classLoader = new ExtensionClassLoader(file, temporaryFiles, parent);
 
         }
 
diff --git a/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionClassLoader.java b/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionClassLoader.java
index 03faf53..6473aa2 100644
--- a/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionClassLoader.java
+++ b/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionClassLoader.java
@@ -29,9 +29,6 @@
 import java.net.URLClassLoader;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.security.AccessController;
-import java.security.PrivilegedActionException;
-import java.security.PrivilegedExceptionAction;
 import java.util.ArrayList;
 import java.util.Enumeration;
 import java.util.List;
@@ -75,56 +72,6 @@
     private final ClassLoader parent;
 
     /**
-     * Returns an instance of ExtensionClassLoader configured to load classes
-     * from the given extension .jar. If a necessary class cannot be found
-     * within the .jar, the given parent ClassLoader is used. Calling this
-     * function multiple times will not affect previously-returned instances of
-     * ExtensionClassLoader.
-     *
-     * @param extension
-     *     The extension .jar file from which classes should be loaded.
-     *
-     * @param temporaryFiles
-     *     A modifiable List that should be populated with all temporary files
-     *     created for the given extension. These files should be deleted on
-     *     application shutdown in reverse order.
-     *
-     * @param parent
-     *     The ClassLoader to use if class resolution through the extension
-     *     .jar fails.
-     *
-     * @return
-     *     A ExtensionClassLoader instance which loads classes from the
-     *     given extension .jar file.
-     *
-     * @throws GuacamoleException
-     *     If the given file is not actually a file, or the contents of the
-     *     file cannot be read.
-     */
-    public static ExtensionClassLoader getInstance(final File extension,
-            final List<File> temporaryFiles, final ClassLoader parent)
-            throws GuacamoleException {
-
-        try {
-            // Attempt to create classloader which loads classes from the given
-            // .jar file
-            return AccessController.doPrivileged(new PrivilegedExceptionAction<ExtensionClassLoader>() {
-
-                @Override
-                public ExtensionClassLoader run() throws GuacamoleException {
-                    return new ExtensionClassLoader(extension, temporaryFiles, parent);
-                }
-
-            });
-        }
-
-        catch (PrivilegedActionException e) {
-            throw (GuacamoleException) e.getException();
-        }
-
-    }
-
-    /**
      * Returns the URL that refers to the given file. If the given file refers
      * to a directory, an exception is thrown.
      *
@@ -320,7 +267,7 @@
      *     If the given file is not actually a file, or the contents of the
      *     file cannot be read.
      */
-    private ExtensionClassLoader(File extension, List<File> temporaryFiles,
+    public ExtensionClassLoader(File extension, List<File> temporaryFiles,
             ClassLoader parent) throws GuacamoleException {
         super(getExtensionURLs(extension, temporaryFiles), null);
         this.parent = parent;
diff --git a/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionModule.java b/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionModule.java
index f5ba7c3..ebedacc 100644
--- a/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionModule.java
+++ b/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionModule.java
@@ -35,6 +35,7 @@
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleServerException;
 import org.apache.guacamole.environment.Environment;
+import org.apache.guacamole.event.EventLoggingListener;
 import org.apache.guacamole.net.auth.AuthenticationProvider;
 import org.apache.guacamole.net.event.listener.Listener;
 import org.apache.guacamole.properties.StringSetProperty;
@@ -184,7 +185,7 @@
             return ExtensionModule.class.getClassLoader();
 
         // Return classloader which loads classes from all .jars within the lib directory
-        return DirectoryClassLoader.getInstance(libDir);
+        return new DirectoryClassLoader(libDir);
 
     }
 
@@ -631,8 +632,9 @@
         final Set<String> toleratedAuthProviders = getToleratedAuthenticationProviders();
         loadExtensions(javaScriptResources, cssResources, toleratedAuthProviders);
 
-        // Always bind default file-driven auth last
+        // Always bind default file-driven auth and event logging last
         bindAuthenticationProvider(FileAuthenticationProvider.class, toleratedAuthProviders);
+        bindListener(EventLoggingListener.class);
 
         // Dynamically generate app.js and app.css from extensions
         serve("/app.js").with(new ResourceServlet(new SequenceResource(javaScriptResources)));
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/APIError.java b/guacamole/src/main/java/org/apache/guacamole/rest/APIError.java
index 846e293..b4ee59f 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/APIError.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/APIError.java
@@ -21,6 +21,7 @@
 
 import java.util.Collection;
 import java.util.Collections;
+import java.util.List;
 import org.apache.guacamole.GuacamoleClientException;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleResourceNotFoundException;
@@ -31,6 +32,8 @@
 import org.apache.guacamole.net.auth.credentials.GuacamoleCredentialsException;
 import org.apache.guacamole.net.auth.credentials.GuacamoleInsufficientCredentialsException;
 import org.apache.guacamole.net.auth.credentials.GuacamoleInvalidCredentialsException;
+import org.apache.guacamole.rest.jsonpatch.APIPatchFailureException;
+import org.apache.guacamole.rest.jsonpatch.APIPatchOutcome;
 import org.apache.guacamole.tunnel.GuacamoleStreamException;
 
 /**
@@ -72,6 +75,12 @@
     private final Collection<Field> expected;
 
     /**
+     * The outcome of each patch in the associated request, if this was a
+     * JSON Patch request. Otherwise null.
+     */
+    private List<APIPatchOutcome> patches = null;
+
+    /**
      * The type of error that occurred.
      */
     private final Type type;
@@ -207,6 +216,9 @@
             this.translatableMessage = new TranslatableMessage(UNTRANSLATED_MESSAGE_KEY,
                     Collections.singletonMap(UNTRANSLATED_MESSAGE_VARIABLE_NAME, this.message));
 
+        if (exception instanceof APIPatchFailureException)
+            this.patches = ((APIPatchFailureException) exception).getPatches();
+
     }
 
     /**
@@ -244,6 +256,18 @@
     }
 
     /**
+     * Return the outcome for every patch in the request, if the request was
+     * a JSON patch request. Otherwise, null.
+     *
+     * @return
+     *     The outcome for every patch if responding to a JSON Patch request,
+     *     otherwise null.
+     */
+    public List<APIPatchOutcome> getPatches() {
+        return patches;
+    }
+
+    /**
      * Returns a human-readable error message describing the error that
      * occurred.
      *
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionDirectoryResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionDirectoryResource.java
index 5296565..88319f0 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionDirectoryResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionDirectoryResource.java
@@ -26,6 +26,7 @@
 import javax.ws.rs.core.MediaType;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.net.auth.ActiveConnection;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.Permissions;
 import org.apache.guacamole.net.auth.UserContext;
@@ -48,6 +49,9 @@
      * operations and subresources available for the given ActiveConnection
      * Directory.
      *
+     * @param authenticatedUser
+     *     The user that is accessing this resource.
+     *
      * @param userContext
      *     The UserContext associated with the given Directory.
      *
@@ -63,11 +67,13 @@
      *     representing ActiveConnections.
      */
     @AssistedInject
-    public ActiveConnectionDirectoryResource(@Assisted UserContext userContext,
+    public ActiveConnectionDirectoryResource(
+            @Assisted AuthenticatedUser authenticatedUser,
+            @Assisted UserContext userContext,
             @Assisted Directory<ActiveConnection> directory,
             DirectoryObjectTranslator<ActiveConnection, APIActiveConnection> translator,
             DirectoryObjectResourceFactory<ActiveConnection, APIActiveConnection> resourceFactory) {
-        super(userContext, directory, translator, resourceFactory);
+        super(authenticatedUser, userContext, ActiveConnection.class, directory, translator, resourceFactory);
     }
 
     @Override
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionResource.java
index 514daa1..59afe18 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionResource.java
@@ -30,6 +30,7 @@
 import javax.ws.rs.core.MediaType;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.net.auth.ActiveConnection;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.Connection;
 import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.UserContext;
@@ -48,17 +49,6 @@
         extends DirectoryObjectResource<ActiveConnection, APIActiveConnection> {
 
     /**
-     * The UserContext associated with the Directory which contains the
-     * Connection exposed by this resource.
-     */
-    private final UserContext userContext;
-
-    /**
-     * The ActiveConnection exposed by this ActiveConnectionResource.
-     */
-    private final ActiveConnection activeConnection;
-
-    /**
      * A factory which can be used to create instances of resources representing
      * Connection.
      */
@@ -70,6 +60,9 @@
      * Creates a new ActiveConnectionResource which exposes the operations and
      * subresources available for the given ActiveConnection.
      *
+     * @param authenticatedUser
+     *     The user that is accessing this resource.
+     *
      * @param userContext
      *     The UserContext associated with the given Directory.
      *
@@ -85,13 +78,12 @@
      *     ActiveConnections.
      */
     @AssistedInject
-    public ActiveConnectionResource(@Assisted UserContext userContext,
+    public ActiveConnectionResource(@Assisted AuthenticatedUser authenticatedUser,
+            @Assisted UserContext userContext,
             @Assisted Directory<ActiveConnection> directory,
             @Assisted ActiveConnection activeConnection,
             DirectoryObjectTranslator<ActiveConnection, APIActiveConnection> translator) {
-        super(userContext, directory, activeConnection, translator);
-        this.userContext = userContext;
-        this.activeConnection = activeConnection;
+        super(authenticatedUser, userContext, ActiveConnection.class, directory, activeConnection, translator);
     }
 
     /**
@@ -109,9 +101,12 @@
     public DirectoryObjectResource<Connection, APIConnection> getConnection()
             throws GuacamoleException {
 
+        UserContext userContext = getUserContext();
+        ActiveConnection activeConnection = getInternalObject();
+        
         // Return the underlying connection as a resource
         return connectionDirectoryResourceFactory
-                .create(userContext, userContext.getConnectionDirectory())
+                .create(getAuthenticatedUser(), userContext, userContext.getConnectionDirectory())
                 .getObjectResource(activeConnection.getConnectionIdentifier());
 
     }
@@ -137,7 +132,7 @@
             throws GuacamoleException {
 
         // Generate and return sharing credentials for the active connection
-        return new APIUserCredentials(activeConnection.getSharingCredentials(sharingProfileIdentifier));
+        return new APIUserCredentials(getInternalObject().getSharingCredentials(sharingProfileIdentifier));
 
     }
 
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/auth/AuthenticationService.java b/guacamole/src/main/java/org/apache/guacamole/rest/auth/AuthenticationService.java
index ce8a9fb..305073d 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/auth/AuthenticationService.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/auth/AuthenticationService.java
@@ -21,24 +21,22 @@
 
 import java.util.ArrayList;
 import java.util.List;
-import java.util.regex.Pattern;
 import javax.inject.Inject;
-import javax.servlet.http.HttpServletRequest;
 
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleSecurityException;
+import org.apache.guacamole.GuacamoleServerException;
 import org.apache.guacamole.GuacamoleUnauthorizedException;
 import org.apache.guacamole.GuacamoleSession;
-import org.apache.guacamole.environment.Environment;
 import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.AuthenticationProvider;
 import org.apache.guacamole.net.auth.Credentials;
 import org.apache.guacamole.net.auth.UserContext;
-import org.apache.guacamole.net.auth.credentials.CredentialsInfo;
 import org.apache.guacamole.net.auth.credentials.GuacamoleCredentialsException;
 import org.apache.guacamole.net.auth.credentials.GuacamoleInsufficientCredentialsException;
 import org.apache.guacamole.net.auth.credentials.GuacamoleInvalidCredentialsException;
 import org.apache.guacamole.net.event.AuthenticationFailureEvent;
+import org.apache.guacamole.net.event.AuthenticationRequestReceivedEvent;
 import org.apache.guacamole.net.event.AuthenticationSuccessEvent;
 import org.apache.guacamole.rest.event.ListenerService;
 import org.glassfish.jersey.server.ContainerRequest;
@@ -56,12 +54,6 @@
     private static final Logger logger = LoggerFactory.getLogger(AuthenticationService.class);
 
     /**
-     * The Guacamole server environment.
-     */
-    @Inject
-    private Environment environment;
-
-    /**
      * All configured authentication providers which can be used to
      * authenticate users or retrieve data associated with authenticated users.
      */
@@ -105,57 +97,6 @@
     public static final String TOKEN_PARAMETER_NAME = "token";
 
     /**
-     * Regular expression which matches any IPv4 address.
-     */
-    private static final String IPV4_ADDRESS_REGEX = "([0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3})";
-
-    /**
-     * Regular expression which matches any IPv6 address.
-     */
-    private static final String IPV6_ADDRESS_REGEX = "([0-9a-fA-F]*(:[0-9a-fA-F]*){0,7})";
-
-    /**
-     * Regular expression which matches any IP address, regardless of version.
-     */
-    private static final String IP_ADDRESS_REGEX = "(" + IPV4_ADDRESS_REGEX + "|" + IPV6_ADDRESS_REGEX + ")";
-
-    /**
-     * Regular expression which matches any Port Number.
-     */
-    private static final String PORT_NUMBER_REGEX = "(:[0-9]{1,5})?";
-    
-    /**
-     * Pattern which matches valid values of the de-facto standard
-     * "X-Forwarded-For" header.
-     */
-    private static final Pattern X_FORWARDED_FOR = Pattern.compile("^" + IP_ADDRESS_REGEX + PORT_NUMBER_REGEX + "(, " + IP_ADDRESS_REGEX + PORT_NUMBER_REGEX + ")*$");
-
-    /**
-     * Returns a formatted string containing an IP address, or list of IP
-     * addresses, which represent the HTTP client and any involved proxies. As
-     * the headers used to determine proxies can easily be forged, this data is
-     * superficially validated to ensure that it at least looks like a list of
-     * IPs.
-     *
-     * @param request
-     *     The HTTP request to format.
-     *
-     * @return
-     *     A formatted string containing one or more IP addresses.
-     */
-    private String getLoggableAddress(HttpServletRequest request) {
-
-        // Log X-Forwarded-For, if present and valid
-        String header = request.getHeader("X-Forwarded-For");
-        if (header != null && X_FORWARDED_FOR.matcher(header).matches())
-            return "[" + header + ", " + request.getRemoteAddr() + "]";
-
-        // If header absent or invalid, just use source IP
-        return request.getRemoteAddr();
-
-    }
-
-    /**
      * Attempts authentication against all AuthenticationProviders, in order,
      * using the provided credentials. The first authentication failure takes
      * priority, but remaining AuthenticationProviders are attempted. If any
@@ -169,14 +110,15 @@
      *     The AuthenticatedUser given by the highest-priority
      *     AuthenticationProvider for which the given credentials are valid.
      *
-     * @throws GuacamoleException
+     * @throws GuacamoleAuthenticationProcessException
      *     If the given credentials are not valid for any
      *     AuthenticationProvider, or if an error occurs while authenticating
      *     the user.
      */
     private AuthenticatedUser authenticateUser(Credentials credentials)
-        throws GuacamoleException {
+        throws GuacamoleAuthenticationProcessException {
 
+        AuthenticationProvider failedAuthProvider = null;
         GuacamoleCredentialsException authFailure = null;
 
         // Attempt authentication against each AuthenticationProvider
@@ -191,27 +133,29 @@
 
             // Insufficient credentials should take precedence
             catch (GuacamoleInsufficientCredentialsException e) {
-                if (authFailure == null || authFailure instanceof GuacamoleInvalidCredentialsException)
+                if (authFailure == null || authFailure instanceof GuacamoleInvalidCredentialsException) {
+                    failedAuthProvider = authProvider;
                     authFailure = e;
+                }
             }
-            
+
             // Catch other credentials exceptions and assign the first one
             catch (GuacamoleCredentialsException e) {
-                if (authFailure == null)
+                if (authFailure == null) {
+                    failedAuthProvider = authProvider;
                     authFailure = e;
+                }
+            }
+
+            catch (GuacamoleException | RuntimeException | Error e) {
+                throw new GuacamoleAuthenticationProcessException("User "
+                        + "authentication was aborted.", authProvider, e);
             }
 
         }
 
-        // If a specific failure occured, rethrow that
-        if (authFailure != null)
-            throw authFailure;
-
-        // Otherwise, request standard username/password
-        throw new GuacamoleInvalidCredentialsException(
-            "Permission Denied.",
-            CredentialsInfo.USERNAME_PASSWORD
-        );
+        throw new GuacamoleAuthenticationProcessException("User authentication "
+                + "failed.", failedAuthProvider, authFailure);
 
     }
 
@@ -230,51 +174,29 @@
      *     A AuthenticatedUser which may have been updated due to re-
      *     authentication.
      *
-     * @throws GuacamoleException
+     * @throws GuacamoleAuthenticationProcessException
      *     If an error prevents the user from being re-authenticated.
      */
     private AuthenticatedUser updateAuthenticatedUser(AuthenticatedUser authenticatedUser,
-            Credentials credentials) throws GuacamoleException {
+            Credentials credentials) throws GuacamoleAuthenticationProcessException {
 
         // Get original AuthenticationProvider
         AuthenticationProvider authProvider = authenticatedUser.getAuthenticationProvider();
 
-        // Re-authenticate the AuthenticatedUser against the original AuthenticationProvider only
-        authenticatedUser = authProvider.updateAuthenticatedUser(authenticatedUser, credentials);
-        if (authenticatedUser == null)
-            throw new GuacamoleSecurityException("User re-authentication failed.");
+        try {
 
-        return authenticatedUser;
+            // Re-authenticate the AuthenticatedUser against the original AuthenticationProvider only
+            authenticatedUser = authProvider.updateAuthenticatedUser(authenticatedUser, credentials);
+            if (authenticatedUser == null)
+                throw new GuacamoleSecurityException("User re-authentication failed.");
 
-    }
+            return authenticatedUser;
 
-    /**
-     * Notify all bound listeners that a successful authentication
-     * has occurred.
-     *
-     * @param authenticatedUser
-     *      The user that was successfully authenticated.
-     *
-     * @throws GuacamoleException
-     *      If thrown by a listener.
-     */
-    private void fireAuthenticationSuccessEvent(AuthenticatedUser authenticatedUser)
-            throws GuacamoleException {
-        listenerService.handleEvent(new AuthenticationSuccessEvent(authenticatedUser));
-    }
+        }
+        catch (GuacamoleException | RuntimeException | Error e) {
+            throw new GuacamoleAuthenticationProcessException("User re-authentication failed.", authProvider, e);
+        }
 
-    /**
-     * Notify all bound listeners that an authentication attempt has failed.
-     *
-     * @param credentials
-     *      The credentials that failed to authenticate.
-     *
-     * @throws GuacamoleException
-     *      If thrown by a listener.
-     */
-    private void fireAuthenticationFailedEvent(Credentials credentials)
-            throws GuacamoleException {
-        listenerService.handleEvent(new AuthenticationFailureEvent(credentials));
     }
 
     /**
@@ -292,61 +214,23 @@
      *     The AuthenticatedUser associated with the given session and
      *     credentials.
      *
-     * @throws GuacamoleException
+     * @throws GuacamoleAuthenticationProcessException
      *     If an error occurs while authenticating or re-authenticating the
      *     user.
      */
     private AuthenticatedUser getAuthenticatedUser(GuacamoleSession existingSession,
-            Credentials credentials) throws GuacamoleException {
+            Credentials credentials) throws GuacamoleAuthenticationProcessException {
 
-        try {
-
-            // Re-authenticate user if session exists
-            if (existingSession != null) {
-                AuthenticatedUser updatedUser = updateAuthenticatedUser(
-                        existingSession.getAuthenticatedUser(), credentials);
-                fireAuthenticationSuccessEvent(updatedUser);
-                return updatedUser;
-            }
-
-            // Otherwise, attempt authentication as a new user
-            AuthenticatedUser authenticatedUser = AuthenticationService.this.authenticateUser(credentials);
-            fireAuthenticationSuccessEvent(authenticatedUser);
-
-            if (logger.isInfoEnabled())
-                logger.info("User \"{}\" successfully authenticated from {}.",
-                        authenticatedUser.getIdentifier(),
-                        getLoggableAddress(credentials.getRequest()));
-
-            return authenticatedUser;
-
+        // Re-authenticate user if session exists
+        if (existingSession != null) {
+            AuthenticatedUser updatedUser = updateAuthenticatedUser(
+                    existingSession.getAuthenticatedUser(), credentials);
+            return updatedUser;
         }
 
-        // Log and rethrow any authentication errors
-        catch (GuacamoleException e) {
-
-            fireAuthenticationFailedEvent(credentials);
-
-            // Get request and username for sake of logging
-            HttpServletRequest request = credentials.getRequest();
-            String username = credentials.getUsername();
-
-            // Log authentication failures with associated usernames
-            if (username != null) {
-                if (logger.isWarnEnabled())
-                    logger.warn("Authentication attempt from {} for user \"{}\" failed.",
-                            getLoggableAddress(request), username);
-            }
-
-            // Log anonymous authentication failures
-            else if (logger.isDebugEnabled())
-                logger.debug("Anonymous authentication attempt from {} failed.",
-                        getLoggableAddress(request));
-
-            // Rethrow exception
-            throw e;
-
-        }
+        // Otherwise, attempt authentication as a new user
+        AuthenticatedUser authenticatedUser = AuthenticationService.this.authenticateUser(credentials);
+        return authenticatedUser;
 
     }
 
@@ -371,15 +255,14 @@
      *     A List of all UserContexts associated with the given
      *     AuthenticatedUser.
      *
-     * @throws GuacamoleException
+     * @throws GuacamoleAuthenticationProcessException
      *     If an error occurs while creating or updating any UserContext.
      */
     private List<DecoratedUserContext> getUserContexts(GuacamoleSession existingSession,
             AuthenticatedUser authenticatedUser, Credentials credentials)
-            throws GuacamoleException {
+            throws GuacamoleAuthenticationProcessException {
 
-        List<DecoratedUserContext> userContexts =
-                new ArrayList<DecoratedUserContext>(authProviders.size());
+        List<DecoratedUserContext> userContexts = new ArrayList<>(authProviders.size());
 
         // If UserContexts already exist, update them and add to the list
         if (existingSession != null) {
@@ -392,7 +275,15 @@
 
                 // Update existing UserContext
                 AuthenticationProvider authProvider = oldUserContext.getAuthenticationProvider();
-                UserContext updatedUserContext = authProvider.updateUserContext(oldUserContext, authenticatedUser, credentials);
+                UserContext updatedUserContext;
+                try {
+                    updatedUserContext = authProvider.updateUserContext(oldUserContext, authenticatedUser, credentials);
+                }
+                catch (GuacamoleException | RuntimeException | Error e) {
+                    throw new GuacamoleAuthenticationProcessException("User "
+                            + "authentication aborted during UserContext update.",
+                            authProvider, e);
+                }
 
                 // Add to available data, if successful
                 if (updatedUserContext != null)
@@ -415,7 +306,15 @@
             for (AuthenticationProvider authProvider : authProviders) {
 
                 // Generate new UserContext
-                UserContext userContext = authProvider.getUserContext(authenticatedUser);
+                UserContext userContext;
+                try {
+                    userContext = authProvider.getUserContext(authenticatedUser);
+                }
+                catch (GuacamoleException | RuntimeException | Error e) {
+                    throw new GuacamoleAuthenticationProcessException("User "
+                            + "authentication aborted during initial "
+                            + "UserContext creation.", authProvider, e);
+                }
 
                 // Add to available data, if successful
                 if (userContext != null)
@@ -453,7 +352,10 @@
      *     If the authentication or re-authentication attempt fails.
      */
     public String authenticate(Credentials credentials, String token)
-        throws GuacamoleException {
+            throws GuacamoleException {
+
+        // Fire pre-authentication event before ANY authn/authz occurs at all
+        listenerService.handleEvent((AuthenticationRequestReceivedEvent) () -> credentials);
 
         // Pull existing session if token provided
         GuacamoleSession existingSession;
@@ -462,23 +364,61 @@
         else
             existingSession = null;
 
-        // Get up-to-date AuthenticatedUser and associated UserContexts
-        AuthenticatedUser authenticatedUser = getAuthenticatedUser(existingSession, credentials);
-        List<DecoratedUserContext> userContexts = getUserContexts(existingSession, authenticatedUser, credentials);
-
-        // Update existing session, if it exists
+        AuthenticatedUser authenticatedUser;
         String authToken;
-        if (existingSession != null) {
-            authToken = token;
-            existingSession.setAuthenticatedUser(authenticatedUser);
-            existingSession.setUserContexts(userContexts);
+
+        try {
+
+            // Get up-to-date AuthenticatedUser and associated UserContexts
+            authenticatedUser = getAuthenticatedUser(existingSession, credentials);
+            List<DecoratedUserContext> userContexts = getUserContexts(existingSession, authenticatedUser, credentials);
+
+            // Update existing session, if it exists
+            if (existingSession != null) {
+                authToken = token;
+                existingSession.setAuthenticatedUser(authenticatedUser);
+                existingSession.setUserContexts(userContexts);
+            }
+
+            // If no existing session, generate a new token/session pair
+            else {
+                authToken = authTokenGenerator.getToken();
+                tokenSessionMap.put(authToken, new GuacamoleSession(listenerService, authenticatedUser, userContexts));
+            }
+
+            // Report authentication success
+            try {
+                listenerService.handleEvent(new AuthenticationSuccessEvent(authenticatedUser,
+                        existingSession != null));
+            }
+            catch (GuacamoleException e) {
+                throw new GuacamoleAuthenticationProcessException("User "
+                        + "authentication aborted by event listener.", null, e);
+            }
+
         }
 
-        // If no existing session, generate a new token/session pair
-        else {
-            authToken = authTokenGenerator.getToken();
-            tokenSessionMap.put(authToken, new GuacamoleSession(environment, authenticatedUser, userContexts));
-            logger.debug("Login was successful for user \"{}\".", authenticatedUser.getIdentifier());
+        // Log and rethrow any authentication errors
+        catch (GuacamoleAuthenticationProcessException e) {
+
+            listenerService.handleEvent(new AuthenticationFailureEvent(credentials,
+                    e.getAuthenticationProvider(), e.getCause()));
+
+            // Rethrow exception
+            e.rethrowCause();
+
+            // This line SHOULD be unreachable unless a bug causes
+            // rethrowCause() to not actually rethrow the underlying failure
+            Throwable cause = e.getCause();
+            if (cause != null) {
+                logger.warn("An underlying internal error was not correctly rethrown by rethrowCause(): {}", cause.getMessage());
+                logger.debug("Internal error not rethrown by rethrowCause().", cause);
+            }
+            else
+                logger.warn("An underlying internal error was not correctly rethrown by rethrowCause().");
+
+            throw e.getCauseAsGuacamoleException();
+
         }
 
         return authToken;
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/auth/DecoratedUserContext.java b/guacamole/src/main/java/org/apache/guacamole/rest/auth/DecoratedUserContext.java
index d773068..2be283a 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/auth/DecoratedUserContext.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/auth/DecoratedUserContext.java
@@ -76,21 +76,29 @@
      *     given AuthenticationProvider, or the original UserContext if the
      *     given AuthenticationProvider originated the UserContext.
      *
-     * @throws GuacamoleException
+     * @throws GuacamoleAuthenticationProcessException
      *     If the given AuthenticationProvider fails while decorating the
      *     UserContext.
      */
     private static UserContext decorate(AuthenticationProvider authProvider,
             UserContext userContext, AuthenticatedUser authenticatedUser,
-            Credentials credentials) throws GuacamoleException {
+            Credentials credentials) throws GuacamoleAuthenticationProcessException {
 
         // Skip the AuthenticationProvider which produced the UserContext
         // being decorated
         if (authProvider != userContext.getAuthenticationProvider()) {
 
             // Apply layer of wrapping around UserContext
-            UserContext decorated = authProvider.decorate(userContext,
-                    authenticatedUser, credentials);
+            UserContext decorated;
+            try {
+                decorated = authProvider.decorate(userContext,
+                        authenticatedUser, credentials);
+            }
+            catch (GuacamoleException | RuntimeException | Error e) {
+                throw new GuacamoleAuthenticationProcessException("User "
+                        + "authentication aborted by decorating UserContext.",
+                        authProvider, e);
+            }
 
             // Do not allow misbehaving extensions to wipe out the
             // UserContext entirely
@@ -130,13 +138,13 @@
      *     given AuthenticationProvider, or the original UserContext if the
      *     given AuthenticationProvider originated the UserContext.
      *
-     * @throws GuacamoleException
+     * @throws GuacamoleAuthenticationProcessException
      *     If the given AuthenticationProvider fails while decorating the
      *     UserContext.
      */
     private static UserContext redecorate(DecoratedUserContext decorated,
             UserContext userContext, AuthenticatedUser authenticatedUser,
-            Credentials credentials) throws GuacamoleException {
+            Credentials credentials) throws GuacamoleAuthenticationProcessException {
 
         AuthenticationProvider authProvider = decorated.getDecoratingAuthenticationProvider();
 
@@ -145,8 +153,16 @@
         if (authProvider != userContext.getAuthenticationProvider()) {
 
             // Apply next layer of wrapping around UserContext
-            UserContext redecorated = authProvider.redecorate(decorated,
-                    userContext, authenticatedUser, credentials);
+            UserContext redecorated;
+            try {
+                redecorated = authProvider.redecorate(decorated.getDelegateUserContext(),
+                        userContext, authenticatedUser, credentials);
+            }
+            catch (GuacamoleException | RuntimeException | Error e) {
+                throw new GuacamoleAuthenticationProcessException("User "
+                        + "authentication aborted by redecorating UserContext.",
+                        authProvider, e);
+            }
 
             // Do not allow misbehaving extensions to wipe out the
             // UserContext entirely
@@ -181,13 +197,13 @@
      *     The credentials associated with the request which produced the given
      *     UserContext.
      *
-     * @throws GuacamoleException
+     * @throws GuacamoleAuthenticationProcessException
      *     If any of the given AuthenticationProviders fails while decorating
      *     the UserContext.
      */
     public DecoratedUserContext(AuthenticationProvider authProvider,
             UserContext userContext, AuthenticatedUser authenticatedUser,
-            Credentials credentials) throws GuacamoleException {
+            Credentials credentials) throws GuacamoleAuthenticationProcessException {
 
         // Wrap the result of invoking decorate() on the given AuthenticationProvider
         super(decorate(authProvider, userContext, authenticatedUser, credentials));
@@ -221,13 +237,13 @@
      *     The credentials associated with the request which produced the given
      *     UserContext.
      *
-     * @throws GuacamoleException
+     * @throws GuacamoleAuthenticationProcessException
      *     If any of the given AuthenticationProviders fails while decorating
      *     the UserContext.
      */
     public DecoratedUserContext(AuthenticationProvider authProvider,
             DecoratedUserContext userContext, AuthenticatedUser authenticatedUser,
-            Credentials credentials) throws GuacamoleException {
+            Credentials credentials) throws GuacamoleAuthenticationProcessException {
 
         // Wrap the result of invoking decorate() on the given AuthenticationProvider
         super(decorate(authProvider, userContext, authenticatedUser, credentials));
@@ -261,13 +277,13 @@
      *     The credentials associated with the request which produced the given
      *     UserContext.
      *
-     * @throws GuacamoleException
+     * @throws GuacamoleAuthenticationProcessException
      *     If any of the given AuthenticationProviders fails while decorating
      *     the UserContext.
      */
     public DecoratedUserContext(DecoratedUserContext decorated,
             UserContext userContext, AuthenticatedUser authenticatedUser,
-            Credentials credentials) throws GuacamoleException {
+            Credentials credentials) throws GuacamoleAuthenticationProcessException {
 
         // Wrap the result of invoking redecorate() on the given AuthenticationProvider
         super(redecorate(decorated, userContext, authenticatedUser, credentials));
@@ -303,13 +319,13 @@
      *     The credentials associated with the request which produced the given
      *     UserContext.
      *
-     * @throws GuacamoleException
+     * @throws GuacamoleAuthenticationProcessException
      *     If any of the given AuthenticationProviders fails while decorating
      *     the UserContext.
      */
     public DecoratedUserContext(DecoratedUserContext decorated,
             DecoratedUserContext userContext, AuthenticatedUser authenticatedUser,
-            Credentials credentials) throws GuacamoleException {
+            Credentials credentials) throws GuacamoleAuthenticationProcessException {
 
         // Wrap the result of invoking redecorate() on the given AuthenticationProvider
         super(redecorate(decorated, userContext, authenticatedUser, credentials));
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/auth/DecorationService.java b/guacamole/src/main/java/org/apache/guacamole/rest/auth/DecorationService.java
index b28dc03..0b7fc12 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/auth/DecorationService.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/auth/DecorationService.java
@@ -23,7 +23,6 @@
 import java.util.List;
 import javax.inject.Inject;
 
-import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.AuthenticationProvider;
 import org.apache.guacamole.net.auth.Credentials;
@@ -65,12 +64,12 @@
      *     A new DecoratedUserContext which has been decorated by all
      *     AuthenticationProviders.
      *
-     * @throws GuacamoleException
+     * @throws GuacamoleAuthenticationProcessException
      *     If any AuthenticationProvider fails while decorating the UserContext.
      */
     public DecoratedUserContext decorate(UserContext userContext,
             AuthenticatedUser authenticatedUser, Credentials credentials)
-            throws GuacamoleException {
+            throws GuacamoleAuthenticationProcessException {
 
         // Get first AuthenticationProvider in list
         Iterator<AuthenticationProvider> current = authProviders.iterator();
@@ -119,12 +118,12 @@
      *     A new DecoratedUserContext which has been decorated by all
      *     AuthenticationProviders.
      *
-     * @throws GuacamoleException
+     * @throws GuacamoleAuthenticationProcessException
      *     If any AuthenticationProvider fails while decorating the UserContext.
      */
     public DecoratedUserContext redecorate(DecoratedUserContext decorated,
             UserContext userContext, AuthenticatedUser authenticatedUser,
-            Credentials credentials) throws GuacamoleException {
+            Credentials credentials) throws GuacamoleAuthenticationProcessException {
 
         // If the given DecoratedUserContext contains further decorated layers,
         // redecorate those first
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/auth/GuacamoleAuthenticationProcessException.java b/guacamole/src/main/java/org/apache/guacamole/rest/auth/GuacamoleAuthenticationProcessException.java
new file mode 100644
index 0000000..bec73f0
--- /dev/null
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/auth/GuacamoleAuthenticationProcessException.java
@@ -0,0 +1,196 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.rest.auth;
+
+import java.io.Serializable;
+import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.GuacamoleServerException;
+import org.apache.guacamole.net.auth.AuthenticationProvider;
+import org.apache.guacamole.net.auth.credentials.CredentialsInfo;
+import org.apache.guacamole.net.auth.credentials.GuacamoleInvalidCredentialsException;
+import org.apache.guacamole.protocol.GuacamoleStatus;
+
+/**
+ * An exception that occurs during Guacamole's authentication and authorization
+ * process, possibly associated with a specific AuthenticationProvider.
+ */
+public class GuacamoleAuthenticationProcessException extends GuacamoleException {
+
+    /**
+     * Internal identifier unique to this version of
+     * GuacamoleAuthenticationProcessException, as required by Java's
+     * {@link Serializable} interface.
+     */
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * The AuthenticationProvider that caused the failure, or null if there is
+     * no such specific AuthenticationProvider involved in this failure.
+     */
+    private final transient AuthenticationProvider authProvider;
+
+    /**
+     * A GuacamoleException representation of the failure that occurred. If
+     * the cause provided when this GuacamoleAuthenticationProcessException
+     * was created was a GuacamoleException, this will just be that exception.
+     * Otherwise, this will be a GuacamoleServerException wrapping the cause
+     * or a generic GuacamoleInvalidCredentialsException requesting a
+     * username/password if there is no specific cause at all.
+     */
+    private final GuacamoleException guacCause;
+
+    /**
+     * Converts the given Throwable to a GuacamoleException representing the
+     * failure that occurred. If the Throwable already is a GuacamoleException,
+     * this will just be that Throwable. For all other cases, a new
+     * GuacamoleException will be created that best represents the provided
+     * failure. If no failure is provided at all, a generic
+     * GuacamoleInvalidCredentialsException requesting a username/password is
+     * created.
+     *
+     * @param message
+     *     A human-readable message describing the failure that occurred.
+     *
+     * @param cause
+     *     The Throwable cause of the failure that occurred, if any, or null if
+     *     the cause is not known to be a specific Throwable.
+     *
+     * @return
+     *     A GuacamoleException representation of the message and cause
+     *     provided.
+     */
+    private static GuacamoleException toGuacamoleException(String message,
+            Throwable cause) {
+
+        // Create generic invalid username/password exception if we have no
+        // specific cause
+        if (cause == null)
+            return new GuacamoleInvalidCredentialsException(
+                "Permission Denied.",
+                CredentialsInfo.USERNAME_PASSWORD
+            );
+
+        // If the specific cause is already a GuacamoleException, there's
+        // nothing for us to do here
+        if (cause instanceof GuacamoleException)
+            return (GuacamoleException) cause;
+
+        // Wrap all other Throwables as generic internal errors
+        return new GuacamoleServerException(message, cause);
+
+    }
+
+    /**
+     * Creates a new GuacamoleAuthenticationProcessException with the given
+     * message, associated AuthenticationProvider, and cause.
+     *
+     * @param message
+     *     A human readable description of the exception that occurred.
+     *
+     * @param authProvider
+     *     The AuthenticationProvider that caused the failure, or null if there
+     *     is no such specific AuthenticationProvider involved in this failure.
+     *
+     * @param cause
+     *     The cause of this exception, or null if the cause is unknown or
+     *     there is no such cause.
+     */
+    public GuacamoleAuthenticationProcessException(String message,
+            AuthenticationProvider authProvider, Throwable cause) {
+        super(message, cause);
+        this.authProvider = authProvider;
+        this.guacCause = toGuacamoleException(message, cause);
+    }
+
+    /**
+     * Returns the AuthenticationProvider that caused the failure, if any. If
+     * there is no specific AuthenticationProvider involved in this failure,
+     * including if the failure is due to multiple AuthenticationProviders,
+     * this will be null.
+     *
+     * @return
+     *     The AuthenticationProvider that caused the failure, or null if there
+     *     is no such specific AuthenticationProvider involved in this failure.
+     */
+    public AuthenticationProvider getAuthenticationProvider() {
+        return authProvider;
+    }
+
+    /**
+     * Returns a GuacamoleException that represents the user-facing cause of
+     * this exception. A GuacamoleException will be returned by this function
+     * in all cases, including if no specific cause was given.
+     *
+     * @return
+     *     A GuacamoleException that represents the user-facing cause of this
+     *     exception.
+     */
+    public GuacamoleException getCauseAsGuacamoleException() {
+        return guacCause;
+    }
+
+    /**
+     * Rethrows the original GuacamoleException wrapped within this
+     * GuacamoleAuthenticationProcessException. If there is no such exception,
+     * and the cause of this failure is an unchecked RuntimeException or Error,
+     * that unchecked exception/error is rethrown as-is.
+     *
+     * @throws GuacamoleException
+     *     If the underlying cause of this exception is a checked
+     *     GuacamoleException subclass.
+     *
+     * @throws RuntimeException
+     *     If the underlying cause of this exception is an unchecked
+     *     RuntimeException.
+     *
+     * @throws Error
+     *     If the underlying cause of this exception is an unchecked Error.
+     */
+    public void rethrowCause() throws GuacamoleException, RuntimeException, Error {
+
+        // Rethrow any unchecked exceptions/errors as-is
+        Throwable cause = getCause();
+        if (cause instanceof RuntimeException)
+            throw (RuntimeException) cause;
+        if (cause instanceof Error)
+            throw (Error) cause;
+
+        // Pass through all other exceptions as normal GuacamoleException
+        // subclassses
+        throw getCauseAsGuacamoleException();
+
+    }
+
+    @Override
+    public GuacamoleStatus getStatus() {
+        return getCauseAsGuacamoleException().getStatus();
+    }
+
+    @Override
+    public int getHttpStatusCode() {
+        return getCauseAsGuacamoleException().getHttpStatusCode();
+    }
+
+    @Override
+    public int getWebSocketCode() {
+        return getCauseAsGuacamoleException().getWebSocketCode();
+    }
+
+}
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/auth/HashTokenSessionMap.java b/guacamole/src/main/java/org/apache/guacamole/rest/auth/HashTokenSessionMap.java
index a344ab9..cabbb78 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/auth/HashTokenSessionMap.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/auth/HashTokenSessionMap.java
@@ -94,7 +94,8 @@
 
     /**
      * Task which iterates through all active sessions, evicting those sessions
-     * which are beyond the session timeout.
+     * which are beyond the session timeout, or are marked as invalid by an
+     * extension.
      */
     private class SessionEvictionTask implements Runnable {
 
@@ -105,7 +106,8 @@
 
         /**
          * Creates a new task which automatically evicts sessions which are
-         * older than the specified timeout.
+         * older than the specified timeout, or are marked as invalid by an
+         * extension.
          * 
          * @param sessionTimeout The maximum age of any session, in
          *                       milliseconds.
@@ -116,16 +118,16 @@
 
         /**
          * Iterates through all active sessions, evicting those sessions which
-         * are beyond the session timeout. Internal errors which would
-         * otherwise stop the session eviction process are caught, logged, and
-         * the process is allowed to proceed.
+         * are beyond the session timeout, or are marked as invalid. Internal
+         * errors which would otherwise stop the session eviction process are
+         * caught, logged, and the process is allowed to proceed.
          */
-        private void evictExpiredSessions() {
+        private void evictExpiredOrInvalidSessions() {
 
             // Get start time of session check time
             long sessionCheckStart = System.currentTimeMillis();
 
-            logger.debug("Checking for expired sessions...");
+            logger.debug("Checking for expired or invalid sessions...");
 
             // For each session, remove sesions which have expired
             Iterator<Map.Entry<String, GuacamoleSession>> entries = sessionMap.entrySet().iterator();
@@ -136,6 +138,15 @@
 
                 try {
 
+                    // Invalidate any sessions which have been flagged as invalid by extensions
+                    if (!session.isValid()) {
+                        logger.debug(
+                                "Session \"{}\" has been invalidated by an extension.",
+                                entry.getKey());
+                        entries.remove();
+                        session.invalidate();
+                    }
+
                     // Do not expire sessions which are active
                     if (session.hasTunnels())
                         continue;
@@ -170,13 +181,13 @@
         @Override
         public void run() {
 
-            // The evictExpiredSessions() function should already
+            // The evictExpiredOrInvalidSessions() function should already
             // automatically handle and log all unexpected internal errors,
             // but wrap the entire call in a try/catch plus additional logging
             // to ensure that absolutely no errors can result in the entire
             // thread dying
             try {
-                evictExpiredSessions();
+                evictExpiredOrInvalidSessions();
             }
             catch (Throwable t) {
                 logger.error("An unexpected internal error prevented the "
@@ -197,12 +208,12 @@
         if (authToken == null)
             return null;
 
-        // Update the last access time and return the GuacamoleSession
-        GuacamoleSession session = sessionMap.get(authToken);
-        if (session != null)
-            session.access();
-
-        return session;
+        // Return the GuacamoleSession having the given auth token (NOTE: We
+        // do not update the access time here, as it is necessary to be able
+        // to retrieve and check the session without causing that session to
+        // be marked as active. Instead, those updates occur as needed when
+        // functions within the GuacamoleSession are invoked.)
+        return sessionMap.get(authToken);
 
     }
 
@@ -225,7 +236,13 @@
 
     @Override
     public void shutdown() {
+
+        // Terminate the automatic session invalidation thread
         executor.shutdownNow();
+
+        // Forcibly invalidate any remaining sessions
+        sessionMap.values().stream().forEach(GuacamoleSession::invalidate);
+
     }
 
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionDirectoryResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionDirectoryResource.java
index 88408a7..612e055 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionDirectoryResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionDirectoryResource.java
@@ -25,6 +25,7 @@
 import javax.ws.rs.Produces;
 import javax.ws.rs.core.MediaType;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.Connection;
 import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.Permissions;
@@ -47,6 +48,9 @@
      * Creates a new ConnectionDirectoryResource which exposes the operations
      * and subresources available for the given Connection Directory.
      *
+     * @param authenticatedUser
+     *     The user that is accessing this resource.
+     *
      * @param userContext
      *     The UserContext associated with the given Directory.
      *
@@ -62,11 +66,11 @@
      *     representing Connections.
      */
     @AssistedInject
-    public ConnectionDirectoryResource(@Assisted UserContext userContext,
-            @Assisted Directory<Connection> directory,
+    public ConnectionDirectoryResource(@Assisted AuthenticatedUser authenticatedUser,
+            @Assisted UserContext userContext, @Assisted Directory<Connection> directory,
             DirectoryObjectTranslator<Connection, APIConnection> translator,
             DirectoryObjectResourceFactory<Connection, APIConnection> resourceFactory) {
-        super(userContext, directory, translator, resourceFactory);
+        super(authenticatedUser, userContext, Connection.class, directory, translator, resourceFactory);
     }
 
     @Override
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionResource.java
index 15586e1..484543b 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionResource.java
@@ -32,6 +32,7 @@
 import org.apache.guacamole.GuacamoleSecurityException;
 import org.apache.guacamole.GuacamoleUnsupportedException;
 import org.apache.guacamole.net.auth.ActivityRecordSet;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.Connection;
 import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.Permissions;
@@ -65,17 +66,6 @@
      * Logger for this class.
      */
     private static final Logger logger = LoggerFactory.getLogger(ConnectionResource.class);
-    
-    /**
-     * The UserContext associated with the Directory which contains the
-     * Connection exposed by this resource.
-     */
-    private final UserContext userContext;
-
-    /**
-     * The Connection object represented by this ConnectionResource.
-     */
-    private final Connection connection;
 
     /**
      * A factory which can be used to create instances of resources representing
@@ -89,6 +79,9 @@
      * Creates a new ConnectionResource which exposes the operations and
      * subresources available for the given Connection.
      *
+     * @param authenticatedUser
+     *     The user that is accessing this resource.
+     *
      * @param userContext
      *     The UserContext associated with the given Directory.
      *
@@ -103,13 +96,12 @@
      *     object given.
      */
     @AssistedInject
-    public ConnectionResource(@Assisted UserContext userContext,
+    public ConnectionResource(@Assisted AuthenticatedUser authenticatedUser,
+            @Assisted UserContext userContext,
             @Assisted Directory<Connection> directory,
             @Assisted Connection connection,
             DirectoryObjectTranslator<Connection, APIConnection> translator) {
-        super(userContext, directory, connection, translator);
-        this.userContext = userContext;
-        this.connection = connection;
+        super(authenticatedUser, userContext, Connection.class, directory, connection, translator);
     }
 
     /**
@@ -126,8 +118,10 @@
     public Map<String, String> getConnectionParameters()
             throws GuacamoleException {
 
+        Connection connection = getInternalObject();
+
         // Pull effective permissions
-        Permissions effective = userContext.self().getEffectivePermissions();
+        Permissions effective = getUserContext().self().getEffectivePermissions();
 
         // Retrieve permission sets
         SystemPermissionSet systemPermissions = effective.getSystemPermissions();
@@ -162,6 +156,8 @@
     public ConnectionHistoryResource getConnectionHistory()
             throws GuacamoleException {
 
+        Connection connection = getInternalObject();
+
         // Try the current getConnectionHistory() method, first, for connection history.
         try {
             return new ConnectionHistoryResource(connection.getConnectionHistory()
@@ -201,6 +197,9 @@
     public DirectoryResource<SharingProfile, APISharingProfile>
             getSharingProfileDirectoryResource() throws GuacamoleException {
 
+        UserContext userContext = getUserContext();
+        Connection connection = getInternalObject();
+                
         // Produce subset of all SharingProfiles, containing only those which
         // are associated with this connection
         Directory<SharingProfile> sharingProfiles = new DirectoryView<>(
@@ -209,7 +208,7 @@
         );
 
         // Return a new resource which provides access to only those SharingProfiles
-        return sharingProfileDirectoryResourceFactory.create(userContext, sharingProfiles);
+        return sharingProfileDirectoryResourceFactory.create(getAuthenticatedUser(), userContext, sharingProfiles);
 
     }
 
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupDirectoryResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupDirectoryResource.java
index 2be3a88..adf4278 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupDirectoryResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupDirectoryResource.java
@@ -25,6 +25,7 @@
 import javax.ws.rs.Produces;
 import javax.ws.rs.core.MediaType;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.ConnectionGroup;
 import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.Permissions;
@@ -45,27 +46,13 @@
         extends DirectoryResource<ConnectionGroup, APIConnectionGroup> {
 
     /**
-     * The UserContext associated with the Directory which contains the
-     * ConnectionGroup exposed by this resource.
-     */
-    private final UserContext userContext;
-
-    /**
-     * The Directory exposed by this resource.
-     */
-    private final Directory<ConnectionGroup> directory;
-
-    /**
-     * A factory which can be used to create instances of resources representing
-     * ConnectionGroups.
-     */
-    private final DirectoryObjectResourceFactory<ConnectionGroup, APIConnectionGroup> resourceFactory;
-
-    /**
      * Creates a new ConnectionGroupDirectoryResource which exposes the
      * operations and subresources available for the given ConnectionGroup
      * Directory.
      *
+     * @param authenticatedUser
+     *     The user that is accessing this resource.
+     *
      * @param userContext
      *     The UserContext associated with the given Directory.
      *
@@ -81,23 +68,24 @@
      *     representing ConnectionGroups.
      */
     @AssistedInject
-    public ConnectionGroupDirectoryResource(@Assisted UserContext userContext,
+    public ConnectionGroupDirectoryResource(
+            @Assisted AuthenticatedUser authenticatedUser,
+            @Assisted UserContext userContext,
             @Assisted Directory<ConnectionGroup> directory,
             DirectoryObjectTranslator<ConnectionGroup, APIConnectionGroup> translator,
             DirectoryObjectResourceFactory<ConnectionGroup, APIConnectionGroup> resourceFactory) {
-        super(userContext, directory, translator, resourceFactory);
-        this.userContext = userContext;
-        this.directory = directory;
-        this.resourceFactory = resourceFactory;
+        super(authenticatedUser, userContext, ConnectionGroup.class, directory, translator, resourceFactory);
     }
 
     @Override
     public DirectoryObjectResource<ConnectionGroup, APIConnectionGroup>
         getObjectResource(String identifier) throws GuacamoleException {
 
+        UserContext userContext = getUserContext();
+
         // Use root group if identifier is the standard root identifier
         if (identifier != null && identifier.equals(APIConnectionGroup.ROOT_IDENTIFIER))
-            return resourceFactory.create(userContext, directory,
+            return getResourceFactory().create(getAuthenticatedUser(), userContext, getDirectory(),
                     userContext.getRootConnectionGroup());
 
         return super.getObjectResource(identifier);
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupResource.java
index f91b8eb..22f41b0 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupResource.java
@@ -29,6 +29,7 @@
 import javax.ws.rs.QueryParam;
 import javax.ws.rs.core.MediaType;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.ConnectionGroup;
 import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.UserContext;
@@ -46,20 +47,12 @@
         extends DirectoryObjectResource<ConnectionGroup, APIConnectionGroup> {
 
     /**
-     * The UserContext associated with the Directory which contains the
-     * ConnectionGroup exposed by this resource.
-     */
-    private final UserContext userContext;
-
-    /**
-     * The ConnectionGroup object represented by this ConnectionGroupResource.
-     */
-    private final ConnectionGroup connectionGroup;
-
-    /**
      * Creates a new ConnectionGroupResource which exposes the operations and
      * subresources available for the given ConnectionGroup.
      *
+     * @param authenticatedUser
+     *     The user that is accessing this resource.
+     *
      * @param userContext
      *     The UserContext associated with the given Directory.
      *
@@ -75,13 +68,13 @@
      *     object given.
      */
     @AssistedInject
-    public ConnectionGroupResource(@Assisted UserContext userContext,
+    public ConnectionGroupResource(
+            @Assisted AuthenticatedUser authenticatedUser,
+            @Assisted UserContext userContext,
             @Assisted Directory<ConnectionGroup> directory,
             @Assisted ConnectionGroup connectionGroup,
             DirectoryObjectTranslator<ConnectionGroup, APIConnectionGroup> translator) {
-        super(userContext, directory, connectionGroup, translator);
-        this.userContext = userContext;
-        this.connectionGroup = connectionGroup;
+        super(authenticatedUser, userContext, ConnectionGroup.class, directory, connectionGroup, translator);
     }
 
     /**
@@ -107,8 +100,8 @@
             throws GuacamoleException {
 
         // Retrieve the requested tree, filtering by the given permissions
-        ConnectionGroupTree tree = new ConnectionGroupTree(userContext,
-                connectionGroup, permissions);
+        ConnectionGroupTree tree = new ConnectionGroupTree(getUserContext(),
+                getInternalObject(), permissions);
 
         // Return tree as a connection group
         return tree.getRootAPIConnectionGroup();
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryObjectResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryObjectResource.java
index 7b354a2..8ed36fe 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryObjectResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryObjectResource.java
@@ -19,6 +19,7 @@
 
 package org.apache.guacamole.rest.directory;
 
+import javax.inject.Inject;
 import javax.ws.rs.Consumes;
 import javax.ws.rs.DELETE;
 import javax.ws.rs.GET;
@@ -27,9 +28,15 @@
 import javax.ws.rs.core.MediaType;
 import org.apache.guacamole.GuacamoleClientException;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
+import org.apache.guacamole.net.auth.AuthenticationProvider;
 import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.Identifiable;
 import org.apache.guacamole.net.auth.UserContext;
+import org.apache.guacamole.net.event.DirectoryEvent;
+import org.apache.guacamole.net.event.DirectoryFailureEvent;
+import org.apache.guacamole.net.event.DirectorySuccessEvent;
+import org.apache.guacamole.rest.event.ListenerService;
 
 /**
  * A REST resource which abstracts the operations available on an existing
@@ -52,12 +59,22 @@
 public abstract class DirectoryObjectResource<InternalType extends Identifiable, ExternalType> {
 
     /**
+     * The user that is accessing this resource.
+     */
+    private final AuthenticatedUser authenticatedUser;
+    
+    /**
      * The UserContext associated with the Directory containing the object
      * represented by this DirectoryObjectResource.
      */
     private final UserContext userContext;
 
     /**
+     * The type of object represented by this DirectoryObjectResource.
+     */
+    private final Class<InternalType> internalType;
+    
+    /**
      * The Directory which contains the object represented by this
      * DirectoryObjectResource.
      */
@@ -75,12 +92,25 @@
     private final DirectoryObjectTranslator<InternalType, ExternalType> translator;
 
     /**
+     * Service for dispatching events to registered listeners.
+     */
+    @Inject
+    private ListenerService listenerService;
+
+    /**
      * Creates a new DirectoryObjectResource which exposes the operations
      * available for the given object.
      *
+     * @param authenticatedUser
+     *     The user that is accessing this resource.
+     *
      * @param userContext
      *     The UserContext associated with the given Directory.
      *
+     * @param internalType
+     *     The type of object that this DirectoryObjectResource should
+     *     represent.
+     * 
      * @param directory
      *     The Directory which contains the given object.
      *
@@ -91,16 +121,163 @@
      *     A DirectoryObjectTranslator implementation which handles the type of
      *     object given.
      */
-    public DirectoryObjectResource(UserContext userContext,
+    public DirectoryObjectResource(AuthenticatedUser authenticatedUser,
+            UserContext userContext, Class<InternalType> internalType,
             Directory<InternalType> directory, InternalType object,
             DirectoryObjectTranslator<InternalType, ExternalType> translator) {
+        this.authenticatedUser = authenticatedUser;
         this.userContext = userContext;
         this.directory = directory;
+        this.internalType = internalType;
         this.object = object;
         this.translator = translator;
     }
 
     /**
+     * Notifies all registered listeners that the given operation has succeeded
+     * against the object represented by this resource.
+     * 
+     * @param operation
+     *     The operation that was performed.
+     *
+     * @throws GuacamoleException
+     *     If a listener throws a GuacamoleException from its event handler.
+     */
+    protected void fireDirectorySuccessEvent(DirectoryEvent.Operation operation)
+            throws GuacamoleException {
+        listenerService.handleEvent(new DirectorySuccessEvent<InternalType>() {
+
+            @Override
+            public Directory.Type getDirectoryType() {
+                return Directory.Type.of(internalType);
+            }
+
+            @Override
+            public DirectoryEvent.Operation getOperation() {
+                return operation;
+            }
+
+            @Override
+            public String getObjectIdentifier() {
+                return object.getIdentifier();
+            }
+
+            @Override
+            public InternalType getObject() {
+                return object;
+            }
+
+            @Override
+            public AuthenticatedUser getAuthenticatedUser() {
+                return authenticatedUser;
+            }
+
+            @Override
+            public AuthenticationProvider getAuthenticationProvider() {
+                return userContext.getAuthenticationProvider();
+            }
+
+        });
+    }
+
+    /**
+     * Notifies all registered listeners that the given operation has failed
+     * against the object represented by this resource.
+     *
+     * @param operation
+     *     The operation that failed.
+     *
+     * @param failure
+     *     The failure that occurred.
+     *
+     * @throws GuacamoleException
+     *     If a listener throws a GuacamoleException from its event handler.
+     */
+    protected void fireDirectoryFailureEvent(DirectoryEvent.Operation operation,
+            Throwable failure) throws GuacamoleException {
+        listenerService.handleEvent(new DirectoryFailureEvent<InternalType>() {
+
+            @Override
+            public Directory.Type getDirectoryType() {
+                return Directory.Type.of(internalType);
+            }
+
+            @Override
+            public DirectoryEvent.Operation getOperation() {
+                return operation;
+            }
+
+            @Override
+            public String getObjectIdentifier() {
+                return object.getIdentifier();
+            }
+
+            @Override
+            public InternalType getObject() {
+                return object;
+            }
+
+            @Override
+            public AuthenticatedUser getAuthenticatedUser() {
+                return authenticatedUser;
+            }
+
+            @Override
+            public AuthenticationProvider getAuthenticationProvider() {
+                return userContext.getAuthenticationProvider();
+            }
+
+            @Override
+            public Throwable getFailure() {
+                return failure;
+            }
+
+        });
+    }
+
+    /**
+     * Returns the user accessing this resource.
+     *
+     * @return
+     *     The user accessing this resource.
+     */
+    protected AuthenticatedUser getAuthenticatedUser() {
+        return authenticatedUser;
+    }
+
+    /**
+     * Returns the UserContext providing the Directory that contains the object
+     * exposed by this resource.
+     *
+     * @return 
+     *     The UserContext providing the Directory that contains the object
+     *     exposed by this resource.
+     */
+    protected UserContext getUserContext() {
+        return userContext;
+    }
+
+    /**
+     * Returns the Directory containing the object exposed by this resource.
+     *
+     * @return 
+     *     The Directory containing the object exposed by this resource.
+     */
+    protected Directory<InternalType> getDirectory() {
+        return directory;
+    }
+
+    /**
+     * Returns the object exposed by this resource.
+     *
+     * @return
+     *     The object exposed by this resource.
+     */
+    protected InternalType getInternalObject() {
+        return object;
+    }
+
+    /**
      * Returns the object represented by this DirectoryObjectResource, in a
      * format intended for interchange.
      *
@@ -138,7 +315,15 @@
 
         // Perform update
         translator.applyExternalChanges(object, modifiedObject);
-        directory.update(object);
+
+        try {
+            directory.update(object);
+            fireDirectorySuccessEvent(DirectoryEvent.Operation.UPDATE);
+        }
+        catch (GuacamoleException | RuntimeException | Error e) {
+            fireDirectoryFailureEvent(DirectoryEvent.Operation.UPDATE, e);
+            throw e;
+        }
 
     }
 
@@ -150,7 +335,14 @@
      */
     @DELETE
     public void deleteObject() throws GuacamoleException {
-        directory.remove(object.getIdentifier());
+        try {
+            directory.remove(object.getIdentifier());
+            fireDirectorySuccessEvent(DirectoryEvent.Operation.REMOVE);
+        }
+        catch (GuacamoleException | RuntimeException | Error e) {
+            fireDirectoryFailureEvent(DirectoryEvent.Operation.REMOVE, e);
+            throw e;
+        }
     }
 
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryObjectResourceFactory.java b/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryObjectResourceFactory.java
index 613ac42..423cb74 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryObjectResourceFactory.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryObjectResourceFactory.java
@@ -19,6 +19,7 @@
 
 package org.apache.guacamole.rest.directory;
 
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.Identifiable;
 import org.apache.guacamole.net.auth.UserContext;
@@ -41,6 +42,9 @@
     /**
      * Creates a new DirectoryObjectResource which exposes the given object.
      *
+     * @param authenticatedUser
+     *     The user that is accessing the resource.
+     *
      * @param userContext
      *     The UserContext which contains the given Directory.
      *
@@ -55,7 +59,7 @@
      *     A new DirectoryObjectResource which exposes the given object.
      */
     DirectoryObjectResource<InternalType, ExternalType>
-        create(UserContext userContext, Directory<InternalType> directory,
-                InternalType object);
+        create(AuthenticatedUser authenticatedUser, UserContext userContext,
+                Directory<InternalType> directory, InternalType object);
 
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryResource.java
index e9b4b22..7b83424 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryResource.java
@@ -19,10 +19,16 @@
 
 package org.apache.guacamole.rest.directory;
 
+import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.inject.Inject;
 import javax.ws.rs.Consumes;
 import javax.ws.rs.GET;
 import javax.ws.rs.PATCH;
@@ -36,6 +42,11 @@
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleResourceNotFoundException;
 import org.apache.guacamole.GuacamoleUnsupportedException;
+import org.apache.guacamole.language.Translatable;
+import org.apache.guacamole.language.TranslatableMessage;
+import org.apache.guacamole.net.auth.AtomicDirectoryOperation;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
+import org.apache.guacamole.net.auth.AuthenticationProvider;
 import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.Identifiable;
 import org.apache.guacamole.net.auth.Permissions;
@@ -44,7 +55,16 @@
 import org.apache.guacamole.net.auth.permission.ObjectPermissionSet;
 import org.apache.guacamole.net.auth.permission.SystemPermission;
 import org.apache.guacamole.net.auth.permission.SystemPermissionSet;
-import org.apache.guacamole.rest.APIPatch;
+import org.apache.guacamole.net.event.DirectoryEvent;
+import org.apache.guacamole.net.event.DirectoryFailureEvent;
+import org.apache.guacamole.net.event.DirectorySuccessEvent;
+import org.apache.guacamole.rest.APIError;
+import org.apache.guacamole.rest.event.ListenerService;
+import org.apache.guacamole.rest.jsonpatch.APIPatch;
+import org.apache.guacamole.rest.jsonpatch.APIPatchError;
+import org.apache.guacamole.rest.jsonpatch.APIPatchFailureException;
+import org.apache.guacamole.rest.jsonpatch.APIPatchOutcome;
+import org.apache.guacamole.rest.jsonpatch.APIPatchResponse;
 
 /**
  * A REST resource which abstracts the operations available on all Guacamole
@@ -69,12 +89,23 @@
 public abstract class DirectoryResource<InternalType extends Identifiable, ExternalType> {
 
     /**
+     * The user that is accessing this resource.
+     */
+    private final AuthenticatedUser authenticatedUser;
+    
+    /**
      * The UserContext associated with the Directory being exposed by this
      * DirectoryResource.
      */
     private final UserContext userContext;
 
     /**
+     * The type of object contained within the Directory exposed by this
+     * DirectoryResource.
+     */
+    private final Class<InternalType> internalType;
+
+    /**
      * The Directory being exposed by this DirectoryResource.
      */
     private final Directory<InternalType> directory;
@@ -93,12 +124,24 @@
     private final DirectoryObjectResourceFactory<InternalType, ExternalType> resourceFactory;
 
     /**
+     * Service for dispatching events to registered listeners.
+     */
+    @Inject
+    private ListenerService listenerService;
+
+    /**
      * Creates a new DirectoryResource which exposes the operations available
      * for the given Directory.
      *
+     * @param authenticatedUser
+     *     The user that is accessing this resource.
+     *
      * @param userContext
      *     The UserContext associated with the given Directory.
      *
+     * @param internalType
+     *     The type of object contained within the given Directory.
+     *
      * @param directory
      *     The Directory being exposed by this DirectoryResource.
      *
@@ -110,11 +153,15 @@
      *     A factory which can be used to create instances of resources
      *     representing individual objects contained within the given Directory.
      */
-    public DirectoryResource(UserContext userContext, Directory<InternalType> directory,
+    public DirectoryResource(AuthenticatedUser authenticatedUser,
+            UserContext userContext, Class<InternalType> internalType,
+            Directory<InternalType> directory,
             DirectoryObjectTranslator<InternalType, ExternalType> translator,
             DirectoryObjectResourceFactory<InternalType, ExternalType> resourceFactory) {
+        this.authenticatedUser = authenticatedUser;
         this.userContext = userContext;
         this.directory = directory;
+        this.internalType = internalType;
         this.translator = translator;
         this.resourceFactory = resourceFactory;
     }
@@ -140,6 +187,198 @@
             Permissions permissions) throws GuacamoleException;
 
     /**
+     * Notifies all registered listeners that the given operation has succeeded
+     * against the object having the given identifier within the Directory
+     * represented by this resource.
+     * 
+     * @param operation
+     *     The operation that was performed.
+     *
+     * @param identifier
+     *     The identifier of the object affected by the operation.
+     *
+     * @param object
+     *     The specific object affected by the operation, if available. If not
+     *     available, this may be null.
+     *
+     * @throws GuacamoleException
+     *     If a listener throws a GuacamoleException from its event handler.
+     */
+    protected void fireDirectorySuccessEvent(DirectoryEvent.Operation operation,
+                String identifier, InternalType object)
+            throws GuacamoleException {
+        listenerService.handleEvent(new DirectorySuccessEvent<InternalType>() {
+
+            @Override
+            public Directory.Type getDirectoryType() {
+                return Directory.Type.of(internalType);
+            }
+
+            @Override
+            public DirectoryEvent.Operation getOperation() {
+                return operation;
+            }
+
+            @Override
+            public String getObjectIdentifier() {
+                return identifier;
+            }
+
+            @Override
+            public InternalType getObject() {
+                return object;
+            }
+
+            @Override
+            public AuthenticatedUser getAuthenticatedUser() {
+                return authenticatedUser;
+            }
+
+            @Override
+            public AuthenticationProvider getAuthenticationProvider() {
+                return userContext.getAuthenticationProvider();
+            }
+
+        });
+    }
+
+    /**
+     * Notifies all registered listeners that the given operation has failed
+     * against the object having the given identifier within the Directory
+     * represented by this resource.
+     *
+     * @param operation
+     *     The operation that failed.
+     *
+     * @param identifier
+     *     The identifier of the object that would have been affected by the
+     *     operation had it succeeded.
+     *
+     * @param object
+     *     The specific object would have been affected by the operation, if
+     *     available, had it succeeded, including any changes that were
+     *     intended to be applied to the object. If not available, this may be
+     *     null.
+     *
+     * @param failure
+     *     The failure that occurred.
+     *
+     * @throws GuacamoleException
+     *     If a listener throws a GuacamoleException from its event handler.
+     */
+    protected void fireDirectoryFailureEvent(DirectoryEvent.Operation operation,
+                String identifier, InternalType object, Throwable failure)
+            throws GuacamoleException {
+        listenerService.handleEvent(new DirectoryFailureEvent<InternalType>() {
+
+            @Override
+            public Directory.Type getDirectoryType() {
+                return Directory.Type.of(internalType);
+            }
+
+            @Override
+            public DirectoryEvent.Operation getOperation() {
+                return operation;
+            }
+
+            @Override
+            public String getObjectIdentifier() {
+                return identifier;
+            }
+
+            @Override
+            public InternalType getObject() {
+                return object;
+            }
+
+            @Override
+            public AuthenticatedUser getAuthenticatedUser() {
+                return authenticatedUser;
+            }
+
+            @Override
+            public AuthenticationProvider getAuthenticationProvider() {
+                return userContext.getAuthenticationProvider();
+            }
+
+            @Override
+            public Throwable getFailure() {
+                return failure;
+            }
+
+        });
+    }
+
+    /**
+     * Returns the user accessing this resource.
+     *
+     * @return
+     *     The user accessing this resource.
+     */
+    protected AuthenticatedUser getAuthenticatedUser() {
+        return authenticatedUser;
+    }
+
+    /**
+     * Returns the UserContext providing the Directory exposed by this
+     * resource.
+     *
+     * @return 
+     *     The UserContext providing the Directory exposed by this resource.
+     */
+    protected UserContext getUserContext() {
+        return userContext;
+    }
+
+    /**
+     * Returns the Directory exposed by this resource.
+     *
+     * @return 
+     *     The Directory exposed by this resource.
+     */
+    protected Directory<InternalType> getDirectory() {
+        return directory;
+    }
+
+    /**
+     * Returns a factory that can be used to create instances of resources
+     * representing individual objects contained within the Directory exposed
+     * by this resource.
+     *
+     * @return 
+     *     A factory that can be used to create instances of resources
+     *     representing individual objects contained within the Directory
+     *     exposed by this resource.
+     */
+    public DirectoryObjectResourceFactory<InternalType, ExternalType> getResourceFactory() {
+        return resourceFactory;
+    }
+
+    /**
+     * Filter and sanitize the provided external object, translate to the
+     * internal type, and return the translated internal object.
+     *
+     * @param object
+     *     The external object to filter and translate.
+     *
+     * @return
+     *     The filtered and translated internal object.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while filtering or translating the external
+     *     object.
+     */
+    private InternalType filterAndTranslate(ExternalType object)
+            throws GuacamoleException {
+
+        // Filter and sanitize the external object
+        translator.filterExternalObject(userContext, object);
+
+        // Translate to the internal type
+        return translator.toInternalObject(object);
+    }
+
+    /**
      * Returns a map of all objects available within this DirectoryResource,
      * filtering the returned map by the given permission, if specified.
      *
@@ -183,39 +422,380 @@
     }
 
     /**
+     * Retrieve and return the object having the given identifier from the
+     * directory, throwing a GuacamoleResourceNotFoundException and firing a
+     * directory GET failure event if no object exists with the given identifier
+     * in the directory.
+     *
+     * @param identifier
+     *     The identifier of the object to retrieve from the directory.
+     *
+     * @param directory
+     *     The directory to fetch the object from. If null, the directory
+     *     associated with this DirectoryResource instance will be used.
+     *
+     * @return
+     *     The object from the directory with the provided identifier.
+     *
+     * @throws GuacamoleException
+     *     If no object with the provided identifier exists within the
+     *     directory, or if any other error occurs while attempting to retrieve
+     *     the object.
+     */
+    @Nonnull
+    private InternalType getObjectByIdentifier(
+            String identifier, @Nullable Directory<InternalType> directory)
+            throws GuacamoleException {
+
+        // Use the directory associated with this instance if not otherwise
+        // specified
+        if (directory == null)
+            directory = this.directory;
+
+        // Retrieve the object having the given identifier
+        InternalType object;
+        try {
+            object = directory.get(identifier);
+            if (object == null)
+                throw new GuacamoleResourceNotFoundException(
+                        "Not found: \"" + identifier + "\"");
+        }
+        catch (GuacamoleException | RuntimeException | Error e) {
+            fireDirectoryFailureEvent(
+                    DirectoryEvent.Operation.GET, identifier, null, e);
+            throw e;
+        }
+
+        // Return the object; it is guaranteed to be non-null at this point
+        return object;
+    }
+
+    /**
+     * If the provided throwable is a known Guacamole-specific type, create and
+     * return a APIPatchError with an error message extracted from the error.
+     * If the provided throwable is not a known type, null will be returned.
+     *
+     * @param op
+     *     The operation being attempted when the error occurred.
+     *
+     * @param identifier
+     *     The identifier of the object in question, if any.
+     *
+     * @param path
+     *     The path for the patch that was being applied when the error occurred.
+     *
+     * @param t
+     *     The error that occurred while attempting to apply the patch.
+     *
+     * @return
+     *     A APIPatchError with an error message extracted from the provided
+     *     throwable - if it's a known type, otherwise null.
+     */
+    @Nullable
+    private APIPatchError createPatchFailure(
+            @Nonnull APIPatch.Operation op, @Nullable String identifier,
+            @Nonnull String path, @Nonnull Throwable t) {
+
+        /*
+         * If the failure is a translatable type, use the translation directly
+         * in the patch error.
+         */
+        if (t instanceof Translatable)
+            return new APIPatchError(
+                op, identifier, path,
+                ((Translatable) t).getTranslatableMessage());
+
+        /*
+         * If the failure represents a known Guacamole exception but is not
+         * translateable, create a patch error containing the raw untranslated
+         * exception message.
+         */
+        if (t instanceof GuacamoleException) {
+
+            // Create a translated message that will fall
+            // through to the untranslated message
+            TranslatableMessage message = new TranslatableMessage(
+                    "APP.TEXT_UNTRANSLATED", Collections.singletonMap(
+                            "MESSAGE", ((GuacamoleException) t).getMessage()));
+
+            return new APIPatchError(op, identifier, path, message);
+        }
+
+        // The error is not a known type - no patch error can be generated
+        return null;
+    }
+
+    /**
      * Applies the given object patches, updating the underlying directory
-     * accordingly. This operation currently only supports deletion of objects
-     * through the "remove" patch operation. The path of each patch operation is
-     * of the form "/ID" where ID is the identifier of the object being
-     * modified.
+     * accordingly. This operation supports addition, replacement, and removal of
+     * objects through the "add", "replace", or "remove" patch operations. The
+     * path of each patch operation is of the form "/ID" where ID is the 
+     * identifier of the object being modified. In the case of object creation, 
+     * the identifier is ignored, as the identifier will be automatically 
+     * provided. This operation is atomic.
      *
      * @param patches
      *     The patches to apply for this request.
      *
      * @throws GuacamoleException
-     *     If an error occurs while deleting the objects.
+     *     If an error occurs while adding, replacing, or removing objects.
+     *
+     * @return
+     *     A response describing the outcome of each patch. Only the identifier
+     *     of each patched object will be included in the response, not the
+     *     full object.
      */
     @PATCH
-    public void patchObjects(List<APIPatch<String>> patches)
+    public APIPatchResponse patchObjects(List<APIPatch<ExternalType>> patches)
             throws GuacamoleException {
 
-        // Apply each operation specified within the patch
-        for (APIPatch<String> patch : patches) {
+        // An outcome for each patch included in the request. This list
+        // may include both success and failure responses, though the
+        // presence of any failure would indicated that the entire
+        // request has failed and no changes have been made.
+        List<APIPatchOutcome> patchOutcomes = new ArrayList<>();
 
-            // Only remove is supported
-            if (patch.getOp() != APIPatch.Operation.remove)
-                throw new GuacamoleUnsupportedException("Only the \"remove\" "
-                        + "operation is supported.");
+        // Perform all requested operations atomically
+        directory.tryAtomically(new AtomicDirectoryOperation<InternalType>() {
 
-            // Retrieve and validate path
-            String path = patch.getPath();
-            if (!path.startsWith("/"))
-                throw new GuacamoleClientException("Patch paths must start with \"/\".");
+            @Override
+            public void executeOperation(boolean atomic, Directory<InternalType> directory)
+                    throws GuacamoleException {
 
-            // Remove specified object
-            directory.remove(path.substring(1));
+                // If the underlying directory implentation does not support
+                // atomic operations, abort the patch operation. This REST
+                // endpoint requires that operations be performed atomically.
+                if (!atomic)
+                    throw new GuacamoleUnsupportedException(
+                            "The extension providing this directory does not " +
+                            "support Atomic Operations. The patch cannot be " +
+                            "executed.");
 
-        }
+                // Keep a list of all objects that have been successfully
+                // added, replaced, or removed
+                Collection<InternalType> addedObjects = new ArrayList<>();
+                Collection<InternalType> replacedObjects = new ArrayList<>();
+                Collection<String> removedIdentifiers = new ArrayList<>();
+
+                // A list of all responses associated with the successful
+                // creation of new objects
+                List<APIPatchOutcome> creationSuccesses = new ArrayList<>();
+
+                // True if any operation in the patch failed. Any failure will
+                // fail the request, though won't result in immediate stoppage
+                // since more errors may yet be uncovered.
+                boolean failed = false;
+
+                // Apply each operation specified within the patch
+                for (APIPatch<ExternalType> patch : patches) {
+
+                    // Retrieve and validate path
+                    String path = patch.getPath();
+                    if (!path.startsWith("/"))
+                        throw new GuacamoleClientException("Patch paths must start with \"/\".");
+
+                    APIPatch.Operation op = patch.getOp();
+
+                    if (op == APIPatch.Operation.add) {
+
+                        // Filter/sanitize object contents
+                        InternalType internal = filterAndTranslate(patch.getValue());
+
+                        try {
+
+                            // Attempt to add the new object
+                            directory.add(internal);
+
+                            // Add the object to the list if addition was successful
+                            addedObjects.add(internal);
+
+                            // Add a success outcome describing the object creation
+                            APIPatchOutcome response = new APIPatchOutcome(
+                                    op, internal.getIdentifier(), path);
+                            patchOutcomes.add(response);
+                            creationSuccesses.add(response);
+
+                        }
+
+                        catch (GuacamoleException | RuntimeException | Error e) {
+                            failed = true;
+                            fireDirectoryFailureEvent(
+                                    DirectoryEvent.Operation.ADD,
+                                    internal.getIdentifier(), internal, e);
+
+                            // Attempt to generate an API Patch error using the
+                            // caught exception
+                            APIPatchError patchError = createPatchFailure(
+                                    op, null, path, e);
+
+                            if (patchError != null)
+                                patchOutcomes.add(patchError);
+
+                            // If an unexpected failure occurs, fall through to
+                            // the standard API error handling
+                            else
+                                throw e;
+
+                        }
+
+                    }
+
+                    else if (op == APIPatch.Operation.replace) {
+
+                        // The identifier of the object to be replaced
+                        String identifier = path.substring(1);
+
+                        InternalType original = null;
+
+                        try {
+
+                            // Fetch the object to be updated from the atomic
+                            // directory instance. If no object is found, a 
+                            // directory GET failure event will be logged, and
+                            // the update attempt will be aborted.
+                            original = getObjectByIdentifier(identifier, directory);
+                            
+                            // Apply the changes to the original object
+                            translator.applyExternalChanges(
+                                    original, patch.getValue());
+
+                            // Update the directory
+                            directory.update(original);
+
+                            replacedObjects.add(original);
+
+                            // Add a success outcome describing the replacement
+                            APIPatchOutcome response = new APIPatchOutcome(
+                                    op, identifier, path);
+                            patchOutcomes.add(response);
+                            
+                        }
+
+                        catch (GuacamoleException | RuntimeException | Error e) {
+                            failed = true;
+                            fireDirectoryFailureEvent(
+                                    DirectoryEvent.Operation.UPDATE,
+                                    identifier, original, e);
+
+                            // Attempt to generate an API Patch error using the
+                            // caught exception
+                            APIPatchError patchError = createPatchFailure(
+                                    op, identifier, path, e);
+
+                            if (patchError != null)
+                                patchOutcomes.add(patchError);
+
+                            // If an unexpected failure occurs, fall through to
+                            // the standard API error handling
+                            else
+                                throw e;
+
+                        }
+                    }
+
+                    else if (op == APIPatch.Operation.remove) {
+
+                        String identifier = path.substring(1);
+
+                        try {
+
+                            // Attempt to remove the object
+                            directory.remove(identifier);
+
+                            // Add the object to the list if the removal was successful
+                            removedIdentifiers.add(identifier);
+
+                            // Add a success outcome describing the object removal
+                            APIPatchOutcome response = new APIPatchOutcome(
+                                    op, identifier, path);
+                            patchOutcomes.add(response);
+                            creationSuccesses.add(response);
+                        }
+                        catch (GuacamoleException | RuntimeException | Error e) {
+                            failed = true;
+                            fireDirectoryFailureEvent(
+                                    DirectoryEvent.Operation.REMOVE,
+                                    identifier, null, e);
+
+                            // Attempt to generate an API Patch error using the
+                            // caught exception
+                            APIPatchError patchError = createPatchFailure(
+                                    op, identifier, path, e);
+
+                            if (patchError != null)
+                                patchOutcomes.add(patchError);
+
+                            // If an unexpected failure occurs, fall through to
+                            // the standard API error handling
+                            else
+                                throw e;
+                        }
+                    }
+                    
+                    else {
+                        throw new GuacamoleUnsupportedException(
+                                "Unsupported patch operation \"" + op + "\". "
+                                + "Only add, replace, and remove are supported.");
+                    }
+
+
+                }
+
+                // If any operation failed
+                if (failed) {
+
+                    // Any identifiers for objects created during this request
+                    // will no longer be valid, since the creation of those
+                    // objects will be rolled back.
+                    creationSuccesses.forEach(
+                            response -> response.clearIdentifier());
+
+                    // Return an error response, including any failures that
+                    // caused the failure of any patch in the request
+                    throw new APIPatchFailureException(
+                            "The provided patches failed to apply.", patchOutcomes);
+
+                }
+
+                // Fire directory success events for each created object
+                Iterator<InternalType> addedIterator = addedObjects.iterator();
+                while (addedIterator.hasNext()) {
+
+                    InternalType internal = addedIterator.next();
+                    fireDirectorySuccessEvent(
+                            DirectoryEvent.Operation.ADD,
+                            internal.getIdentifier(), internal);
+
+                }
+
+                // Fire directory success events for each updated object
+                Iterator<InternalType> updatedIterator = replacedObjects.iterator();
+                while (updatedIterator.hasNext()) {
+
+                    InternalType internal = updatedIterator.next();
+                    fireDirectorySuccessEvent(
+                            DirectoryEvent.Operation.UPDATE,
+                            internal.getIdentifier(), internal);
+
+                }
+
+                // Fire directory success events for each removed object
+                Iterator<String> removedIterator = removedIdentifiers.iterator();
+                while (removedIterator.hasNext()) {
+
+                    String identifier = removedIterator.next();
+                    fireDirectorySuccessEvent(
+                            DirectoryEvent.Operation.REMOVE,
+                            identifier, null);
+
+                }
+
+            }
+
+        });
+
+        // Return a list of outcomes, one for each patch in the request
+        return new APIPatchResponse(patchOutcomes);
 
     }
 
@@ -243,12 +823,18 @@
             throw new GuacamoleClientException("Data must be submitted when creating objects.");
 
         // Filter/sanitize object contents
-        translator.filterExternalObject(userContext, object);
+        InternalType internal = filterAndTranslate(object);
 
         // Create the new object within the directory
-        directory.add(translator.toInternalObject(object));
-
-        return object;
+        try {
+            directory.add(internal);
+            fireDirectorySuccessEvent(DirectoryEvent.Operation.ADD, internal.getIdentifier(), internal);
+            return object;
+        }
+        catch (GuacamoleException | RuntimeException | Error e) {
+            fireDirectoryFailureEvent(DirectoryEvent.Operation.ADD, internal.getIdentifier(), internal, e);
+            throw e;
+        }
 
     }
 
@@ -271,13 +857,15 @@
         getObjectResource(@PathParam("identifier") String identifier)
             throws GuacamoleException {
 
-        // Retrieve the object having the given identifier
-        InternalType object = directory.get(identifier);
-        if (object == null)
-            throw new GuacamoleResourceNotFoundException("Not found: \"" + identifier + "\"");
+        // Fetch the object to be updated. If no object is found, a directory
+        // GET failure event will be logged. If no exception is thrown, the
+        // object is guaranteed to exist
+        InternalType object = getObjectByIdentifier(identifier, null);
 
         // Return a resource which provides access to the retrieved object
-        return resourceFactory.create(userContext, directory, object);
+        DirectoryObjectResource<InternalType, ExternalType> resource = resourceFactory.create(authenticatedUser, userContext, directory, object);
+        fireDirectorySuccessEvent(DirectoryEvent.Operation.GET, identifier, object);
+        return resource;
 
     }
 
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryResourceFactory.java b/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryResourceFactory.java
index 357777a..c28294b 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryResourceFactory.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryResourceFactory.java
@@ -19,6 +19,7 @@
 
 package org.apache.guacamole.rest.directory;
 
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.Identifiable;
 import org.apache.guacamole.net.auth.UserContext;
@@ -41,6 +42,9 @@
     /**
      * Creates a new DirectoryResource which exposes the given Directory.
      *
+     * @param authenticatedUser
+     *     The user that is accessing the resource.
+     *
      * @param userContext
      *     The UserContext from which the given Directory was obtained.
      *
@@ -52,6 +56,7 @@
      *     A new DirectoryResource which exposes the given Directory.
      */
     DirectoryResource<InternalType, ExternalType>
-        create(UserContext userContext, Directory<InternalType> directory);
+        create(AuthenticatedUser authenticatedUser, UserContext userContext,
+                Directory<InternalType> directory);
 
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/identifier/RelatedObjectSetResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/identifier/RelatedObjectSetResource.java
index 77b0b40..7703be3 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/identifier/RelatedObjectSetResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/identifier/RelatedObjectSetResource.java
@@ -29,7 +29,7 @@
 import org.apache.guacamole.GuacamoleClientException;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.net.auth.RelatedObjectSet;
-import org.apache.guacamole.rest.APIPatch;
+import org.apache.guacamole.rest.jsonpatch.APIPatch;
 
 /**
  * A REST resource which abstracts the operations available on arbitrary sets
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/APIPatch.java b/guacamole/src/main/java/org/apache/guacamole/rest/jsonpatch/APIPatch.java
similarity index 92%
rename from guacamole/src/main/java/org/apache/guacamole/rest/APIPatch.java
rename to guacamole/src/main/java/org/apache/guacamole/rest/jsonpatch/APIPatch.java
index 28ff788..9f92685 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/APIPatch.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/jsonpatch/APIPatch.java
@@ -17,11 +17,11 @@
  * under the License.
  */
 
-package org.apache.guacamole.rest;
+package org.apache.guacamole.rest.jsonpatch;
 
 /**
- * An object for representing the body of a HTTP PATCH method.
- * See https://tools.ietf.org/html/rfc6902
+ * An object for representing an entry within the body of a
+ * JSON PATCH request. See https://tools.ietf.org/html/rfc6902
  *
  * @param <T>
  *     The type of object being patched.
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/jsonpatch/APIPatchError.java b/guacamole/src/main/java/org/apache/guacamole/rest/jsonpatch/APIPatchError.java
new file mode 100644
index 0000000..45dcd5b
--- /dev/null
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/jsonpatch/APIPatchError.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.rest.jsonpatch;
+
+import org.apache.guacamole.language.TranslatableMessage;
+import org.apache.guacamole.rest.jsonpatch.APIPatch.Operation;
+
+/**
+ * A failure outcome associated with a particular patch within a JSON Patch
+ * request. This status indicates that a particular patch failed to apply,
+ * and includes the error describing the failure, along with the operation and
+ * path from the original patch, and the identifier of the object
+ * referenced by the original patch.
+ */
+public class APIPatchError extends APIPatchOutcome {
+
+    /**
+     * The error associated with the submitted patch.
+     */
+    private final TranslatableMessage error;
+
+    /**
+     * Create a failure status associated with a submitted patch from a JSON
+     * patch API request.
+     *
+     * @param op
+     *     The operation requested by the failed patch.
+     *
+     * @param identifier
+     *     The identifier of the object associated with the failed patch. If
+     *     the patch failed to create a new object, this will be null.
+     *
+     * @param path
+     *     The patch from the failed patch.
+     *
+     * @param error
+     *     The error message associated with the failure that prevented the
+     *     patch from applying.
+     */
+    public APIPatchError(
+            Operation op, String identifier, String path,
+            TranslatableMessage error) {
+        super(op, identifier, path);
+        this.error = error;
+    }
+
+    /**
+     * Return the error associated with the patch failure.
+     *
+     * @return
+     *     The error associated with the patch failure.
+     */
+    public TranslatableMessage getError() {
+        return error;
+    }
+}
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/jsonpatch/APIPatchFailureException.java b/guacamole/src/main/java/org/apache/guacamole/rest/jsonpatch/APIPatchFailureException.java
new file mode 100644
index 0000000..e77be6d
--- /dev/null
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/jsonpatch/APIPatchFailureException.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.rest.jsonpatch;
+
+import java.util.List;
+
+import org.apache.guacamole.GuacamoleClientException;
+
+/**
+ * An exception describing a failure to apply the patches from a JSON Patch
+ * request. A list of outcomes is included, one for each patch in the request.
+ */
+public class APIPatchFailureException extends GuacamoleClientException {
+
+    /**
+     * A list of outcomes, each one corresponding to a patch in the request
+     * corresponding to this response. This may include a mix of successes and
+     * failures. Any failure will result in a failure of the entire request
+     * since JSON Patch requests are handled atomically.
+     */
+    public final List<APIPatchOutcome> patches;
+
+    /**
+     * Create a new patch request failure with the provided list of outcomes
+     * for individual patches.
+     *
+     * @param message
+     *     A human-readable message describing the overall request failure.
+     *
+     * @param patches
+     *     A list of patch outcomes, one for each patch in the request
+     *     associated with this response.
+     */
+    public APIPatchFailureException(
+            String message, List<APIPatchOutcome> patches) {
+
+        super(message);
+        this.patches = patches;
+    }
+
+    /**
+     * Return the outcome for each patch in the request corresponding to this
+     * response.
+     */
+    public List<APIPatchOutcome> getPatches() {
+        return patches;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/jsonpatch/APIPatchOutcome.java b/guacamole/src/main/java/org/apache/guacamole/rest/jsonpatch/APIPatchOutcome.java
new file mode 100644
index 0000000..f40d53f
--- /dev/null
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/jsonpatch/APIPatchOutcome.java
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.rest.jsonpatch;
+
+import org.apache.guacamole.rest.jsonpatch.APIPatch.Operation;
+
+/**
+ * A successful outcome associated with a particular patch within a JSON Patch
+ * request. The outcome contains the operation requested by the original patch,
+ * the path from the original patch, and the identifier of the object corresponding
+ * to the value from the original patch.
+ *
+ * The purpose of this class is to present a relatively lightweight outcome for
+ * the user who submitted the Patch request. Rather than including the full
+ * contents of the value, only the identifier is included, allowing the user to
+ * determine the identifier of any newly-created objects as part of the request.
+ */
+public class APIPatchOutcome {
+
+    /**
+     * The requested operation for the patch corresponding to this outcome.
+     */
+    private final Operation op;
+
+    /**
+     * The identifier for the value in patch corresponding to this outcome.
+     * If the value in the patch was null, this identifier should also be null.
+     */
+    private String identifier;
+
+    /**
+     * The path for the patch corresponding to this outcome.
+     */
+    private final String path;
+
+    /**
+     * Create an outcome associated with a submitted patch, as part of a JSON
+     * patch API request.
+     *
+     * @param op
+     *     The requested operation for the patch corresponding to this outcome.
+     *
+     * @param identifier
+     *     The identifier for the value in patch corresponding to this outcome.
+     *
+     * @param path
+     *     The path for the patch corresponding to this outcome.
+     */
+    public APIPatchOutcome(Operation op, String identifier, String path) {
+        this.op = op;
+        this.identifier = identifier;
+        this.path = path;
+    }
+
+    /**
+     * Clear the identifier associated with this patch outcome. This must
+     * be done when an identifier in a outcome refers to a temporary object
+     * that was rolled back during processing of a request.
+     */
+    public void clearIdentifier() {
+        this.identifier = null;
+    }
+
+    /**
+     * Returns the requested operation for the patch corresponding to this
+     * outcome.
+     *
+     * @return
+     *     The requested operation for the patch corresponding to this outcome.
+     */
+    public Operation getOp() {
+        return op;
+    }
+
+    /**
+     * Returns the path for the patch corresponding to this outcome.
+     *
+     * @return
+     *     The path for the patch corresponding to this outcome.
+     */
+    public String getPath() {
+        return path;
+    }
+
+    /**
+     * Returns the identifier for the value in patch corresponding to this
+     * outcome, or null if the value in the patch was null.
+     *
+     * @return
+     *     The identifier for the value in patch corresponding to this
+     *     outcome, or null if the value was null.
+     */
+    public String getIdentifier() {
+        return identifier;
+    }
+
+}
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/jsonpatch/APIPatchResponse.java b/guacamole/src/main/java/org/apache/guacamole/rest/jsonpatch/APIPatchResponse.java
new file mode 100644
index 0000000..21096a2
--- /dev/null
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/jsonpatch/APIPatchResponse.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.guacamole.rest.jsonpatch;
+
+import java.util.List;
+
+/**
+ * A REST response describing the successful application of a JSON PATCH
+ * request to a directory. This consists of a list of outcomes, one for each
+ * patch within the request, in the same order.
+ */
+public class APIPatchResponse {
+
+    /**
+     * A list of outcomes, each one corresponding to a patch in the request
+     * corresponding to this response.
+     */
+    public final List<APIPatchOutcome> patches;
+
+    /**
+     * Create a new patch response with the provided list of outcomes for
+     * individual patches.
+     *
+     * @param patches
+     *     A list of patch outcomes, one for each patch in the request
+     *     associated with this response.
+     */
+    public APIPatchResponse(List<APIPatchOutcome> patches) {
+        this.patches = patches;
+    }
+
+    /**
+     * Return the outcome for each patch in the request corresponding to this
+     * response.
+     */
+    public List<APIPatchOutcome> getPatches() {
+        return patches;
+    }
+}
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/jsonpatch/package-info.java b/guacamole/src/main/java/org/apache/guacamole/rest/jsonpatch/package-info.java
new file mode 100644
index 0000000..b5f824f
--- /dev/null
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/jsonpatch/package-info.java
@@ -0,0 +1,24 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Classes related to JSON Patch HTTP requests or responses.
+ * See https://www.rfc-editor.org/rfc/rfc6902.
+ */
+package org.apache.guacamole.rest.jsonpatch;
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/permission/PermissionSetResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/permission/PermissionSetResource.java
index 38b337e..62f4d0e 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/permission/PermissionSetResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/permission/PermissionSetResource.java
@@ -31,7 +31,7 @@
 import org.apache.guacamole.net.auth.permission.ObjectPermission;
 import org.apache.guacamole.net.auth.permission.Permission;
 import org.apache.guacamole.net.auth.permission.SystemPermission;
-import org.apache.guacamole.rest.APIPatch;
+import org.apache.guacamole.rest.jsonpatch.APIPatch;
 
 /**
  * A REST resource which abstracts the operations available on the permissions
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/schema/SchemaResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/schema/SchemaResource.java
index 9086ac9..a7ed08d 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/schema/SchemaResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/schema/SchemaResource.java
@@ -78,6 +78,26 @@
     }
 
     /**
+     * Retrieves the possible user preference attributes of a user object.
+     *
+     * @return
+     *     A collection of forms which describe the possible preference attributes of a
+     *     user object.
+     *
+     * @throws GuacamoleException
+     *     If an error occurs while retrieving the possible attributes.
+     */
+    @GET
+    @Path("userPreferenceAttributes")
+    public Collection<Form> getUserPreferenceAttributes()
+            throws GuacamoleException {
+
+        // Retrieve all possible user preference attributes
+        return userContext.getUserPreferenceAttributes();
+
+    }
+
+    /**
      * Retrieves the possible attributes of a user group object.
      *
      * @return
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/session/SessionResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/session/SessionResource.java
index 0c334b0..adf8ed6 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/session/SessionResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/session/SessionResource.java
@@ -24,6 +24,7 @@
 import javax.inject.Inject;
 import javax.ws.rs.Consumes;
 import javax.ws.rs.DELETE;
+import javax.ws.rs.HEAD;
 import javax.ws.rs.Path;
 import javax.ws.rs.PathParam;
 import javax.ws.rs.Produces;
@@ -116,7 +117,7 @@
         UserContext userContext = session.getUserContext(authProviderIdentifier);
 
         // Return a resource exposing the retrieved UserContext
-        return userContextResourceFactory.create(userContext);
+        return userContextResourceFactory.create(session.getAuthenticatedUser(), userContext);
 
     }
 
@@ -184,4 +185,15 @@
 
     }
 
+    /**
+     * Tests whether this session resource represented a valid session at the
+     * time it was created. This function always succeeds. It is possible for
+     * an HTTP request aimed at this operation to fail, but that failure occurs
+     * further up the chain when locating the session.
+     */
+    @HEAD
+    public void checkValidity() {
+        // Do nothing
+    }
+
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/session/UserContextResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/session/UserContextResource.java
index cbb04d1..b696259 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/session/UserContextResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/session/UserContextResource.java
@@ -30,6 +30,7 @@
 import javax.ws.rs.core.MediaType;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.net.auth.ActiveConnection;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.Connection;
 import org.apache.guacamole.net.auth.ConnectionGroup;
 import org.apache.guacamole.net.auth.SharingProfile;
@@ -60,6 +61,11 @@
     private final UserContext userContext;
 
     /**
+     * The user that is accessing this resource.
+     */
+    private final AuthenticatedUser authenticatedUser;
+
+    /**
      * Factory for creating DirectoryObjectResources which expose a given User.
      */
     @Inject
@@ -115,12 +121,17 @@
      * Creates a new UserContextResource which exposes the data within the
      * given UserContext.
      *
+     * @param authenticatedUser
+     *     The user that is accessing this resource.
+     *
      * @param userContext
      *     The UserContext which should be exposed through this
      *     UserContextResource.
      */
     @AssistedInject
-    public UserContextResource(@Assisted UserContext userContext) {
+    public UserContextResource(@Assisted AuthenticatedUser authenticatedUser,
+            @Assisted UserContext userContext) {
+        this.authenticatedUser = authenticatedUser;
         this.userContext = userContext;
     }
 
@@ -140,7 +151,7 @@
     @Path("self")
     public DirectoryObjectResource<User, APIUser> getSelfResource()
             throws GuacamoleException {
-        return userResourceFactory.create(userContext,
+        return userResourceFactory.create(authenticatedUser, userContext,
                 userContext.getUserDirectory(), userContext.self());
     }
 
@@ -158,8 +169,8 @@
     @Path("activeConnections")
     public DirectoryResource<ActiveConnection, APIActiveConnection>
         getActiveConnectionDirectoryResource() throws GuacamoleException {
-        return activeConnectionDirectoryResourceFactory.create(userContext,
-                userContext.getActiveConnectionDirectory());
+        return activeConnectionDirectoryResourceFactory.create(authenticatedUser,
+                userContext, userContext.getActiveConnectionDirectory());
     }
 
     /**
@@ -176,8 +187,8 @@
     @Path("connections")
     public DirectoryResource<Connection, APIConnection> getConnectionDirectoryResource()
             throws GuacamoleException {
-        return connectionDirectoryResourceFactory.create(userContext,
-                userContext.getConnectionDirectory());
+        return connectionDirectoryResourceFactory.create(authenticatedUser,
+                userContext, userContext.getConnectionDirectory());
     }
 
     /**
@@ -194,8 +205,8 @@
     @Path("connectionGroups")
     public DirectoryResource<ConnectionGroup, APIConnectionGroup> getConnectionGroupDirectoryResource()
             throws GuacamoleException {
-        return connectionGroupDirectoryResourceFactory.create(userContext,
-                userContext.getConnectionGroupDirectory());
+        return connectionGroupDirectoryResourceFactory.create(authenticatedUser,
+                userContext, userContext.getConnectionGroupDirectory());
     }
 
     /**
@@ -212,8 +223,8 @@
     @Path("sharingProfiles")
     public DirectoryResource<SharingProfile, APISharingProfile>
         getSharingProfileDirectoryResource() throws GuacamoleException {
-        return sharingProfileDirectoryResourceFactory.create(userContext,
-                userContext.getSharingProfileDirectory());
+        return sharingProfileDirectoryResourceFactory.create(authenticatedUser,
+                userContext, userContext.getSharingProfileDirectory());
     }
 
     /**
@@ -230,8 +241,8 @@
     @Path("users")
     public DirectoryResource<User, APIUser> getUserDirectoryResource()
             throws GuacamoleException {
-        return userDirectoryResourceFactory.create(userContext,
-                userContext.getUserDirectory());
+        return userDirectoryResourceFactory.create(authenticatedUser,
+                userContext, userContext.getUserDirectory());
     }
 
     /**
@@ -248,8 +259,8 @@
     @Path("userGroups")
     public DirectoryResource<UserGroup, APIUserGroup> getUserGroupDirectoryResource()
             throws GuacamoleException {
-        return userGroupDirectoryResourceFactory.create(userContext,
-                userContext.getUserGroupDirectory());
+        return userGroupDirectoryResourceFactory.create(authenticatedUser,
+                userContext, userContext.getUserGroupDirectory());
     }
 
     /**
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/session/UserContextResourceFactory.java b/guacamole/src/main/java/org/apache/guacamole/rest/session/UserContextResourceFactory.java
index 5eea3ed..c969a63 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/session/UserContextResourceFactory.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/session/UserContextResourceFactory.java
@@ -19,6 +19,7 @@
 
 package org.apache.guacamole.rest.session;
 
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.UserContext;
 
 /**
@@ -31,6 +32,9 @@
      * Creates a new UserContextResource which exposes the contents of the
      * given UserContext.
      *
+     * @param authenticatedUser
+     *     The user that is accessing the resource.
+     *
      * @param userContext
      *     The UserContext whose contents should be exposed.
      *
@@ -38,6 +42,7 @@
      *     A new UserContextResource which exposes the contents of the given
      *     UserContext.
      */
-    UserContextResource create(UserContext userContext);
+    UserContextResource create(AuthenticatedUser authenticatedUser,
+            UserContext userContext);
 
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileDirectoryResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileDirectoryResource.java
index ab24ef3..16ae295 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileDirectoryResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileDirectoryResource.java
@@ -25,6 +25,7 @@
 import javax.ws.rs.Produces;
 import javax.ws.rs.core.MediaType;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.Permissions;
 import org.apache.guacamole.net.auth.SharingProfile;
@@ -48,6 +49,9 @@
      * operations and subresources available for the given SharingProfile
      * Directory.
      *
+     * @param authenticatedUser
+     *     The user that is accessing this resource.
+     *
      * @param userContext
      *     The UserContext associated with the given Directory.
      *
@@ -63,11 +67,13 @@
      *     representing SharingProfiles.
      */
     @AssistedInject
-    public SharingProfileDirectoryResource(@Assisted UserContext userContext,
+    public SharingProfileDirectoryResource(
+            @Assisted AuthenticatedUser authenticatedUser,
+            @Assisted UserContext userContext,
             @Assisted Directory<SharingProfile> directory,
             DirectoryObjectTranslator<SharingProfile, APISharingProfile> translator,
             DirectoryObjectResourceFactory<SharingProfile, APISharingProfile> resourceFactory) {
-        super(userContext, directory, translator, resourceFactory);
+        super(authenticatedUser, userContext, SharingProfile.class, directory, translator, resourceFactory);
     }
 
     @Override
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileResource.java
index 4797ade..46daac9 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileResource.java
@@ -29,6 +29,7 @@
 import javax.ws.rs.core.MediaType;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleSecurityException;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.Permissions;
 import org.apache.guacamole.net.auth.SharingProfile;
@@ -50,20 +51,12 @@
         extends DirectoryObjectResource<SharingProfile, APISharingProfile> {
 
     /**
-     * The UserContext associated with the Directory which contains the
-     * SharingProfile exposed by this resource.
-     */
-    private final UserContext userContext;
-
-    /**
-     * The SharingProfile object represented by this SharingProfileResource.
-     */
-    private final SharingProfile sharingProfile;
-
-    /**
      * Creates a new SharingProfileResource which exposes the operations and
      * subresources available for the given SharingProfile.
      *
+     * @param authenticatedUser
+     *     The user that is accessing this resource.
+     *
      * @param userContext
      *     The UserContext associated with the given Directory.
      *
@@ -78,13 +71,12 @@
      *     object given.
      */
     @AssistedInject
-    public SharingProfileResource(@Assisted UserContext userContext,
+    public SharingProfileResource(@Assisted AuthenticatedUser authenticatedUser,
+            @Assisted UserContext userContext,
             @Assisted Directory<SharingProfile> directory,
             @Assisted SharingProfile sharingProfile,
             DirectoryObjectTranslator<SharingProfile, APISharingProfile> translator) {
-        super(userContext, directory, sharingProfile, translator);
-        this.userContext = userContext;
-        this.sharingProfile = sharingProfile;
+        super(authenticatedUser, userContext, SharingProfile.class, directory, sharingProfile, translator);
     }
 
     /**
@@ -103,8 +95,10 @@
     public Map<String, String> getParameters()
             throws GuacamoleException {
 
+        SharingProfile sharingProfile = getInternalObject();
+        
         // Pull effective permissions
-        Permissions effective = userContext.self().getEffectivePermissions();
+        Permissions effective = getUserContext().self().getEffectivePermissions();
 
         // Retrieve permission sets
         SystemPermissionSet systemPermissions = effective.getSystemPermissions();
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/tunnel/TunnelCollectionResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/tunnel/TunnelCollectionResource.java
index abd7b42..770469c 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/tunnel/TunnelCollectionResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/tunnel/TunnelCollectionResource.java
@@ -105,7 +105,7 @@
             throw new GuacamoleResourceNotFoundException("No such tunnel.");
 
         // Return corresponding tunnel resource
-        return tunnelResourceFactory.create(tunnel);
+        return tunnelResourceFactory.create(session.getAuthenticatedUser(), tunnel);
 
     }
 
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/tunnel/TunnelResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/tunnel/TunnelResource.java
index 7c0ec7e..7107ab2 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/tunnel/TunnelResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/tunnel/TunnelResource.java
@@ -34,6 +34,7 @@
 import org.apache.guacamole.GuacamoleResourceNotFoundException;
 import org.apache.guacamole.environment.Environment;
 import org.apache.guacamole.net.auth.ActiveConnection;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.UserContext;
 import org.apache.guacamole.protocols.ProtocolInfo;
 import org.apache.guacamole.rest.activeconnection.APIActiveConnection;
@@ -56,6 +57,11 @@
     private static final String DEFAULT_MEDIA_TYPE = MediaType.APPLICATION_OCTET_STREAM;
 
     /**
+     * The user that is accessing this resource.
+     */
+    private final AuthenticatedUser authenticatedUser;
+    
+    /**
      * The tunnel that this TunnelResource represents.
      */
     private final UserTunnel tunnel;
@@ -78,11 +84,16 @@
      * Creates a new TunnelResource which exposes the operations and
      * subresources available for the given tunnel.
      *
+     * @param authenticatedUser
+     *     The user that is accessing this resource.
+     *
      * @param tunnel
      *     The tunnel that this TunnelResource should represent.
      */
     @AssistedInject
-    public TunnelResource(@Assisted UserTunnel tunnel) {
+    public TunnelResource(@Assisted AuthenticatedUser authenticatedUser,
+            @Assisted UserTunnel tunnel) {
+        this.authenticatedUser = authenticatedUser;
         this.tunnel = tunnel;
     }
 
@@ -110,7 +121,7 @@
             throw new GuacamoleResourceNotFoundException("No readable active connection for tunnel.");
 
         // Return the associated ActiveConnection as a resource
-        return activeConnectionResourceFactory.create(userContext,
+        return activeConnectionResourceFactory.create(authenticatedUser, userContext,
                 userContext.getActiveConnectionDirectory(), activeConnection);
 
     }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/tunnel/TunnelResourceFactory.java b/guacamole/src/main/java/org/apache/guacamole/rest/tunnel/TunnelResourceFactory.java
index 2deacf8..9efdf9c 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/tunnel/TunnelResourceFactory.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/tunnel/TunnelResourceFactory.java
@@ -19,6 +19,7 @@
 
 package org.apache.guacamole.rest.tunnel;
 
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.tunnel.UserTunnel;
 
 /**
@@ -31,12 +32,15 @@
      * Creates a new TunnelResource which exposes the contents of the
      * given tunnel.
      *
+     * @param authenticatedUser
+     *     The user that is accessing the resource.
+     *
      * @param tunnel
      *     The tunnel whose contents should be exposed.
      *
      * @return
      *     A new TunnelResource which exposes the contents of the given tunnel.
      */
-    TunnelResource create(UserTunnel tunnel);
+    TunnelResource create(AuthenticatedUser authenticatedUser, UserTunnel tunnel);
 
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/user/UserDirectoryResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/user/UserDirectoryResource.java
index f93016f..0918ca8 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/user/UserDirectoryResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/user/UserDirectoryResource.java
@@ -25,6 +25,7 @@
 import javax.ws.rs.Produces;
 import javax.ws.rs.core.MediaType;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.User;
 import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.Permissions;
@@ -46,6 +47,9 @@
      * Creates a new UserDirectoryResource which exposes the operations and
      * subresources available for the given User Directory.
      *
+     * @param authenticatedUser
+     *     The user that is accessing this resource.
+     *
      * @param userContext
      *     The UserContext associated with the given Directory.
      *
@@ -61,11 +65,12 @@
      *     representing Users.
      */
     @AssistedInject
-    public UserDirectoryResource(@Assisted UserContext userContext,
+    public UserDirectoryResource(@Assisted AuthenticatedUser authenticatedUser,
+            @Assisted UserContext userContext,
             @Assisted Directory<User> directory,
             DirectoryObjectTranslator<User, APIUser> translator,
             DirectoryObjectResourceFactory<User, APIUser> resourceFactory) {
-        super(userContext, directory, translator, resourceFactory);
+        super(authenticatedUser, userContext, User.class, directory, translator, resourceFactory);
     }
 
     @Override
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/user/UserObjectTranslator.java b/guacamole/src/main/java/org/apache/guacamole/rest/user/UserObjectTranslator.java
index 8536b35..8c63d72 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/user/UserObjectTranslator.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/user/UserObjectTranslator.java
@@ -59,9 +59,22 @@
     public void filterExternalObject(UserContext userContext, APIUser object)
             throws GuacamoleException {
 
-        // Filter object attributes by defined schema
-        object.setAttributes(filterAttributes(userContext.getUserAttributes(),
-                object.getAttributes()));
+        // If a user is editing themselves ...
+        if (object.getUsername().equals(userContext.self().getIdentifier())) {
+
+            // ... they may only edit preference attributes
+            object.setAttributes(filterAttributes(userContext.getUserPreferenceAttributes(),
+                    object.getAttributes()));
+
+        }
+
+        else {
+
+            // In all other cases, filter object attributes by defined schema
+            object.setAttributes(filterAttributes(userContext.getUserAttributes(),
+                    object.getAttributes()));
+
+        }
 
     }
 
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/user/UserResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/user/UserResource.java
index f31ce5d..91600f7 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/user/UserResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/user/UserResource.java
@@ -21,6 +21,7 @@
 
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
+
 import javax.servlet.http.HttpServletRequest;
 import javax.ws.rs.Consumes;
 import javax.ws.rs.GET;
@@ -32,6 +33,7 @@
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleSecurityException;
 import org.apache.guacamole.GuacamoleUnsupportedException;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.AuthenticationProvider;
 import org.apache.guacamole.net.auth.Credentials;
 import org.apache.guacamole.net.auth.User;
@@ -39,6 +41,7 @@
 import org.apache.guacamole.net.auth.UserContext;
 import org.apache.guacamole.net.auth.credentials.GuacamoleCredentialsException;
 import org.apache.guacamole.net.auth.simple.SimpleActivityRecordSet;
+import org.apache.guacamole.net.event.DirectoryEvent;
 import org.apache.guacamole.rest.directory.DirectoryObjectResource;
 import org.apache.guacamole.rest.directory.DirectoryObjectTranslator;
 import org.apache.guacamole.rest.history.UserHistoryResource;
@@ -63,26 +66,12 @@
     private static final Logger logger = LoggerFactory.getLogger(UserResource.class);
     
     /**
-     * The UserContext associated with the Directory which contains the User
-     * exposed by this resource.
-     */
-    private final UserContext userContext;
-
-    /**
-     * The Directory which contains the User object represented by this
-     * UserResource.
-     */
-    private final Directory<User> directory;
-
-    /**
-     * The User object represented by this UserResource.
-     */
-    private final User user;
-
-    /**
      * Creates a new UserResource which exposes the operations and subresources
      * available for the given User.
      *
+     * @param authenticatedUser
+     *     The user that is accessing this resource.
+     *
      * @param userContext
      *     The UserContext associated with the given Directory.
      *
@@ -96,14 +85,12 @@
      *     A DirectoryObjectTranslator implementation which handles Users.
      */
     @AssistedInject
-    public UserResource(@Assisted UserContext userContext,
+    public UserResource(@Assisted AuthenticatedUser authenticatedUser,
+            @Assisted UserContext userContext,
             @Assisted Directory<User> directory,
             @Assisted User user,
             DirectoryObjectTranslator<User, APIUser> translator) {
-        super(userContext, directory, user, translator);
-        this.userContext = userContext;
-        this.directory = directory;
-        this.user = user;
+        super(authenticatedUser, userContext, User.class, directory, user, translator);
     }
 
     /**
@@ -121,6 +108,8 @@
     public UserHistoryResource getUserHistory()
             throws GuacamoleException {
 
+        User user = getInternalObject();
+
         // First try to retrieve history using the current getUserHistory() method.
         try {
             return new UserHistoryResource(user.getUserHistory());
@@ -145,9 +134,21 @@
     @Override
     public void updateObject(APIUser modifiedObject) throws GuacamoleException {
 
-        // A user may not use this endpoint to modify himself
-        if (userContext.self().getIdentifier().equals(modifiedObject.getUsername()))
-            throw new GuacamoleSecurityException("Permission denied.");
+        // A user may not use this endpoint to update their password
+        try {
+            User currentUser = getUserContext().self();
+            if (
+                    currentUser.getIdentifier().equals(modifiedObject.getUsername())
+                    && modifiedObject.getPassword() != null) {
+                throw new GuacamoleSecurityException(
+                        "Permission denied. The password update endpoint must"
+                        + " be used to change the current user's password.");
+            }
+        }
+        catch (GuacamoleException | RuntimeException | Error e) {
+            fireDirectoryFailureEvent(DirectoryEvent.Operation.UPDATE, e);
+            throw e;
+        }
 
         super.updateObject(modifiedObject);
 
@@ -171,13 +172,15 @@
     public void updatePassword(APIUserPasswordUpdate userPasswordUpdate,
             @Context HttpServletRequest request) throws GuacamoleException {
 
+        User user = getInternalObject();
+
         // Build credentials
         Credentials credentials = new Credentials(user.getIdentifier(),
                 userPasswordUpdate.getOldPassword(), request);
 
         // Verify that the old password was correct
         try {
-            AuthenticationProvider authProvider = userContext.getAuthenticationProvider();
+            AuthenticationProvider authProvider = getUserContext().getAuthenticationProvider();
             if (authProvider.authenticateUser(credentials) == null)
                 throw new GuacamoleSecurityException("Permission denied.");
         }
@@ -188,8 +191,15 @@
         }
 
         // Set password to the newly provided one
-        user.setPassword(userPasswordUpdate.getNewPassword());
-        directory.update(user);
+        try {
+            user.setPassword(userPasswordUpdate.getNewPassword());
+            getDirectory().update(user);
+            fireDirectorySuccessEvent(DirectoryEvent.Operation.UPDATE);
+        }
+        catch (GuacamoleException | RuntimeException | Error e) {
+            fireDirectoryFailureEvent(DirectoryEvent.Operation.UPDATE, e);
+            throw e;
+        }
 
     }
 
@@ -204,7 +214,7 @@
      */
     @Path("permissions")
     public PermissionSetResource getPermissions() {
-        return new PermissionSetResource(user);
+        return new PermissionSetResource(getInternalObject());
     }
 
     /**
@@ -221,7 +231,7 @@
     @GET
     @Path("effectivePermissions")
     public APIPermissionSet getEffectivePermissions() throws GuacamoleException {
-        return new APIPermissionSet(user.getEffectivePermissions());
+        return new APIPermissionSet(getInternalObject().getEffectivePermissions());
     }
 
     /**
@@ -238,7 +248,7 @@
      */
     @Path("userGroups")
     public RelatedObjectSetResource getUserGroups() throws GuacamoleException {
-        return new RelatedObjectSetResource(user.getUserGroups());
+        return new RelatedObjectSetResource(getInternalObject().getUserGroups());
     }
 
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/usergroup/UserGroupDirectoryResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/usergroup/UserGroupDirectoryResource.java
index fc4d48b..44327a2 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/usergroup/UserGroupDirectoryResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/usergroup/UserGroupDirectoryResource.java
@@ -25,6 +25,7 @@
 import javax.ws.rs.Produces;
 import javax.ws.rs.core.MediaType;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.UserGroup;
 import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.Permissions;
@@ -46,6 +47,9 @@
      * Creates a new UserGroupDirectoryResource which exposes the operations
      * and subresources available for the given UserGroup Directory.
      *
+     * @param authenticatedUser
+     *     The user that is accessing this resource.
+     *
      * @param userContext
      *     The UserContext associated with the given Directory.
      *
@@ -61,11 +65,13 @@
      *     representing UserGroups.
      */
     @AssistedInject
-    public UserGroupDirectoryResource(@Assisted UserContext userContext,
+    public UserGroupDirectoryResource(
+            @Assisted AuthenticatedUser authenticatedUser,
+            @Assisted UserContext userContext,
             @Assisted Directory<UserGroup> directory,
             DirectoryObjectTranslator<UserGroup, APIUserGroup> translator,
             DirectoryObjectResourceFactory<UserGroup, APIUserGroup> resourceFactory) {
-        super(userContext, directory, translator, resourceFactory);
+        super(authenticatedUser, userContext, UserGroup.class, directory, translator, resourceFactory);
     }
 
     @Override
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/usergroup/UserGroupResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/usergroup/UserGroupResource.java
index 350b59f..6994484 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/usergroup/UserGroupResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/usergroup/UserGroupResource.java
@@ -26,6 +26,7 @@
 import javax.ws.rs.Produces;
 import javax.ws.rs.core.MediaType;
 import org.apache.guacamole.GuacamoleException;
+import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.UserGroup;
 import org.apache.guacamole.net.auth.Directory;
 import org.apache.guacamole.net.auth.UserContext;
@@ -44,14 +45,12 @@
         extends DirectoryObjectResource<UserGroup, APIUserGroup> {
 
     /**
-     * The UserGroup object represented by this UserGroupResource.
-     */
-    private final UserGroup userGroup;
-
-    /**
      * Creates a new UserGroupResource which exposes the operations and
      * subresources available for the given UserGroup.
      *
+     * @param authenticatedUser
+     *     The user that is accessing this resource.
+     *
      * @param userContext
      *     The UserContext associated with the given Directory.
      *
@@ -65,12 +64,12 @@
      *     A DirectoryObjectTranslator implementation which handles Users.
      */
     @AssistedInject
-    public UserGroupResource(@Assisted UserContext userContext,
+    public UserGroupResource(@Assisted AuthenticatedUser authenticatedUser,
+            @Assisted UserContext userContext,
             @Assisted Directory<UserGroup> directory,
             @Assisted UserGroup userGroup,
             DirectoryObjectTranslator<UserGroup, APIUserGroup> translator) {
-        super(userContext, directory, userGroup, translator);
-        this.userGroup = userGroup;
+        super(authenticatedUser, userContext, UserGroup.class, directory, userGroup, translator);
     }
 
     /**
@@ -87,7 +86,7 @@
      */
     @Path("userGroups")
     public RelatedObjectSetResource getUserGroups() throws GuacamoleException {
-        return new RelatedObjectSetResource(userGroup.getUserGroups());
+        return new RelatedObjectSetResource(getInternalObject().getUserGroups());
     }
 
     /**
@@ -104,7 +103,7 @@
      */
     @Path("memberUsers")
     public RelatedObjectSetResource getMemberUsers() throws GuacamoleException {
-        return new RelatedObjectSetResource(userGroup.getMemberUsers());
+        return new RelatedObjectSetResource(getInternalObject().getMemberUsers());
     }
 
     /**
@@ -121,7 +120,7 @@
      */
     @Path("memberUserGroups")
     public RelatedObjectSetResource getMemberUserGroups() throws GuacamoleException {
-        return new RelatedObjectSetResource(userGroup.getMemberUserGroups());
+        return new RelatedObjectSetResource(getInternalObject().getMemberUserGroups());
     }
 
     /**
@@ -135,7 +134,7 @@
      */
     @Path("permissions")
     public PermissionSetResource getPermissions() {
-        return new PermissionSetResource(userGroup);
+        return new PermissionSetResource(getInternalObject());
     }
 
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/tunnel/InputStreamInterceptingFilter.java b/guacamole/src/main/java/org/apache/guacamole/tunnel/InputStreamInterceptingFilter.java
index f8e0334..6f725bf 100644
--- a/guacamole/src/main/java/org/apache/guacamole/tunnel/InputStreamInterceptingFilter.java
+++ b/guacamole/src/main/java/org/apache/guacamole/tunnel/InputStreamInterceptingFilter.java
@@ -94,8 +94,7 @@
     /**
      * Reads the next chunk of data from the InputStream associated with an
      * intercepted stream, sending that data as a "blob" instruction over the
-     * GuacamoleTunnel associated with this filter. If the end of the
-     * InputStream is reached, an "end" instruction will automatically be sent.
+     * GuacamoleTunnel associated with this filter.
      *
      * @param stream
      *     The stream from which the next chunk of data should be read.
@@ -112,9 +111,8 @@
             // End stream if no more data
             if (length == -1) {
 
-                // Close stream, send end if the stream is still valid
-                if (closeInterceptedStream(stream))
-                    sendEnd(stream.getIndex());
+                // Close stream
+                closeInterceptedStream(stream);
 
                 return;