This is an automated email from the ASF dual-hosted git repository.

lahirujayathilake pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git


The following commit(s) were added to refs/heads/master by this push:
     new c3aed3ae7 User page UI and code refinements (#416)
c3aed3ae7 is described below

commit c3aed3ae7ce70cec3760762fda9881004c45acaa
Author: Ganning Xu <[email protected]>
AuthorDate: Fri Feb 7 11:31:40 2025 -0500

    User page UI and code refinements (#416)
    
    * UI of user's page
    
    * refactor components
    
    * remove unused imports
---
 custos-portal/package-lock.json                    | 561 ++++++++++++++++++++-
 custos-portal/src/App.tsx                          |  45 +-
 .../src/components/Groups/GroupSettings.tsx        |  49 +-
 custos-portal/src/components/LeftRightLayout.tsx   |  16 +
 custos-portal/src/components/NavContainer.tsx      | 358 +++++++------
 custos-portal/src/components/StackedBorderBox.tsx  |  23 +
 .../src/components/Users/UserSettings.tsx          | 196 +++++++
 custos-portal/src/components/Users/index.tsx       | 117 +++++
 custos-portal/src/index.tsx                        |  52 +-
 custos-portal/src/interfaces/Users.tsx             |   6 +
 custos-portal/src/lib/constants.ts                 |  12 +-
 11 files changed, 1179 insertions(+), 256 deletions(-)

diff --git a/custos-portal/package-lock.json b/custos-portal/package-lock.json
index cc346a33e..efbe98684 100644
--- a/custos-portal/package-lock.json
+++ b/custos-portal/package-lock.json
@@ -1908,6 +1908,20 @@
         "@zag-js/dom-query": "0.16.0"
       }
     },
+    "@zeit/schemas": {
+      "version": "2.36.0",
+      "resolved": 
"https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz";,
+      "integrity": 
"sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg=="
+    },
+    "accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz";,
+      "integrity": 
"sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "requires": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      }
+    },
     "acorn": {
       "version": "8.12.1",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz";,
@@ -1932,11 +1946,30 @@
         "uri-js": "^4.2.2"
       }
     },
+    "ansi-align": {
+      "version": "3.0.1",
+      "resolved": 
"https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz";,
+      "integrity": 
"sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==",
+      "requires": {
+        "string-width": "^4.1.0"
+      },
+      "dependencies": {
+        "string-width": {
+          "version": "4.2.3",
+          "resolved": 
"https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz";,
+          "integrity": 
"sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+          "requires": {
+            "emoji-regex": "^8.0.0",
+            "is-fullwidth-code-point": "^3.0.0",
+            "strip-ansi": "^6.0.1"
+          }
+        }
+      }
+    },
     "ansi-regex": {
       "version": "5.0.1",
       "resolved": 
"https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz";,
-      "integrity": 
"sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
-      "dev": true
+      "integrity": 
"sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
     },
     "ansi-styles": {
       "version": "3.2.1",
@@ -1946,6 +1979,16 @@
         "color-convert": "^1.9.0"
       }
     },
+    "arch": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz";,
+      "integrity": 
"sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ=="
+    },
+    "arg": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz";,
+      "integrity": 
"sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="
+    },
     "argparse": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz";,
@@ -2019,14 +2062,34 @@
     "balanced-match": {
       "version": "1.0.2",
       "resolved": 
"https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz";,
-      "integrity": 
"sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
-      "dev": true
+      "integrity": 
"sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
+    },
+    "boxen": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz";,
+      "integrity": 
"sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==",
+      "requires": {
+        "ansi-align": "^3.0.1",
+        "camelcase": "^7.0.0",
+        "chalk": "^5.0.1",
+        "cli-boxes": "^3.0.0",
+        "string-width": "^5.1.2",
+        "type-fest": "^2.13.0",
+        "widest-line": "^4.0.1",
+        "wrap-ansi": "^8.0.1"
+      },
+      "dependencies": {
+        "chalk": {
+          "version": "5.4.1",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz";,
+          "integrity": 
"sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="
+        }
+      }
     },
     "brace-expansion": {
       "version": "1.1.11",
       "resolved": 
"https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz";,
       "integrity": 
"sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
-      "dev": true,
       "requires": {
         "balanced-match": "^1.0.0",
         "concat-map": "0.0.1"
@@ -2053,11 +2116,21 @@
         "update-browserslist-db": "^1.1.0"
       }
     },
+    "bytes": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz";,
+      "integrity": 
"sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw=="
+    },
     "callsites": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz";,
       "integrity": 
"sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="
     },
+    "camelcase": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz";,
+      "integrity": 
"sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw=="
+    },
     "caniuse-lite": {
       "version": "1.0.30001651",
       "resolved": 
"https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz";,
@@ -2074,6 +2147,74 @@
         "supports-color": "^5.3.0"
       }
     },
+    "chalk-template": {
+      "version": "0.4.0",
+      "resolved": 
"https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz";,
+      "integrity": 
"sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==",
+      "requires": {
+        "chalk": "^4.1.2"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "4.3.0",
+          "resolved": 
"https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz";,
+          "integrity": 
"sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+          "requires": {
+            "color-convert": "^2.0.1"
+          }
+        },
+        "chalk": {
+          "version": "4.1.2",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz";,
+          "integrity": 
"sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+          "requires": {
+            "ansi-styles": "^4.1.0",
+            "supports-color": "^7.1.0"
+          }
+        },
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": 
"https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz";,
+          "integrity": 
"sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": 
"https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz";,
+          "integrity": 
"sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+        },
+        "has-flag": {
+          "version": "4.0.0",
+          "resolved": 
"https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz";,
+          "integrity": 
"sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
+        },
+        "supports-color": {
+          "version": "7.2.0",
+          "resolved": 
"https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz";,
+          "integrity": 
"sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        }
+      }
+    },
+    "cli-boxes": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz";,
+      "integrity": 
"sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="
+    },
+    "clipboardy": {
+      "version": "3.0.0",
+      "resolved": 
"https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz";,
+      "integrity": 
"sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==",
+      "requires": {
+        "arch": "^2.2.0",
+        "execa": "^5.1.1",
+        "is-wsl": "^2.2.0"
+      }
+    },
     "color-convert": {
       "version": "1.9.3",
       "resolved": 
"https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz";,
@@ -2100,6 +2241,43 @@
         "delayed-stream": "~1.0.0"
       }
     },
+    "compressible": {
+      "version": "2.0.18",
+      "resolved": 
"https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz";,
+      "integrity": 
"sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+      "requires": {
+        "mime-db": ">= 1.43.0 < 2"
+      }
+    },
+    "compression": {
+      "version": "1.7.4",
+      "resolved": 
"https://registry.npmjs.org/compression/-/compression-1.7.4.tgz";,
+      "integrity": 
"sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==",
+      "requires": {
+        "accepts": "~1.3.5",
+        "bytes": "3.0.0",
+        "compressible": "~2.0.16",
+        "debug": "2.6.9",
+        "on-headers": "~1.0.2",
+        "safe-buffer": "5.1.2",
+        "vary": "~1.1.2"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "2.6.9",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz";,
+          "integrity": 
"sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+          "requires": {
+            "ms": "2.0.0"
+          }
+        },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz";,
+          "integrity": 
"sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
+        }
+      }
+    },
     "compute-scroll-into-view": {
       "version": "3.0.3",
       "resolved": 
"https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.0.3.tgz";,
@@ -2108,8 +2286,12 @@
     "concat-map": {
       "version": "0.0.1",
       "resolved": 
"https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz";,
-      "integrity": 
"sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
-      "dev": true
+      "integrity": 
"sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
+    },
+    "content-disposition": {
+      "version": "0.5.2",
+      "resolved": 
"https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz";,
+      "integrity": 
"sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA=="
     },
     "convert-source-map": {
       "version": "2.0.0",
@@ -2141,7 +2323,6 @@
       "version": "7.0.3",
       "resolved": 
"https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz";,
       "integrity": 
"sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
-      "dev": true,
       "requires": {
         "path-key": "^3.1.0",
         "shebang-command": "^2.0.0",
@@ -2169,6 +2350,11 @@
         "ms": "2.1.2"
       }
     },
+    "deep-extend": {
+      "version": "0.6.0",
+      "resolved": 
"https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz";,
+      "integrity": 
"sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="
+    },
     "deep-is": {
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz";,
@@ -2199,12 +2385,22 @@
         "path-type": "^4.0.0"
       }
     },
+    "eastasianwidth": {
+      "version": "0.2.0",
+      "resolved": 
"https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz";,
+      "integrity": 
"sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
+    },
     "electron-to-chromium": {
       "version": "1.5.8",
       "resolved": 
"https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.8.tgz";,
       "integrity": 
"sha512-4Nx0gP2tPNBLTrFxBMHpkQbtn2hidPVr/+/FTtcCiBYTucqc70zRyVZiOLj17Ui3wTO7SQ1/N+hkHYzJjBzt6A==",
       "dev": true
     },
+    "emoji-regex": {
+      "version": "8.0.0",
+      "resolved": 
"https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz";,
+      "integrity": 
"sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
+    },
     "error-ex": {
       "version": "1.3.2",
       "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz";,
@@ -2423,11 +2619,26 @@
       "integrity": 
"sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
       "dev": true
     },
+    "execa": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz";,
+      "integrity": 
"sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+      "requires": {
+        "cross-spawn": "^7.0.3",
+        "get-stream": "^6.0.0",
+        "human-signals": "^2.1.0",
+        "is-stream": "^2.0.0",
+        "merge-stream": "^2.0.0",
+        "npm-run-path": "^4.0.1",
+        "onetime": "^5.1.2",
+        "signal-exit": "^3.0.3",
+        "strip-final-newline": "^2.0.0"
+      }
+    },
     "fast-deep-equal": {
       "version": "3.1.3",
       "resolved": 
"https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz";,
-      "integrity": 
"sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
-      "dev": true
+      "integrity": 
"sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
     },
     "fast-glob": {
       "version": "3.3.2",
@@ -2592,6 +2803,11 @@
       "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz";,
       "integrity": 
"sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="
     },
+    "get-stream": {
+      "version": "6.0.1",
+      "resolved": 
"https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz";,
+      "integrity": 
"sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="
+    },
     "glob-parent": {
       "version": "6.0.2",
       "resolved": 
"https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz";,
@@ -2648,6 +2864,11 @@
         "react-is": "^16.7.0"
       }
     },
+    "human-signals": {
+      "version": "2.1.0",
+      "resolved": 
"https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz";,
+      "integrity": 
"sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="
+    },
     "ignore": {
       "version": "5.3.2",
       "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz";,
@@ -2669,6 +2890,11 @@
       "integrity": 
"sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
       "dev": true
     },
+    "ini": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz";,
+      "integrity": 
"sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
+    },
     "invariant": {
       "version": "2.2.4",
       "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz";,
@@ -2690,12 +2916,22 @@
         "hasown": "^2.0.2"
       }
     },
+    "is-docker": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz";,
+      "integrity": 
"sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="
+    },
     "is-extglob": {
       "version": "2.1.1",
       "resolved": 
"https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz";,
       "integrity": 
"sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
       "dev": true
     },
+    "is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": 
"https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz";,
+      "integrity": 
"sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
+    },
     "is-glob": {
       "version": "4.0.3",
       "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz";,
@@ -2717,11 +2953,28 @@
       "integrity": 
"sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
       "dev": true
     },
+    "is-port-reachable": {
+      "version": "4.0.0",
+      "resolved": 
"https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz";,
+      "integrity": 
"sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig=="
+    },
+    "is-stream": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz";,
+      "integrity": 
"sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="
+    },
+    "is-wsl": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz";,
+      "integrity": 
"sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+      "requires": {
+        "is-docker": "^2.0.0"
+      }
+    },
     "isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz";,
-      "integrity": 
"sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
-      "dev": true
+      "integrity": 
"sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
     },
     "js-tokens": {
       "version": "4.0.0",
@@ -2837,6 +3090,11 @@
         "yallist": "^3.0.2"
       }
     },
+    "merge-stream": {
+      "version": "2.0.0",
+      "resolved": 
"https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz";,
+      "integrity": 
"sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
+    },
     "merge2": {
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz";,
@@ -2866,15 +3124,24 @@
         "mime-db": "1.52.0"
       }
     },
+    "mimic-fn": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz";,
+      "integrity": 
"sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="
+    },
     "minimatch": {
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz";,
       "integrity": 
"sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
-      "dev": true,
       "requires": {
         "brace-expansion": "^1.1.7"
       }
     },
+    "minimist": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz";,
+      "integrity": 
"sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="
+    },
     "ms": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz";,
@@ -2892,12 +3159,25 @@
       "integrity": 
"sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
       "dev": true
     },
+    "negotiator": {
+      "version": "0.6.3",
+      "resolved": 
"https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz";,
+      "integrity": 
"sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="
+    },
     "node-releases": {
       "version": "2.0.18",
       "resolved": 
"https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz";,
       "integrity": 
"sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
       "dev": true
     },
+    "npm-run-path": {
+      "version": "4.0.1",
+      "resolved": 
"https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz";,
+      "integrity": 
"sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+      "requires": {
+        "path-key": "^3.0.0"
+      }
+    },
     "object-assign": {
       "version": "4.1.1",
       "resolved": 
"https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz";,
@@ -2911,6 +3191,19 @@
         "jwt-decode": "^4.0.0"
       }
     },
+    "on-headers": {
+      "version": "1.0.2",
+      "resolved": 
"https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz";,
+      "integrity": 
"sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA=="
+    },
+    "onetime": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz";,
+      "integrity": 
"sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+      "requires": {
+        "mimic-fn": "^2.1.0"
+      }
+    },
     "optionator": {
       "version": "0.9.4",
       "resolved": 
"https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz";,
@@ -2968,17 +3261,26 @@
       "integrity": 
"sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
       "dev": true
     },
+    "path-is-inside": {
+      "version": "1.0.2",
+      "resolved": 
"https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz";,
+      "integrity": 
"sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w=="
+    },
     "path-key": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz";,
-      "integrity": 
"sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
-      "dev": true
+      "integrity": 
"sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="
     },
     "path-parse": {
       "version": "1.0.7",
       "resolved": 
"https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz";,
       "integrity": 
"sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
     },
+    "path-to-regexp": {
+      "version": "3.3.0",
+      "resolved": 
"https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz";,
+      "integrity": 
"sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw=="
+    },
     "path-type": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz";,
@@ -3030,8 +3332,7 @@
     "punycode": {
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz";,
-      "integrity": 
"sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
-      "dev": true
+      "integrity": 
"sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="
     },
     "queue-microtask": {
       "version": "1.2.3",
@@ -3039,6 +3340,29 @@
       "integrity": 
"sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
       "dev": true
     },
+    "range-parser": {
+      "version": "1.2.0",
+      "resolved": 
"https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz";,
+      "integrity": 
"sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A=="
+    },
+    "rc": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz";,
+      "integrity": 
"sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+      "requires": {
+        "deep-extend": "^0.6.0",
+        "ini": "~1.3.0",
+        "minimist": "^1.2.0",
+        "strip-json-comments": "~2.0.1"
+      },
+      "dependencies": {
+        "strip-json-comments": {
+          "version": "2.0.1",
+          "resolved": 
"https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz";,
+          "integrity": 
"sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="
+        }
+      }
+    },
     "react": {
       "version": "18.3.1",
       "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz";,
@@ -3161,6 +3485,28 @@
       "resolved": 
"https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz";,
       "integrity": 
"sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
     },
+    "registry-auth-token": {
+      "version": "3.3.2",
+      "resolved": 
"https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz";,
+      "integrity": 
"sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==",
+      "requires": {
+        "rc": "^1.1.6",
+        "safe-buffer": "^5.0.1"
+      }
+    },
+    "registry-url": {
+      "version": "3.1.0",
+      "resolved": 
"https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz";,
+      "integrity": 
"sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==",
+      "requires": {
+        "rc": "^1.0.1"
+      }
+    },
+    "require-from-string": {
+      "version": "2.0.2",
+      "resolved": 
"https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz";,
+      "integrity": 
"sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="
+    },
     "resolve": {
       "version": "1.22.8",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz";,
@@ -3217,6 +3563,11 @@
         "queue-microtask": "^1.2.2"
       }
     },
+    "safe-buffer": {
+      "version": "5.1.2",
+      "resolved": 
"https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz";,
+      "integrity": 
"sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+    },
     "scheduler": {
       "version": "0.23.2",
       "resolved": 
"https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz";,
@@ -3231,11 +3582,80 @@
       "integrity": 
"sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
       "dev": true
     },
+    "serve": {
+      "version": "14.2.4",
+      "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.4.tgz";,
+      "integrity": 
"sha512-qy1S34PJ/fcY8gjVGszDB3EXiPSk5FKhUa7tQe0UPRddxRidc2V6cNHPNewbE1D7MAkgLuWEt3Vw56vYy73tzQ==",
+      "requires": {
+        "@zeit/schemas": "2.36.0",
+        "ajv": "8.12.0",
+        "arg": "5.0.2",
+        "boxen": "7.0.0",
+        "chalk": "5.0.1",
+        "chalk-template": "0.4.0",
+        "clipboardy": "3.0.0",
+        "compression": "1.7.4",
+        "is-port-reachable": "4.0.0",
+        "serve-handler": "6.1.6",
+        "update-check": "1.5.4"
+      },
+      "dependencies": {
+        "ajv": {
+          "version": "8.12.0",
+          "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz";,
+          "integrity": 
"sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
+          "requires": {
+            "fast-deep-equal": "^3.1.1",
+            "json-schema-traverse": "^1.0.0",
+            "require-from-string": "^2.0.2",
+            "uri-js": "^4.2.2"
+          }
+        },
+        "chalk": {
+          "version": "5.0.1",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz";,
+          "integrity": 
"sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w=="
+        },
+        "json-schema-traverse": {
+          "version": "1.0.0",
+          "resolved": 
"https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz";,
+          "integrity": 
"sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
+        }
+      }
+    },
+    "serve-handler": {
+      "version": "6.1.6",
+      "resolved": 
"https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.6.tgz";,
+      "integrity": 
"sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==",
+      "requires": {
+        "bytes": "3.0.0",
+        "content-disposition": "0.5.2",
+        "mime-types": "2.1.18",
+        "minimatch": "3.1.2",
+        "path-is-inside": "1.0.2",
+        "path-to-regexp": "3.3.0",
+        "range-parser": "1.2.0"
+      },
+      "dependencies": {
+        "mime-db": {
+          "version": "1.33.0",
+          "resolved": 
"https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz";,
+          "integrity": 
"sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ=="
+        },
+        "mime-types": {
+          "version": "2.1.18",
+          "resolved": 
"https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz";,
+          "integrity": 
"sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==",
+          "requires": {
+            "mime-db": "~1.33.0"
+          }
+        }
+      }
+    },
     "shebang-command": {
       "version": "2.0.0",
       "resolved": 
"https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz";,
       "integrity": 
"sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
-      "dev": true,
       "requires": {
         "shebang-regex": "^3.0.0"
       }
@@ -3243,8 +3663,12 @@
     "shebang-regex": {
       "version": "3.0.0",
       "resolved": 
"https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz";,
-      "integrity": 
"sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
-      "dev": true
+      "integrity": 
"sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="
+    },
+    "signal-exit": {
+      "version": "3.0.7",
+      "resolved": 
"https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz";,
+      "integrity": 
"sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
     },
     "slash": {
       "version": "3.0.0",
@@ -3263,15 +3687,49 @@
       "integrity": 
"sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
       "dev": true
     },
+    "string-width": {
+      "version": "5.1.2",
+      "resolved": 
"https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz";,
+      "integrity": 
"sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+      "requires": {
+        "eastasianwidth": "^0.2.0",
+        "emoji-regex": "^9.2.2",
+        "strip-ansi": "^7.0.1"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "6.1.0",
+          "resolved": 
"https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz";,
+          "integrity": 
"sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="
+        },
+        "emoji-regex": {
+          "version": "9.2.2",
+          "resolved": 
"https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz";,
+          "integrity": 
"sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
+        },
+        "strip-ansi": {
+          "version": "7.1.0",
+          "resolved": 
"https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz";,
+          "integrity": 
"sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+          "requires": {
+            "ansi-regex": "^6.0.1"
+          }
+        }
+      }
+    },
     "strip-ansi": {
       "version": "6.0.1",
       "resolved": 
"https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz";,
       "integrity": 
"sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
-      "dev": true,
       "requires": {
         "ansi-regex": "^5.0.1"
       }
     },
+    "strip-final-newline": {
+      "version": "2.0.0",
+      "resolved": 
"https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz";,
+      "integrity": 
"sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="
+    },
     "strip-json-comments": {
       "version": "3.1.1",
       "resolved": 
"https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz";,
@@ -3346,6 +3804,11 @@
         "prelude-ls": "^1.2.1"
       }
     },
+    "type-fest": {
+      "version": "2.19.0",
+      "resolved": 
"https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz";,
+      "integrity": 
"sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="
+    },
     "typescript": {
       "version": "5.5.4",
       "resolved": 
"https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz";,
@@ -3379,11 +3842,19 @@
         "picocolors": "^1.0.1"
       }
     },
+    "update-check": {
+      "version": "1.5.4",
+      "resolved": 
"https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz";,
+      "integrity": 
"sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==",
+      "requires": {
+        "registry-auth-token": "3.3.2",
+        "registry-url": "3.1.0"
+      }
+    },
     "uri-js": {
       "version": "4.4.1",
       "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz";,
       "integrity": 
"sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
-      "dev": true,
       "requires": {
         "punycode": "^2.1.0"
       }
@@ -3405,6 +3876,11 @@
         "tslib": "^2.0.0"
       }
     },
+    "vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz";,
+      "integrity": 
"sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="
+    },
     "vite": {
       "version": "5.4.1",
       "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.1.tgz";,
@@ -3421,17 +3897,54 @@
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz";,
       "integrity": 
"sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
-      "dev": true,
       "requires": {
         "isexe": "^2.0.0"
       }
     },
+    "widest-line": {
+      "version": "4.0.1",
+      "resolved": 
"https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz";,
+      "integrity": 
"sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==",
+      "requires": {
+        "string-width": "^5.0.1"
+      }
+    },
     "word-wrap": {
       "version": "1.2.5",
       "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz";,
       "integrity": 
"sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
       "dev": true
     },
+    "wrap-ansi": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz";,
+      "integrity": 
"sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+      "requires": {
+        "ansi-styles": "^6.1.0",
+        "string-width": "^5.0.1",
+        "strip-ansi": "^7.0.1"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "6.1.0",
+          "resolved": 
"https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz";,
+          "integrity": 
"sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="
+        },
+        "ansi-styles": {
+          "version": "6.2.1",
+          "resolved": 
"https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz";,
+          "integrity": 
"sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="
+        },
+        "strip-ansi": {
+          "version": "7.1.0",
+          "resolved": 
"https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz";,
+          "integrity": 
"sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+          "requires": {
+            "ansi-regex": "^6.0.1"
+          }
+        }
+      }
+    },
     "yallist": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz";,
@@ -3450,4 +3963,4 @@
       "dev": true
     }
   }
-}
\ No newline at end of file
+}
diff --git a/custos-portal/src/App.tsx b/custos-portal/src/App.tsx
index da7b30496..71bf14dbd 100644
--- a/custos-portal/src/App.tsx
+++ b/custos-portal/src/App.tsx
@@ -17,34 +17,51 @@
  *  under the License.
  */
 
-import { Routes, Route, BrowserRouter } from 'react-router-dom';
-import { Heading } from '@chakra-ui/react';
-import { Groups } from './components/Groups';
-import { NavContainer } from './components/NavContainer';
-import { GroupDetails } from './components/Groups/GroupDetails';
-import { Login } from './components/Login';
-import ProtectedComponent from './components/ProtectedComponent';
+import { Routes, Route, BrowserRouter } from "react-router-dom";
+import { Heading } from "@chakra-ui/react";
+import { Groups } from "./components/Groups";
+import { NavContainer } from "./components/NavContainer";
+import { GroupDetails } from "./components/Groups/GroupDetails";
+import { Login } from "./components/Login";
+import { Users } from "./components/Users";
+import { UserSettings } from "./components/Users/UserSettings";
+import ProtectedComponent from "./components/ProtectedComponent";
 
 function NotImplemented() {
   return (
-    <NavContainer activeTab='N/A'>
-      <Heading size='lg' fontWeight={500}>
+    <NavContainer activeTab="N/A">
+      <Heading size="lg" fontWeight={500}>
         Not Implemented
       </Heading>
     </NavContainer>
   );
 }
 
-
 export default function App() {
   return (
     <BrowserRouter>
       <Routes>
         <Route path="/" element={<Login />} />
-          <Route path="/applications" element={<ProtectedComponent 
Component={NotImplemented}  />} />
-          <Route path="/users" element={<ProtectedComponent 
Component={NotImplemented}  />} />
-          <Route path="/groups/:id/:path" element={<ProtectedComponent 
Component={GroupDetails}  />} />
-          <Route path="/groups" element={<ProtectedComponent 
Component={Groups}  />} />
+        <Route
+          path="/applications"
+          element={<ProtectedComponent Component={NotImplemented} />}
+        />
+        <Route
+          path="/users"
+          element={<ProtectedComponent Component={Users} />}
+        />
+        <Route
+          path="/users/:email"
+          element={<ProtectedComponent Component={UserSettings} />}
+        />
+        <Route
+          path="/groups/:id/:path"
+          element={<ProtectedComponent Component={GroupDetails} />}
+        />
+        <Route
+          path="/groups"
+          element={<ProtectedComponent Component={Groups} />}
+        />
       </Routes>
     </BrowserRouter>
   );
diff --git a/custos-portal/src/components/Groups/GroupSettings.tsx 
b/custos-portal/src/components/Groups/GroupSettings.tsx
index 00e6e3566..e8119941c 100644
--- a/custos-portal/src/components/Groups/GroupSettings.tsx
+++ b/custos-portal/src/components/Groups/GroupSettings.tsx
@@ -24,9 +24,7 @@ import {
   Text,
   FormLabel,
   Input,
-  SimpleGrid,
   Stack,
-  Divider,
   Button,
   Table,
   Thead,
@@ -52,26 +50,13 @@ import { Group, Member } from "../../interfaces/Groups";
 import { useNavigate } from "react-router-dom";
 import axios from "axios";
 import { TransferOwnershipModal } from "./TransferOwnershipModal";
+import { LeftRightLayout } from "../LeftRightLayout";
+import { StackedBorderBox } from "../StackedBorderBox";
 
 interface GroupSettingsProps {
   groupId: string | undefined;
 }
 
-const LeftRightLayout = ({
-  left,
-  right,
-}: {
-  left: React.ReactNode;
-  right: React.ReactNode;
-}) => {
-  return (
-    <SimpleGrid columns={2} spacing={8}>
-      <Box>{left}</Box>
-      <Box>{right}</Box>
-    </SimpleGrid>
-  );
-};
-
 export const GroupSettings = ({ groupId }: GroupSettingsProps) => {
   const [name, setName] = React.useState("");
   const [description, setDescription] = React.useState("");
@@ -131,7 +116,7 @@ export const GroupSettings = ({ groupId }: 
GroupSettingsProps) => {
     })();
   }, []);
 
-  const handleSaveChanges = async() => {
+  const handleSaveChanges = async () => {
     console.log(name, description);
 
     const resp = await customFetch(
@@ -140,11 +125,11 @@ export const GroupSettings = ({ groupId }: 
GroupSettingsProps) => {
         method: "PUT",
         body: JSON.stringify({
           name,
-          description
+          description,
         }),
         headers: {
-          "Content-Type": "application/json"
-        }
+          "Content-Type": "application/json",
+        },
       }
     );
 
@@ -155,22 +140,18 @@ export const GroupSettings = ({ groupId }: 
GroupSettingsProps) => {
       navigate(0);
     } else {
       toast({
-        title: 'Could not save group',
-        status: 'error',
+        title: "Could not save group",
+        status: "error",
         duration: 3000,
         isClosable: true,
       });
     }
-
-
-  }
+  };
 
   if (!groupId) {
     return;
   }
 
-
-
   return (
     <>
       <PageTitle size="md">Group Settings</PageTitle>
@@ -178,15 +159,7 @@ export const GroupSettings = ({ groupId }: 
GroupSettingsProps) => {
         Edit group membership, roles, and other information.
       </Text>
 
-      <Stack
-        border="1px solid"
-        borderColor="border.neutral.tertiary"
-        rounded="xl"
-        p={8}
-        mt={8}
-        divider={<Divider />}
-        spacing={8}
-      >
+      <StackedBorderBox>
         <LeftRightLayout
           left={<Text fontSize="lg">Basic Information</Text>}
           right={
@@ -349,7 +322,7 @@ export const GroupSettings = ({ groupId }: 
GroupSettingsProps) => {
             </Flex>
           }
         />
-      </Stack>
+      </StackedBorderBox>
 
       <Stack direction="row" mt={8} spacing={4}>
         <ActionButton onClick={handleSaveChanges}>Save Changes</ActionButton>
diff --git a/custos-portal/src/components/LeftRightLayout.tsx 
b/custos-portal/src/components/LeftRightLayout.tsx
new file mode 100644
index 000000000..a66603fd7
--- /dev/null
+++ b/custos-portal/src/components/LeftRightLayout.tsx
@@ -0,0 +1,16 @@
+import { SimpleGrid, Box } from "@chakra-ui/react";
+
+export const LeftRightLayout = ({
+  left,
+  right,
+}: {
+  left: React.ReactNode;
+  right: React.ReactNode;
+}) => {
+  return (
+    <SimpleGrid columns={2} spacing={8}>
+      <Box>{left}</Box>
+      <Box>{right}</Box>
+    </SimpleGrid>
+  );
+};
diff --git a/custos-portal/src/components/NavContainer.tsx 
b/custos-portal/src/components/NavContainer.tsx
index 138127596..e800485e9 100644
--- a/custos-portal/src/components/NavContainer.tsx
+++ b/custos-portal/src/components/NavContainer.tsx
@@ -17,7 +17,7 @@
  *  under the License.
  */
 
-import React, { useState, useEffect, memo } from 'react';
+import React, { useState, useEffect, memo } from "react";
 import {
   Grid,
   GridItem,
@@ -34,14 +34,20 @@ import {
   DrawerCloseButton,
   DrawerBody,
   useDisclosure,
-  Spacer
-} from '@chakra-ui/react';
-import { Link } from 'react-router-dom';
-import { FiUser, FiUsers, FiChevronLeft, FiChevronRight, FiMenu } from 
"react-icons/fi";
+  Spacer,
+} from "@chakra-ui/react";
+import { Link } from "react-router-dom";
+import {
+  FiUser,
+  FiUsers,
+  FiChevronLeft,
+  FiChevronRight,
+  FiMenu,
+} from "react-icons/fi";
 import { AiOutlineAppstore } from "react-icons/ai";
 import { IconType } from "react-icons";
 import { MdLogout } from "react-icons/md";
-import { useAuth } from 'react-oidc-context';
+import { useAuth } from "react-oidc-context";
 
 interface NavContainerProps {
   activeTab: string;
@@ -57,157 +63,211 @@ interface NavItemProps {
   onClose: () => void;
 }
 
-const NavItem = memo(({ to, icon, text, activeTab, isCollapsed, onClose }: 
NavItemProps) => {
-  const isActive = activeTab.toLowerCase() === text.toLowerCase();
-  return (
-    <Link to={to} onClick={onClose}>
-      <Stack
-        direction="row"
-        align="center"
-        color={isActive ? 'black' : 'default.secondary'}
-        py={2}
-        px={1}
-        _hover={{ bg: 'gray.100' }}
-        fontSize="sm"
-      >
-        <Icon as={icon} />
-        {!isCollapsed && <Text fontWeight="semibold">{text}</Text>}
-      </Stack>
-    </Link>
-  );
-});
+const NavItem = memo(
+  ({ to, icon, text, activeTab, isCollapsed, onClose }: NavItemProps) => {
+    const isActive = activeTab.toLowerCase() === text.toLowerCase();
+    return (
+      <Link to={to} onClick={onClose}>
+        <Stack
+          direction="row"
+          align="center"
+          color={isActive ? "black" : "default.secondary"}
+          py={2}
+          px={1}
+          _hover={{ bg: "gray.100" }}
+          fontSize="sm"
+        >
+          <Icon as={icon} />
+          {!isCollapsed && <Text fontWeight="semibold">{text}</Text>}
+        </Stack>
+      </Link>
+    );
+  }
+);
+
+export const NavContainer = memo(
+  ({ activeTab, children }: NavContainerProps) => {
+    const auth = useAuth();
 
-export const NavContainer = memo(({ activeTab, children }: NavContainerProps) 
=> {
-  const auth = useAuth();
-  
-  const [isCollapsed, setIsCollapsed] = useState(() => {
-    const saved = localStorage.getItem('navCollapsed');
-    return saved ? JSON.parse(saved) : false;
-  });
-  
-  const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
-  const { isOpen, onOpen, onClose } = useDisclosure();
+    const [isCollapsed, setIsCollapsed] = useState(() => {
+      const saved = localStorage.getItem("navCollapsed");
+      return saved ? JSON.parse(saved) : false;
+    });
 
-  useEffect(() => {
-    const handleResize = () => setIsMobile(window.innerWidth < 768);
-    window.addEventListener('resize', handleResize);
-    return () => window.removeEventListener('resize', handleResize);
-  }, []);
+    const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
+    const { isOpen, onOpen, onClose } = useDisclosure();
 
-  useEffect(() => {
-    localStorage.setItem('navCollapsed', JSON.stringify(isCollapsed));
-  }, [isCollapsed]);
+    useEffect(() => {
+      const handleResize = () => setIsMobile(window.innerWidth < 768);
+      window.addEventListener("resize", handleResize);
+      return () => window.removeEventListener("resize", handleResize);
+    }, []);
 
-  const toggleCollapse = () => {
-    setIsCollapsed((prev: boolean) => !prev);
-  };
+    useEffect(() => {
+      localStorage.setItem("navCollapsed", JSON.stringify(isCollapsed));
+    }, [isCollapsed]);
+
+    const toggleCollapse = () => {
+      setIsCollapsed((prev: boolean) => !prev);
+    };
+
+    if (isMobile) {
+      return (
+        <>
+          <Box position="fixed" top={4} left={4} zIndex={10}>
+            <Button onClick={onOpen} variant="ghost">
+              <Icon as={FiMenu} w={6} h={6} />
+            </Button>
+          </Box>
+          <Drawer isOpen={isOpen} placement="left" onClose={onClose}>
+            <DrawerOverlay />
+            <DrawerContent bg="#F7F7F7">
+              <DrawerCloseButton />
+              <DrawerBody p={4} display="flex" flexDirection="column">
+                <Heading size="md">Custos Auth Portal</Heading>
+                <Stack direction="column" mt={4}>
+                  <NavItem
+                    to="/applications"
+                    icon={AiOutlineAppstore}
+                    text="Applications"
+                    activeTab={activeTab}
+                    isCollapsed={false}
+                    onClose={onClose}
+                  />
+                  <NavItem
+                    to="/groups"
+                    icon={FiUsers}
+                    text="Groups"
+                    activeTab={activeTab}
+                    isCollapsed={false}
+                    onClose={onClose}
+                  />
+                  <NavItem
+                    to="/users"
+                    icon={FiUser}
+                    text="Users"
+                    activeTab={activeTab}
+                    isCollapsed={false}
+                    onClose={onClose}
+                  />
+                </Stack>
+                <Spacer />
+                <Box>
+                  <Text fontWeight="bold">{auth.user?.profile?.name}</Text>
+                  <Text fontSize="sm" color="gray.500">
+                    {auth.user?.profile?.email}
+                  </Text>
+                  <Button
+                    variant="unstyled"
+                    w="fit-content"
+                    size="sm"
+                    _hover={{ color: "gray.500" }}
+                    onClick={async () => {
+                      await auth.removeUser();
+                      onClose();
+                    }}
+                  >
+                    <Flex alignItems="center" gap={2} w="fit-content">
+                      <Icon as={MdLogout} />
+                      <Text as="span">Logout</Text>
+                    </Flex>
+                  </Button>
+                </Box>
+              </DrawerBody>
+            </DrawerContent>
+          </Drawer>
+          <Box p={5} pt={16}>
+            {children}
+          </Box>
+        </>
+      );
+    }
 
-  if (isMobile) {
     return (
-      <>
-        <Box position="fixed" top={4} left={4} zIndex={10}>
-          <Button onClick={onOpen} variant="ghost">
-            <Icon as={FiMenu} w={6} h={6} />
-          </Button>
-        </Box>
-        <Drawer isOpen={isOpen} placement="left" onClose={onClose}>
-          <DrawerOverlay />
-          <DrawerContent bg="#F7F7F7">
-            <DrawerCloseButton />
-            <DrawerBody p={4} display="flex" flexDirection="column">
-              <Heading size="md">Custos Auth Portal</Heading>
+      <Grid templateColumns="repeat(15, 1fr)" minHeight="100vh">
+        <GridItem
+          colSpan={isCollapsed ? 1 : 3}
+          minWidth={isCollapsed ? "60px" : "240px"}
+          maxWidth={isCollapsed ? "60px" : "240px"}
+          bg="#F7F7F7"
+          position="fixed"
+          h="100vh"
+        >
+          <Flex
+            h="100vh"
+            p={4}
+            direction="column"
+            justifyContent="space-between"
+          >
+            <Box>
+              <Flex justifyContent="space-between" align="center">
+                {!isCollapsed && <Heading size="md">Custos Portal</Heading>}
+                <Button variant="ghost" onClick={toggleCollapse} size="sm">
+                  <Icon as={isCollapsed ? FiChevronRight : FiChevronLeft} />
+                </Button>
+              </Flex>
               <Stack direction="column" mt={4}>
-                <NavItem to="/applications" icon={AiOutlineAppstore} 
text="Applications" activeTab={activeTab} isCollapsed={false} onClose={onClose} 
/>
-                <NavItem to="/groups" icon={FiUsers} text="Groups" 
activeTab={activeTab} isCollapsed={false} onClose={onClose} />
-                <NavItem to="/users" icon={FiUser} text="Users" 
activeTab={activeTab} isCollapsed={false} onClose={onClose} />
+                <NavItem
+                  to="/applications"
+                  icon={AiOutlineAppstore}
+                  text="Applications"
+                  activeTab={activeTab}
+                  isCollapsed={isCollapsed}
+                  onClose={onClose}
+                />
+                <NavItem
+                  to="/groups"
+                  icon={FiUsers}
+                  text="Groups"
+                  activeTab={activeTab}
+                  isCollapsed={isCollapsed}
+                  onClose={onClose}
+                />
+                <NavItem
+                  to="/users"
+                  icon={FiUser}
+                  text="Users"
+                  activeTab={activeTab}
+                  isCollapsed={isCollapsed}
+                  onClose={onClose}
+                />
               </Stack>
-              <Spacer />
-              <Box>
-                <Text fontWeight="bold">{auth.user?.profile?.name}</Text>
-                <Text fontSize="sm" 
color="gray.500">{auth.user?.profile?.email}</Text>
-                <Button
-                  variant="unstyled"
-                  w="fit-content"
-                  size="sm"
-                  _hover={{ color: "gray.500" }}
-                  onClick={async () => {
-                    await auth.removeUser();
-                    onClose();
-                  }}
-                >
-                  <Flex alignItems="center" gap={2} w="fit-content">
-                    <Icon as={MdLogout} />
-                    <Text as="span">Logout</Text>
-                  </Flex>
-                </Button>
-              </Box>
-            </DrawerBody>
-          </DrawerContent>
-        </Drawer>
-        <Box p={5} pt={16}>
+            </Box>
+            <Box mt="auto">
+              {!isCollapsed && (
+                <>
+                  <Text fontWeight="bold">{auth.user?.profile?.name}</Text>
+                  <Text fontSize="sm" color="gray.500">
+                    {auth.user?.profile?.email}
+                  </Text>
+                </>
+              )}
+              <Button
+                variant="unstyled"
+                w="fit-content"
+                size="sm"
+                _hover={{ color: "gray.500" }}
+                onClick={async () => {
+                  await auth.removeUser();
+                }}
+              >
+                <Flex alignItems="center" gap={2} w="fit-content">
+                  <Icon as={MdLogout} />
+                  {!isCollapsed && <Text as="span">Logout</Text>}
+                </Flex>
+              </Button>
+            </Box>
+          </Flex>
+        </GridItem>
+        <GridItem
+          colSpan={isCollapsed ? 14 : 15}
+          p={10}
+          ml={isCollapsed ? "60px" : "240px"}
+          minWidth="0"
+        >
           {children}
-        </Box>
-      </>
+        </GridItem>
+      </Grid>
     );
   }
-
-  return (
-    <Grid templateColumns="repeat(15, 1fr)" minHeight="100vh">
-      <GridItem
-        colSpan={isCollapsed ? 1 : 3}
-        minWidth={isCollapsed ? "60px" : "240px"}
-        maxWidth={isCollapsed ? "60px" : "240px"}
-        bg="#F7F7F7"
-        position="fixed"
-        h="100vh"
-      >
-        <Flex h="100vh" p={4} direction="column" 
justifyContent="space-between">
-          <Box>
-            <Flex justifyContent="space-between" align="center">
-              {!isCollapsed && <Heading size="md">Custos Auth Portal</Heading>}
-              <Button variant="ghost" onClick={toggleCollapse} size="sm">
-                <Icon as={isCollapsed ? FiChevronRight : FiChevronLeft} />
-              </Button>
-            </Flex>
-            <Stack direction="column" mt={4}>
-              <NavItem to="/applications" icon={AiOutlineAppstore} 
text="Applications" activeTab={activeTab} isCollapsed={isCollapsed} 
onClose={onClose} />
-              <NavItem to="/groups" icon={FiUsers} text="Groups" 
activeTab={activeTab} isCollapsed={isCollapsed} onClose={onClose} />
-              <NavItem to="/users" icon={FiUser} text="Users" 
activeTab={activeTab} isCollapsed={isCollapsed} onClose={onClose} />
-            </Stack>
-          </Box>
-          <Box mt="auto">
-            {!isCollapsed && (
-              <>
-                <Text fontWeight="bold">{auth.user?.profile?.name}</Text>
-                <Text fontSize="sm" 
color="gray.500">{auth.user?.profile?.email}</Text>
-              </>
-            )}
-            <Button
-              variant="unstyled"
-              w="fit-content"
-              size="sm"
-              _hover={{ color: "gray.500" }}
-              onClick={async () => {
-                await auth.removeUser();
-              }}
-            >
-              <Flex alignItems="center" gap={2} w="fit-content">
-                <Icon as={MdLogout} />
-                {!isCollapsed && <Text as="span">Logout</Text>}
-              </Flex>
-            </Button>
-          </Box>
-        </Flex>
-      </GridItem>
-      <GridItem
-        colSpan={isCollapsed ? 14 : 12}
-        p={10} 
-        ml={isCollapsed ? "60px" : "240px"}
-        minWidth="0"
-        overflowY="auto"
-      >
-        {children}
-      </GridItem>
-    </Grid>
-  );
-});
+);
diff --git a/custos-portal/src/components/StackedBorderBox.tsx 
b/custos-portal/src/components/StackedBorderBox.tsx
new file mode 100644
index 000000000..3a80de698
--- /dev/null
+++ b/custos-portal/src/components/StackedBorderBox.tsx
@@ -0,0 +1,23 @@
+import { Divider, Stack } from "@chakra-ui/react";
+
+export const StackedBorderBox = ({
+  children,
+}: {
+  children: React.ReactNode;
+}) => {
+  return (
+    <>
+      <Stack
+        border="1px solid"
+        borderColor="border.neutral.tertiary"
+        rounded="xl"
+        p={8}
+        mt={8}
+        divider={<Divider />}
+        spacing={8}
+      >
+        {children}
+      </Stack>
+    </>
+  );
+};
diff --git a/custos-portal/src/components/Users/UserSettings.tsx 
b/custos-portal/src/components/Users/UserSettings.tsx
new file mode 100644
index 000000000..ba768d2ac
--- /dev/null
+++ b/custos-portal/src/components/Users/UserSettings.tsx
@@ -0,0 +1,196 @@
+import { NavContainer } from "../NavContainer";
+import {
+  Box,
+  Flex,
+  Text,
+  Input,
+  Icon,
+  TableContainer,
+  Table,
+  Thead,
+  Tr,
+  Th,
+  Td,
+  Tbody,
+  Stack,
+  FormControl,
+  FormLabel,
+  IconButton,
+  Code,
+} from "@chakra-ui/react";
+import { PageTitle } from "../PageTitle";
+import { ActionButton } from "../ActionButton";
+import { Link, useParams } from "react-router-dom";
+import { FaArrowLeft } from "react-icons/fa6";
+import { LeftRightLayout } from "../LeftRightLayout";
+import { FiTrash2 } from "react-icons/fi";
+import { StackedBorderBox } from "../StackedBorderBox";
+
+const DUMMY_ROLES: any = [
+  {
+    application: "Grafana",
+    role: "grafana:viewer",
+    description: "Grafana Viewer",
+  },
+  {
+    application: "Grafana",
+    role: "grafana:editor",
+    description: "Grafana Editor",
+  },
+  {
+    application: "Grafana",
+    role: "grafana:admin",
+    description: "Grafana Admin",
+  },
+];
+
+const DUMMY_ACTIVITY: any = [
+  {
+    action: "User Created",
+    timestamp: "2021-10-01",
+  },
+  {
+    action: "User Disabled",
+    timestamp: "2021-10-01",
+  },
+  {
+    action: "User Enabled",
+    timestamp: "2021-10-01",
+  },
+];
+
+export const UserSettings = () => {
+  const { email } = useParams();
+
+  return (
+    <>
+      <NavContainer activeTab="Users">
+        <Link to="/users">
+          <Flex alignItems="center" gap={2} color="default.secondary">
+            <Icon as={FaArrowLeft} />
+            <Text fontWeight="bold" fontSize="sm">
+              Back to Users
+            </Text>
+          </Flex>
+        </Link>
+
+        <Flex mt={4} justify="space-between">
+          <Box>
+            <PageTitle>John Doe</PageTitle>
+            <Text color="default.secondary" mt={2}>
+              {email}
+            </Text>
+          </Box>
+          <ActionButton icon={FiTrash2} onClick={() => {}}>
+            Disable User
+          </ActionButton>
+        </Flex>
+
+        <StackedBorderBox>
+          <LeftRightLayout
+            left={<Text fontSize="lg">Basic Information</Text>}
+            right={
+              <>
+                <Stack spacing={4}>
+                  <FormControl color="default.default">
+                    <FormLabel>Name</FormLabel>
+                    <Input type="text" />
+                  </FormControl>
+                  <FormControl>
+                    <FormLabel>Email</FormLabel>
+                    <Input type="text" />
+                  </FormControl>
+                  <FormControl>
+                    <FormLabel>Joined</FormLabel>
+                    <Input type="text" disabled={true} />
+                  </FormControl>
+                  <FormControl>
+                    <FormLabel>Last Signed In</FormLabel>
+                    <Input type="text" disabled={true} />
+                  </FormControl>
+                </Stack>
+              </>
+            }
+          />
+
+          <Box>
+            <Text fontSize="lg">Groups</Text>
+            <TableContainer mt={4}>
+              <Table variant="simple">
+                <Thead>
+                  <Tr>
+                    <Th>Name</Th>
+                    <Th>Role</Th>
+                    <Th>Owner</Th>
+                    <Th>Actions</Th>
+                  </Tr>
+                </Thead>
+                <Tbody>
+                  <Tr>
+                    <Td>
+                      <Link to="/groups/1">Group 1</Link>
+                    </Td>
+                    <Td>Admin</Td>
+                    <Td>Stella Zhou</Td>
+                    <Td>
+                      {/* remove icon */}
+                      <IconButton
+                        aria-label="Delete Role"
+                        icon={<FiTrash2 />}
+                        size="sm"
+                        bg=""
+                      />
+                    </Td>
+                  </Tr>
+                </Tbody>
+              </Table>
+            </TableContainer>
+          </Box>
+
+          <Box>
+            <Text fontSize="lg">Roles</Text>
+            <Text mt={2} color="gray.600">
+              Through their group memberships, this user has the following 
roles
+            </Text>
+
+            <TableContainer mt={4}>
+              <Table variant="simple">
+                <Thead>
+                  <Tr>
+                    <Th>Application</Th>
+                    <Th>Role</Th>
+                    <Th>Description</Th>
+                  </Tr>
+                </Thead>
+                <Tbody>
+                  {DUMMY_ROLES.map((role: any) => (
+                    <Tr key={role.role}>
+                      <Td>{role.application}</Td>
+                      <Td>
+                        <Code>{role.role}</Code>
+                      </Td>
+                      <Td>{role.description}</Td>
+                    </Tr>
+                  ))}
+                </Tbody>
+              </Table>
+            </TableContainer>
+          </Box>
+
+          <Box>
+            <Text fontSize="lg">Activity</Text>
+            {
+              // eslint-disable-next-line @typescript-eslint/no-explicit-any
+              DUMMY_ACTIVITY.map((activity: any) => (
+                <Flex key={activity.action} gap={4} mt={4}>
+                  <Text color="gray.400">{activity.timestamp}</Text>
+                  <Text fontWeight="bold">{activity.action}</Text>
+                </Flex>
+              ))
+            }
+          </Box>
+        </StackedBorderBox>
+      </NavContainer>
+    </>
+  );
+};
diff --git a/custos-portal/src/components/Users/index.tsx 
b/custos-portal/src/components/Users/index.tsx
new file mode 100644
index 000000000..6715c5bb9
--- /dev/null
+++ b/custos-portal/src/components/Users/index.tsx
@@ -0,0 +1,117 @@
+import { NavContainer } from "../NavContainer";
+import {
+  Box,
+  Flex,
+  Text,
+  Input,
+  InputGroup,
+  InputRightElement,
+  Icon,
+  TableContainer,
+  Table,
+  Thead,
+  Tr,
+  Th,
+  Td,
+  Tbody,
+} from "@chakra-ui/react";
+import { PageTitle } from "../PageTitle";
+import { ActionButton } from "../ActionButton";
+import { CiSearch } from "react-icons/ci";
+import { User } from "../../interfaces/Users";
+import { Link } from "react-router-dom";
+
+const DUMMY_DATA: User[] = [
+  {
+    name: "Stella Zhou",
+    email: "[email protected]",
+    joined: "2021-10-01",
+    lastSignedIn: "2021-10-01",
+  },
+  {
+    name: "John Doe",
+    email: "[email protected]",
+    joined: "2021-10-01",
+    lastSignedIn: "2021-10-01",
+  },
+  {
+    name: "Jane Doe",
+    email: "[email protected]",
+    joined: "2021-10-01",
+    lastSignedIn: "2021-10-01",
+  },
+];
+
+export const Users = () => {
+  return (
+    <>
+      <NavContainer activeTab="Users">
+        <Flex justifyContent="space-between" alignItems="flex-start">
+          <Box>
+            <PageTitle>Users</PageTitle>
+            <Text color="gray.500" mt={2}>
+              View and manage the list of all end users.
+            </Text>
+          </Box>
+        </Flex>
+
+        <InputGroup mt={4}>
+          <InputRightElement pointerEvents="none">
+            <Icon as={CiSearch} color="black" />
+          </InputRightElement>
+          <Input
+            type="text"
+            placeholder="Search users"
+            _focus={{
+              borderColor: "black",
+            }}
+            _hover={{
+              borderColor: "black",
+            }}
+          />
+        </InputGroup>
+
+        {/* TABLE */}
+        <TableContainer mt={4}>
+          <Table variant="simple">
+            <Thead>
+              <Tr>
+                <Th>Name</Th>
+                <Th>Email</Th>
+                <Th>Joined</Th>
+                <Th>Last Signed In</Th>
+                <Th>Actions</Th>
+              </Tr>
+            </Thead>
+
+            <Tbody>
+              {DUMMY_DATA.map((user) => (
+                <Tr key={user.email}>
+                  <Td>
+                    <Link to={`/users/${user.email}`}>
+                      <Text
+                        color="blue.400"
+                        _hover={{
+                          color: "blue.600",
+                          cursor: "pointer",
+                        }}
+                      >
+                        {user.name}
+                      </Text>
+                    </Link>
+                  </Td>
+                  <Td>{user.email}</Td>
+                  <Td>{user.joined}</Td>
+                  <Td>{user.lastSignedIn}</Td>
+                  <Td>
+                    <ActionButton onClick={() => {}}>Disable</ActionButton>
+                  </Td>
+                </Tr>
+              ))}
+            </Tbody>
+          </Table>
+        </TableContainer>
+      </NavContainer>
+    </>
+  );
+};
diff --git a/custos-portal/src/index.tsx b/custos-portal/src/index.tsx
index 16c862ba4..b4e855719 100644
--- a/custos-portal/src/index.tsx
+++ b/custos-portal/src/index.tsx
@@ -17,29 +17,33 @@
  *  under the License.
  */
 
-import { createRoot } from 'react-dom/client';
-import App from './App';
-import { extendTheme, ChakraProvider } from '@chakra-ui/react';
-import { AuthProvider, AuthProviderProps } from 'react-oidc-context';
-import { APP_REDIRECT_URI, BACKEND_URL, CLIENT_ID, TENANT_ID } from 
'./lib/constants';
-import { WebStorageStateStore } from 'oidc-client-ts';
-import { useEffect, useState } from 'react';
-import localOidcConfig from './lib/localOidcConfig.json';
+import { createRoot } from "react-dom/client";
+import App from "./App";
+import { extendTheme, ChakraProvider } from "@chakra-ui/react";
+import { AuthProvider, AuthProviderProps } from "react-oidc-context";
+import {
+  APP_REDIRECT_URI,
+  BACKEND_URL,
+  CLIENT_ID,
+  TENANT_ID,
+} from "./lib/constants";
+import { WebStorageStateStore } from "oidc-client-ts";
+import { useEffect, useState } from "react";
 
 const theme = extendTheme({
   colors: {
     default: {
-      "default": "#1E1E1E",
-      "secondary": "#757575",
-      "tertiary": "#B3B3B3"
+      default: "#1E1E1E",
+      secondary: "#757575",
+      tertiary: "#B3B3B3",
     },
     border: {
       neutral: {
-        "default": "#303030",
-        "secondary": "#767676",
-        "tertiary": "#B2B2B2",
-      }
-    }
+        default: "#303030",
+        secondary: "#767676",
+        tertiary: "#B2B2B2",
+      },
+    },
   },
 });
 
@@ -50,7 +54,9 @@ const Index = () => {
     const fetchOidcConfig = async () => {
       try {
         let data;
-        const response = await 
fetch(`${BACKEND_URL}/api/v1/identity-management/tenant/${TENANT_ID}/.well-known/openid-configuration`);
 // Replace with actual API endpoint
+        const response = await fetch(
+          
`${BACKEND_URL}/api/v1/identity-management/tenant/${TENANT_ID}/.well-known/openid-configuration`
+        ); // Replace with actual API endpoint
         data = await response.json();
         const redirectUri = APP_REDIRECT_URI;
 
@@ -58,8 +64,8 @@ const Index = () => {
           authority: `${BACKEND_URL}/api/v1/identity-management/`,
           client_id: CLIENT_ID,
           redirect_uri: redirectUri,
-          response_type: 'code',
-          scope: 'openid email',
+          response_type: "code",
+          scope: "openid email",
           metadata: {
             authorization_endpoint: data.authorization_endpoint,
             token_endpoint: data.token_endpoint,
@@ -74,7 +80,7 @@ const Index = () => {
 
         setOidcConfig(theConfig);
       } catch (error) {
-        console.error('Error fetching OIDC config:', error);
+        console.error("Error fetching OIDC config:", error);
       }
     };
 
@@ -90,8 +96,8 @@ const Index = () => {
       <AuthProvider
         {...oidcConfig}
         onSigninCallback={async (user) => {
-          console.log('User signed in', user);
-          window.location.href = '/groups';
+          console.log("User signed in", user);
+          window.location.href = "/groups";
         }}
       >
         <App />
@@ -100,6 +106,6 @@ const Index = () => {
   );
 };
 
-const container = document.getElementById('root') as HTMLElement;
+const container = document.getElementById("root") as HTMLElement;
 const root = createRoot(container);
 root.render(<Index />);
diff --git a/custos-portal/src/interfaces/Users.tsx 
b/custos-portal/src/interfaces/Users.tsx
new file mode 100644
index 000000000..83bf47471
--- /dev/null
+++ b/custos-portal/src/interfaces/Users.tsx
@@ -0,0 +1,6 @@
+export interface User {
+  name: string;
+  email: string;
+  joined: string;
+  lastSignedIn: string;
+}
diff --git a/custos-portal/src/lib/constants.ts 
b/custos-portal/src/lib/constants.ts
index 11ccbe5e1..652cc4cd5 100644
--- a/custos-portal/src/lib/constants.ts
+++ b/custos-portal/src/lib/constants.ts
@@ -20,13 +20,9 @@
 import packageJson from '../../package.json';
 
 export const PORTAL_VERSION = packageJson.version;
-export let CLIENT_ID:string;
-export let BACKEND_URL:string;
-export let APP_URL:string;
+export const CLIENT_ID = 'custos-gcq8jxkwpvs2gcudzmfn-10000000';;
+export const BACKEND_URL = 'http://localhost:8081';
+export const APP_URL = "http://localhost:5173";;
 
-    CLIENT_ID = 'custos-kgap8hu6ih4hddvlzzlb-10000000';
-    BACKEND_URL = 'https://api.playground.usecustos.org';
-    APP_URL = 'http://localhost:5173'
-
-export const APP_REDIRECT_URI = `${APP_URL}/oauth-callback`;
+export const APP_REDIRECT_URI = `${APP_URL}/callback/`;
 export const TENANT_ID = '10000000';
\ No newline at end of file

Reply via email to