diff --git a/.dockerignore b/.dockerignore
index ce4738ec2e..8c611ae669 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -9,3 +9,5 @@
!CHANGELOG.md
!LICENSE
!README.md
+!frontend
+frontend/node_modules
diff --git a/.eslintignore b/.eslintignore
index 62ea7713bb..a5d4f44329 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -10,3 +10,4 @@ website/core
website/pages
website
setupJest.js
+frontend
diff --git a/.github/workflows/build_coverage.yaml b/.github/workflows/build_coverage.yaml
index a785212973..90d2066ee6 100644
--- a/.github/workflows/build_coverage.yaml
+++ b/.github/workflows/build_coverage.yaml
@@ -6,6 +6,7 @@ on:
- main
paths-ignore:
- website/**
+ - frontend/**
- coverage/**
jobs:
diff --git a/.github/workflows/build_frontend_prs.yml b/.github/workflows/build_frontend_prs.yml
new file mode 100644
index 0000000000..caf5ca3610
--- /dev/null
+++ b/.github/workflows/build_frontend_prs.yml
@@ -0,0 +1,25 @@
+name: PR -> Frontend Build & Test
+
+on:
+ pull_request:
+ paths:
+ - frontend/**
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: frontend
+ strategy:
+ matrix:
+ node-version: [14.x]
+ steps:
+ - uses: actions/checkout@v3
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v3
+ with:
+ node-version: ${{ matrix.node-version }}
+ - run: yarn --frozen-lockfile
+ - run: yarn run test
+ - run: yarn run fmt:check
diff --git a/.github/workflows/build_prs.yaml b/.github/workflows/build_prs.yaml
index 39f18db44a..24b108ad93 100644
--- a/.github/workflows/build_prs.yaml
+++ b/.github/workflows/build_prs.yaml
@@ -17,6 +17,6 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
- - run: yarn
+ - run: yarn install --frozen-lockfile --ignore-scripts
- run: yarn lint
- run: yarn build
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index afea456143..3e2c3b8986 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -28,3 +28,14 @@ jobs:
npm publish --tag ${TAG:-latest}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ - uses: aws-actions/configure-aws-credentials@v1
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-region: ${{ secrets.AWS_DEFAULT_REGION }}
+ - name: Get the version
+ id: get_version
+ run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}
+ - name: Publish static assets to S3
+ run: |
+ aws s3 cp frontend/build s3://getunleash-static/unleash/${{ steps.get_version.outputs.VERSION }} --recursive
diff --git a/.gitignore b/.gitignore
index 81bc76805d..4ab27b404f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,10 +23,6 @@ coverage/lcov-report
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
-# webpack output
-packages/unleash-frontend/public/bundle.js
-packages/unleash-frontend/public/bundle.js.map
-
# liquibase stuff
/sql
unleash-db.jar
diff --git a/Dockerfile b/Dockerfile
index f875b773c0..5a322f517c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -7,7 +7,7 @@ WORKDIR /unleash
COPY . /unleash
-RUN yarn install --frozen-lockfile --ignore-scripts && yarn run build && yarn run local:package
+RUN yarn install --frozen-lockfile && yarn run local:package
WORKDIR /unleash/docker
diff --git a/docker/package.json b/docker/package.json
index 7d55dcf9c1..24a9cd7e3a 100644
--- a/docker/package.json
+++ b/docker/package.json
@@ -17,7 +17,7 @@
"@passport-next/passport-google-oauth2": "^1.0.0",
"basic-auth": "^2.0.1",
"passport": "^0.6.0",
- "unleash-server": "file:./../build/"
+ "unleash-server": "file:../build"
},
"resolutions": {
"async": "^3.2.3",
diff --git a/docker/yarn.lock b/docker/yarn.lock
index 04769d0833..9fed7f479a 100644
--- a/docker/yarn.lock
+++ b/docker/yarn.lock
@@ -52,9 +52,9 @@
integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==
"@hapi/hoek@^9.0.0":
- version "9.2.1"
- resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.2.1.tgz#9551142a1980503752536b5050fd99f4a7f13b17"
- integrity sha512-gfta+H8aziZsm8pZa0vj04KO6biEiisppNgA1kbJvFrrWu9Vm7eaUEy76DIxsuTaWvti5fkJVhllWc6ZTE+Mdw==
+ version "9.3.0"
+ resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb"
+ integrity sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==
"@hapi/topo@^5.0.0":
version "5.1.0"
@@ -69,17 +69,17 @@
integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==
"@npmcli/fs@^2.1.0":
- version "2.1.0"
- resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-2.1.0.tgz#f2a21c28386e299d1a9fae8051d35ad180e33109"
- integrity sha512-DmfBvNXGaetMxj9LTp8NAN9vEidXURrf5ZTslQzEAi/6GbW+4yjaLFQc6Tue5cpZ9Frlk4OBo/Snf1Bh/S7qTQ==
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-2.1.2.tgz#a9e2541a4a2fec2e69c29b35e6060973da79b865"
+ integrity sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==
dependencies:
"@gar/promisify" "^1.1.3"
semver "^7.3.5"
"@npmcli/move-file@^2.0.0":
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-2.0.0.tgz#417f585016081a0184cef3e38902cd917a9bbd02"
- integrity sha512-UR6D5f4KEGWJV6BGPH3Qb2EtgH+t+1XQ1Tt85c7qicN6cezzuHPdZwwAxqZr4JLtnQu0LZsTza/5gmNmSl8XLg==
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-2.0.1.tgz#26f6bdc379d87f75e55739bab89db525b06100e4"
+ integrity sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==
dependencies:
mkdirp "^1.0.4"
rimraf "^3.0.2"
@@ -227,7 +227,7 @@ ansi-styles@^4.0.0:
append-field@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56"
- integrity sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY=
+ integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==
argparse@^2.0.1:
version "2.0.1"
@@ -237,12 +237,12 @@ argparse@^2.0.1:
arr-union@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
- integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=
+ integrity sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==
array-flatten@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
- integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=
+ integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==
array-flatten@3.0.0:
version "3.0.0"
@@ -256,7 +256,7 @@ asn1@^0.2.4:
dependencies:
safer-buffer "~2.1.0"
-async@^3.2.3, async@~0.9.0, async@~1.0.0:
+async@3.2.3, async@^3.2.3, async@^3.2.4:
version "3.2.3"
resolved "https://registry.yarnpkg.com/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9"
integrity sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==
@@ -281,19 +281,19 @@ basic-auth@^2.0.1:
bcrypt-pbkdf@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
- integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=
+ integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==
dependencies:
tweetnacl "^0.14.3"
bcryptjs@^2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb"
- integrity sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=
+ integrity sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==
-bintrees@1.0.1:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.1.tgz#0e655c9b9c2435eaab68bf4027226d2b55a34524"
- integrity sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ=
+bintrees@1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.2.tgz#49f896d6e858a4a499df85c38fb399b9aff840f8"
+ integrity sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==
bluebird@^3.1.1, bluebird@^3.7.2:
version "3.7.2"
@@ -305,21 +305,23 @@ blueimp-md5@^2.10.0:
resolved "https://registry.yarnpkg.com/blueimp-md5/-/blueimp-md5-2.19.0.tgz#b53feea5498dcb53dc6ec4b823adb84b729c4af0"
integrity sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==
-body-parser@1.19.2:
- version "1.19.2"
- resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.2.tgz#4714ccd9c157d44797b8b5607d72c0b89952f26e"
- integrity sha512-SAAwOxgoCKMGs9uUAUFHygfLAyaniaoun6I8mFY9pRAJL9+Kec34aU+oIjDhTycub1jozEfEwx1W1IuOYxVSFw==
+body-parser@1.20.0:
+ version "1.20.0"
+ resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.0.tgz#3de69bd89011c11573d7bfee6a64f11b6bd27cc5"
+ integrity sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==
dependencies:
bytes "3.1.2"
content-type "~1.0.4"
debug "2.6.9"
- depd "~1.1.2"
- http-errors "1.8.1"
+ depd "2.0.0"
+ destroy "1.2.0"
+ http-errors "2.0.0"
iconv-lite "0.4.24"
- on-finished "~2.3.0"
- qs "6.9.7"
- raw-body "2.4.3"
+ on-finished "2.4.1"
+ qs "6.10.3"
+ raw-body "2.5.1"
type-is "~1.6.18"
+ unpipe "1.0.0"
brace-expansion@^1.1.7:
version "1.1.11"
@@ -361,17 +363,17 @@ busboy@^1.0.0:
bytes@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
- integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=
+ integrity sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==
bytes@3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
-cacache@^16.0.2:
- version "16.0.7"
- resolved "https://registry.yarnpkg.com/cacache/-/cacache-16.0.7.tgz#74a5d9bc4c17b4c0b373c1f5d42dadf5dc06638d"
- integrity sha512-a4zfQpp5vm4Ipdvbj+ZrPonikRhm6WBEd4zT1Yc1DXsmAxrPgDwWBLF/u/wTVXSFPIgOJ1U3ghSa2Xm4s3h28w==
+cacache@^16.1.0:
+ version "16.1.3"
+ resolved "https://registry.yarnpkg.com/cacache/-/cacache-16.1.3.tgz#a02b9f34ecfaf9a78c9f4bc16fceb94d5d67a38e"
+ integrity sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==
dependencies:
"@npmcli/fs" "^2.1.0"
"@npmcli/move-file" "^2.0.0"
@@ -390,12 +392,20 @@ cacache@^16.0.2:
rimraf "^3.0.2"
ssri "^9.0.0"
tar "^6.1.11"
- unique-filename "^1.1.1"
+ unique-filename "^2.0.0"
+
+call-bind@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
+ integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==
+ dependencies:
+ function-bind "^1.1.1"
+ get-intrinsic "^1.0.2"
call-me-maybe@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b"
- integrity sha1-JtII6onje1y95gJQoV8DHBak1ms=
+ integrity sha512-wCyFsDQkKPwwF8BDwOiWNx/9K45L/hvggQiDbve+viMNMQnWhrlYIuBk09offfwCRtCO9P6XwUttufzU11WCVw==
camelcase@^5.0.0:
version "5.3.1"
@@ -424,7 +434,7 @@ cliui@^6.0.0:
clone-deep@^0.2.4:
version "0.2.4"
resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-0.2.4.tgz#4e73dd09e9fb971cc38670c5dced9c1896481cc6"
- integrity sha1-TnPdCen7lxzDhnDF3O2cGJZIHMY=
+ integrity sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==
dependencies:
for-own "^0.1.3"
is-plain-object "^2.0.1"
@@ -444,30 +454,25 @@ color-name@~1.1.4:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
-colorette@2.0.16:
- version "2.0.16"
- resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.16.tgz#713b9af84fdb000139f04546bd4a93f62a5085da"
- integrity sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==
+colorette@2.0.19:
+ version "2.0.19"
+ resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798"
+ integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==
colors@1.0.x:
version "1.0.3"
resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b"
- integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=
+ integrity sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==
commander@^2.20.3:
version "2.20.3"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
-commander@^7.1.0:
- version "7.2.0"
- resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
- integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
-
commander@^9.1.0:
- version "9.2.0"
- resolved "https://registry.yarnpkg.com/commander/-/commander-9.2.0.tgz#6e21014b2ed90d8b7c9647230d8b7a94a4a419a9"
- integrity sha512-e2i4wANQiSXgnrBlIatyHtP1odfUp0BbV5Y5nEGbxtIrStkEOAAzCUirvLBNXHLr7kwLvJl6V+4V3XV9x7Wd9w==
+ version "9.4.0"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-9.4.0.tgz#bc4a40918fefe52e22450c111ecd6b7acce6f11c"
+ integrity sha512-sRPT+umqkz90UA8M1yqYfnHlZA7fF6nSphDtxeywPZ49ysjxDQybzk13CL+mXekDRG92skbcqCLVovuCusNmFw==
compressible@~2.0.16:
version "2.0.18"
@@ -492,7 +497,7 @@ compression@^1.7.4:
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
- integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
+ integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
concat-stream@^1.5.2:
version "1.6.2"
@@ -504,13 +509,13 @@ concat-stream@^1.5.2:
readable-stream "^2.2.2"
typedarray "^0.0.6"
-connect-session-knex@^2.1.0:
- version "2.1.1"
- resolved "https://registry.yarnpkg.com/connect-session-knex/-/connect-session-knex-2.1.1.tgz#3543417a0c2bb6700b219e3c88f4e1b4b304f09a"
- integrity sha512-gIOqwoU4mWe9uwkWsnBI9KsBr2sYp0IyXX6NJG7oGW6wJjy5CpWufB3FoJPEYb2OqNPMmshr07vS12pcMfok2g==
+connect-session-knex@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/connect-session-knex/-/connect-session-knex-3.0.0.tgz#23fb0fe086cdfbf2c1db9aac56fc3b0c30530810"
+ integrity sha512-2XmV0ELjLvtda1MhRQhTfdjLhZ3My6Ebz7TCO718O2MEo0+l6Vn3UQVpvu9wLu/dNFUnfE3UgsJIk2ZJ/OR6pA==
dependencies:
bluebird "^3.7.2"
- knex "^0.95.6"
+ knex "^2.0.0"
content-disposition@0.5.4:
version "0.5.4"
@@ -545,7 +550,7 @@ cookie-session@^2.0.0-rc.1:
cookie-signature@1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
- integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
+ integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==
cookie@0.4.1:
version "0.4.1"
@@ -557,6 +562,11 @@ cookie@0.4.2:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==
+cookie@0.5.0:
+ version "0.5.0"
+ resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
+ integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
+
cookies@0.8.0:
version "0.8.0"
resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90"
@@ -589,7 +599,7 @@ cpu-features@~0.0.4:
cycle@1.0.x:
version "1.0.3"
resolved "https://registry.yarnpkg.com/cycle/-/cycle-1.0.3.tgz#21e80b2be8580f98b468f379430662b046c34ad2"
- integrity sha1-IegLK+hYD5i0aPN5QwZisEbDStI=
+ integrity sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA==
d@1, d@^1.0.1:
version "1.0.1"
@@ -600,14 +610,14 @@ d@1, d@^1.0.1:
type "^1.0.1"
date-fns@^2.25.0:
- version "2.28.0"
- resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2"
- integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==
+ version "2.29.2"
+ resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.2.tgz#0d4b3d0f3dff0f920820a070920f0d9662c51931"
+ integrity sha512-0VNbwmWJDS/G3ySwFSJA3ayhbURMTJLtwM2DTxf9CWondCnh6DTNlO9JgRSq6ibf4eD0lfMJNBxUdEAHHix+bA==
-date-format@^4.0.6:
- version "4.0.6"
- resolved "https://registry.yarnpkg.com/date-format/-/date-format-4.0.6.tgz#f6138b8f17968df9815b3d101fc06b0523f066c5"
- integrity sha512-B9vvg5rHuQ8cbUXE/RMWMyX2YA5TecT3jKF5fLtGNlzPlU7zblSPmAm2OImDbWL+LDOQ6pUm+4LOFz+ywS41Zw==
+date-format@^4.0.13:
+ version "4.0.13"
+ resolved "https://registry.yarnpkg.com/date-format/-/date-format-4.0.13.tgz#87c3aab3a4f6f37582c5f5f63692d2956fa67890"
+ integrity sha512-bnYCwf8Emc3pTD8pXnre+wfnjGtfi5ncMDKy7+cWZXbmRAsdWkOQHrfC1yz/KiwP5thDp2kCHWYWKBX4HP1hoQ==
db-migrate-base@^2.3.0:
version "2.3.1"
@@ -673,17 +683,10 @@ debug@4, debug@4.3.4, debug@^4.1.0, debug@^4.3.3, debug@^4.3.4:
dependencies:
ms "2.1.2"
-debug@4.3.2:
- version "4.3.2"
- resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b"
- integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==
- dependencies:
- ms "2.1.2"
-
decamelize@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
- integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
+ integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
deep-extend@^0.6.0:
version "0.6.0"
@@ -703,18 +706,13 @@ depd@2.0.0, depd@~2.0.0:
depd@^1.1.2, depd@~1.1.0, depd@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
- integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
+ integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==
destroy@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
-destroy@~1.0.4:
- version "1.0.4"
- resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
- integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=
-
dotenv@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-5.0.1.tgz#a5317459bd3d79ab88cff6e44057a6a3fbb1fcef"
@@ -723,7 +721,7 @@ dotenv@^5.0.1:
ee-first@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
- integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
+ integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
emoji-regex@^8.0.0:
version "8.0.0"
@@ -733,7 +731,7 @@ emoji-regex@^8.0.0:
encodeurl@~1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
- integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
+ integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
encoding@^0.1.13:
version "0.1.13"
@@ -756,9 +754,9 @@ errorhandler@^1.5.1:
escape-html "~1.0.3"
es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.50, es5-ext@^0.10.53, es5-ext@~0.10.14, es5-ext@~0.10.2, es5-ext@~0.10.46:
- version "0.10.59"
- resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.59.tgz#71038939730eb6f4f165f1421308fb60be363bc6"
- integrity sha512-cOgyhW0tIJyQY1Kfw6Kr0viu9ZlUctVchRMZ7R0HiH3dxTSp5zJDLecwxUqPUrGKMsgBI1wd1FL+d9Jxfi4cLw==
+ version "0.10.62"
+ resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.62.tgz#5e6adc19a6da524bf3d1e02bbc8960e5eb49a9a5"
+ integrity sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==
dependencies:
es6-iterator "^2.0.3"
es6-symbol "^3.1.3"
@@ -767,7 +765,7 @@ es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.50, es5-ext@^0.10.53, es5-ext@
es6-iterator@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7"
- integrity sha1-p96IkUGgWpSwhUQDstCg+/qY87c=
+ integrity sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==
dependencies:
d "1"
es5-ext "^0.10.35"
@@ -799,7 +797,7 @@ escalade@^3.1.1:
escape-html@~1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
- integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
+ integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
esm@^3.2.25:
version "3.2.25"
@@ -809,22 +807,22 @@ esm@^3.2.25:
etag@~1.8.1:
version "1.8.1"
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
- integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
+ integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
event-emitter@^0.3.5:
version "0.3.5"
resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39"
- integrity sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=
+ integrity sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==
dependencies:
d "1"
es5-ext "~0.10.14"
express-session@^1.17.1:
- version "1.17.2"
- resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.17.2.tgz#397020374f9bf7997f891b85ea338767b30d0efd"
- integrity sha512-mPcYcLA0lvh7D4Oqr5aNJFMtBMKPLl++OKKxkHzZ0U0oDq1rpKBnkR5f5vCHR26VeArlTOEF9td4x5IjICksRQ==
+ version "1.17.3"
+ resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.17.3.tgz#14b997a15ed43e5949cb1d073725675dd2777f36"
+ integrity sha512-4+otWXlShYlG1Ma+2Jnn+xgKUZTMJ5QD3YvfilX3AcocOAbIkVylSWEklzALe/+Pu4qV6TYBj5GwOBFfdKqLBw==
dependencies:
- cookie "0.4.1"
+ cookie "0.4.2"
cookie-signature "1.0.6"
debug "2.6.9"
depd "~2.0.0"
@@ -834,37 +832,38 @@ express-session@^1.17.1:
uid-safe "~2.1.5"
express@^4.17.1:
- version "4.17.3"
- resolved "https://registry.yarnpkg.com/express/-/express-4.17.3.tgz#f6c7302194a4fb54271b73a1fe7a06478c8f85a1"
- integrity sha512-yuSQpz5I+Ch7gFrPCk4/c+dIBKlQUxtgwqzph132bsT6qhuzss6I8cLJQz7B3rFblzd6wtcI0ZbGltH/C4LjUg==
+ version "4.18.1"
+ resolved "https://registry.yarnpkg.com/express/-/express-4.18.1.tgz#7797de8b9c72c857b9cd0e14a5eea80666267caf"
+ integrity sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q==
dependencies:
accepts "~1.3.8"
array-flatten "1.1.1"
- body-parser "1.19.2"
+ body-parser "1.20.0"
content-disposition "0.5.4"
content-type "~1.0.4"
- cookie "0.4.2"
+ cookie "0.5.0"
cookie-signature "1.0.6"
debug "2.6.9"
- depd "~1.1.2"
+ depd "2.0.0"
encodeurl "~1.0.2"
escape-html "~1.0.3"
etag "~1.8.1"
- finalhandler "~1.1.2"
+ finalhandler "1.2.0"
fresh "0.5.2"
+ http-errors "2.0.0"
merge-descriptors "1.0.1"
methods "~1.1.2"
- on-finished "~2.3.0"
+ on-finished "2.4.1"
parseurl "~1.3.3"
path-to-regexp "0.1.7"
proxy-addr "~2.0.7"
- qs "6.9.7"
+ qs "6.10.3"
range-parser "~1.2.1"
safe-buffer "5.2.1"
- send "0.17.2"
- serve-static "1.14.2"
+ send "0.18.0"
+ serve-static "1.15.0"
setprototypeof "1.2.0"
- statuses "~1.5.0"
+ statuses "2.0.1"
type-is "~1.6.18"
utils-merge "1.0.1"
vary "~1.1.2"
@@ -879,7 +878,7 @@ ext@^1.1.2:
eyes@0.1.x:
version "0.1.8"
resolved "https://registry.yarnpkg.com/eyes/-/eyes-0.1.8.tgz#62cf120234c683785d902348a800ef3e0cc20bc0"
- integrity sha1-Ys8SAjTGg3hdkCNIqADvPgzCC8A=
+ integrity sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==
fast-deep-equal@^3.1.1:
version "3.1.3"
@@ -899,22 +898,22 @@ fast-json-stable-stringify@^2.0.0:
final-fs@^1.6.0:
version "1.6.1"
resolved "https://registry.yarnpkg.com/final-fs/-/final-fs-1.6.1.tgz#d6dcd92ef6fe4fe8c07abd568c7135610ede3236"
- integrity sha1-1tzZLvb+T+jAer1WjHE1YQ7eMjY=
+ integrity sha512-r5dgz23H8qh1LxKVJK84zet2PhWSWkIOgbLVUd5PlNFAULD/kCDBH9JEMwJt9dpdTnLsSD4rEqS56p2MH7Wbvw==
dependencies:
node-fs "~0.1.5"
when "~2.0.1"
-finalhandler@~1.1.2:
- version "1.1.2"
- resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
- integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==
+finalhandler@1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32"
+ integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==
dependencies:
debug "2.6.9"
encodeurl "~1.0.2"
escape-html "~1.0.3"
- on-finished "~2.3.0"
+ on-finished "2.4.1"
parseurl "~1.3.3"
- statuses "~1.5.0"
+ statuses "2.0.1"
unpipe "~1.0.0"
find-up@^4.1.0:
@@ -925,25 +924,25 @@ find-up@^4.1.0:
locate-path "^5.0.0"
path-exists "^4.0.0"
-flatted@^3.2.5:
- version "3.2.5"
- resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.5.tgz#76c8584f4fc843db64702a6bd04ab7a8bd666da3"
- integrity sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==
+flatted@^3.2.6:
+ version "3.2.7"
+ resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787"
+ integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==
for-in@^0.1.3:
version "0.1.8"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1"
- integrity sha1-2Hc5COMSVhCZUrH9ubP6hn0ndeE=
+ integrity sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==
for-in@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
- integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=
+ integrity sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==
for-own@^0.1.3:
version "0.1.5"
resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce"
- integrity sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=
+ integrity sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==
dependencies:
for-in "^1.0.1"
@@ -955,16 +954,16 @@ forwarded@0.2.0:
fresh@0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
- integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
+ integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
-fs-extra@^10.0.1:
- version "10.0.1"
- resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.0.1.tgz#27de43b4320e833f6867cc044bfce29fdf0ef3b8"
- integrity sha512-NbdoVMZso2Lsrn/QwLXOy6rm0ufY2zEOKCDzJR/0kBsb0E6qed0P3iYK+Ath3BfvXEeu4JhEtXLgILx5psUfag==
+fs-extra@^8.1.0:
+ version "8.1.0"
+ resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
+ integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==
dependencies:
graceful-fs "^4.2.0"
- jsonfile "^6.0.1"
- universalify "^2.0.0"
+ jsonfile "^4.0.0"
+ universalify "^0.1.0"
fs-minipass@^2.0.0, fs-minipass@^2.1.0:
version "2.1.0"
@@ -976,7 +975,7 @@ fs-minipass@^2.0.0, fs-minipass@^2.1.0:
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
- integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
+ integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
function-bind@^1.1.1:
version "1.1.1"
@@ -988,44 +987,47 @@ get-caller-file@^2.0.1:
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
+get-intrinsic@^1.0.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.2.tgz#336975123e05ad0b7ba41f152ee4aadbea6cf598"
+ integrity sha512-Jfm3OyCxHh9DJyc28qGk+JmfkpO41A4XkneDSujN9MDXrm4oDKdHvndhZ2dN94+ERNfkYJWDclW6k2L/ZGHjXA==
+ dependencies:
+ function-bind "^1.1.1"
+ has "^1.0.3"
+ has-symbols "^1.0.3"
+
get-package-type@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a"
integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==
-getopts@2.2.5:
- version "2.2.5"
- resolved "https://registry.yarnpkg.com/getopts/-/getopts-2.2.5.tgz#67a0fe471cacb9c687d817cab6450b96dde8313b"
- integrity sha512-9jb7AW5p3in+IiJWhQiZmmwkpLaR/ccTWdWQCtZM66HJcHHLegowh4q4tSD7gouUyeNvFWRavfK9GXosQHDpFA==
-
getopts@2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/getopts/-/getopts-2.3.0.tgz#71e5593284807e03e2427449d4f6712a268666f4"
integrity sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==
glob@^7.1.3:
- version "7.2.0"
- resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023"
- integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==
+ version "7.2.3"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
+ integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
- minimatch "^3.0.4"
+ minimatch "^3.1.1"
once "^1.3.0"
path-is-absolute "^1.0.0"
glob@^8.0.1:
- version "8.0.1"
- resolved "https://registry.yarnpkg.com/glob/-/glob-8.0.1.tgz#00308f5c035aa0b2a447cd37ead267ddff1577d3"
- integrity sha512-cF7FYZZ47YzmCu7dDy50xSRRfO3ErRfrXuLZcNIuyiJEco0XSrGtuilG19L5xp3NcwTx7Gn+X6Tv3fmsUPTbow==
+ version "8.0.3"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-8.0.3.tgz#415c6eb2deed9e502c68fa44a272e6da6eeca42e"
+ integrity sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
minimatch "^5.0.1"
once "^1.3.0"
- path-is-absolute "^1.0.0"
graceful-fs@^4.1.6, graceful-fs@^4.2.0:
version "4.2.10"
@@ -1040,6 +1042,11 @@ gravatar-url@^3.1.0:
md5-hex "^3.0.1"
type-fest "^0.8.1"
+has-symbols@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
+ integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
+
has@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
@@ -1048,26 +1055,15 @@ has@^1.0.3:
function-bind "^1.1.1"
helmet@^5.0.0:
- version "5.0.2"
- resolved "https://registry.yarnpkg.com/helmet/-/helmet-5.0.2.tgz#3264ec6bab96c82deaf65e3403c369424cb2366c"
- integrity sha512-QWlwUZZ8BtlvwYVTSDTBChGf8EOcQ2LkGMnQJxSzD1mUu8CCjXJZq/BXP8eWw4kikRnzlhtYo3lCk0ucmYA3Vg==
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/helmet/-/helmet-5.1.1.tgz#609823c5c2e78aea62dd9afc8f544ca409da5e85"
+ integrity sha512-/yX0oVZBggA9cLJh8aw3PPCfedBnbd7J2aowjzsaWwZh7/UFY0nccn/aHAggIgWUFfnykX8GKd3a1pSbrmlcVQ==
http-cache-semantics@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390"
integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==
-http-errors@1.8.1, http-errors@^1.7.3:
- version "1.8.1"
- resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c"
- integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==
- dependencies:
- depd "~1.1.2"
- inherits "2.0.4"
- setprototypeof "1.2.0"
- statuses ">= 1.5.0 < 2"
- toidentifier "1.0.1"
-
http-errors@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
@@ -1079,6 +1075,17 @@ http-errors@2.0.0:
statuses "2.0.1"
toidentifier "1.0.1"
+http-errors@^1.7.3:
+ version "1.8.1"
+ resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c"
+ integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==
+ dependencies:
+ depd "~1.1.2"
+ inherits "2.0.4"
+ setprototypeof "1.2.0"
+ statuses ">= 1.5.0 < 2"
+ toidentifier "1.0.1"
+
http-proxy-agent@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43"
@@ -1099,7 +1106,7 @@ https-proxy-agent@^5.0.0:
humanize-ms@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed"
- integrity sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0=
+ integrity sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==
dependencies:
ms "^2.0.0"
@@ -1120,7 +1127,7 @@ iconv-lite@^0.6.2:
imurmurhash@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
- integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
+ integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==
indent-string@^4.0.0:
version "4.0.0"
@@ -1140,7 +1147,7 @@ inflection@^1.10.0:
inflight@^1.0.4:
version "1.0.6"
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
- integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
+ integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==
dependencies:
once "^1.3.0"
wrappy "1"
@@ -1160,10 +1167,15 @@ interpret@^2.2.0:
resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9"
integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==
-ip@^1.1.5:
- version "1.1.5"
- resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
- integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=
+ip@^1.1.5, ip@^1.1.8:
+ version "1.1.8"
+ resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48"
+ integrity sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==
+
+ip@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/ip/-/ip-2.0.0.tgz#4cf4ab182fee2314c75ede1276f8c80b479936da"
+ integrity sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==
ipaddr.js@1.9.1:
version "1.9.1"
@@ -1175,17 +1187,17 @@ is-buffer@^1.0.2, is-buffer@^1.1.5:
resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
-is-core-module@^2.8.1:
- version "2.8.1"
- resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.1.tgz#f59fdfca701d5879d0a6b100a40aa1560ce27211"
- integrity sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==
+is-core-module@^2.9.0:
+ version "2.10.0"
+ resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed"
+ integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==
dependencies:
has "^1.0.3"
is-extendable@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
- integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=
+ integrity sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==
is-fullwidth-code-point@^3.0.0:
version "3.0.0"
@@ -1195,7 +1207,7 @@ is-fullwidth-code-point@^3.0.0:
is-lambda@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5"
- integrity sha1-PZh3iZ5qU+/AFgUEzeFfgubwYdU=
+ integrity sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==
is-plain-object@^2.0.1, is-plain-object@^2.0.4:
version "2.0.4"
@@ -1217,17 +1229,17 @@ is-promise@^2.2.2:
isarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
- integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
+ integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==
isobject@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
- integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
+ integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==
isstream@0.1.x:
version "0.1.2"
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
- integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
+ integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==
joi@^17.3.0:
version "17.6.0"
@@ -1247,10 +1259,10 @@ js-yaml@^4.1.0:
dependencies:
argparse "^2.0.1"
-json-schema-to-ts@^2.5.3:
- version "2.5.3"
- resolved "https://registry.yarnpkg.com/json-schema-to-ts/-/json-schema-to-ts-2.5.3.tgz#10a1ad27a3cc6117ae9c652cc583a9e0ed10f0c8"
- integrity sha512-2vABI+1IZNkChaPfLu7PG192ZY9gvRY00RbuN3VGlNNZkvYRpIECdBZPBVMe41r3wX0sl9emjRyhHT3gTm7HIg==
+json-schema-to-ts@2.5.5:
+ version "2.5.5"
+ resolved "https://registry.yarnpkg.com/json-schema-to-ts/-/json-schema-to-ts-2.5.5.tgz#09022355466dc07451b0d1235d4056ae67bceaa1"
+ integrity sha512-GFD5t0fUnX/B0gE9xbHjxv2BwFXRJND2+OKoLoMElJ3XRJ7dOBlLT7KXpg96aETeZ0RJbAZOfqHALBf5k4aIIA==
dependencies:
"@types/json-schema" "^7.0.9"
ts-algebra "^1.1.1"
@@ -1271,12 +1283,10 @@ json-schema@^0.4.0:
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5"
integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==
-jsonfile@^6.0.1:
- version "6.1.0"
- resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae"
- integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==
- dependencies:
- universalify "^2.0.0"
+jsonfile@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb"
+ integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==
optionalDependencies:
graceful-fs "^4.1.6"
@@ -1290,14 +1300,14 @@ keygrip@~1.1.0:
kind-of@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-2.0.1.tgz#018ec7a4ce7e3a86cb9141be519d24c8faa981b5"
- integrity sha1-AY7HpM5+OobLkUG+UZ0kyPqpgbU=
+ integrity sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==
dependencies:
is-buffer "^1.0.2"
kind-of@^3.0.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
- integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=
+ integrity sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==
dependencies:
is-buffer "^1.1.5"
@@ -1306,31 +1316,12 @@ kind-of@^6.0.3:
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
-knex@^0.95.6:
- version "0.95.15"
- resolved "https://registry.yarnpkg.com/knex/-/knex-0.95.15.tgz#39d7e7110a6e2ad7de5d673d2dea94143015e0e7"
- integrity sha512-Loq6WgHaWlmL2bfZGWPsy4l8xw4pOE+tmLGkPG0auBppxpI0UcK+GYCycJcqz9W54f2LiGewkCVLBm3Wq4ur/w==
- dependencies:
- colorette "2.0.16"
- commander "^7.1.0"
- debug "4.3.2"
- escalade "^3.1.1"
- esm "^3.2.25"
- getopts "2.2.5"
- interpret "^2.2.0"
- lodash "^4.17.21"
- pg-connection-string "2.5.0"
- rechoir "0.7.0"
- resolve-from "^5.0.0"
- tarn "^3.0.1"
- tildify "2.0.0"
-
knex@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/knex/-/knex-2.0.0.tgz#84296ced7ef27e0f3b954302ac7d9da67c28b5d3"
- integrity sha512-LchC8/GLfreMz8d4kCwh/ymXttsoJG8zO1O0AJBjnxdyr2oT/k2ik77hP1PpZkZH9mDQrq6WsQcIu18Pnqppzg==
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/knex/-/knex-2.2.0.tgz#86a3176924d37303b3f9ff7f70087418c263ce7a"
+ integrity sha512-yhm1Qe9Ok0TeXBq3nNHqZYJPrQ4Iw2tq9k/HxjrZ/EWec2ifOjJlkNHr26v8cQrWtk5iG3iwfUazTIWy+VKG5g==
dependencies:
- colorette "2.0.16"
+ colorette "2.0.19"
commander "^9.1.0"
debug "4.3.4"
escalade "^3.1.1"
@@ -1348,12 +1339,12 @@ knex@^2.0.0:
lazy-cache@^0.2.3:
version "0.2.7"
resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-0.2.7.tgz#7feddf2dcb6edb77d11ef1d117ab5ffdf0ab1b65"
- integrity sha1-f+3fLctu23fRHvHRF6tf/fCrG2U=
+ integrity sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==
lazy-cache@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e"
- integrity sha1-odePw6UEdMuAhF07O24dpJpEbo4=
+ integrity sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==
locate-path@^5.0.0:
version "5.0.0"
@@ -1365,17 +1356,17 @@ locate-path@^5.0.0:
lodash.defaults@^4.1.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
- integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=
+ integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==
lodash.get@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
- integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
+ integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==
lodash.isequal@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
- integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
+ integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==
lodash@^4.17.11, lodash@^4.17.21:
version "4.17.21"
@@ -1383,40 +1374,42 @@ lodash@^4.17.11, lodash@^4.17.21:
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
log4js@^6.0.0:
- version "6.4.4"
- resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.4.4.tgz#c9bc75569f3f40bba22fe1bd0677afa7a6a13bac"
- integrity sha512-ncaWPsuw9Vl1CKA406hVnJLGQKy1OHx6buk8J4rE2lVW+NW5Y82G5/DIloO7NkqLOUtNPEANaWC1kZYVjXssPw==
+ version "6.6.1"
+ resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.6.1.tgz#48f23de8a87d2f5ffd3d913f24ca9ce77895272f"
+ integrity sha512-J8VYFH2UQq/xucdNu71io4Fo+purYYudyErgBbswWKO0MC6QVOERRomt5su/z6d3RJSmLyTGmXl3Q/XjKCf+/A==
dependencies:
- date-format "^4.0.6"
+ date-format "^4.0.13"
debug "^4.3.4"
- flatted "^3.2.5"
+ flatted "^3.2.6"
rfdc "^1.3.0"
- streamroller "^3.0.6"
+ streamroller "^3.1.2"
-lru-cache@^7.4.0:
- version "7.7.3"
- resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.7.3.tgz#98cd19eef89ce6a4a3c4502c17c833888677c252"
- integrity sha512-WY9wjJNQt9+PZilnLbuFKM+SwDull9+6IAguOrarOMoOHTcJ9GnXSO11+Gw6c7xtDkBkthR57OZMtZKYr+1CEw==
+lru-cache@^6.0.0:
+ version "6.0.0"
+ resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
+ integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
+ dependencies:
+ yallist "^4.0.0"
lru-cache@^7.7.1:
- version "7.9.0"
- resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.9.0.tgz#29c2a989b6c10f32ceccc66ff44059e1490af3e1"
- integrity sha512-lkcNMUKqdJk96TuIXUidxaPuEg5sJo/+ZyVE2BDFnuZGzwXem7d8582eG8vbu4todLfT14snP6iHriCHXXi5Rw==
+ version "7.14.0"
+ resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.14.0.tgz#21be64954a4680e303a09e9468f880b98a0b3c7f"
+ integrity sha512-EIRtP1GrSJny0dqb50QXRUNBxHJhcpxHC++M5tD7RYbvLLn5KVWKsbyswSSqDuU15UFi3bgTQIY8nhDMeF6aDQ==
lru-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3"
- integrity sha1-Jzi9nw089PhEkMVzbEhpmsYyzaM=
+ integrity sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==
dependencies:
es5-ext "~0.10.2"
-make-fetch-happen@^10.1.2:
- version "10.1.2"
- resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-10.1.2.tgz#acffef43f86250602b932eecc0ad3acc992ae233"
- integrity sha512-GWMGiZsKVeJACQGJ1P3Z+iNec7pLsU6YW1q11eaPn3RR8nRXHppFWfP7Eu0//55JK3hSjrAQRl8sDa5uXpq1Ew==
+make-fetch-happen@^10.0.0, make-fetch-happen@^10.1.2:
+ version "10.2.1"
+ resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz#f5e3835c5e9817b617f2770870d9492d28678164"
+ integrity sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==
dependencies:
agentkeepalive "^4.2.1"
- cacache "^16.0.2"
+ cacache "^16.1.0"
http-cache-semantics "^4.1.0"
http-proxy-agent "^5.0.0"
https-proxy-agent "^5.0.0"
@@ -1429,7 +1422,7 @@ make-fetch-happen@^10.1.2:
minipass-pipeline "^1.2.4"
negotiator "^0.6.3"
promise-retry "^2.0.1"
- socks-proxy-agent "^6.1.1"
+ socks-proxy-agent "^7.0.0"
ssri "^9.0.0"
md5-hex@^3.0.1:
@@ -1442,7 +1435,7 @@ md5-hex@^3.0.1:
media-typer@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
- integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
+ integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
memoizee@^0.4.15:
version "0.4.15"
@@ -1470,12 +1463,12 @@ merge-deep@^3.0.2:
merge-descriptors@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
- integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=
+ integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==
methods@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
- integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
+ integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
version "1.52.0"
@@ -1499,7 +1492,7 @@ mime@^3.0.0:
resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7"
integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==
-minimatch@^3.0.4:
+minimatch@^3.1.1:
version "3.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
@@ -1507,9 +1500,9 @@ minimatch@^3.0.4:
brace-expansion "^1.1.7"
minimatch@^5.0.1:
- version "5.0.1"
- resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.0.1.tgz#fb9022f7528125187c92bd9e9b6366be1cf3415b"
- integrity sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.0.tgz#1717b464f4971b144f6aabe8f2d0b8e4511e09c7"
+ integrity sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==
dependencies:
brace-expansion "^2.0.1"
@@ -1526,9 +1519,9 @@ minipass-collect@^1.0.2:
minipass "^3.0.0"
minipass-fetch@^2.0.3:
- version "2.1.0"
- resolved "https://registry.yarnpkg.com/minipass-fetch/-/minipass-fetch-2.1.0.tgz#ca1754a5f857a3be99a9271277246ac0b44c3ff8"
- integrity sha512-H9U4UVBGXEyyWJnqYDCLp1PwD8XIkJ4akNHp1aGVI+2Ym7wQMlxDKi4IB4JbmyU+pl9pEs/cVrK6cOuvmbK4Sg==
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/minipass-fetch/-/minipass-fetch-2.1.2.tgz#95560b50c472d81a3bc76f20ede80eaed76d8add"
+ integrity sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==
dependencies:
minipass "^3.1.6"
minipass-sized "^1.0.3"
@@ -1558,9 +1551,9 @@ minipass-sized@^1.0.3:
minipass "^3.0.0"
minipass@^3.0.0, minipass@^3.1.1, minipass@^3.1.6:
- version "3.1.6"
- resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.6.tgz#3b8150aa688a711a1521af5e8779c1d3bb4f45ee"
- integrity sha512-rty5kpw9/z8SX9dmxblFA6edItUmwJgMeYDZRrwlIVN27i8gysGbznJwUggw2V/FVqFSDdWy040ZPS811DYAqQ==
+ version "3.3.4"
+ resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.4.tgz#ca99f95dd77c43c7a76bf51e6d200025eee0ffae"
+ integrity sha512-I9WPbWHCGu8W+6k1ZiGpPu0GkoKBeorkfKNuAFBNS1HNFJvke82sxvI5bzcCNpWPorkOO5QQ+zomzzwRxejXiw==
dependencies:
yallist "^4.0.0"
@@ -1575,7 +1568,7 @@ minizlib@^2.1.1, minizlib@^2.1.2:
mixin-object@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/mixin-object/-/mixin-object-2.0.1.tgz#4fb949441dab182540f1fe035ba60e1947a5e57e"
- integrity sha1-T7lJRB2rGCVA8f4DW6YOGUel5X4=
+ integrity sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==
dependencies:
for-in "^0.1.3"
is-extendable "^0.1.1"
@@ -1595,12 +1588,12 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
"mongodb-uri@>= 0.9.7":
version "0.9.7"
resolved "https://registry.yarnpkg.com/mongodb-uri/-/mongodb-uri-0.9.7.tgz#0f771ad16f483ae65f4287969428e9fbc4aa6181"
- integrity sha1-D3ca0W9IOuZfQoeWlCjp+8SqYYE=
+ integrity sha512-s6BdnqNoEYfViPJgkH85X5Nw5NpzxN8hoflKLweNa7vBxt2V7kaS06d74pAtqDxde8fn4r9h4dNdLiFGoNV0KA==
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
- integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
+ integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
ms@2.1.1:
version "2.1.1"
@@ -1630,6 +1623,11 @@ multer@^1.4.5-lts.1:
type-is "^1.6.4"
xtend "^4.0.0"
+murmurhash3js@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/murmurhash3js/-/murmurhash3js-3.0.1.tgz#3e983e5b47c2a06f43a713174e7e435ca044b998"
+ integrity sha512-KL8QYUaxq7kUbcl0Yto51rMcYt7E/4N4BG3/c96Iqw1PQrTRspu8Cpx4TZ4Nunib1d4bEkIH3gjCYlP2RLBdow==
+
mustache@^4.1.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64"
@@ -1641,9 +1639,9 @@ mute-stream@~0.0.4:
integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
nan@^2.15.0:
- version "2.15.0"
- resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee"
- integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==
+ version "2.16.0"
+ resolved "https://registry.yarnpkg.com/nan/-/nan-2.16.0.tgz#664f43e45460fb98faf00edca0bb0d7b8dce7916"
+ integrity sha512-UdAqHyFngu7TfQKsCBgAA6pWDkT8MAO7d0jyOecVhN5354xbLqdn8mV9Tat9gepAupm0bt2DbeaSC8vS52MuFA==
negotiator@0.6.3, negotiator@^0.6.3:
version "0.6.3"
@@ -1658,12 +1656,12 @@ next-tick@1, next-tick@^1.1.0:
node-fs@~0.1.5:
version "0.1.7"
resolved "https://registry.yarnpkg.com/node-fs/-/node-fs-0.1.7.tgz#32323cccb46c9fbf0fc11812d45021cc31d325bb"
- integrity sha1-MjI8zLRsn78PwRgS1FAhzDHTJbs=
+ integrity sha512-XqDBlmUKgDGe76+lZ/0sRBF3XW2vVcK07+ZPvdpUTK8jrvtPahUd0aBqJ9+ZjB01ANjZLuvK3O/eoMVmz62rpA==
nodemailer@^6.5.0:
- version "6.7.3"
- resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.3.tgz#b73f9a81b9c8fa8acb4ea14b608f5e725ea8e018"
- integrity sha512-KUdDsspqx89sD4UUyUKzdlUOper3hRkDVkrKh/89G+d9WKsU5ox51NWS4tB1XR5dPUdR4SP0E3molyEfOvSa3g==
+ version "6.7.8"
+ resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.8.tgz#9f1af9911314960c0b889079e1754e8d9e3f740a"
+ integrity sha512-2zaTFGqZixVmTxpJRCFC+Vk5eGRd/fYtvIR+dl5u9QXLTQWGIf48x/JXvo58g9sa0bU6To04XUv554Paykum3g==
oauth@0.9.x:
version "0.9.15"
@@ -1673,7 +1671,12 @@ oauth@0.9.x:
object-assign@^4, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
- integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
+ integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
+
+object-inspect@^1.9.0:
+ version "1.12.2"
+ resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea"
+ integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==
on-finished@2.4.1:
version "2.4.1"
@@ -1682,13 +1685,6 @@ on-finished@2.4.1:
dependencies:
ee-first "1.1.1"
-on-finished@~2.3.0:
- version "2.3.0"
- resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
- integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=
- dependencies:
- ee-first "1.1.1"
-
on-headers@~1.0.1, on-headers@~1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f"
@@ -1697,7 +1693,7 @@ on-headers@~1.0.1, on-headers@~1.0.2:
once@^1.3.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
- integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
+ integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
dependencies:
wrappy "1"
@@ -1709,7 +1705,7 @@ openapi-types@^12.0.0:
owasp-password-strength-test@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/owasp-password-strength-test/-/owasp-password-strength-test-1.3.0.tgz#4f629e42903e8f6d279b230d657ab61e58e44b12"
- integrity sha1-T2KeQpA+j20nmyMNZXq2HljkSxI=
+ integrity sha512-33/Z+vyjlFaVZsT7aAFe3SkQZdU6su59XNkYdU5o2Fssz0D9dt6uiFaMm62M7dFQSKogULq8UYvdKnHkeqNB2w==
p-limit@^2.2.0:
version "2.3.0"
@@ -1745,7 +1741,7 @@ packet-reader@1.0.0:
parse-database-url@^0.3.0, parse-database-url@~0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/parse-database-url/-/parse-database-url-0.3.0.tgz#369666321e927c9ade63cdfc1aaaf6fb37453d0d"
- integrity sha1-NpZmMh6SfJreY838Gqr2+zdFPQ0=
+ integrity sha512-YRxDoVBAUk3ksGF9pud+aqWwXmThZzhX9Z1PPxKU03BB3/gu2RcgyMA4rktMYhkIJ9KxwW7lIj00U+TSNz80wg==
dependencies:
mongodb-uri ">= 0.9.7"
@@ -1787,7 +1783,7 @@ path-exists@^4.0.0:
path-is-absolute@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
- integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
+ integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
path-parse@^1.0.7:
version "1.0.7"
@@ -1797,7 +1793,7 @@ path-parse@^1.0.7:
path-to-regexp@0.1.7:
version "0.1.7"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
- integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
+ integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==
path-to-regexp@^2.4.0:
version "2.4.0"
@@ -1819,10 +1815,10 @@ pg-int8@1.0.1:
resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c"
integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==
-pg-pool@^3.5.1:
- version "3.5.1"
- resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.5.1.tgz#f499ce76f9bf5097488b3b83b19861f28e4ed905"
- integrity sha512-6iCR0wVrro6OOHFsyavV+i6KYL4lVNyYAB9RD18w66xSzN+d8b66HiwuP30Gp1SH5O9T82fckkzsRjlrhD0ioQ==
+pg-pool@^3.5.2:
+ version "3.5.2"
+ resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.5.2.tgz#ed1bed1fb8d79f1c6fd5fb1c99e990fbf9ddf178"
+ integrity sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w==
pg-protocol@^1.5.0:
version "1.5.0"
@@ -1841,14 +1837,14 @@ pg-types@^2.1.0:
postgres-interval "^1.1.0"
pg@^8.0.3, pg@^8.7.3:
- version "8.7.3"
- resolved "https://registry.yarnpkg.com/pg/-/pg-8.7.3.tgz#8a5bdd664ca4fda4db7997ec634c6e5455b27c44"
- integrity sha512-HPmH4GH4H3AOprDJOazoIcpI49XFsHCe8xlrjHkWiapdbHK+HLtbm/GQzXYAZwmPju/kzKhjaSfMACG+8cgJcw==
+ version "8.8.0"
+ resolved "https://registry.yarnpkg.com/pg/-/pg-8.8.0.tgz#a77f41f9d9ede7009abfca54667c775a240da686"
+ integrity sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw==
dependencies:
buffer-writer "2.0.0"
packet-reader "1.0.0"
pg-connection-string "^2.5.0"
- pg-pool "^3.5.1"
+ pg-pool "^3.5.2"
pg-protocol "^1.5.0"
pg-types "^2.1.0"
pgpass "1.x"
@@ -1863,7 +1859,7 @@ pgpass@1.x:
pkginfo@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.4.1.tgz#b5418ef0439de5425fc4995042dced14fb2a84ff"
- integrity sha1-tUGO8EOd5UJfxJlQQtztFPsqhP8=
+ integrity sha512-8xCNE/aT/EXKenuMDZ+xTVwkT8gsoHN2z/Q29l80u0ppGEXVvsKRzNMbtKhg8LS8k1tJLAHHylf6p4VFmP6XUQ==
postgres-array@~2.0.0:
version "2.0.0"
@@ -1873,7 +1869,7 @@ postgres-array@~2.0.0:
postgres-bytea@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35"
- integrity sha1-AntTPAqokOJtFy1Hz5zOzFIazTU=
+ integrity sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==
postgres-date@~1.0.4:
version "1.0.7"
@@ -1893,16 +1889,16 @@ process-nextick-args@~2.0.0:
integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
prom-client@^14.0.0:
- version "14.0.1"
- resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-14.0.1.tgz#bdd9583e02ec95429677c0e013712d42ef1f86a8"
- integrity sha512-HxTArb6fkOntQHoRGvv4qd/BkorjliiuO2uSWC2KC17MUTKYttWdDoXX/vxOhQdkoECEM9BBH0pj2l8G8kev6w==
+ version "14.1.0"
+ resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-14.1.0.tgz#049609859483d900844924df740722c76ed1fdbb"
+ integrity sha512-iFWCchQmi4170omLpFXbzz62SQTmPhtBL35v0qGEVRHKcqIeiexaoYeP0vfZTujxEq3tA87iqOdRbC9svS1B9A==
dependencies:
tdigest "^0.1.1"
promise-inflight@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
- integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM=
+ integrity sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==
promise-retry@^2.0.1:
version "2.0.1"
@@ -1913,12 +1909,12 @@ promise-retry@^2.0.1:
retry "^0.12.0"
prompt@^1.0.0:
- version "1.2.2"
- resolved "https://registry.yarnpkg.com/prompt/-/prompt-1.2.2.tgz#b624fcf53aa6c8c5637e009c193ef69eee45dbe0"
- integrity sha512-XNXhNv3PUHJDcDkISpCwSJxtw9Bor4FZnlMUDW64N/KCPdxhfVlpD5+YUXI/Z8a9QWmOhs9KSiVtR8nzPS0BYA==
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/prompt/-/prompt-1.3.0.tgz#b1f6d47cb1b6beed4f0660b470f5d3ec157ad7ce"
+ integrity sha512-ZkaRWtaLBZl7KKAKndKYUL8WqNT+cQHKRZnT4RYYms48jQkFw3rrBL+/N5K/KtdEveHkxs982MX2BkDKub2ZMg==
dependencies:
"@colors/colors" "1.5.0"
- async "~0.9.0"
+ async "3.2.3"
read "1.0.x"
revalidator "0.1.x"
winston "2.x"
@@ -1936,28 +1932,30 @@ punycode@^2.1.0:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
-qs@6.9.7:
- version "6.9.7"
- resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.7.tgz#4610846871485e1e048f44ae3b94033f0e675afe"
- integrity sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==
+qs@6.10.3:
+ version "6.10.3"
+ resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.3.tgz#d6cde1b2ffca87b5aa57889816c5f81535e22e8e"
+ integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==
+ dependencies:
+ side-channel "^1.0.4"
random-bytes@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b"
- integrity sha1-T2ih3Arli9P7lYSMMDJNt11kNgs=
+ integrity sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==
range-parser@~1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
-raw-body@2.4.3:
- version "2.4.3"
- resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.3.tgz#8f80305d11c2a0a545c2d9d89d7a0286fcead43c"
- integrity sha512-UlTNLIcu0uzb4D2f4WltY6cVjLi+/jEN4lgEUj3E04tpMDpUlkBo/eSn6zou9hum2VMNpCCUone0O0WeJim07g==
+raw-body@2.5.1:
+ version "2.5.1"
+ resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857"
+ integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==
dependencies:
bytes "3.1.2"
- http-errors "1.8.1"
+ http-errors "2.0.0"
iconv-lite "0.4.24"
unpipe "1.0.0"
@@ -1974,7 +1972,7 @@ rc@^1.2.8:
read@1.0.x:
version "1.0.7"
resolved "https://registry.yarnpkg.com/read/-/read-1.0.7.tgz#b3da19bd052431a97671d44a42634adf710b40c4"
- integrity sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ=
+ integrity sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==
dependencies:
mute-stream "~0.0.4"
@@ -1991,13 +1989,6 @@ readable-stream@^2.2.2:
string_decoder "~1.1.1"
util-deprecate "~1.0.1"
-rechoir@0.7.0:
- version "0.7.0"
- resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.7.0.tgz#32650fd52c21ab252aa5d65b19310441c7e03aca"
- integrity sha512-ADsDEH2bvbjltXEP+hTIAmeFekTFK0V2BTxMkok6qILyAJEXV0AFfoWcAq4yfll5VdIMd/RVXq0lR+wQi5ZU3Q==
- dependencies:
- resolve "^1.9.0"
-
rechoir@^0.8.0:
version "0.8.0"
resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.8.0.tgz#49f866e0d32146142da3ad8f0eff352b3215ff22"
@@ -2008,7 +1999,7 @@ rechoir@^0.8.0:
require-directory@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
- integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I=
+ integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
require-from-string@^2.0.2:
version "2.0.2"
@@ -2025,19 +2016,19 @@ resolve-from@^5.0.0:
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69"
integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==
-resolve@^1.1.6, resolve@^1.20.0, resolve@^1.9.0:
- version "1.22.0"
- resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198"
- integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==
+resolve@^1.1.6, resolve@^1.20.0:
+ version "1.22.1"
+ resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
+ integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
dependencies:
- is-core-module "^2.8.1"
+ is-core-module "^2.9.0"
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
response-time@^2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/response-time/-/response-time-2.3.2.tgz#ffa71bab952d62f7c1d49b7434355fbc68dffc5a"
- integrity sha1-/6cbq5UtYvfB1Jt0NDVfvGjf/Fo=
+ integrity sha512-MUIDaDQf+CVqflfTdQ5yam+aYCkXj1PY8fjlPDQ6ppxJlmgZb864pHtA750mayywNg8tx4rS7qH9JXd/OF+3gw==
dependencies:
depd "~1.1.0"
on-headers "~1.0.1"
@@ -2045,12 +2036,12 @@ response-time@^2.3.2:
retry@^0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
- integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=
+ integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==
revalidator@0.1.x:
version "0.1.8"
resolved "https://registry.yarnpkg.com/revalidator/-/revalidator-0.1.8.tgz#fece61bfa0c1b52a206bd6b18198184bdd523a3b"
- integrity sha1-/s5hv6DBtSoga9axgZgYS91SOjs=
+ integrity sha512-xcBILK2pA9oh4SiinPEZfhP8HfrB/ha+a2fTMyl7Om2WjlDVrOQy99N2MXXlUHqGJz4qEu2duXxHJjDWuK/0xg==
rfdc@^1.3.0:
version "1.3.0"
@@ -2103,30 +2094,11 @@ semver@^5.0.3, semver@^5.3.0:
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
semver@^7.3.5:
- version "7.3.6"
- resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.6.tgz#5d73886fb9c0c6602e79440b97165c29581cbb2b"
- integrity sha512-HZWqcgwLsjaX1HBD31msI/rXktuIhS+lWvdE4kN9z+8IVT4Itc7vqU2WvYsyD6/sjYCt4dEKH/m1M3dwI9CC5w==
+ version "7.3.7"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
+ integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
dependencies:
- lru-cache "^7.4.0"
-
-send@0.17.2:
- version "0.17.2"
- resolved "https://registry.yarnpkg.com/send/-/send-0.17.2.tgz#926622f76601c41808012c8bf1688fe3906f7820"
- integrity sha512-UJYB6wFSJE3G00nEivR5rgWp8c2xXvJ3OPWPhmuteU0IKj8nKbG3DrjiOmLwpnHGYWAVwA69zmTm++YG0Hmwww==
- dependencies:
- debug "2.6.9"
- depd "~1.1.2"
- destroy "~1.0.4"
- encodeurl "~1.0.2"
- escape-html "~1.0.3"
- etag "~1.8.1"
- fresh "0.5.2"
- http-errors "1.8.1"
- mime "1.6.0"
- ms "2.1.3"
- on-finished "~2.3.0"
- range-parser "~1.2.1"
- statuses "~1.5.0"
+ lru-cache "^6.0.0"
send@0.18.0:
version "0.18.0"
@@ -2150,7 +2122,7 @@ send@0.18.0:
serve-favicon@^2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/serve-favicon/-/serve-favicon-2.5.0.tgz#935d240cdfe0f5805307fdfe967d88942a2cbcf0"
- integrity sha1-k10kDN/g9YBTB/3+ln2IlCosvPA=
+ integrity sha512-FMW2RvqNr03x+C0WxTyu6sOv21oOjkq5j8tjquWccwa6ScNyGFOGJVpuS1NmTVGBAHS07xnSKotgf2ehQmf9iA==
dependencies:
etag "~1.8.1"
fresh "0.5.2"
@@ -2158,17 +2130,7 @@ serve-favicon@^2.5.0:
parseurl "~1.3.2"
safe-buffer "5.1.1"
-serve-static@1.14.2:
- version "1.14.2"
- resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.2.tgz#722d6294b1d62626d41b43a013ece4598d292bfa"
- integrity sha512-+TMNA9AFxUEGuC0z2mevogSnn9MXKb4fa7ngeRMJaaGv8vTwnIEkKi+QGvPt33HSnf8pRS+WGM0EbMtCJLKMBQ==
- dependencies:
- encodeurl "~1.0.2"
- escape-html "~1.0.3"
- parseurl "~1.3.3"
- send "0.17.2"
-
-serve-static@^1.13.2:
+serve-static@1.15.0, serve-static@^1.13.2:
version "1.15.0"
resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540"
integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==
@@ -2181,7 +2143,7 @@ serve-static@^1.13.2:
set-blocking@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
- integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
+ integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==
set-value@^4.0.1:
version "4.1.0"
@@ -2199,33 +2161,42 @@ setprototypeof@1.2.0:
shallow-clone@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-0.1.2.tgz#5909e874ba77106d73ac414cfec1ffca87d97060"
- integrity sha1-WQnodLp3EG1zrEFM/sH/yofZcGA=
+ integrity sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==
dependencies:
is-extendable "^0.1.1"
kind-of "^2.0.1"
lazy-cache "^0.2.3"
mixin-object "^2.0.1"
+side-channel@^1.0.4:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
+ integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==
+ dependencies:
+ call-bind "^1.0.0"
+ get-intrinsic "^1.0.2"
+ object-inspect "^1.9.0"
+
smart-buffer@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
-socks-proxy-agent@^6.1.1:
- version "6.2.0"
- resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-6.2.0.tgz#f6b5229cc0cbd6f2f202d9695f09d871e951c85e"
- integrity sha512-wWqJhjb32Q6GsrUqzuFkukxb/zzide5quXYcMVpIjxalDBBYy2nqKCFQ/9+Ie4dvOYSQdOk3hUlZSdzZOd3zMQ==
+socks-proxy-agent@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz#dc069ecf34436621acb41e3efa66ca1b5fed15b6"
+ integrity sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==
dependencies:
agent-base "^6.0.2"
debug "^4.3.3"
socks "^2.6.2"
socks@^2.6.2:
- version "2.6.2"
- resolved "https://registry.yarnpkg.com/socks/-/socks-2.6.2.tgz#ec042d7960073d40d94268ff3bb727dc685f111a"
- integrity sha512-zDZhHhZRY9PxRruRMR7kMhnf3I8hDs4S3f9RecfnGxvcBHQcKcIH/oUcEWffsfl1XxdYlA7nnlGbbTvPz9D8gA==
+ version "2.7.0"
+ resolved "https://registry.yarnpkg.com/socks/-/socks-2.7.0.tgz#f9225acdb841e874dca25f870e9130990f3913d0"
+ integrity sha512-scnOe9y4VuiNUULJN72GrM26BNOjVsfPXI+j+98PkyEfsIXroa5ofyjT+FzGvn/xHs73U2JtoBYAVx9Hl4quSA==
dependencies:
- ip "^1.1.5"
+ ip "^2.0.0"
smart-buffer "^4.2.0"
split2@^4.1.0:
@@ -2245,40 +2216,40 @@ ssh2@1.4.0, ssh2@^1.4.0:
nan "^2.15.0"
ssri@^9.0.0:
- version "9.0.0"
- resolved "https://registry.yarnpkg.com/ssri/-/ssri-9.0.0.tgz#70ad90e339eb910f1a7ff1dcf4afc268326c4547"
- integrity sha512-Y1Z6J8UYnexKFN1R/hxUaYoY2LVdKEzziPmVAFKiKX8fiwvCJTVzn/xYE9TEWod5OVyNfIHHuVfIEuBClL/uJQ==
+ version "9.0.1"
+ resolved "https://registry.yarnpkg.com/ssri/-/ssri-9.0.1.tgz#544d4c357a8d7b71a19700074b6883fcb4eae057"
+ integrity sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==
dependencies:
minipass "^3.1.1"
stack-trace@0.0.x:
version "0.0.10"
resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0"
- integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=
+ integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==
statuses@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
-"statuses@>= 1.5.0 < 2", statuses@~1.5.0:
+"statuses@>= 1.5.0 < 2":
version "1.5.0"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
- integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
+ integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
stoppable@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/stoppable/-/stoppable-1.1.0.tgz#32da568e83ea488b08e4d7ea2c3bcc9d75015d5b"
integrity sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==
-streamroller@^3.0.6:
- version "3.0.6"
- resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-3.0.6.tgz#52823415800ded79a49aa3f7712f50a422b97493"
- integrity sha512-Qz32plKq/MZywYyhEatxyYc8vs994Gz0Hu2MSYXXLD233UyPeIeRBZARIIGwFer4Mdb8r3Y2UqKkgyDghM6QCg==
+streamroller@^3.1.2:
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-3.1.2.tgz#abd444560768b340f696307cf84d3f46e86c0e63"
+ integrity sha512-wZswqzbgGGsXYIrBYhOE0yP+nQ6XRk7xDcYwuQAGTYXdyAUmvgVFE0YU1g5pvQT0m7GBaQfYcSnlHbapuK0H0A==
dependencies:
- date-format "^4.0.6"
+ date-format "^4.0.13"
debug "^4.3.4"
- fs-extra "^10.0.1"
+ fs-extra "^8.1.0"
streamsearch@^1.1.0:
version "1.1.0"
@@ -2311,7 +2282,7 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1:
strip-json-comments@~2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
- integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
+ integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==
supports-preserve-symlinks-flag@^1.0.0:
version "1.0.0"
@@ -2326,9 +2297,9 @@ swagger-parser@^10.0.3:
"@apidevtools/swagger-parser" "10.0.3"
swagger-ui-dist@^4.10.3:
- version "4.10.3"
- resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-4.10.3.tgz#d67066716ce20cb6afb23474c9ca2727633a9eb3"
- integrity sha512-eR4vsd7sYo0Sx7ZKRP5Z04yij7JkNmIlUQfrDQgC+xO5ABYx+waabzN+nDsQTLAJ4Z04bjkRd8xqkJtbxr3G7w==
+ version "4.14.0"
+ resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-4.14.0.tgz#e34d807464eb84578c43902e393084a1a6fbda52"
+ integrity sha512-TBzhheU15s+o54Cgk9qxuYcZMiqSm/SkvKnapoGHOF66kz0Y5aGjpzj5BT/vpBbn6rTPJ9tUYXQxuDWfsjiGMw==
tar@^6.1.11:
version "6.1.11"
@@ -2342,17 +2313,17 @@ tar@^6.1.11:
mkdirp "^1.0.3"
yallist "^4.0.0"
-tarn@^3.0.1, tarn@^3.0.2:
+tarn@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/tarn/-/tarn-3.0.2.tgz#73b6140fbb881b71559c4f8bfde3d9a4b3d27693"
integrity sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==
tdigest@^0.1.1:
- version "0.1.1"
- resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.1.tgz#2e3cb2c39ea449e55d1e6cd91117accca4588021"
- integrity sha1-Ljyyw56kSeVdHmzZEReszKRYgCE=
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.2.tgz#96c64bac4ff10746b910b0e23b515794e12faced"
+ integrity sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==
dependencies:
- bintrees "1.0.1"
+ bintrees "1.0.2"
tildify@2.0.0:
version "2.0.0"
@@ -2401,7 +2372,7 @@ tunnel-ssh@^4.0.0:
tweetnacl@^0.14.3:
version "0.14.5"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
- integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
+ integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==
type-fest@^0.8.1:
version "0.8.1"
@@ -2422,14 +2393,14 @@ type@^1.0.1:
integrity sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==
type@^2.5.0:
- version "2.6.0"
- resolved "https://registry.yarnpkg.com/type/-/type-2.6.0.tgz#3ca6099af5981d36ca86b78442973694278a219f"
- integrity sha512-eiDBDOmkih5pMbo9OqsqPRGMljLodLcwd5XD5JbtNB0o89xZAwynY9EdCDsJU7LtcVCClu9DvM7/0Ep1hYX3EQ==
+ version "2.7.2"
+ resolved "https://registry.yarnpkg.com/type/-/type-2.7.2.tgz#2376a15a3a28b1efa0f5350dcf72d24df6ef98d0"
+ integrity sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==
typedarray@^0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
- integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
+ integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==
uid-safe@~2.1.5:
version "2.1.5"
@@ -2443,40 +2414,45 @@ uid2@0.0.x:
resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.4.tgz#033f3b1d5d32505f5ce5f888b9f3b667123c0a44"
integrity sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==
-unique-filename@^1.1.1:
- version "1.1.1"
- resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230"
- integrity sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==
+unique-filename@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-2.0.0.tgz#c844c84c3b22e92038b0c53950f9dc34d2b55490"
+ integrity sha512-tpzoz2RpZ//6Zt4GPpOFTyrnfZuSvjIfe8lvx6Thp4yTQwJtAFwPlssEBE62VhGA2We5/COyNpcIu+OABu3/Yg==
dependencies:
- unique-slug "^2.0.0"
+ unique-slug "^2.0.2"
-unique-slug@^2.0.0:
+unique-slug@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.2.tgz#baabce91083fc64e945b0f3ad613e264f7cd4e6c"
integrity sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==
dependencies:
imurmurhash "^0.1.4"
-universalify@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717"
- integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==
+universalify@^0.1.0:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
+ integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
-unleash-frontend@4.14.0-beta.0:
- version "4.14.0-beta.0"
- resolved "https://registry.yarnpkg.com/unleash-frontend/-/unleash-frontend-4.14.0-beta.0.tgz#c68335f92f92494bdd25eb3aeb5f2dd9ce7950de"
- integrity sha512-RIBkNR2S/uayMFwc88xlUwluYix6GH+7Cf1DqVV1fD/s0MMFQHQzL7qpE3XUmiUw5rn4BqXs95V1bmjh00+ACg==
+unleash-client@3.15.0:
+ version "3.15.0"
+ resolved "https://registry.yarnpkg.com/unleash-client/-/unleash-client-3.15.0.tgz#6ba4d917a0d8d628e73267ae8114d261d210a1a9"
+ integrity sha512-pNfzJa7QWhtSMTGNhmanpgqjg3xIJK4gJgQiZdkJlUY6GPDXit8p4fGs94jC8zM/xzpa1ji9+sSx6GC9YDeCiQ==
+ dependencies:
+ ip "^1.1.5"
+ make-fetch-happen "^10.0.0"
+ murmurhash3js "^3.0.1"
+ semver "^7.3.5"
"unleash-server@file:../build":
- version "4.14.0-beta.0"
+ version "4.15.0-beta.2"
dependencies:
"@unleash/express-openapi" "^0.2.0"
ajv "^8.11.0"
ajv-formats "^2.1.1"
- async "^3.2.3"
+ async "^3.2.4"
bcryptjs "^2.4.3"
compression "^1.7.4"
- connect-session-knex "^2.1.0"
+ connect-session-knex "^3.0.0"
cookie-parser "^1.4.5"
cookie-session "^2.0.0-rc.1"
cors "^2.8.5"
@@ -2491,15 +2467,17 @@ unleash-frontend@4.14.0-beta.0:
fast-json-patch "^3.1.0"
gravatar-url "^3.1.0"
helmet "^5.0.0"
+ ip "^1.1.8"
joi "^17.3.0"
js-yaml "^4.1.0"
- json-schema-to-ts "^2.5.3"
+ json-schema-to-ts "2.5.5"
knex "^2.0.0"
log4js "^6.0.0"
make-fetch-happen "^10.1.2"
memoizee "^0.4.15"
mime "^3.0.0"
multer "^1.4.5-lts.1"
+ murmurhash3js "^3.0.1"
mustache "^4.1.0"
nodemailer "^6.5.0"
openapi-types "^12.0.0"
@@ -2513,14 +2491,15 @@ unleash-frontend@4.14.0-beta.0:
semver "^7.3.5"
serve-favicon "^2.5.0"
stoppable "^1.1.0"
+ ts-toolbelt "^9.6.0"
type-is "^1.6.18"
- unleash-frontend "4.14.0-beta.0"
+ unleash-client "3.15.0"
uuid "^8.3.2"
unpipe@1.0.0, unpipe@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
- integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=
+ integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
uri-js@^4.2.2:
version "4.4.1"
@@ -2532,7 +2511,7 @@ uri-js@^4.2.2:
util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
- integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
+ integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
utils-merge@1.0.1, utils-merge@1.x.x, utils-merge@^1.0.1:
version "1.0.1"
@@ -2552,24 +2531,24 @@ validator@^13.7.0:
vary@^1, vary@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
- integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
+ integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
when@~2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/when/-/when-2.0.1.tgz#8d872fe15e68424c91b4b724e848e0807dab6642"
- integrity sha1-jYcv4V5oQkyRtLck6EjggH2rZkI=
+ integrity sha512-h0l57vFJ4YQe1/U+C+oqBfAoopxXABUm6VqWM0x2gg4pARru4IUWo/PAxyawWgbGtndXrZbA41EzsfxacZVEXQ==
which-module@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
- integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=
+ integrity sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==
winston@2.x:
- version "2.4.5"
- resolved "https://registry.yarnpkg.com/winston/-/winston-2.4.5.tgz#f2e431d56154c4ea765545fc1003bd340c95b59a"
- integrity sha512-TWoamHt5yYvsMarGlGEQE59SbJHqGsZV8/lwC+iCcGeAe0vUaOh+Lv6SYM17ouzC/a/LB1/hz/7sxFBtlu1l4A==
+ version "2.4.6"
+ resolved "https://registry.yarnpkg.com/winston/-/winston-2.4.6.tgz#da616f332928f70aac482f59b43d62228f29e478"
+ integrity sha512-J5Zu4p0tojLde8mIOyDSsmLmcP8I3Z6wtwpTDHx1+hGcdhxcJaAmG4CFtagkb+NiN1M9Ek4b42pzMWqfc9jm8w==
dependencies:
- async "~1.0.0"
+ async "^3.2.3"
colors "1.0.x"
cycle "1.0.x"
eyes "0.1.x"
@@ -2588,7 +2567,7 @@ wrap-ansi@^6.2.0:
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
- integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
+ integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
xtend@^4.0.0:
version "4.0.2"
diff --git a/frontend/.editorconfig b/frontend/.editorconfig
new file mode 100644
index 0000000000..afff24bb76
--- /dev/null
+++ b/frontend/.editorconfig
@@ -0,0 +1,16 @@
+# editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_style = space
+indent_size = 4
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.json]
+indent_size = 2
diff --git a/frontend/.github/workflows/e2e.feature.yml b/frontend/.github/workflows/e2e.feature.yml
new file mode 100644
index 0000000000..ad89346f22
--- /dev/null
+++ b/frontend/.github/workflows/e2e.feature.yml
@@ -0,0 +1,25 @@
+name: e2e:feature
+# https://docs.github.com/en/actions/reference/events-that-trigger-workflows
+on: [deployment_status]
+jobs:
+ e2e:
+ # only runs this job on successful deploy
+ if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Dump GitHub context
+ env:
+ GITHUB_CONTEXT: ${{ toJson(github) }}
+ run: |
+ echo "$GITHUB_CONTEXT"
+ - name: Checkout
+ uses: actions/checkout@v3
+ - name: Run Cypress
+ uses: cypress-io/github-action@v2
+ with:
+ env: AUTH_USER=admin,AUTH_PASSWORD=unleash4all
+ config: baseUrl=${{ github.event.deployment_status.target_url }}
+ record: true
+ spec: cypress/integration/feature/feature.spec.ts
+ env:
+ CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
diff --git a/frontend/.github/workflows/e2e.groups.yml b/frontend/.github/workflows/e2e.groups.yml
new file mode 100644
index 0000000000..7eb5e056e6
--- /dev/null
+++ b/frontend/.github/workflows/e2e.groups.yml
@@ -0,0 +1,25 @@
+name: e2e:groups
+# https://docs.github.com/en/actions/reference/events-that-trigger-workflows
+on: [deployment_status]
+jobs:
+ e2e:
+ # only runs this job on successful deploy
+ if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Dump GitHub context
+ env:
+ GITHUB_CONTEXT: ${{ toJson(github) }}
+ run: |
+ echo "$GITHUB_CONTEXT"
+ - name: Checkout
+ uses: actions/checkout@v3
+ - name: Run Cypress
+ uses: cypress-io/github-action@v2
+ with:
+ env: AUTH_USER=admin,AUTH_PASSWORD=unleash4all
+ config: baseUrl=${{ github.event.deployment_status.target_url }}
+ record: true
+ spec: cypress/integration/groups/groups.spec.ts
+ env:
+ CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
diff --git a/frontend/.github/workflows/e2e.project-access.yml b/frontend/.github/workflows/e2e.project-access.yml
new file mode 100644
index 0000000000..f2dfff64df
--- /dev/null
+++ b/frontend/.github/workflows/e2e.project-access.yml
@@ -0,0 +1,25 @@
+name: e2e:project-access
+# https://docs.github.com/en/actions/reference/events-that-trigger-workflows
+on: [deployment_status]
+jobs:
+ e2e:
+ # only runs this job on successful deploy
+ if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Dump GitHub context
+ env:
+ GITHUB_CONTEXT: ${{ toJson(github) }}
+ run: |
+ echo "$GITHUB_CONTEXT"
+ - name: Checkout
+ uses: actions/checkout@v3
+ - name: Run Cypress
+ uses: cypress-io/github-action@v2
+ with:
+ env: AUTH_USER=admin,AUTH_PASSWORD=unleash4all
+ config: baseUrl=${{ github.event.deployment_status.target_url }}
+ record: true
+ spec: cypress/integration/projects/access/project-access.spec.ts
+ env:
+ CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
diff --git a/frontend/.github/workflows/e2e.segments.yml b/frontend/.github/workflows/e2e.segments.yml
new file mode 100644
index 0000000000..b8111b2e32
--- /dev/null
+++ b/frontend/.github/workflows/e2e.segments.yml
@@ -0,0 +1,25 @@
+name: e2e:segments
+# https://docs.github.com/en/actions/reference/events-that-trigger-workflows
+on: [deployment_status]
+jobs:
+ e2e:
+ # only runs this job on successful deploy
+ if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Dump GitHub context
+ env:
+ GITHUB_CONTEXT: ${{ toJson(github) }}
+ run: |
+ echo "$GITHUB_CONTEXT"
+ - name: Checkout
+ uses: actions/checkout@v3
+ - name: Run Cypress
+ uses: cypress-io/github-action@v2
+ with:
+ env: AUTH_USER=admin,AUTH_PASSWORD=unleash4all
+ config: baseUrl=${{ github.event.deployment_status.target_url }}
+ record: true
+ spec: cypress/integration/segments/segments.spec.ts
+ env:
+ CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000000..10bbc1c1fa
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,56 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules
+jspm_packages
+package-lock.json
+
+# Optional npm cache directory
+.npm
+
+# Optional REPL history
+.node_repl_history
+
+typings/
+
+# Built
+dist
+build
+
+# IDE
+.idea/
+.vscode/
+
+.DS_Store
+
+cypress/downloads/*
+cypress/videos/*
+cypress/downloads/*
+cypress/screenshots/*
+.env.local
diff --git a/frontend/.nvmrc b/frontend/.nvmrc
new file mode 100644
index 0000000000..8bb247c432
--- /dev/null
+++ b/frontend/.nvmrc
@@ -0,0 +1 @@
+14.20
diff --git a/frontend/.prettierignore b/frontend/.prettierignore
new file mode 100644
index 0000000000..a06b11e1a7
--- /dev/null
+++ b/frontend/.prettierignore
@@ -0,0 +1,3 @@
+.github/*
+/src/openapi
+CHANGELOG.md
diff --git a/frontend/.prettierrc b/frontend/.prettierrc
new file mode 100644
index 0000000000..552578130e
--- /dev/null
+++ b/frontend/.prettierrc
@@ -0,0 +1,7 @@
+{
+ "singleQuote": true,
+ "bracketSpacing": true,
+ "bracketSameLine": false,
+ "arrowParens": "avoid",
+ "printWidth": 80
+}
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000000..e93e8f0e44
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,68 @@
+# frontend
+
+This directory contains the Unleash Admin UI frontend app.
+
+## Run with a local instance of the unleash-api
+
+First, start the unleash-api backend on port 4242.
+Then, start the frontend dev server:
+
+```
+cd ~/frontend
+yarn install
+yarn run start
+```
+
+## Run with a heroku-hosted instance of unleash-api
+
+Alternatively, instead of running unleash-api on localhost, use a remote instance:
+
+```
+cd ~/frontend
+yarn install
+yarn run start:heroku
+```
+
+## Running end-to-end tests
+
+We have a set of Cypress tests that run on the build before a PR can be merged
+so it's important that you check these yourself before submitting a PR.
+On the server the tests will run against the deployed Heroku app so this is what you probably want to test against:
+
+```
+yarn run start:heroku
+```
+
+In a different shell, you can run the tests themselves:
+
+```
+yarn run e2e:heroku
+```
+
+If you need to test against patches against a local server instance,
+you'll need to run that, and then run the end to end tests using:
+
+```
+yarn run e2e
+```
+
+You may also need to test that a feature works against the enterprise version of unleash.
+Assuming the Heroku instance is still running, this can be done by:
+
+```
+yarn run start:enterprise
+yarn run e2e
+```
+
+## Generating the OpenAPI client
+
+The frontend uses an OpenAPI client generated from the backend's OpenAPI spec.
+Whenever there are changes to the backend API, the client should be regenerated:
+
+```
+./scripts/generate-openapi.sh
+```
+
+This script assumes that you have a running instance of the enterprise backend at `http://localhost:4242`.
+The new OpenAPI client will be generated from the runtime schema of this instance.
+The target URL can be changed by setting the `UNLEASH_OPENAPI_URL` env var.
diff --git a/frontend/cypress.json b/frontend/cypress.json
new file mode 100644
index 0000000000..ea78e63c8d
--- /dev/null
+++ b/frontend/cypress.json
@@ -0,0 +1,7 @@
+{
+ "projectId": "tc2qff",
+ "defaultCommandTimeout": 12000,
+ "screenshotOnRunFailure": false,
+ "video": false,
+ "experimentalSessionAndOrigin": true
+}
diff --git a/frontend/cypress/fixtures/example.json b/frontend/cypress/fixtures/example.json
new file mode 100644
index 0000000000..02e4254378
--- /dev/null
+++ b/frontend/cypress/fixtures/example.json
@@ -0,0 +1,5 @@
+{
+ "name": "Using fixtures to represent data",
+ "email": "hello@cypress.io",
+ "body": "Fixtures are a great way to mock data for responses to routes"
+}
diff --git a/frontend/cypress/integration/feature/feature.spec.ts b/frontend/cypress/integration/feature/feature.spec.ts
new file mode 100644
index 0000000000..f2651b5f5c
--- /dev/null
+++ b/frontend/cypress/integration/feature/feature.spec.ts
@@ -0,0 +1,331 @@
+///
+
+export {};
+
+const ENTERPRISE = Boolean(Cypress.env('ENTERPRISE'));
+const randomId = String(Math.random()).split('.')[1];
+const featureToggleName = `unleash-e2e-${randomId}`;
+const baseUrl = Cypress.config().baseUrl;
+const variant1 = 'variant1';
+const variant2 = 'variant2';
+let strategyId = '';
+
+// Disable the prod guard modal by marking it as seen.
+const disableFeatureStrategiesProdGuard = () => {
+ localStorage.setItem(
+ 'useFeatureStrategyProdGuardSettings:v2',
+ JSON.stringify({ hide: true })
+ );
+};
+
+// Disable all active splash pages by visiting them.
+const disableActiveSplashScreens = () => {
+ cy.visit(`/splash/operators`);
+};
+
+describe('feature', () => {
+ before(() => {
+ disableFeatureStrategiesProdGuard();
+ disableActiveSplashScreens();
+ });
+
+ after(() => {
+ cy.request({
+ method: 'DELETE',
+ url: `${baseUrl}/api/admin/features/${featureToggleName}`,
+ });
+ cy.request({
+ method: 'DELETE',
+ url: `${baseUrl}/api/admin/archive/${featureToggleName}`,
+ });
+ });
+
+ beforeEach(() => {
+ cy.login();
+ cy.visit('/');
+ });
+
+ it('can create a feature toggle', () => {
+ if (document.querySelector("[data-testid='CLOSE_SPLASH']")) {
+ cy.get("[data-testid='CLOSE_SPLASH']").click();
+ }
+
+ cy.get('[data-testid=NAVIGATE_TO_CREATE_FEATURE').click();
+
+ cy.intercept('POST', '/api/admin/projects/default/features').as(
+ 'createFeature'
+ );
+
+ cy.get("[data-testid='CF_NAME_ID'").type(featureToggleName);
+ cy.get("[data-testid='CF_DESC_ID'").type('hello-world');
+ cy.get("[data-testid='CF_CREATE_BTN_ID']").click();
+ cy.wait('@createFeature');
+ cy.url().should('include', featureToggleName);
+ });
+
+ it('gives an error if a toggle exists with the same name', () => {
+ cy.get('[data-testid=NAVIGATE_TO_CREATE_FEATURE').click();
+
+ cy.intercept('POST', '/api/admin/projects/default/features').as(
+ 'createFeature'
+ );
+
+ cy.get("[data-testid='CF_NAME_ID'").type(featureToggleName);
+ cy.get("[data-testid='CF_DESC_ID'").type('hello-world');
+ cy.get("[data-testid='CF_CREATE_BTN_ID']").click();
+ cy.get("[data-testid='INPUT_ERROR_TEXT']").contains(
+ 'A toggle with that name already exists'
+ );
+ });
+
+ it('gives an error if a toggle name is url unsafe', () => {
+ cy.get('[data-testid=NAVIGATE_TO_CREATE_FEATURE').click();
+
+ cy.intercept('POST', '/api/admin/projects/default/features').as(
+ 'createFeature'
+ );
+
+ cy.get("[data-testid='CF_NAME_ID'").type('featureToggleUnsafe####$#//');
+ cy.get("[data-testid='CF_DESC_ID'").type('hello-world');
+ cy.get("[data-testid='CF_CREATE_BTN_ID']").click();
+ cy.get("[data-testid='INPUT_ERROR_TEXT']").contains(
+ `"name" must be URL friendly`
+ );
+ });
+
+ it('can add a gradual rollout strategy to the development environment', () => {
+ cy.visit(
+ `/projects/default/features/${featureToggleName}/strategies/create?environmentId=development&strategyName=flexibleRollout`
+ );
+
+ if (ENTERPRISE) {
+ cy.get('[data-testid=ADD_CONSTRAINT_ID]').click();
+ cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click();
+ }
+
+ cy.intercept(
+ 'POST',
+ `/api/admin/projects/default/features/${featureToggleName}/environments/*/strategies`,
+ req => {
+ expect(req.body.name).to.equal('flexibleRollout');
+ expect(req.body.parameters.groupId).to.equal(featureToggleName);
+ expect(req.body.parameters.stickiness).to.equal('default');
+ expect(req.body.parameters.rollout).to.equal('50');
+
+ if (ENTERPRISE) {
+ expect(req.body.constraints.length).to.equal(1);
+ } else {
+ expect(req.body.constraints.length).to.equal(0);
+ }
+
+ req.continue(res => {
+ strategyId = res.body.id;
+ });
+ }
+ ).as('addStrategyToFeature');
+
+ cy.get(`[data-testid=STRATEGY_FORM_SUBMIT_ID]`).first().click();
+ cy.wait('@addStrategyToFeature');
+ });
+
+ it('can update a strategy in the development environment', () => {
+ cy.visit(
+ `/projects/default/features/${featureToggleName}/strategies/edit?environmentId=development&strategyId=${strategyId}`
+ );
+
+ cy.get('[data-testid=FLEXIBLE_STRATEGY_STICKINESS_ID]')
+ .first()
+ .click()
+ .get('[data-testid=SELECT_ITEM_ID-sessionId')
+ .first()
+ .click();
+
+ cy.get('[data-testid=FLEXIBLE_STRATEGY_GROUP_ID]')
+ .first()
+ .clear()
+ .type('new-group-id');
+
+ cy.intercept(
+ 'PUT',
+ `/api/admin/projects/default/features/${featureToggleName}/environments/*/strategies/${strategyId}`,
+ req => {
+ expect(req.body.parameters.groupId).to.equal('new-group-id');
+ expect(req.body.parameters.stickiness).to.equal('sessionId');
+ expect(req.body.parameters.rollout).to.equal('50');
+
+ if (ENTERPRISE) {
+ expect(req.body.constraints.length).to.equal(1);
+ } else {
+ expect(req.body.constraints.length).to.equal(0);
+ }
+
+ req.continue(res => {
+ expect(res.statusCode).to.equal(200);
+ });
+ }
+ ).as('updateStrategy');
+
+ cy.get(`[data-testid=STRATEGY_FORM_SUBMIT_ID]`).first().click();
+ cy.wait('@updateStrategy');
+ });
+
+ it('can delete a strategy in the development environment', () => {
+ cy.visit(`/projects/default/features/${featureToggleName}`);
+
+ cy.intercept(
+ 'DELETE',
+ `/api/admin/projects/default/features/${featureToggleName}/environments/*/strategies/${strategyId}`,
+ req => {
+ req.continue(res => {
+ expect(res.statusCode).to.equal(200);
+ });
+ }
+ ).as('deleteStrategy');
+
+ cy.get(
+ '[data-testid=FEATURE_ENVIRONMENT_ACCORDION_development]'
+ ).click();
+ cy.get('[data-testid=STRATEGY_FORM_REMOVE_ID]').click();
+ cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click();
+ cy.wait('@deleteStrategy');
+ });
+
+ it('can add a userId strategy to the development environment', () => {
+ cy.visit(
+ `/projects/default/features/${featureToggleName}/strategies/create?environmentId=development&strategyName=userWithId`
+ );
+
+ if (ENTERPRISE) {
+ cy.get('[data-testid=ADD_CONSTRAINT_ID]').click();
+ cy.get('[data-testid=CONSTRAINT_AUTOCOMPLETE_ID]')
+ .type('{downArrow}'.repeat(1))
+ .type('{enter}');
+ cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click();
+ }
+
+ cy.get('[data-testid=STRATEGY_INPUT_LIST]')
+ .type('user1')
+ .type('{enter}')
+ .type('user2')
+ .type('{enter}');
+ cy.get('[data-testid=ADD_TO_STRATEGY_INPUT_LIST]').click();
+
+ cy.intercept(
+ 'POST',
+ `/api/admin/projects/default/features/${featureToggleName}/environments/*/strategies`,
+ req => {
+ expect(req.body.name).to.equal('userWithId');
+
+ expect(req.body.parameters.userIds.length).to.equal(11);
+
+ if (ENTERPRISE) {
+ expect(req.body.constraints.length).to.equal(1);
+ } else {
+ expect(req.body.constraints.length).to.equal(0);
+ }
+
+ req.continue(res => {
+ strategyId = res.body.id;
+ });
+ }
+ ).as('addStrategyToFeature');
+
+ cy.get(`[data-testid=STRATEGY_FORM_SUBMIT_ID]`).first().click();
+ cy.wait('@addStrategyToFeature');
+ });
+
+ it('can add two variant to the feature', () => {
+ cy.visit(`/projects/default/features/${featureToggleName}/variants`);
+
+ cy.intercept(
+ 'PATCH',
+ `/api/admin/projects/default/features/${featureToggleName}/variants`,
+ req => {
+ if (req.body.length === 1) {
+ expect(req.body[0].op).to.equal('add');
+ expect(req.body[0].path).to.match(/\//);
+ expect(req.body[0].value.name).to.equal(variant1);
+ } else if (req.body.length === 2) {
+ expect(req.body[0].op).to.equal('replace');
+ expect(req.body[0].path).to.match(/weight/);
+ expect(req.body[0].value).to.equal(500);
+ expect(req.body[1].op).to.equal('add');
+ expect(req.body[1].path).to.match(/\//);
+ expect(req.body[1].value.name).to.equal(variant2);
+ }
+ }
+ ).as('variantCreation');
+
+ cy.get('[data-testid=ADD_VARIANT_BUTTON]').click();
+ cy.wait(1000);
+ cy.get('[data-testid=VARIANT_NAME_INPUT]').type(variant1);
+ cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click();
+ cy.wait('@variantCreation');
+ cy.get('[data-testid=ADD_VARIANT_BUTTON]').click();
+ cy.wait(1000);
+ cy.get('[data-testid=VARIANT_NAME_INPUT]').type(variant2);
+ cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click();
+ cy.wait('@variantCreation');
+ });
+
+ it('can set weight to fixed value for one of the variants', () => {
+ cy.visit(`/projects/default/features/${featureToggleName}/variants`);
+
+ cy.get(`[data-testid=VARIANT_EDIT_BUTTON_${variant1}]`).click();
+ cy.wait(1000);
+ cy.get('[data-testid=VARIANT_NAME_INPUT]')
+ .children()
+ .find('input')
+ .should('have.attr', 'disabled');
+ cy.get('[data-testid=VARIANT_WEIGHT_CHECK]').find('input').check();
+ cy.get('[data-testid=VARIANT_WEIGHT_INPUT]').clear().type('15');
+
+ cy.intercept(
+ 'PATCH',
+ `/api/admin/projects/default/features/${featureToggleName}/variants`,
+ req => {
+ expect(req.body[0].op).to.equal('replace');
+ expect(req.body[0].path).to.match(/weight/);
+ expect(req.body[0].value).to.equal(850);
+ expect(req.body[1].op).to.equal('replace');
+ expect(req.body[1].path).to.match(/weightType/);
+ expect(req.body[1].value).to.equal('fix');
+ expect(req.body[2].op).to.equal('replace');
+ expect(req.body[2].path).to.match(/weight/);
+ expect(req.body[2].value).to.equal(150);
+ }
+ ).as('variantUpdate');
+
+ cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click();
+ cy.wait('@variantUpdate');
+ cy.get(`[data-testid=VARIANT_WEIGHT_${variant1}]`).should(
+ 'have.text',
+ '15 %'
+ );
+ });
+
+ it('can delete variant', () => {
+ const variantName = 'to-be-deleted';
+
+ cy.visit(`/projects/default/features/${featureToggleName}/variants`);
+ cy.get('[data-testid=ADD_VARIANT_BUTTON]').click();
+ cy.wait(1000);
+ cy.get('[data-testid=VARIANT_NAME_INPUT]').type(variantName);
+ cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click();
+
+ cy.intercept(
+ 'PATCH',
+ `/api/admin/projects/default/features/${featureToggleName}/variants`,
+ req => {
+ const patch = req.body.find(
+ (patch: Record) => patch.op === 'remove'
+ );
+ expect(patch.path).to.match(/\//);
+ }
+ ).as('delete');
+
+ cy.get(`[data-testid=VARIANT_DELETE_BUTTON_${variantName}]`).click();
+ cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click();
+ cy.wait('@delete');
+ });
+});
diff --git a/frontend/cypress/integration/groups/groups.spec.ts b/frontend/cypress/integration/groups/groups.spec.ts
new file mode 100644
index 0000000000..efc9931f1c
--- /dev/null
+++ b/frontend/cypress/integration/groups/groups.spec.ts
@@ -0,0 +1,113 @@
+///
+
+export {};
+const baseUrl = Cypress.config().baseUrl;
+const randomId = String(Math.random()).split('.')[1];
+const groupName = `unleash-e2e-${randomId}`;
+const userIds: any[] = [];
+
+// Disable all active splash pages by visiting them.
+const disableActiveSplashScreens = () => {
+ cy.visit(`/splash/operators`);
+};
+
+describe('groups', () => {
+ before(() => {
+ disableActiveSplashScreens();
+ cy.login();
+ for (let i = 1; i <= 2; i++) {
+ cy.request('POST', `${baseUrl}/api/admin/user-admin`, {
+ name: `unleash-e2e-user${i}-${randomId}`,
+ email: `unleash-e2e-user${i}-${randomId}@test.com`,
+ sendEmail: false,
+ rootRole: 3,
+ }).then(response => userIds.push(response.body.id));
+ }
+ });
+
+ after(() => {
+ userIds.forEach(id =>
+ cy.request('DELETE', `${baseUrl}/api/admin/user-admin/${id}`)
+ );
+ });
+
+ beforeEach(() => {
+ cy.login();
+ cy.visit('/admin/groups');
+ if (document.querySelector("[data-testid='CLOSE_SPLASH']")) {
+ cy.get("[data-testid='CLOSE_SPLASH']").click();
+ }
+ });
+
+ it('can create a group', () => {
+ cy.get("[data-testid='NAVIGATE_TO_CREATE_GROUP']").click();
+
+ cy.intercept('POST', '/api/admin/groups').as('createGroup');
+
+ cy.get("[data-testid='UG_NAME_ID']").type(groupName);
+ cy.get("[data-testid='UG_DESC_ID']").type('hello-world');
+ cy.get("[data-testid='UG_USERS_ID']").click();
+ cy.contains(`unleash-e2e-user1-${randomId}`).click();
+
+ cy.get("[data-testid='UG_CREATE_BTN_ID']").click();
+ cy.wait('@createGroup');
+ cy.contains(groupName);
+ });
+
+ it('gives an error if a group exists with the same name', () => {
+ cy.get("[data-testid='NAVIGATE_TO_CREATE_GROUP']").click();
+
+ cy.intercept('POST', '/api/admin/groups').as('createGroup');
+
+ cy.get("[data-testid='UG_NAME_ID']").type(groupName);
+ cy.get("[data-testid='INPUT_ERROR_TEXT'").contains(
+ 'A group with that name already exists.'
+ );
+ });
+
+ it('can edit a group', () => {
+ cy.contains(groupName).click();
+
+ cy.get("[data-testid='UG_EDIT_BTN_ID']").click();
+
+ cy.get("[data-testid='UG_DESC_ID']").type('-my edited description');
+
+ cy.get("[data-testid='UG_SAVE_BTN_ID']").click();
+
+ cy.contains('hello-world-my edited description');
+ });
+
+ it('can add user to a group', () => {
+ cy.contains(groupName).click();
+
+ cy.get("[data-testid='UG_EDIT_USERS_BTN_ID']").click();
+
+ cy.get("[data-testid='UG_USERS_ID']").click();
+ cy.contains(`unleash-e2e-user2-${randomId}`).click();
+
+ cy.get("[data-testid='UG_SAVE_BTN_ID']").click();
+
+ cy.contains(`unleash-e2e-user1-${randomId}`);
+ cy.contains(`unleash-e2e-user2-${randomId}`);
+ });
+
+ it('can remove user from a group', () => {
+ cy.contains(groupName).click();
+
+ cy.get(`[data-testid='UG_REMOVE_USER_BTN_ID-${userIds[1]}']`).click();
+
+ cy.get("[data-testid='DIALOGUE_CONFIRM_ID'").click();
+
+ cy.contains(`unleash-e2e-user1-${randomId}`);
+ cy.contains(`unleash-e2e-user2-${randomId}`).should('not.exist');
+ });
+
+ it('can delete a group', () => {
+ cy.contains(groupName).click();
+
+ cy.get("[data-testid='UG_DELETE_BTN_ID']").click();
+ cy.get("[data-testid='DIALOGUE_CONFIRM_ID'").click();
+
+ cy.contains(groupName).should('not.exist');
+ });
+});
diff --git a/frontend/cypress/integration/projects/access/project-access.spec.ts b/frontend/cypress/integration/projects/access/project-access.spec.ts
new file mode 100644
index 0000000000..cd9039449e
--- /dev/null
+++ b/frontend/cypress/integration/projects/access/project-access.spec.ts
@@ -0,0 +1,147 @@
+///
+
+import {
+ PA_ASSIGN_BUTTON_ID,
+ PA_ASSIGN_CREATE_ID,
+ PA_EDIT_BUTTON_ID,
+ PA_REMOVE_BUTTON_ID,
+ PA_ROLE_ID,
+ PA_USERS_GROUPS_ID,
+ PA_USERS_GROUPS_TITLE_ID,
+} from '../../../../src/utils/testIds';
+
+export {};
+const baseUrl = Cypress.config().baseUrl;
+const randomId = String(Math.random()).split('.')[1];
+const groupAndProjectName = `group-e2e-${randomId}`;
+const userName = `user-e2e-${randomId}`;
+const groupIds: any[] = [];
+const userIds: any[] = [];
+
+// Disable all active splash pages by visiting them.
+const disableActiveSplashScreens = () => {
+ cy.visit(`/splash/operators`);
+};
+
+describe('project-access', () => {
+ before(() => {
+ disableActiveSplashScreens();
+ cy.login();
+ for (let i = 1; i <= 2; i++) {
+ const name = `${i}-${userName}`;
+ cy.request('POST', `${baseUrl}/api/admin/user-admin`, {
+ name: name,
+ email: `${name}@test.com`,
+ sendEmail: false,
+ rootRole: 3,
+ })
+ .as(name)
+ .then(response => {
+ const id = response.body.id;
+ userIds.push(id);
+ cy.request('POST', `${baseUrl}/api/admin/groups`, {
+ name: `${i}-${groupAndProjectName}`,
+ users: [{ user: { id: id } }],
+ }).then(response => {
+ const id = response.body.id;
+ groupIds.push(id);
+ });
+ });
+ }
+ cy.request('POST', `${baseUrl}/api/admin/projects`, {
+ id: groupAndProjectName,
+ name: groupAndProjectName,
+ });
+ });
+
+ after(() => {
+ userIds.forEach(id =>
+ cy.request('DELETE', `${baseUrl}/api/admin/user-admin/${id}`)
+ );
+ groupIds.forEach(id =>
+ cy.request('DELETE', `${baseUrl}/api/admin/groups/${id}`)
+ );
+
+ cy.request(
+ 'DELETE',
+ `${baseUrl}/api/admin/projects/${groupAndProjectName}`
+ );
+ });
+
+ beforeEach(() => {
+ cy.login();
+ cy.visit(`/projects/${groupAndProjectName}/access`);
+ if (document.querySelector("[data-testid='CLOSE_SPLASH']")) {
+ cy.get("[data-testid='CLOSE_SPLASH']").click();
+ }
+ });
+
+ it('can assign permissions to user', () => {
+ cy.get(`[data-testid='${PA_ASSIGN_BUTTON_ID}']`).click();
+
+ cy.intercept(
+ 'POST',
+ `/api/admin/projects/${groupAndProjectName}/role/4/access`
+ ).as('assignAccess');
+
+ cy.get(`[data-testid='${PA_USERS_GROUPS_ID}']`).click();
+ cy.contains(`1-${userName}`).click();
+ cy.get(`[data-testid='${PA_USERS_GROUPS_TITLE_ID}']`).click();
+ cy.get(`[data-testid='${PA_ROLE_ID}']`).click();
+ cy.contains('full control over the project').click({ force: true });
+
+ cy.get(`[data-testid='${PA_ASSIGN_CREATE_ID}']`).click();
+ cy.wait('@assignAccess');
+ cy.contains(`1-${userName}`);
+ });
+
+ it('can assign permissions to group', () => {
+ cy.get(`[data-testid='${PA_ASSIGN_BUTTON_ID}']`).click();
+
+ cy.intercept(
+ 'POST',
+ `/api/admin/projects/${groupAndProjectName}/role/4/access`
+ ).as('assignAccess');
+
+ cy.get(`[data-testid='${PA_USERS_GROUPS_ID}']`).click();
+ cy.contains(`1-${groupAndProjectName}`).click({ force: true });
+ cy.get(`[data-testid='${PA_USERS_GROUPS_TITLE_ID}']`).click();
+ cy.get(`[data-testid='${PA_ROLE_ID}']`).click();
+ cy.contains('full control over the project').click({ force: true });
+
+ cy.get(`[data-testid='${PA_ASSIGN_CREATE_ID}']`).click();
+ cy.wait('@assignAccess');
+ cy.contains(`1-${groupAndProjectName}`);
+ });
+
+ it('can edit role', () => {
+ cy.get(`[data-testid='${PA_EDIT_BUTTON_ID}']`).first().click();
+
+ cy.intercept(
+ 'PUT',
+ `/api/admin/projects/${groupAndProjectName}/groups/${groupIds[0]}/roles/5`
+ ).as('editAccess');
+
+ cy.get(`[data-testid='${PA_ROLE_ID}']`).click();
+ cy.contains('within a project are allowed').click({ force: true });
+
+ cy.get(`[data-testid='${PA_ASSIGN_CREATE_ID}']`).click();
+ cy.wait('@editAccess');
+ cy.get("td span:contains('Owner')").should('have.length', 2);
+ cy.get("td span:contains('Member')").should('have.length', 1);
+ });
+
+ it('can remove access', () => {
+ cy.get(`[data-testid='${PA_REMOVE_BUTTON_ID}']`).first().click();
+
+ cy.intercept(
+ 'DELETE',
+ `/api/admin/projects/${groupAndProjectName}/groups/${groupIds[0]}/roles/5`
+ ).as('removeAccess');
+
+ cy.contains("Yes, I'm sure").click();
+
+ cy.wait('@removeAccess');
+ cy.contains(`1-${groupAndProjectName} has been removed from project`);
+ });
+});
diff --git a/frontend/cypress/integration/segments/segments.spec.ts b/frontend/cypress/integration/segments/segments.spec.ts
new file mode 100644
index 0000000000..7dc3a54d3f
--- /dev/null
+++ b/frontend/cypress/integration/segments/segments.spec.ts
@@ -0,0 +1,57 @@
+///
+
+export {};
+const randomId = String(Math.random()).split('.')[1];
+const segmentName = `unleash-e2e-${randomId}`;
+
+// Disable all active splash pages by visiting them.
+const disableActiveSplashScreens = () => {
+ cy.visit(`/splash/operators`);
+};
+
+describe('segments', () => {
+ before(() => {
+ disableActiveSplashScreens();
+ });
+
+ beforeEach(() => {
+ cy.login();
+ cy.visit('/segments');
+ });
+
+ it('can create a segment', () => {
+ if (document.querySelector("[data-testid='CLOSE_SPLASH']")) {
+ cy.get("[data-testid='CLOSE_SPLASH']").click();
+ }
+
+ cy.get("[data-testid='NAVIGATE_TO_CREATE_SEGMENT']").click();
+
+ cy.intercept('POST', '/api/admin/segments').as('createSegment');
+
+ cy.get("[data-testid='SEGMENT_NAME_ID']").type(segmentName);
+ cy.get("[data-testid='SEGMENT_DESC_ID']").type('hello-world');
+ cy.get("[data-testid='SEGMENT_NEXT_BTN_ID']").click();
+ cy.get("[data-testid='SEGMENT_CREATE_BTN_ID']").click();
+ cy.wait('@createSegment');
+ cy.contains(segmentName);
+ });
+
+ it('gives an error if a segment exists with the same name', () => {
+ cy.get("[data-testid='NAVIGATE_TO_CREATE_SEGMENT']").click();
+
+ cy.get("[data-testid='SEGMENT_NAME_ID']").type(segmentName);
+ cy.get("[data-testid='SEGMENT_NEXT_BTN_ID']").should('be.disabled');
+ cy.get("[data-testid='INPUT_ERROR_TEXT']").contains(
+ 'Segment name already exists'
+ );
+ });
+
+ it('can delete a segment', () => {
+ cy.get(`[data-testid='SEGMENT_DELETE_BTN_ID_${segmentName}']`).click();
+
+ cy.get("[data-testid='SEGMENT_DIALOG_NAME_ID']").type(segmentName);
+ cy.get("[data-testid='DIALOGUE_CONFIRM_ID'").click();
+
+ cy.contains(segmentName).should('not.exist');
+ });
+});
diff --git a/frontend/cypress/plugins/index.ts b/frontend/cypress/plugins/index.ts
new file mode 100644
index 0000000000..59b2bab6e4
--- /dev/null
+++ b/frontend/cypress/plugins/index.ts
@@ -0,0 +1,22 @@
+///
+// ***********************************************************
+// This example plugins/index.js can be used to load plugins
+//
+// You can change the location of this file or turn off loading
+// the plugins file with the 'pluginsFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/plugins-guide
+// ***********************************************************
+
+// This function is called when a project is opened or re-opened (e.g. due to
+// the project's config changing)
+
+/**
+ * @type {Cypress.PluginConfig}
+ */
+// eslint-disable-next-line no-unused-vars
+module.exports = (on, config) => {
+ // `on` is used to hook into various events Cypress emits
+ // `config` is the resolved Cypress config
+}
diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts
new file mode 100644
index 0000000000..fdbea23436
--- /dev/null
+++ b/frontend/cypress/support/commands.ts
@@ -0,0 +1,45 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
+
+const AUTH_USER = Cypress.env('AUTH_USER');
+const AUTH_PASSWORD = Cypress.env('AUTH_PASSWORD');
+
+Cypress.Commands.add('login', (user = AUTH_USER, password = AUTH_PASSWORD) =>
+ cy.session(user, () => {
+ cy.visit('/');
+ cy.wait(1000);
+ cy.get("[data-testid='LOGIN_EMAIL_ID']").type(user);
+
+ if (AUTH_PASSWORD) {
+ cy.get("[data-testid='LOGIN_PASSWORD_ID']").type(password);
+ }
+
+ cy.get("[data-testid='LOGIN_BUTTON']").click();
+
+ // Wait for the login redirect to complete.
+ cy.get("[data-testid='HEADER_USER_AVATAR']");
+ })
+);
diff --git a/frontend/cypress/support/index.ts b/frontend/cypress/support/index.ts
new file mode 100644
index 0000000000..1f43f103c0
--- /dev/null
+++ b/frontend/cypress/support/index.ts
@@ -0,0 +1,28 @@
+// ***********************************************************
+// This example support/index.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands';
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
+
+declare global {
+ namespace Cypress {
+ interface Chainable {
+ login(user?: string, password?: string): Chainable;
+ }
+ }
+}
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000000..a7fa6b0b73
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+ Unleash
+
+
+
+
+
+
+
+
+
diff --git a/frontend/index.js b/frontend/index.js
new file mode 100644
index 0000000000..1eff7e6952
--- /dev/null
+++ b/frontend/index.js
@@ -0,0 +1,9 @@
+require('pkginfo')(module, 'version');
+const path = require('path');
+
+const { version } = module.exports;
+
+module.exports = {
+ publicFolder: path.join(__dirname, 'build'),
+ version
+};
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000000..5600892fec
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,131 @@
+{
+ "name": "unleash-frontend-local",
+ "version": "0.0.0",
+ "private": true,
+ "files": [
+ "index.js",
+ "build/"
+ ],
+ "engines": {
+ "node": ">=14"
+ },
+ "scripts": {
+ "build": "tsc && vite build",
+ "lint": "eslint src --max-warnings 0",
+ "start": "vite",
+ "start:heroku": "UNLEASH_API=https://unleash.herokuapp.com yarn run start",
+ "start:enterprise": "UNLEASH_API=https://unleash4.herokuapp.com yarn run start",
+ "start:demo": "UNLEASH_BASE_PATH=/demo/ yarn start",
+ "test": "vitest run",
+ "test:watch": "vitest watch",
+ "fmt": "prettier src --write --loglevel warn",
+ "fmt:check": "prettier src --check",
+ "e2e": "yarn run cypress open --config baseUrl='http://localhost:3000' --env AUTH_USER=admin,AUTH_PASSWORD=unleash4all",
+ "e2e:heroku": "yarn run cypress open --config baseUrl='http://localhost:3000' --env AUTH_USER=example@example.com",
+ "prepare": "yarn run build"
+ },
+ "devDependencies": {
+ "@codemirror/lang-json": "6.0.0",
+ "@emotion/react": "11.9.3",
+ "@emotion/styled": "11.9.3",
+ "@mui/icons-material": "5.8.4",
+ "@mui/lab": "5.0.0-alpha.95",
+ "@mui/material": "5.10.1",
+ "@openapitools/openapi-generator-cli": "2.5.1",
+ "@testing-library/dom": "8.17.1",
+ "@testing-library/jest-dom": "5.16.5",
+ "@testing-library/react": "12.1.5",
+ "@testing-library/react-hooks": "7.0.2",
+ "@testing-library/user-event": "14.4.3",
+ "@types/debounce": "1.2.1",
+ "@types/deep-diff": "1.0.1",
+ "@types/jest": "28.1.7",
+ "@types/lodash.clonedeep": "4.5.7",
+ "@types/node": "17.0.18",
+ "@types/react": "17.0.48",
+ "@types/react-dom": "17.0.17",
+ "@types/react-router-dom": "5.3.3",
+ "@types/react-table": "7.7.12",
+ "@types/react-test-renderer": "17.0.2",
+ "@types/react-timeago": "4.1.3",
+ "@types/semver": "7.3.12",
+ "@uiw/react-codemirror": "4.11.5",
+ "@vitejs/plugin-react": "1.3.2",
+ "chart.js": "3.9.1",
+ "chartjs-adapter-date-fns": "2.0.0",
+ "classnames": "2.3.1",
+ "copy-to-clipboard": "3.3.2",
+ "cypress": "9.7.0",
+ "date-fns": "2.29.2",
+ "debounce": "1.2.1",
+ "deep-diff": "1.0.2",
+ "eslint": "8.22.0",
+ "eslint-config-react-app": "7.0.1",
+ "fast-json-patch": "3.1.1",
+ "http-proxy-middleware": "2.0.6",
+ "immer": "9.0.15",
+ "jsdom": "20.0.0",
+ "lodash.clonedeep": "4.5.0",
+ "msw": "0.45.0",
+ "pkginfo": "0.4.1",
+ "plausible-tracker": "0.3.8",
+ "prettier": "2.7.1",
+ "prop-types": "15.8.1",
+ "react": "17.0.2",
+ "react-chartjs-2": "4.3.1",
+ "react-dom": "17.0.2",
+ "react-hooks-global-state": "2.0.0",
+ "react-router-dom": "6.3.0",
+ "react-table": "7.8.0",
+ "react-test-renderer": "17.0.2",
+ "react-timeago": "7.1.0",
+ "sass": "1.54.5",
+ "semver": "7.3.7",
+ "swr": "1.3.0",
+ "tss-react": "3.7.1",
+ "typescript": "4.7.4",
+ "vite": "2.9.15",
+ "vite-plugin-env-compatible": "1.1.1",
+ "vite-plugin-svgr": "2.2.1",
+ "vite-tsconfig-paths": "3.5.0",
+ "vitest": "0.22.1",
+ "whatwg-fetch": "3.6.2",
+ "@uiw/codemirror-theme-duotone": "4.11.5"
+ },
+ "jest": {
+ "moduleNameMapper": {
+ "\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/src/__mocks__/fileMock.js",
+ "\\.svg": "/src/__mocks__/svgMock.js",
+ "\\.(css|scss)$": "identity-obj-proxy"
+ }
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "eslintConfig": {
+ "extends": [
+ "react-app",
+ "react-app/jest"
+ ],
+ "parserOptions": {
+ "warnOnUnsupportedTypeScriptVersion": false
+ },
+ "rules": {
+ "no-restricted-globals": "off",
+ "no-useless-computed-key": "off",
+ "import/no-anonymous-default-export": "off"
+ },
+ "ignorePatterns": [
+ "cypress"
+ ]
+ }
+}
diff --git a/frontend/public/cs_CZ.png b/frontend/public/cs_CZ.png
new file mode 100644
index 0000000000..4fc3adb599
Binary files /dev/null and b/frontend/public/cs_CZ.png differ
diff --git a/frontend/public/da-DK.png b/frontend/public/da-DK.png
new file mode 100644
index 0000000000..e3471d34e9
Binary files /dev/null and b/frontend/public/da-DK.png differ
diff --git a/frontend/public/datadog.svg b/frontend/public/datadog.svg
new file mode 100644
index 0000000000..49e6f8473f
--- /dev/null
+++ b/frontend/public/datadog.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/de_DE.png b/frontend/public/de_DE.png
new file mode 100644
index 0000000000..eea2e58b4f
Binary files /dev/null and b/frontend/public/de_DE.png differ
diff --git a/frontend/public/en-GB.png b/frontend/public/en-GB.png
new file mode 100644
index 0000000000..f1e0e12640
Binary files /dev/null and b/frontend/public/en-GB.png differ
diff --git a/frontend/public/en-IN.png b/frontend/public/en-IN.png
new file mode 100644
index 0000000000..2f06567b84
Binary files /dev/null and b/frontend/public/en-IN.png differ
diff --git a/frontend/public/en-US.png b/frontend/public/en-US.png
new file mode 100644
index 0000000000..5b96ff2407
Binary files /dev/null and b/frontend/public/en-US.png differ
diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico
new file mode 100644
index 0000000000..551d158eee
Binary files /dev/null and b/frontend/public/favicon.ico differ
diff --git a/frontend/public/favicon_old.ico b/frontend/public/favicon_old.ico
new file mode 100644
index 0000000000..fed2443a05
Binary files /dev/null and b/frontend/public/favicon_old.ico differ
diff --git a/frontend/public/flags-normal/ad.png b/frontend/public/flags-normal/ad.png
new file mode 100644
index 0000000000..886752f6ce
Binary files /dev/null and b/frontend/public/flags-normal/ad.png differ
diff --git a/frontend/public/flags-normal/ae.png b/frontend/public/flags-normal/ae.png
new file mode 100644
index 0000000000..a253cd2db5
Binary files /dev/null and b/frontend/public/flags-normal/ae.png differ
diff --git a/frontend/public/flags-normal/af.png b/frontend/public/flags-normal/af.png
new file mode 100644
index 0000000000..6ae08810c5
Binary files /dev/null and b/frontend/public/flags-normal/af.png differ
diff --git a/frontend/public/flags-normal/ag.png b/frontend/public/flags-normal/ag.png
new file mode 100644
index 0000000000..ee529d2bc2
Binary files /dev/null and b/frontend/public/flags-normal/ag.png differ
diff --git a/frontend/public/flags-normal/al.png b/frontend/public/flags-normal/al.png
new file mode 100644
index 0000000000..4b59dfbdb4
Binary files /dev/null and b/frontend/public/flags-normal/al.png differ
diff --git a/frontend/public/flags-normal/am.png b/frontend/public/flags-normal/am.png
new file mode 100644
index 0000000000..41b497a368
Binary files /dev/null and b/frontend/public/flags-normal/am.png differ
diff --git a/frontend/public/flags-normal/ao.png b/frontend/public/flags-normal/ao.png
new file mode 100644
index 0000000000..f5ff237487
Binary files /dev/null and b/frontend/public/flags-normal/ao.png differ
diff --git a/frontend/public/flags-normal/ar.png b/frontend/public/flags-normal/ar.png
new file mode 100644
index 0000000000..0b25d9cb71
Binary files /dev/null and b/frontend/public/flags-normal/ar.png differ
diff --git a/frontend/public/flags-normal/at.png b/frontend/public/flags-normal/at.png
new file mode 100644
index 0000000000..75646bab58
Binary files /dev/null and b/frontend/public/flags-normal/at.png differ
diff --git a/frontend/public/flags-normal/au.png b/frontend/public/flags-normal/au.png
new file mode 100644
index 0000000000..f2572d728d
Binary files /dev/null and b/frontend/public/flags-normal/au.png differ
diff --git a/frontend/public/flags-normal/az.png b/frontend/public/flags-normal/az.png
new file mode 100644
index 0000000000..f639aefd61
Binary files /dev/null and b/frontend/public/flags-normal/az.png differ
diff --git a/frontend/public/flags-normal/ba.png b/frontend/public/flags-normal/ba.png
new file mode 100644
index 0000000000..a4ac356d60
Binary files /dev/null and b/frontend/public/flags-normal/ba.png differ
diff --git a/frontend/public/flags-normal/bb.png b/frontend/public/flags-normal/bb.png
new file mode 100644
index 0000000000..2bf58e69e6
Binary files /dev/null and b/frontend/public/flags-normal/bb.png differ
diff --git a/frontend/public/flags-normal/bd.png b/frontend/public/flags-normal/bd.png
new file mode 100644
index 0000000000..e9872d1542
Binary files /dev/null and b/frontend/public/flags-normal/bd.png differ
diff --git a/frontend/public/flags-normal/be.png b/frontend/public/flags-normal/be.png
new file mode 100644
index 0000000000..5d1b8325dd
Binary files /dev/null and b/frontend/public/flags-normal/be.png differ
diff --git a/frontend/public/flags-normal/bf.png b/frontend/public/flags-normal/bf.png
new file mode 100644
index 0000000000..5172dbfb31
Binary files /dev/null and b/frontend/public/flags-normal/bf.png differ
diff --git a/frontend/public/flags-normal/bg.png b/frontend/public/flags-normal/bg.png
new file mode 100644
index 0000000000..d78308df87
Binary files /dev/null and b/frontend/public/flags-normal/bg.png differ
diff --git a/frontend/public/flags-normal/bh.png b/frontend/public/flags-normal/bh.png
new file mode 100644
index 0000000000..5e247e7aa4
Binary files /dev/null and b/frontend/public/flags-normal/bh.png differ
diff --git a/frontend/public/flags-normal/bi.png b/frontend/public/flags-normal/bi.png
new file mode 100644
index 0000000000..26186437c3
Binary files /dev/null and b/frontend/public/flags-normal/bi.png differ
diff --git a/frontend/public/flags-normal/bj.png b/frontend/public/flags-normal/bj.png
new file mode 100644
index 0000000000..20e281f272
Binary files /dev/null and b/frontend/public/flags-normal/bj.png differ
diff --git a/frontend/public/flags-normal/bn.png b/frontend/public/flags-normal/bn.png
new file mode 100644
index 0000000000..b4a3e60e65
Binary files /dev/null and b/frontend/public/flags-normal/bn.png differ
diff --git a/frontend/public/flags-normal/bo.png b/frontend/public/flags-normal/bo.png
new file mode 100644
index 0000000000..342267cbbc
Binary files /dev/null and b/frontend/public/flags-normal/bo.png differ
diff --git a/frontend/public/flags-normal/br.png b/frontend/public/flags-normal/br.png
new file mode 100644
index 0000000000..43725657c0
Binary files /dev/null and b/frontend/public/flags-normal/br.png differ
diff --git a/frontend/public/flags-normal/bs.png b/frontend/public/flags-normal/bs.png
new file mode 100644
index 0000000000..1bbb1d8f61
Binary files /dev/null and b/frontend/public/flags-normal/bs.png differ
diff --git a/frontend/public/flags-normal/bt.png b/frontend/public/flags-normal/bt.png
new file mode 100644
index 0000000000..cd4c85391f
Binary files /dev/null and b/frontend/public/flags-normal/bt.png differ
diff --git a/frontend/public/flags-normal/bw.png b/frontend/public/flags-normal/bw.png
new file mode 100644
index 0000000000..555d80b35f
Binary files /dev/null and b/frontend/public/flags-normal/bw.png differ
diff --git a/frontend/public/flags-normal/by.png b/frontend/public/flags-normal/by.png
new file mode 100644
index 0000000000..0dc3102025
Binary files /dev/null and b/frontend/public/flags-normal/by.png differ
diff --git a/frontend/public/flags-normal/bz.png b/frontend/public/flags-normal/bz.png
new file mode 100644
index 0000000000..3b6c39e67e
Binary files /dev/null and b/frontend/public/flags-normal/bz.png differ
diff --git a/frontend/public/flags-normal/ca.png b/frontend/public/flags-normal/ca.png
new file mode 100644
index 0000000000..c939b041da
Binary files /dev/null and b/frontend/public/flags-normal/ca.png differ
diff --git a/frontend/public/flags-normal/cd.png b/frontend/public/flags-normal/cd.png
new file mode 100644
index 0000000000..44043fac2f
Binary files /dev/null and b/frontend/public/flags-normal/cd.png differ
diff --git a/frontend/public/flags-normal/cf.png b/frontend/public/flags-normal/cf.png
new file mode 100644
index 0000000000..5b7cb2252e
Binary files /dev/null and b/frontend/public/flags-normal/cf.png differ
diff --git a/frontend/public/flags-normal/cg.png b/frontend/public/flags-normal/cg.png
new file mode 100644
index 0000000000..2d7ce4c027
Binary files /dev/null and b/frontend/public/flags-normal/cg.png differ
diff --git a/frontend/public/flags-normal/ch.png b/frontend/public/flags-normal/ch.png
new file mode 100644
index 0000000000..5fe151ca63
Binary files /dev/null and b/frontend/public/flags-normal/ch.png differ
diff --git a/frontend/public/flags-normal/ci.png b/frontend/public/flags-normal/ci.png
new file mode 100644
index 0000000000..0534124cb5
Binary files /dev/null and b/frontend/public/flags-normal/ci.png differ
diff --git a/frontend/public/flags-normal/cl.png b/frontend/public/flags-normal/cl.png
new file mode 100644
index 0000000000..af74ffc934
Binary files /dev/null and b/frontend/public/flags-normal/cl.png differ
diff --git a/frontend/public/flags-normal/cm.png b/frontend/public/flags-normal/cm.png
new file mode 100644
index 0000000000..b33c811544
Binary files /dev/null and b/frontend/public/flags-normal/cm.png differ
diff --git a/frontend/public/flags-normal/cn.png b/frontend/public/flags-normal/cn.png
new file mode 100644
index 0000000000..d31bab7106
Binary files /dev/null and b/frontend/public/flags-normal/cn.png differ
diff --git a/frontend/public/flags-normal/co.png b/frontend/public/flags-normal/co.png
new file mode 100644
index 0000000000..b6aae55c71
Binary files /dev/null and b/frontend/public/flags-normal/co.png differ
diff --git a/frontend/public/flags-normal/cr.png b/frontend/public/flags-normal/cr.png
new file mode 100644
index 0000000000..9c92f6de0c
Binary files /dev/null and b/frontend/public/flags-normal/cr.png differ
diff --git a/frontend/public/flags-normal/cu.png b/frontend/public/flags-normal/cu.png
new file mode 100644
index 0000000000..f21090e273
Binary files /dev/null and b/frontend/public/flags-normal/cu.png differ
diff --git a/frontend/public/flags-normal/cv.png b/frontend/public/flags-normal/cv.png
new file mode 100644
index 0000000000..6eeae62b98
Binary files /dev/null and b/frontend/public/flags-normal/cv.png differ
diff --git a/frontend/public/flags-normal/cy.png b/frontend/public/flags-normal/cy.png
new file mode 100644
index 0000000000..554460413a
Binary files /dev/null and b/frontend/public/flags-normal/cy.png differ
diff --git a/frontend/public/flags-normal/cz.png b/frontend/public/flags-normal/cz.png
new file mode 100644
index 0000000000..4fc3adb599
Binary files /dev/null and b/frontend/public/flags-normal/cz.png differ
diff --git a/frontend/public/flags-normal/de.png b/frontend/public/flags-normal/de.png
new file mode 100644
index 0000000000..eea2e58b4f
Binary files /dev/null and b/frontend/public/flags-normal/de.png differ
diff --git a/frontend/public/flags-normal/dj.png b/frontend/public/flags-normal/dj.png
new file mode 100644
index 0000000000..dbc95d77e3
Binary files /dev/null and b/frontend/public/flags-normal/dj.png differ
diff --git a/frontend/public/flags-normal/dk.png b/frontend/public/flags-normal/dk.png
new file mode 100644
index 0000000000..e3471d34e9
Binary files /dev/null and b/frontend/public/flags-normal/dk.png differ
diff --git a/frontend/public/flags-normal/dm.png b/frontend/public/flags-normal/dm.png
new file mode 100644
index 0000000000..a158c88fde
Binary files /dev/null and b/frontend/public/flags-normal/dm.png differ
diff --git a/frontend/public/flags-normal/do.png b/frontend/public/flags-normal/do.png
new file mode 100644
index 0000000000..81fa5e8bbb
Binary files /dev/null and b/frontend/public/flags-normal/do.png differ
diff --git a/frontend/public/flags-normal/dz.png b/frontend/public/flags-normal/dz.png
new file mode 100644
index 0000000000..b2768bcccf
Binary files /dev/null and b/frontend/public/flags-normal/dz.png differ
diff --git a/frontend/public/flags-normal/ec.png b/frontend/public/flags-normal/ec.png
new file mode 100644
index 0000000000..27fe811525
Binary files /dev/null and b/frontend/public/flags-normal/ec.png differ
diff --git a/frontend/public/flags-normal/ee.png b/frontend/public/flags-normal/ee.png
new file mode 100644
index 0000000000..21b4b72de8
Binary files /dev/null and b/frontend/public/flags-normal/ee.png differ
diff --git a/frontend/public/flags-normal/eg.png b/frontend/public/flags-normal/eg.png
new file mode 100644
index 0000000000..d98e5d3aa9
Binary files /dev/null and b/frontend/public/flags-normal/eg.png differ
diff --git a/frontend/public/flags-normal/eh.png b/frontend/public/flags-normal/eh.png
new file mode 100644
index 0000000000..cf451799aa
Binary files /dev/null and b/frontend/public/flags-normal/eh.png differ
diff --git a/frontend/public/flags-normal/er.png b/frontend/public/flags-normal/er.png
new file mode 100644
index 0000000000..3f88fc52a2
Binary files /dev/null and b/frontend/public/flags-normal/er.png differ
diff --git a/frontend/public/flags-normal/es.png b/frontend/public/flags-normal/es.png
new file mode 100644
index 0000000000..f589a83520
Binary files /dev/null and b/frontend/public/flags-normal/es.png differ
diff --git a/frontend/public/flags-normal/et.png b/frontend/public/flags-normal/et.png
new file mode 100644
index 0000000000..d759c2fd13
Binary files /dev/null and b/frontend/public/flags-normal/et.png differ
diff --git a/frontend/public/flags-normal/fi.png b/frontend/public/flags-normal/fi.png
new file mode 100644
index 0000000000..2bcb6a5534
Binary files /dev/null and b/frontend/public/flags-normal/fi.png differ
diff --git a/frontend/public/flags-normal/fj.png b/frontend/public/flags-normal/fj.png
new file mode 100644
index 0000000000..7aef415f91
Binary files /dev/null and b/frontend/public/flags-normal/fj.png differ
diff --git a/frontend/public/flags-normal/fm.png b/frontend/public/flags-normal/fm.png
new file mode 100644
index 0000000000..1dfbdffe8b
Binary files /dev/null and b/frontend/public/flags-normal/fm.png differ
diff --git a/frontend/public/flags-normal/fr.png b/frontend/public/flags-normal/fr.png
new file mode 100644
index 0000000000..fcfa7caf02
Binary files /dev/null and b/frontend/public/flags-normal/fr.png differ
diff --git a/frontend/public/flags-normal/ga.png b/frontend/public/flags-normal/ga.png
new file mode 100644
index 0000000000..2dc5f0fcf8
Binary files /dev/null and b/frontend/public/flags-normal/ga.png differ
diff --git a/frontend/public/flags-normal/gb.png b/frontend/public/flags-normal/gb.png
new file mode 100644
index 0000000000..f1e0e12640
Binary files /dev/null and b/frontend/public/flags-normal/gb.png differ
diff --git a/frontend/public/flags-normal/gd.png b/frontend/public/flags-normal/gd.png
new file mode 100644
index 0000000000..5e3ed13b73
Binary files /dev/null and b/frontend/public/flags-normal/gd.png differ
diff --git a/frontend/public/flags-normal/ge.png b/frontend/public/flags-normal/ge.png
new file mode 100644
index 0000000000..cd5b75deb7
Binary files /dev/null and b/frontend/public/flags-normal/ge.png differ
diff --git a/frontend/public/flags-normal/gh.png b/frontend/public/flags-normal/gh.png
new file mode 100644
index 0000000000..a7b60ce7a2
Binary files /dev/null and b/frontend/public/flags-normal/gh.png differ
diff --git a/frontend/public/flags-normal/gm.png b/frontend/public/flags-normal/gm.png
new file mode 100644
index 0000000000..ca440bb63d
Binary files /dev/null and b/frontend/public/flags-normal/gm.png differ
diff --git a/frontend/public/flags-normal/gn.png b/frontend/public/flags-normal/gn.png
new file mode 100644
index 0000000000..0740a3fc35
Binary files /dev/null and b/frontend/public/flags-normal/gn.png differ
diff --git a/frontend/public/flags-normal/gq.png b/frontend/public/flags-normal/gq.png
new file mode 100644
index 0000000000..bc9c8c46b7
Binary files /dev/null and b/frontend/public/flags-normal/gq.png differ
diff --git a/frontend/public/flags-normal/gr.png b/frontend/public/flags-normal/gr.png
new file mode 100644
index 0000000000..ec65864a50
Binary files /dev/null and b/frontend/public/flags-normal/gr.png differ
diff --git a/frontend/public/flags-normal/gt.png b/frontend/public/flags-normal/gt.png
new file mode 100644
index 0000000000..3c7cee7dfa
Binary files /dev/null and b/frontend/public/flags-normal/gt.png differ
diff --git a/frontend/public/flags-normal/gw.png b/frontend/public/flags-normal/gw.png
new file mode 100644
index 0000000000..515d457f7f
Binary files /dev/null and b/frontend/public/flags-normal/gw.png differ
diff --git a/frontend/public/flags-normal/gy.png b/frontend/public/flags-normal/gy.png
new file mode 100644
index 0000000000..6c3e673302
Binary files /dev/null and b/frontend/public/flags-normal/gy.png differ
diff --git a/frontend/public/flags-normal/hn.png b/frontend/public/flags-normal/hn.png
new file mode 100644
index 0000000000..ee1d10289a
Binary files /dev/null and b/frontend/public/flags-normal/hn.png differ
diff --git a/frontend/public/flags-normal/hr.png b/frontend/public/flags-normal/hr.png
new file mode 100644
index 0000000000..2dae8a8aff
Binary files /dev/null and b/frontend/public/flags-normal/hr.png differ
diff --git a/frontend/public/flags-normal/ht.png b/frontend/public/flags-normal/ht.png
new file mode 100644
index 0000000000..2e15f89987
Binary files /dev/null and b/frontend/public/flags-normal/ht.png differ
diff --git a/frontend/public/flags-normal/hu.png b/frontend/public/flags-normal/hu.png
new file mode 100644
index 0000000000..c1c028ec2e
Binary files /dev/null and b/frontend/public/flags-normal/hu.png differ
diff --git a/frontend/public/flags-normal/id.png b/frontend/public/flags-normal/id.png
new file mode 100644
index 0000000000..619215dacc
Binary files /dev/null and b/frontend/public/flags-normal/id.png differ
diff --git a/frontend/public/flags-normal/ie.png b/frontend/public/flags-normal/ie.png
new file mode 100644
index 0000000000..3881ba3402
Binary files /dev/null and b/frontend/public/flags-normal/ie.png differ
diff --git a/frontend/public/flags-normal/il.png b/frontend/public/flags-normal/il.png
new file mode 100644
index 0000000000..33fc90c2ca
Binary files /dev/null and b/frontend/public/flags-normal/il.png differ
diff --git a/frontend/public/flags-normal/in.png b/frontend/public/flags-normal/in.png
new file mode 100644
index 0000000000..2f06567b84
Binary files /dev/null and b/frontend/public/flags-normal/in.png differ
diff --git a/frontend/public/flags-normal/iq.png b/frontend/public/flags-normal/iq.png
new file mode 100644
index 0000000000..6b5eb22a03
Binary files /dev/null and b/frontend/public/flags-normal/iq.png differ
diff --git a/frontend/public/flags-normal/ir.png b/frontend/public/flags-normal/ir.png
new file mode 100644
index 0000000000..36f7ec834e
Binary files /dev/null and b/frontend/public/flags-normal/ir.png differ
diff --git a/frontend/public/flags-normal/is.png b/frontend/public/flags-normal/is.png
new file mode 100644
index 0000000000..74fef41dff
Binary files /dev/null and b/frontend/public/flags-normal/is.png differ
diff --git a/frontend/public/flags-normal/it.png b/frontend/public/flags-normal/it.png
new file mode 100644
index 0000000000..ff7ed31740
Binary files /dev/null and b/frontend/public/flags-normal/it.png differ
diff --git a/frontend/public/flags-normal/jm.png b/frontend/public/flags-normal/jm.png
new file mode 100644
index 0000000000..68e58fee3d
Binary files /dev/null and b/frontend/public/flags-normal/jm.png differ
diff --git a/frontend/public/flags-normal/jo.png b/frontend/public/flags-normal/jo.png
new file mode 100644
index 0000000000..57bd76a6dc
Binary files /dev/null and b/frontend/public/flags-normal/jo.png differ
diff --git a/frontend/public/flags-normal/jp.png b/frontend/public/flags-normal/jp.png
new file mode 100644
index 0000000000..33f3a75784
Binary files /dev/null and b/frontend/public/flags-normal/jp.png differ
diff --git a/frontend/public/flags-normal/ke.png b/frontend/public/flags-normal/ke.png
new file mode 100644
index 0000000000..9e8373fd49
Binary files /dev/null and b/frontend/public/flags-normal/ke.png differ
diff --git a/frontend/public/flags-normal/kg.png b/frontend/public/flags-normal/kg.png
new file mode 100644
index 0000000000..3e7d661122
Binary files /dev/null and b/frontend/public/flags-normal/kg.png differ
diff --git a/frontend/public/flags-normal/kh.png b/frontend/public/flags-normal/kh.png
new file mode 100644
index 0000000000..cf76786bf4
Binary files /dev/null and b/frontend/public/flags-normal/kh.png differ
diff --git a/frontend/public/flags-normal/ki.png b/frontend/public/flags-normal/ki.png
new file mode 100644
index 0000000000..ff8e470df2
Binary files /dev/null and b/frontend/public/flags-normal/ki.png differ
diff --git a/frontend/public/flags-normal/km.png b/frontend/public/flags-normal/km.png
new file mode 100644
index 0000000000..cbd5e1b5d0
Binary files /dev/null and b/frontend/public/flags-normal/km.png differ
diff --git a/frontend/public/flags-normal/kn.png b/frontend/public/flags-normal/kn.png
new file mode 100644
index 0000000000..fed64fc00b
Binary files /dev/null and b/frontend/public/flags-normal/kn.png differ
diff --git a/frontend/public/flags-normal/kp.png b/frontend/public/flags-normal/kp.png
new file mode 100644
index 0000000000..b25aadc337
Binary files /dev/null and b/frontend/public/flags-normal/kp.png differ
diff --git a/frontend/public/flags-normal/kr.png b/frontend/public/flags-normal/kr.png
new file mode 100644
index 0000000000..d035cab9b5
Binary files /dev/null and b/frontend/public/flags-normal/kr.png differ
diff --git a/frontend/public/flags-normal/ks.png b/frontend/public/flags-normal/ks.png
new file mode 100644
index 0000000000..942e1b5800
Binary files /dev/null and b/frontend/public/flags-normal/ks.png differ
diff --git a/frontend/public/flags-normal/kw.png b/frontend/public/flags-normal/kw.png
new file mode 100644
index 0000000000..8c01668df4
Binary files /dev/null and b/frontend/public/flags-normal/kw.png differ
diff --git a/frontend/public/flags-normal/kz.png b/frontend/public/flags-normal/kz.png
new file mode 100644
index 0000000000..436ac8a1e8
Binary files /dev/null and b/frontend/public/flags-normal/kz.png differ
diff --git a/frontend/public/flags-normal/la.png b/frontend/public/flags-normal/la.png
new file mode 100644
index 0000000000..87d7fb3c88
Binary files /dev/null and b/frontend/public/flags-normal/la.png differ
diff --git a/frontend/public/flags-normal/lb.png b/frontend/public/flags-normal/lb.png
new file mode 100644
index 0000000000..7d3659ab07
Binary files /dev/null and b/frontend/public/flags-normal/lb.png differ
diff --git a/frontend/public/flags-normal/lc.png b/frontend/public/flags-normal/lc.png
new file mode 100644
index 0000000000..4bb0487ca1
Binary files /dev/null and b/frontend/public/flags-normal/lc.png differ
diff --git a/frontend/public/flags-normal/li.png b/frontend/public/flags-normal/li.png
new file mode 100644
index 0000000000..b68b433a88
Binary files /dev/null and b/frontend/public/flags-normal/li.png differ
diff --git a/frontend/public/flags-normal/lk.png b/frontend/public/flags-normal/lk.png
new file mode 100644
index 0000000000..15e45c8197
Binary files /dev/null and b/frontend/public/flags-normal/lk.png differ
diff --git a/frontend/public/flags-normal/lr.png b/frontend/public/flags-normal/lr.png
new file mode 100644
index 0000000000..36948eef99
Binary files /dev/null and b/frontend/public/flags-normal/lr.png differ
diff --git a/frontend/public/flags-normal/ls.png b/frontend/public/flags-normal/ls.png
new file mode 100644
index 0000000000..70cab72308
Binary files /dev/null and b/frontend/public/flags-normal/ls.png differ
diff --git a/frontend/public/flags-normal/lt.png b/frontend/public/flags-normal/lt.png
new file mode 100644
index 0000000000..80bc580526
Binary files /dev/null and b/frontend/public/flags-normal/lt.png differ
diff --git a/frontend/public/flags-normal/lu.png b/frontend/public/flags-normal/lu.png
new file mode 100644
index 0000000000..c5c2246c5b
Binary files /dev/null and b/frontend/public/flags-normal/lu.png differ
diff --git a/frontend/public/flags-normal/lv.png b/frontend/public/flags-normal/lv.png
new file mode 100644
index 0000000000..75431d1965
Binary files /dev/null and b/frontend/public/flags-normal/lv.png differ
diff --git a/frontend/public/flags-normal/ly.png b/frontend/public/flags-normal/ly.png
new file mode 100644
index 0000000000..2914da2986
Binary files /dev/null and b/frontend/public/flags-normal/ly.png differ
diff --git a/frontend/public/flags-normal/ma.png b/frontend/public/flags-normal/ma.png
new file mode 100644
index 0000000000..0f751a1c5a
Binary files /dev/null and b/frontend/public/flags-normal/ma.png differ
diff --git a/frontend/public/flags-normal/mc.png b/frontend/public/flags-normal/mc.png
new file mode 100644
index 0000000000..3f8311b28d
Binary files /dev/null and b/frontend/public/flags-normal/mc.png differ
diff --git a/frontend/public/flags-normal/md.png b/frontend/public/flags-normal/md.png
new file mode 100644
index 0000000000..4645ae109e
Binary files /dev/null and b/frontend/public/flags-normal/md.png differ
diff --git a/frontend/public/flags-normal/me.png b/frontend/public/flags-normal/me.png
new file mode 100644
index 0000000000..941d51d414
Binary files /dev/null and b/frontend/public/flags-normal/me.png differ
diff --git a/frontend/public/flags-normal/mg.png b/frontend/public/flags-normal/mg.png
new file mode 100644
index 0000000000..43922054d4
Binary files /dev/null and b/frontend/public/flags-normal/mg.png differ
diff --git a/frontend/public/flags-normal/mh.png b/frontend/public/flags-normal/mh.png
new file mode 100644
index 0000000000..8438bfa32f
Binary files /dev/null and b/frontend/public/flags-normal/mh.png differ
diff --git a/frontend/public/flags-normal/mk.png b/frontend/public/flags-normal/mk.png
new file mode 100644
index 0000000000..3c08615b9b
Binary files /dev/null and b/frontend/public/flags-normal/mk.png differ
diff --git a/frontend/public/flags-normal/ml.png b/frontend/public/flags-normal/ml.png
new file mode 100644
index 0000000000..ce81958a2f
Binary files /dev/null and b/frontend/public/flags-normal/ml.png differ
diff --git a/frontend/public/flags-normal/mm.png b/frontend/public/flags-normal/mm.png
new file mode 100644
index 0000000000..3c1c085685
Binary files /dev/null and b/frontend/public/flags-normal/mm.png differ
diff --git a/frontend/public/flags-normal/mn.png b/frontend/public/flags-normal/mn.png
new file mode 100644
index 0000000000..2771b270b7
Binary files /dev/null and b/frontend/public/flags-normal/mn.png differ
diff --git a/frontend/public/flags-normal/mr.png b/frontend/public/flags-normal/mr.png
new file mode 100644
index 0000000000..f4dcf1d2f9
Binary files /dev/null and b/frontend/public/flags-normal/mr.png differ
diff --git a/frontend/public/flags-normal/mt.png b/frontend/public/flags-normal/mt.png
new file mode 100644
index 0000000000..950502ab63
Binary files /dev/null and b/frontend/public/flags-normal/mt.png differ
diff --git a/frontend/public/flags-normal/mu.png b/frontend/public/flags-normal/mu.png
new file mode 100644
index 0000000000..a6349637cd
Binary files /dev/null and b/frontend/public/flags-normal/mu.png differ
diff --git a/frontend/public/flags-normal/mv.png b/frontend/public/flags-normal/mv.png
new file mode 100644
index 0000000000..565a40831e
Binary files /dev/null and b/frontend/public/flags-normal/mv.png differ
diff --git a/frontend/public/flags-normal/mw.png b/frontend/public/flags-normal/mw.png
new file mode 100644
index 0000000000..442dbc58de
Binary files /dev/null and b/frontend/public/flags-normal/mw.png differ
diff --git a/frontend/public/flags-normal/mx.png b/frontend/public/flags-normal/mx.png
new file mode 100644
index 0000000000..666424d181
Binary files /dev/null and b/frontend/public/flags-normal/mx.png differ
diff --git a/frontend/public/flags-normal/my.png b/frontend/public/flags-normal/my.png
new file mode 100644
index 0000000000..215448cdd3
Binary files /dev/null and b/frontend/public/flags-normal/my.png differ
diff --git a/frontend/public/flags-normal/mz.png b/frontend/public/flags-normal/mz.png
new file mode 100644
index 0000000000..18e2a94999
Binary files /dev/null and b/frontend/public/flags-normal/mz.png differ
diff --git a/frontend/public/flags-normal/na.png b/frontend/public/flags-normal/na.png
new file mode 100644
index 0000000000..ca31b5d22f
Binary files /dev/null and b/frontend/public/flags-normal/na.png differ
diff --git a/frontend/public/flags-normal/ne.png b/frontend/public/flags-normal/ne.png
new file mode 100644
index 0000000000..e0097297b8
Binary files /dev/null and b/frontend/public/flags-normal/ne.png differ
diff --git a/frontend/public/flags-normal/ng.png b/frontend/public/flags-normal/ng.png
new file mode 100644
index 0000000000..ee5775a8dd
Binary files /dev/null and b/frontend/public/flags-normal/ng.png differ
diff --git a/frontend/public/flags-normal/ni.png b/frontend/public/flags-normal/ni.png
new file mode 100644
index 0000000000..2ebe882abc
Binary files /dev/null and b/frontend/public/flags-normal/ni.png differ
diff --git a/frontend/public/flags-normal/nl.png b/frontend/public/flags-normal/nl.png
new file mode 100644
index 0000000000..0386cc3e80
Binary files /dev/null and b/frontend/public/flags-normal/nl.png differ
diff --git a/frontend/public/flags-normal/no.png b/frontend/public/flags-normal/no.png
new file mode 100644
index 0000000000..bb2f806b51
Binary files /dev/null and b/frontend/public/flags-normal/no.png differ
diff --git a/frontend/public/flags-normal/np.png b/frontend/public/flags-normal/np.png
new file mode 100644
index 0000000000..726500cc63
Binary files /dev/null and b/frontend/public/flags-normal/np.png differ
diff --git a/frontend/public/flags-normal/nr.png b/frontend/public/flags-normal/nr.png
new file mode 100644
index 0000000000..65b58110d7
Binary files /dev/null and b/frontend/public/flags-normal/nr.png differ
diff --git a/frontend/public/flags-normal/nz.png b/frontend/public/flags-normal/nz.png
new file mode 100644
index 0000000000..abe4acf673
Binary files /dev/null and b/frontend/public/flags-normal/nz.png differ
diff --git a/frontend/public/flags-normal/om.png b/frontend/public/flags-normal/om.png
new file mode 100644
index 0000000000..8681267655
Binary files /dev/null and b/frontend/public/flags-normal/om.png differ
diff --git a/frontend/public/flags-normal/pa.png b/frontend/public/flags-normal/pa.png
new file mode 100644
index 0000000000..e821dee88a
Binary files /dev/null and b/frontend/public/flags-normal/pa.png differ
diff --git a/frontend/public/flags-normal/pe.png b/frontend/public/flags-normal/pe.png
new file mode 100644
index 0000000000..5af51ad757
Binary files /dev/null and b/frontend/public/flags-normal/pe.png differ
diff --git a/frontend/public/flags-normal/pg.png b/frontend/public/flags-normal/pg.png
new file mode 100644
index 0000000000..14818457ec
Binary files /dev/null and b/frontend/public/flags-normal/pg.png differ
diff --git a/frontend/public/flags-normal/ph.png b/frontend/public/flags-normal/ph.png
new file mode 100644
index 0000000000..ffa33a921e
Binary files /dev/null and b/frontend/public/flags-normal/ph.png differ
diff --git a/frontend/public/flags-normal/pk.png b/frontend/public/flags-normal/pk.png
new file mode 100644
index 0000000000..645971c5d4
Binary files /dev/null and b/frontend/public/flags-normal/pk.png differ
diff --git a/frontend/public/flags-normal/pl.png b/frontend/public/flags-normal/pl.png
new file mode 100644
index 0000000000..9d4e692506
Binary files /dev/null and b/frontend/public/flags-normal/pl.png differ
diff --git a/frontend/public/flags-normal/pt.png b/frontend/public/flags-normal/pt.png
new file mode 100644
index 0000000000..6526f8c1b3
Binary files /dev/null and b/frontend/public/flags-normal/pt.png differ
diff --git a/frontend/public/flags-normal/pw.png b/frontend/public/flags-normal/pw.png
new file mode 100644
index 0000000000..0a91ea566c
Binary files /dev/null and b/frontend/public/flags-normal/pw.png differ
diff --git a/frontend/public/flags-normal/py.png b/frontend/public/flags-normal/py.png
new file mode 100644
index 0000000000..40dffa497b
Binary files /dev/null and b/frontend/public/flags-normal/py.png differ
diff --git a/frontend/public/flags-normal/qa.png b/frontend/public/flags-normal/qa.png
new file mode 100644
index 0000000000..9cf0068305
Binary files /dev/null and b/frontend/public/flags-normal/qa.png differ
diff --git a/frontend/public/flags-normal/ro.png b/frontend/public/flags-normal/ro.png
new file mode 100644
index 0000000000..0bee8d1a9c
Binary files /dev/null and b/frontend/public/flags-normal/ro.png differ
diff --git a/frontend/public/flags-normal/rs.png b/frontend/public/flags-normal/rs.png
new file mode 100644
index 0000000000..19fd38a653
Binary files /dev/null and b/frontend/public/flags-normal/rs.png differ
diff --git a/frontend/public/flags-normal/ru.png b/frontend/public/flags-normal/ru.png
new file mode 100644
index 0000000000..66741a4d43
Binary files /dev/null and b/frontend/public/flags-normal/ru.png differ
diff --git a/frontend/public/flags-normal/rw.png b/frontend/public/flags-normal/rw.png
new file mode 100644
index 0000000000..24080d6dad
Binary files /dev/null and b/frontend/public/flags-normal/rw.png differ
diff --git a/frontend/public/flags-normal/sa.png b/frontend/public/flags-normal/sa.png
new file mode 100644
index 0000000000..66dadb5b5f
Binary files /dev/null and b/frontend/public/flags-normal/sa.png differ
diff --git a/frontend/public/flags-normal/sb.png b/frontend/public/flags-normal/sb.png
new file mode 100644
index 0000000000..97e0fc7c1d
Binary files /dev/null and b/frontend/public/flags-normal/sb.png differ
diff --git a/frontend/public/flags-normal/sc.png b/frontend/public/flags-normal/sc.png
new file mode 100644
index 0000000000..7686373597
Binary files /dev/null and b/frontend/public/flags-normal/sc.png differ
diff --git a/frontend/public/flags-normal/sd.png b/frontend/public/flags-normal/sd.png
new file mode 100644
index 0000000000..9a6f886d73
Binary files /dev/null and b/frontend/public/flags-normal/sd.png differ
diff --git a/frontend/public/flags-normal/se.png b/frontend/public/flags-normal/se.png
new file mode 100644
index 0000000000..59595199b3
Binary files /dev/null and b/frontend/public/flags-normal/se.png differ
diff --git a/frontend/public/flags-normal/sg.png b/frontend/public/flags-normal/sg.png
new file mode 100644
index 0000000000..8ba42209d7
Binary files /dev/null and b/frontend/public/flags-normal/sg.png differ
diff --git a/frontend/public/flags-normal/si.png b/frontend/public/flags-normal/si.png
new file mode 100644
index 0000000000..3b751344c8
Binary files /dev/null and b/frontend/public/flags-normal/si.png differ
diff --git a/frontend/public/flags-normal/sk.png b/frontend/public/flags-normal/sk.png
new file mode 100644
index 0000000000..0769397a95
Binary files /dev/null and b/frontend/public/flags-normal/sk.png differ
diff --git a/frontend/public/flags-normal/sl.png b/frontend/public/flags-normal/sl.png
new file mode 100644
index 0000000000..96cddd4f40
Binary files /dev/null and b/frontend/public/flags-normal/sl.png differ
diff --git a/frontend/public/flags-normal/sm.png b/frontend/public/flags-normal/sm.png
new file mode 100644
index 0000000000..4ee071c298
Binary files /dev/null and b/frontend/public/flags-normal/sm.png differ
diff --git a/frontend/public/flags-normal/sn.png b/frontend/public/flags-normal/sn.png
new file mode 100644
index 0000000000..9415c60ef5
Binary files /dev/null and b/frontend/public/flags-normal/sn.png differ
diff --git a/frontend/public/flags-normal/so.png b/frontend/public/flags-normal/so.png
new file mode 100644
index 0000000000..93a7fdc9c6
Binary files /dev/null and b/frontend/public/flags-normal/so.png differ
diff --git a/frontend/public/flags-normal/sr.png b/frontend/public/flags-normal/sr.png
new file mode 100644
index 0000000000..47092d9e55
Binary files /dev/null and b/frontend/public/flags-normal/sr.png differ
diff --git a/frontend/public/flags-normal/st.png b/frontend/public/flags-normal/st.png
new file mode 100644
index 0000000000..85f7d3860d
Binary files /dev/null and b/frontend/public/flags-normal/st.png differ
diff --git a/frontend/public/flags-normal/sv.png b/frontend/public/flags-normal/sv.png
new file mode 100644
index 0000000000..977957299f
Binary files /dev/null and b/frontend/public/flags-normal/sv.png differ
diff --git a/frontend/public/flags-normal/sy.png b/frontend/public/flags-normal/sy.png
new file mode 100644
index 0000000000..a80b6b1117
Binary files /dev/null and b/frontend/public/flags-normal/sy.png differ
diff --git a/frontend/public/flags-normal/sz.png b/frontend/public/flags-normal/sz.png
new file mode 100644
index 0000000000..893376774e
Binary files /dev/null and b/frontend/public/flags-normal/sz.png differ
diff --git a/frontend/public/flags-normal/td.png b/frontend/public/flags-normal/td.png
new file mode 100644
index 0000000000..41f123b5bf
Binary files /dev/null and b/frontend/public/flags-normal/td.png differ
diff --git a/frontend/public/flags-normal/tg.png b/frontend/public/flags-normal/tg.png
new file mode 100644
index 0000000000..a4a1d9f9ae
Binary files /dev/null and b/frontend/public/flags-normal/tg.png differ
diff --git a/frontend/public/flags-normal/th.png b/frontend/public/flags-normal/th.png
new file mode 100644
index 0000000000..f0f7207d8f
Binary files /dev/null and b/frontend/public/flags-normal/th.png differ
diff --git a/frontend/public/flags-normal/tj.png b/frontend/public/flags-normal/tj.png
new file mode 100644
index 0000000000..682b5e0f04
Binary files /dev/null and b/frontend/public/flags-normal/tj.png differ
diff --git a/frontend/public/flags-normal/tl.png b/frontend/public/flags-normal/tl.png
new file mode 100644
index 0000000000..8a98e900ea
Binary files /dev/null and b/frontend/public/flags-normal/tl.png differ
diff --git a/frontend/public/flags-normal/tm.png b/frontend/public/flags-normal/tm.png
new file mode 100644
index 0000000000..58567c8145
Binary files /dev/null and b/frontend/public/flags-normal/tm.png differ
diff --git a/frontend/public/flags-normal/tn.png b/frontend/public/flags-normal/tn.png
new file mode 100644
index 0000000000..db4951a6ae
Binary files /dev/null and b/frontend/public/flags-normal/tn.png differ
diff --git a/frontend/public/flags-normal/to.png b/frontend/public/flags-normal/to.png
new file mode 100644
index 0000000000..95b78ce263
Binary files /dev/null and b/frontend/public/flags-normal/to.png differ
diff --git a/frontend/public/flags-normal/tr.png b/frontend/public/flags-normal/tr.png
new file mode 100644
index 0000000000..95d0c8710e
Binary files /dev/null and b/frontend/public/flags-normal/tr.png differ
diff --git a/frontend/public/flags-normal/tt.png b/frontend/public/flags-normal/tt.png
new file mode 100644
index 0000000000..39a4af4229
Binary files /dev/null and b/frontend/public/flags-normal/tt.png differ
diff --git a/frontend/public/flags-normal/tv.png b/frontend/public/flags-normal/tv.png
new file mode 100644
index 0000000000..6bfe412e99
Binary files /dev/null and b/frontend/public/flags-normal/tv.png differ
diff --git a/frontend/public/flags-normal/tw.png b/frontend/public/flags-normal/tw.png
new file mode 100644
index 0000000000..80e07d8144
Binary files /dev/null and b/frontend/public/flags-normal/tw.png differ
diff --git a/frontend/public/flags-normal/tz.png b/frontend/public/flags-normal/tz.png
new file mode 100644
index 0000000000..446ecb4fa4
Binary files /dev/null and b/frontend/public/flags-normal/tz.png differ
diff --git a/frontend/public/flags-normal/ua.png b/frontend/public/flags-normal/ua.png
new file mode 100644
index 0000000000..0023479404
Binary files /dev/null and b/frontend/public/flags-normal/ua.png differ
diff --git a/frontend/public/flags-normal/ug.png b/frontend/public/flags-normal/ug.png
new file mode 100644
index 0000000000..cdcab6a1bd
Binary files /dev/null and b/frontend/public/flags-normal/ug.png differ
diff --git a/frontend/public/flags-normal/us.png b/frontend/public/flags-normal/us.png
new file mode 100644
index 0000000000..5b96ff2407
Binary files /dev/null and b/frontend/public/flags-normal/us.png differ
diff --git a/frontend/public/flags-normal/uy.png b/frontend/public/flags-normal/uy.png
new file mode 100644
index 0000000000..219ef44a12
Binary files /dev/null and b/frontend/public/flags-normal/uy.png differ
diff --git a/frontend/public/flags-normal/uz.png b/frontend/public/flags-normal/uz.png
new file mode 100644
index 0000000000..80e0a44670
Binary files /dev/null and b/frontend/public/flags-normal/uz.png differ
diff --git a/frontend/public/flags-normal/va.png b/frontend/public/flags-normal/va.png
new file mode 100644
index 0000000000..c94c81ddec
Binary files /dev/null and b/frontend/public/flags-normal/va.png differ
diff --git a/frontend/public/flags-normal/vc.png b/frontend/public/flags-normal/vc.png
new file mode 100644
index 0000000000..77196ed9de
Binary files /dev/null and b/frontend/public/flags-normal/vc.png differ
diff --git a/frontend/public/flags-normal/ve.png b/frontend/public/flags-normal/ve.png
new file mode 100644
index 0000000000..40ae68ebd8
Binary files /dev/null and b/frontend/public/flags-normal/ve.png differ
diff --git a/frontend/public/flags-normal/vn.png b/frontend/public/flags-normal/vn.png
new file mode 100644
index 0000000000..d683852312
Binary files /dev/null and b/frontend/public/flags-normal/vn.png differ
diff --git a/frontend/public/flags-normal/vu.png b/frontend/public/flags-normal/vu.png
new file mode 100644
index 0000000000..e1ad764e13
Binary files /dev/null and b/frontend/public/flags-normal/vu.png differ
diff --git a/frontend/public/flags-normal/ws.png b/frontend/public/flags-normal/ws.png
new file mode 100644
index 0000000000..71db01fa40
Binary files /dev/null and b/frontend/public/flags-normal/ws.png differ
diff --git a/frontend/public/flags-normal/ye.png b/frontend/public/flags-normal/ye.png
new file mode 100644
index 0000000000..3a2e0a2b0c
Binary files /dev/null and b/frontend/public/flags-normal/ye.png differ
diff --git a/frontend/public/flags-normal/za.png b/frontend/public/flags-normal/za.png
new file mode 100644
index 0000000000..535fe71092
Binary files /dev/null and b/frontend/public/flags-normal/za.png differ
diff --git a/frontend/public/flags-normal/zm.png b/frontend/public/flags-normal/zm.png
new file mode 100644
index 0000000000..7b0246a85f
Binary files /dev/null and b/frontend/public/flags-normal/zm.png differ
diff --git a/frontend/public/flags-normal/zw.png b/frontend/public/flags-normal/zw.png
new file mode 100644
index 0000000000..abf138692f
Binary files /dev/null and b/frontend/public/flags-normal/zw.png differ
diff --git a/frontend/public/fr-FR.png b/frontend/public/fr-FR.png
new file mode 100644
index 0000000000..fcfa7caf02
Binary files /dev/null and b/frontend/public/fr-FR.png differ
diff --git a/frontend/public/jira.svg b/frontend/public/jira.svg
new file mode 100644
index 0000000000..4ace5cc84a
--- /dev/null
+++ b/frontend/public/jira.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/logo-filled.png b/frontend/public/logo-filled.png
new file mode 100644
index 0000000000..ceef07e1d5
Binary files /dev/null and b/frontend/public/logo-filled.png differ
diff --git a/frontend/public/logo-inverted.png b/frontend/public/logo-inverted.png
new file mode 100644
index 0000000000..8f45ebbf26
Binary files /dev/null and b/frontend/public/logo-inverted.png differ
diff --git a/frontend/public/logo.png b/frontend/public/logo.png
new file mode 100644
index 0000000000..27ff43307c
Binary files /dev/null and b/frontend/public/logo.png differ
diff --git a/frontend/public/logo_old.png b/frontend/public/logo_old.png
new file mode 100644
index 0000000000..97e9ef9e8c
Binary files /dev/null and b/frontend/public/logo_old.png differ
diff --git a/frontend/public/nb-NO.png b/frontend/public/nb-NO.png
new file mode 100644
index 0000000000..bb2f806b51
Binary files /dev/null and b/frontend/public/nb-NO.png differ
diff --git a/frontend/public/pt_BR.png b/frontend/public/pt_BR.png
new file mode 100644
index 0000000000..43725657c0
Binary files /dev/null and b/frontend/public/pt_BR.png differ
diff --git a/frontend/public/slack.svg b/frontend/public/slack.svg
new file mode 100644
index 0000000000..69a4eb6a21
--- /dev/null
+++ b/frontend/public/slack.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/sv-SE.png b/frontend/public/sv-SE.png
new file mode 100644
index 0000000000..59595199b3
Binary files /dev/null and b/frontend/public/sv-SE.png differ
diff --git a/frontend/public/switches.svg b/frontend/public/switches.svg
new file mode 100644
index 0000000000..aad9bd6f54
--- /dev/null
+++ b/frontend/public/switches.svg
@@ -0,0 +1,11 @@
+
diff --git a/frontend/public/teams.svg b/frontend/public/teams.svg
new file mode 100644
index 0000000000..90b3444b60
--- /dev/null
+++ b/frontend/public/teams.svg
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+]>
+
diff --git a/frontend/public/unknown-locale.png b/frontend/public/unknown-locale.png
new file mode 100644
index 0000000000..00b618ad5a
Binary files /dev/null and b/frontend/public/unknown-locale.png differ
diff --git a/frontend/public/webhooks.svg b/frontend/public/webhooks.svg
new file mode 100644
index 0000000000..ec5cddf369
--- /dev/null
+++ b/frontend/public/webhooks.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/scripts/generate-openapi.sh b/frontend/scripts/generate-openapi.sh
new file mode 100755
index 0000000000..19d68b94d8
--- /dev/null
+++ b/frontend/scripts/generate-openapi.sh
@@ -0,0 +1,24 @@
+#!/bin/sh
+
+# Generate OpenAPI bindings for the Unleash API.
+# https://openapi-generator.tech/docs/generators/typescript-fetch
+
+set -feux
+cd "$(dirname "$0")"
+
+# URL to the generated open API spec.
+# Set the UNLEASH_OPENAPI_URL environment variable to override.
+UNLEASH_OPENAPI_URL="${UNLEASH_OPENAPI_URL:-http://localhost:4242/docs/openapi.json}"
+
+rm -rf "../src/openapi"
+mkdir "../src/openapi"
+
+npx @openapitools/openapi-generator-cli generate \
+ -g "typescript-fetch" \
+ -i "$UNLEASH_OPENAPI_URL" \
+ -o "../src/openapi"
+
+# Remove unused files.
+rm "openapitools.json"
+rm "../src/openapi/.openapi-generator-ignore"
+rm -r "../src/openapi/.openapi-generator"
diff --git a/frontend/src/__mocks__/fileMock.js b/frontend/src/__mocks__/fileMock.js
new file mode 100644
index 0000000000..86059f3629
--- /dev/null
+++ b/frontend/src/__mocks__/fileMock.js
@@ -0,0 +1 @@
+module.exports = 'test-file-stub';
diff --git a/frontend/src/__mocks__/svgMock.js b/frontend/src/__mocks__/svgMock.js
new file mode 100644
index 0000000000..ffe2050a02
--- /dev/null
+++ b/frontend/src/__mocks__/svgMock.js
@@ -0,0 +1,2 @@
+export default 'SvgrURL';
+export const ReactComponent = 'div';
diff --git a/frontend/src/assets/fonts/roboto300.ttf b/frontend/src/assets/fonts/roboto300.ttf
new file mode 100644
index 0000000000..0e977514ff
Binary files /dev/null and b/frontend/src/assets/fonts/roboto300.ttf differ
diff --git a/frontend/src/assets/fonts/roboto400.ttf b/frontend/src/assets/fonts/roboto400.ttf
new file mode 100644
index 0000000000..3d6861b423
Binary files /dev/null and b/frontend/src/assets/fonts/roboto400.ttf differ
diff --git a/frontend/src/assets/fonts/roboto500.ttf b/frontend/src/assets/fonts/roboto500.ttf
new file mode 100644
index 0000000000..e89b0b79a2
Binary files /dev/null and b/frontend/src/assets/fonts/roboto500.ttf differ
diff --git a/frontend/src/assets/fonts/roboto700.ttf b/frontend/src/assets/fonts/roboto700.ttf
new file mode 100644
index 0000000000..3742457900
Binary files /dev/null and b/frontend/src/assets/fonts/roboto700.ttf differ
diff --git a/frontend/src/assets/fonts/senBold.ttf b/frontend/src/assets/fonts/senBold.ttf
new file mode 100644
index 0000000000..bd184b83cb
Binary files /dev/null and b/frontend/src/assets/fonts/senBold.ttf differ
diff --git a/frontend/src/assets/fonts/senExtraBold.ttf b/frontend/src/assets/fonts/senExtraBold.ttf
new file mode 100644
index 0000000000..13b3be3618
Binary files /dev/null and b/frontend/src/assets/fonts/senExtraBold.ttf differ
diff --git a/frontend/src/assets/fonts/senRegular.ttf b/frontend/src/assets/fonts/senRegular.ttf
new file mode 100644
index 0000000000..8b08770a02
Binary files /dev/null and b/frontend/src/assets/fonts/senRegular.ttf differ
diff --git a/frontend/src/assets/icons/24_Negator off.svg b/frontend/src/assets/icons/24_Negator off.svg
new file mode 100644
index 0000000000..a3dababecf
--- /dev/null
+++ b/frontend/src/assets/icons/24_Negator off.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/24_Negator.svg b/frontend/src/assets/icons/24_Negator.svg
new file mode 100644
index 0000000000..84e1638591
--- /dev/null
+++ b/frontend/src/assets/icons/24_Negator.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/24_Text format off.svg b/frontend/src/assets/icons/24_Text format off.svg
new file mode 100644
index 0000000000..c4e0102f8a
--- /dev/null
+++ b/frontend/src/assets/icons/24_Text format off.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/24_Text format.svg b/frontend/src/assets/icons/24_Text format.svg
new file mode 100644
index 0000000000..ea8ae72501
--- /dev/null
+++ b/frontend/src/assets/icons/24_Text format.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/icons/Icecream.svg b/frontend/src/assets/icons/Icecream.svg
new file mode 100644
index 0000000000..016aa0fd6c
--- /dev/null
+++ b/frontend/src/assets/icons/Icecream.svg
@@ -0,0 +1,10 @@
+
diff --git a/frontend/src/assets/icons/addfiles.svg b/frontend/src/assets/icons/addfiles.svg
new file mode 100644
index 0000000000..b8952ba5af
--- /dev/null
+++ b/frontend/src/assets/icons/addfiles.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/icons/datadog.svg b/frontend/src/assets/icons/datadog.svg
new file mode 100644
index 0000000000..49e6f8473f
--- /dev/null
+++ b/frontend/src/assets/icons/datadog.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/icons/dots.svg b/frontend/src/assets/icons/dots.svg
new file mode 100644
index 0000000000..1bec57b922
--- /dev/null
+++ b/frontend/src/assets/icons/dots.svg
@@ -0,0 +1,16 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/icons/email.svg b/frontend/src/assets/icons/email.svg
new file mode 100644
index 0000000000..ec1e08b84d
--- /dev/null
+++ b/frontend/src/assets/icons/email.svg
@@ -0,0 +1,17 @@
+
diff --git a/frontend/src/assets/icons/google.svg b/frontend/src/assets/icons/google.svg
new file mode 100644
index 0000000000..f9c6f88d2b
--- /dev/null
+++ b/frontend/src/assets/icons/google.svg
@@ -0,0 +1,100 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/icons/gradual.svg b/frontend/src/assets/icons/gradual.svg
new file mode 100644
index 0000000000..78f94fc766
--- /dev/null
+++ b/frontend/src/assets/icons/gradual.svg
@@ -0,0 +1,17 @@
+
diff --git a/frontend/src/assets/icons/isenabled-false.svg b/frontend/src/assets/icons/isenabled-false.svg
new file mode 100644
index 0000000000..51edee56cc
--- /dev/null
+++ b/frontend/src/assets/icons/isenabled-false.svg
@@ -0,0 +1,5 @@
+
diff --git a/frontend/src/assets/icons/isenabled-true.svg b/frontend/src/assets/icons/isenabled-true.svg
new file mode 100644
index 0000000000..52e560d6d7
--- /dev/null
+++ b/frontend/src/assets/icons/isenabled-true.svg
@@ -0,0 +1,5 @@
+
diff --git a/frontend/src/assets/icons/jira.svg b/frontend/src/assets/icons/jira.svg
new file mode 100644
index 0000000000..4ace5cc84a
--- /dev/null
+++ b/frontend/src/assets/icons/jira.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/icons/logoBg.svg b/frontend/src/assets/icons/logoBg.svg
new file mode 100644
index 0000000000..b79f917031
--- /dev/null
+++ b/frontend/src/assets/icons/logoBg.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/icons/logoInverted.svg b/frontend/src/assets/icons/logoInverted.svg
new file mode 100644
index 0000000000..32da7185f1
--- /dev/null
+++ b/frontend/src/assets/icons/logoInverted.svg
@@ -0,0 +1,4 @@
+
diff --git a/frontend/src/assets/icons/logoPlain.svg b/frontend/src/assets/icons/logoPlain.svg
new file mode 100644
index 0000000000..8cf6ae5438
--- /dev/null
+++ b/frontend/src/assets/icons/logoPlain.svg
@@ -0,0 +1,6 @@
+
diff --git a/frontend/src/assets/icons/logoWhiteBg.svg b/frontend/src/assets/icons/logoWhiteBg.svg
new file mode 100644
index 0000000000..f888d4a288
--- /dev/null
+++ b/frontend/src/assets/icons/logoWhiteBg.svg
@@ -0,0 +1,7 @@
+
diff --git a/frontend/src/assets/icons/projectIcon.svg b/frontend/src/assets/icons/projectIcon.svg
new file mode 100644
index 0000000000..d4604c184d
--- /dev/null
+++ b/frontend/src/assets/icons/projectIcon.svg
@@ -0,0 +1,9 @@
+
diff --git a/frontend/src/assets/icons/reorder.svg b/frontend/src/assets/icons/reorder.svg
new file mode 100644
index 0000000000..99f4cb517f
--- /dev/null
+++ b/frontend/src/assets/icons/reorder.svg
@@ -0,0 +1,6 @@
+
diff --git a/frontend/src/assets/icons/rollout.svg b/frontend/src/assets/icons/rollout.svg
new file mode 100644
index 0000000000..0626d7b34b
--- /dev/null
+++ b/frontend/src/assets/icons/rollout.svg
@@ -0,0 +1,4 @@
+
diff --git a/frontend/src/assets/icons/slack.svg b/frontend/src/assets/icons/slack.svg
new file mode 100644
index 0000000000..69a4eb6a21
--- /dev/null
+++ b/frontend/src/assets/icons/slack.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/icons/star.svg b/frontend/src/assets/icons/star.svg
new file mode 100644
index 0000000000..ff00f4f52d
--- /dev/null
+++ b/frontend/src/assets/icons/star.svg
@@ -0,0 +1,6 @@
+
diff --git a/frontend/src/assets/icons/switches.svg b/frontend/src/assets/icons/switches.svg
new file mode 100644
index 0000000000..aad9bd6f54
--- /dev/null
+++ b/frontend/src/assets/icons/switches.svg
@@ -0,0 +1,11 @@
+
diff --git a/frontend/src/assets/icons/teams.svg b/frontend/src/assets/icons/teams.svg
new file mode 100644
index 0000000000..d005badd9f
--- /dev/null
+++ b/frontend/src/assets/icons/teams.svg
@@ -0,0 +1,26 @@
+
diff --git a/frontend/src/assets/icons/toggleLeft.svg b/frontend/src/assets/icons/toggleLeft.svg
new file mode 100644
index 0000000000..d4e9d2ae49
--- /dev/null
+++ b/frontend/src/assets/icons/toggleLeft.svg
@@ -0,0 +1,4 @@
+
diff --git a/frontend/src/assets/icons/toggleRight.svg b/frontend/src/assets/icons/toggleRight.svg
new file mode 100644
index 0000000000..f6da7f12e6
--- /dev/null
+++ b/frontend/src/assets/icons/toggleRight.svg
@@ -0,0 +1,4 @@
+
diff --git a/frontend/src/assets/icons/unknownUser.png b/frontend/src/assets/icons/unknownUser.png
new file mode 100644
index 0000000000..47214107be
Binary files /dev/null and b/frontend/src/assets/icons/unknownUser.png differ
diff --git a/frontend/src/assets/icons/webhooks.svg b/frontend/src/assets/icons/webhooks.svg
new file mode 100644
index 0000000000..ec5cddf369
--- /dev/null
+++ b/frontend/src/assets/icons/webhooks.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/img/logo.png b/frontend/src/assets/img/logo.png
new file mode 100644
index 0000000000..27ff43307c
Binary files /dev/null and b/frontend/src/assets/img/logo.png differ
diff --git a/frontend/src/assets/img/logo.svg b/frontend/src/assets/img/logo.svg
new file mode 100644
index 0000000000..843669fff4
--- /dev/null
+++ b/frontend/src/assets/img/logo.svg
@@ -0,0 +1,7 @@
+
diff --git a/frontend/src/assets/img/logoDark.svg b/frontend/src/assets/img/logoDark.svg
new file mode 100644
index 0000000000..b79f917031
--- /dev/null
+++ b/frontend/src/assets/img/logoDark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/img/logoDarkWithText.svg b/frontend/src/assets/img/logoDarkWithText.svg
new file mode 100644
index 0000000000..51862398e8
--- /dev/null
+++ b/frontend/src/assets/img/logoDarkWithText.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/img/logoWhiteTransparentHorizontal.svg b/frontend/src/assets/img/logoWhiteTransparentHorizontal.svg
new file mode 100644
index 0000000000..8f78bf75a2
--- /dev/null
+++ b/frontend/src/assets/img/logoWhiteTransparentHorizontal.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/img/logoWithName.svg b/frontend/src/assets/img/logoWithName.svg
new file mode 100644
index 0000000000..ed650f953d
--- /dev/null
+++ b/frontend/src/assets/img/logoWithName.svg
@@ -0,0 +1,9 @@
+
diff --git a/frontend/src/assets/img/logoWithWhiteText.svg b/frontend/src/assets/img/logoWithWhiteText.svg
new file mode 100644
index 0000000000..c966754f9e
--- /dev/null
+++ b/frontend/src/assets/img/logoWithWhiteText.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/img/mobileGuidanceBg.svg b/frontend/src/assets/img/mobileGuidanceBg.svg
new file mode 100644
index 0000000000..20093fdcf3
--- /dev/null
+++ b/frontend/src/assets/img/mobileGuidanceBg.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/img/texture.png b/frontend/src/assets/img/texture.png
new file mode 100644
index 0000000000..4769ce29a2
Binary files /dev/null and b/frontend/src/assets/img/texture.png differ
diff --git a/frontend/src/assets/img/unleashLogoIconDarkAlpha.gif b/frontend/src/assets/img/unleashLogoIconDarkAlpha.gif
new file mode 100644
index 0000000000..077b62d726
Binary files /dev/null and b/frontend/src/assets/img/unleashLogoIconDarkAlpha.gif differ
diff --git a/frontend/src/component/App.styles.ts b/frontend/src/component/App.styles.ts
new file mode 100644
index 0000000000..c6b04a0447
--- /dev/null
+++ b/frontend/src/component/App.styles.ts
@@ -0,0 +1,80 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ '& ul': {
+ margin: 0,
+ },
+ },
+ primaryBreadcrumb: {
+ color: 'white',
+ },
+ headerTitleLink: {
+ color: 'white',
+ textDecoration: 'none',
+ },
+ contentWrapper: {
+ margin: '0 auto',
+ flex: 1,
+ width: '100%',
+ backgroundColor: theme.palette.contentWrapper,
+ },
+ content: {
+ width: '1250px',
+ margin: '16px auto',
+ [theme.breakpoints.down('lg')]: {
+ width: '1024px',
+ },
+ [theme.breakpoints.down(1024)]: {
+ width: '100%',
+ marginLeft: 0,
+ marginRight: 0,
+ },
+ [theme.breakpoints.down('sm')]: {
+ minWidth: '100%',
+ },
+ },
+ contentContainer: {
+ padding: '2rem 0',
+ height: '100%',
+ },
+ drawerTitle: {
+ lineHeight: '1 !important',
+ paddingTop: '16px',
+ paddingBottom: '16px',
+ paddingLeft: '24px !important',
+ [theme.breakpoints.down(1024)]: {
+ paddingTop: '12px',
+ paddingBottom: '12px',
+ paddingLeft: '16px !important',
+ },
+ },
+ drawerTitleLogo: {
+ paddingRight: '16px',
+ },
+ drawerTitleText: {
+ display: 'inline-block',
+ verticalAlign: 'middle',
+ fontSize: theme.fontSizes.smallerBody,
+ },
+ navigation: {
+ padding: '8px 5px 8px 0 !important',
+ },
+ navigationLink: {
+ padding: '12px 20px !important',
+ borderRadius: '0 50px 50px 0',
+ textDecoration: 'none',
+ [theme.breakpoints.down(1024)]: {
+ padding: '12px 16px !important',
+ },
+ },
+ navigationIcon: {
+ marginRight: '16px',
+ },
+ iconGitHub: {
+ width: '24px',
+ height: '24px',
+ background:
+ 'url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHN0eWxlPSJ3aWR0aDoyNHB4O2hlaWdodDoyNHB4IiB2aWV3Qm94PSIwIDAgMjQgMjQiPgogICAgPHBhdGggZmlsbD0iIzc1NzU3NSIgZD0iTTEyLDJBMTAsMTAgMCAwLDAgMiwxMkMyLDE2LjQyIDQuODcsMjAuMTcgOC44NCwyMS41QzkuMzQsMjEuNTggOS41LDIxLjI3IDkuNSwyMUM5LjUsMjAuNzcgOS41LDIwLjE0IDkuNSwxOS4zMUM2LjczLDE5LjkxIDYuMTQsMTcuOTcgNi4xNCwxNy45N0M1LjY4LDE2LjgxIDUuMDMsMTYuNSA1LjAzLDE2LjVDNC4xMiwxNS44OCA1LjEsMTUuOSA1LjEsMTUuOUM2LjEsMTUuOTcgNi42MywxNi45MyA2LjYzLDE2LjkzQzcuNSwxOC40NSA4Ljk3LDE4IDkuNTQsMTcuNzZDOS42MywxNy4xMSA5Ljg5LDE2LjY3IDEwLjE3LDE2LjQyQzcuOTUsMTYuMTcgNS42MiwxNS4zMSA1LjYyLDExLjVDNS42MiwxMC4zOSA2LDkuNSA2LjY1LDguNzlDNi41NSw4LjU0IDYuMiw3LjUgNi43NSw2LjE1QzYuNzUsNi4xNSA3LjU5LDUuODggOS41LDcuMTdDMTAuMjksNi45NSAxMS4xNSw2Ljg0IDEyLDYuODRDMTIuODUsNi44NCAxMy43MSw2Ljk1IDE0LjUsNy4xN0MxNi40MSw1Ljg4IDE3LjI1LDYuMTUgMTcuMjUsNi4xNUMxNy44LDcuNSAxNy40NSw4LjU0IDE3LjM1LDguNzlDMTgsOS41IDE4LjM4LDEwLjM5IDE4LjM4LDExLjVDMTguMzgsMTUuMzIgMTYuMDQsMTYuMTYgMTMuODEsMTYuNDFDMTQuMTcsMTYuNzIgMTQuNSwxNy4zMyAxNC41LDE4LjI2QzE0LjUsMTkuNiAxNC41LDIwLjY4IDE0LjUsMjFDMTQuNSwyMS4yNyAxNC42NiwyMS41OSAxNS4xNywyMS41QzE5LjE0LDIwLjE2IDIyLDE2LjQyIDIyLDEyQTEwLDEwIDAgMCwwIDEyLDJaIiAvPgo8L3N2Zz4=)',
+ },
+}));
diff --git a/frontend/src/component/App.tsx b/frontend/src/component/App.tsx
new file mode 100644
index 0000000000..62c4d55233
--- /dev/null
+++ b/frontend/src/component/App.tsx
@@ -0,0 +1,69 @@
+import { Navigate, Route, Routes } from 'react-router-dom';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { FeedbackNPS } from 'component/feedback/FeedbackNPS/FeedbackNPS';
+import { LayoutPicker } from 'component/layout/LayoutPicker/LayoutPicker';
+import Loader from 'component/common/Loader/Loader';
+import NotFound from 'component/common/NotFound/NotFound';
+import { ProtectedRoute } from 'component/common/ProtectedRoute/ProtectedRoute';
+import SWRProvider from 'component/providers/SWRProvider/SWRProvider';
+import ToastRenderer from 'component/common/ToastRenderer/ToastRenderer';
+import { routes } from 'component/menu/routes';
+import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails';
+import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
+import { SplashPageRedirect } from 'component/splash/SplashPageRedirect/SplashPageRedirect';
+import { useStyles } from './App.styles';
+import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import { Suspense } from 'react';
+
+export const App = () => {
+ const { classes: styles } = useStyles();
+ const { authDetails } = useAuthDetails();
+ const { user } = useAuthUser();
+ const { isOss } = useUiConfig();
+ const isLoggedIn = Boolean(user?.id);
+ const hasFetchedAuth = Boolean(authDetails || user);
+ usePlausibleTracker();
+
+ const availableRoutes = isOss()
+ ? routes.filter(route => !route.enterprise)
+ : routes;
+
+ return (
+
+ }>
+ }
+ elseShow={
+
+
+
+
+ {availableRoutes.map(route => (
+
+ }
+ />
+ ))}
+
+ }
+ />
+ } />
+
+
+
+
+
+ }
+ />
+
+
+ );
+};
diff --git a/frontend/src/component/addons/AddonForm/AddonForm.styles.tsx b/frontend/src/component/addons/AddonForm/AddonForm.styles.tsx
new file mode 100644
index 0000000000..4ae328f0bc
--- /dev/null
+++ b/frontend/src/component/addons/AddonForm/AddonForm.styles.tsx
@@ -0,0 +1,52 @@
+import { styled } from '@mui/system';
+import { FormControlLabel, TextField } from '@mui/material';
+
+export const StyledForm = styled('form')({
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ gap: '1rem',
+});
+
+export const StyledFormSection = styled('section')({
+ marginBottom: '36px',
+});
+
+export const StyledHelpText = styled('p')({
+ marginBottom: '0.5rem',
+});
+
+export const StyledContainer = styled('div')({
+ maxWidth: '600px',
+});
+
+export const StyledButtonContainer = styled('div')({
+ marginTop: 'auto',
+ display: 'flex',
+ justifyContent: 'flex-end',
+});
+
+export const StyledButtonSection = styled('section')(({ theme }) => ({
+ 'padding-top': '16px',
+ '& > *': {
+ marginRight: theme.spacing(1),
+ },
+}));
+
+export const StyledTextField = styled(TextField)({
+ width: '100%',
+ marginBottom: '1rem',
+ marginTop: '0px',
+});
+
+export const StyledSelectAllFormControlLabel = styled(FormControlLabel)({
+ paddingBottom: '16px',
+});
+
+export const StyledTitle = styled('h4')({
+ marginBottom: '8px',
+});
+
+export const StyledAddonParameterContainer = styled('div')({
+ marginTop: '25px',
+});
diff --git a/frontend/src/component/addons/AddonForm/AddonForm.tsx b/frontend/src/component/addons/AddonForm/AddonForm.tsx
new file mode 100644
index 0000000000..3a19477149
--- /dev/null
+++ b/frontend/src/component/addons/AddonForm/AddonForm.tsx
@@ -0,0 +1,348 @@
+import React, {
+ ChangeEventHandler,
+ FormEventHandler,
+ MouseEventHandler,
+ useEffect,
+ useState,
+ VFC,
+} from 'react';
+import { Button, Divider, FormControlLabel, Switch } from '@mui/material';
+import produce from 'immer';
+import { trim } from 'component/common/util';
+import { IAddon, IAddonProvider } from 'interfaces/addons';
+import { AddonParameters } from './AddonParameters/AddonParameters';
+import cloneDeep from 'lodash.clonedeep';
+import { useNavigate } from 'react-router-dom';
+import useAddonsApi from 'hooks/api/actions/useAddonsApi/useAddonsApi';
+import useToast from 'hooks/useToast';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import useProjects from '../../../hooks/api/getters/useProjects/useProjects';
+import { useEnvironments } from '../../../hooks/api/getters/useEnvironments/useEnvironments';
+import { AddonMultiSelector } from './AddonMultiSelector/AddonMultiSelector';
+import FormTemplate from 'component/common/FormTemplate/FormTemplate';
+import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
+import PermissionButton from '../../common/PermissionButton/PermissionButton';
+import { ADMIN } from '../../providers/AccessProvider/permissions';
+import {
+ StyledForm,
+ StyledFormSection,
+ StyledHelpText,
+ StyledTextField,
+ StyledContainer,
+ StyledButtonContainer,
+ StyledButtonSection,
+} from './AddonForm.styles';
+import { useTheme } from '@mui/system';
+import { GO_BACK } from 'constants/navigate';
+
+interface IAddonFormProps {
+ provider?: IAddonProvider;
+ addon: IAddon;
+ fetch: () => void;
+ editMode: boolean;
+}
+
+export const AddonForm: VFC = ({
+ editMode,
+ provider,
+ addon: initialValues,
+ fetch,
+}) => {
+ const { createAddon, updateAddon } = useAddonsApi();
+ const { setToastData, setToastApiError } = useToast();
+ const navigate = useNavigate();
+ const theme = useTheme();
+ const { projects: availableProjects } = useProjects();
+ const selectableProjects = availableProjects.map(project => ({
+ value: project.id,
+ label: project.name,
+ }));
+ const { environments: availableEnvironments } = useEnvironments();
+ const selectableEnvironments = availableEnvironments.map(environment => ({
+ value: environment.name,
+ label: environment.name,
+ }));
+ const selectableEvents = provider?.events.map(event => ({
+ value: event,
+ label: event,
+ }));
+ const { uiConfig } = useUiConfig();
+ const [formValues, setFormValues] = useState(initialValues);
+ const [errors, setErrors] = useState<{
+ containsErrors: boolean;
+ parameters: Record;
+ events?: string;
+ projects?: string;
+ environments?: string;
+ general?: string;
+ description?: string;
+ }>({
+ containsErrors: false,
+ parameters: {},
+ });
+ const submitText = editMode ? 'Update' : 'Create';
+ let url = `${uiConfig.unleashUrl}/api/admin/addons${
+ editMode ? `/${formValues.id}` : ``
+ }`;
+
+ const formatApiCode = () => {
+ return `curl --location --request ${
+ editMode ? 'PUT' : 'POST'
+ } '${url}' \\
+ --header 'Authorization: INSERT_API_KEY' \\
+ --header 'Content-Type: application/json' \\
+ --data-raw '${JSON.stringify(formValues, undefined, 2)}'`;
+ };
+
+ useEffect(() => {
+ if (!provider) {
+ fetch();
+ }
+ }, [fetch, provider]); // empty array => fetch only first time
+
+ useEffect(() => {
+ setFormValues({ ...initialValues });
+ /* eslint-disable-next-line */
+ }, [initialValues.description, initialValues.provider]);
+
+ useEffect(() => {
+ if (provider && !formValues.provider) {
+ setFormValues({ ...initialValues, provider: provider.name });
+ }
+ }, [provider, initialValues, formValues.provider]);
+
+ const setFieldValue =
+ (field: string): ChangeEventHandler =>
+ event => {
+ event.preventDefault();
+ setFormValues({ ...formValues, [field]: event.target.value });
+ };
+
+ const onEnabled: MouseEventHandler = event => {
+ event.preventDefault();
+ setFormValues(({ enabled }) => ({ ...formValues, enabled: !enabled }));
+ };
+
+ const setParameterValue =
+ (param: string): ChangeEventHandler =>
+ event => {
+ event.preventDefault();
+ setFormValues(
+ produce(draft => {
+ draft.parameters[param] = event.target.value;
+ })
+ );
+ };
+
+ const setEventValues = (events: string[]) => {
+ setFormValues(
+ produce(draft => {
+ draft.events = events;
+ })
+ );
+ setErrors(prev => ({
+ ...prev,
+ events: undefined,
+ }));
+ };
+ const setProjects = (projects: string[]) => {
+ setFormValues(
+ produce(draft => {
+ draft.projects = projects;
+ })
+ );
+ setErrors(prev => ({
+ ...prev,
+ projects: undefined,
+ }));
+ };
+ const setEnvironments = (environments: string[]) => {
+ setFormValues(
+ produce(draft => {
+ draft.environments = environments;
+ })
+ );
+ setErrors(prev => ({
+ ...prev,
+ environments: undefined,
+ }));
+ };
+
+ const onCancel = () => {
+ navigate(GO_BACK);
+ };
+
+ const onSubmit: FormEventHandler = async event => {
+ event.preventDefault();
+ if (!provider) {
+ return;
+ }
+
+ const updatedErrors = cloneDeep(errors);
+ updatedErrors.containsErrors = false;
+
+ // Validations
+ if (formValues.events.length === 0) {
+ updatedErrors.events = 'You must listen to at least one event';
+ updatedErrors.containsErrors = true;
+ }
+
+ provider.parameters.forEach(parameterConfig => {
+ const value = trim(formValues.parameters[parameterConfig.name]);
+ if (parameterConfig.required && !value) {
+ updatedErrors.parameters[parameterConfig.name] =
+ 'This field is required';
+ updatedErrors.containsErrors = true;
+ }
+ });
+
+ if (updatedErrors.containsErrors) {
+ setErrors(updatedErrors);
+ return;
+ }
+
+ try {
+ if (editMode) {
+ await updateAddon(formValues);
+ navigate('/addons');
+ setToastData({
+ type: 'success',
+ title: 'Addon updated successfully',
+ });
+ } else {
+ await createAddon(formValues);
+ navigate('/addons');
+ setToastData({
+ type: 'success',
+ confetti: true,
+ title: 'Addon created successfully',
+ });
+ }
+ } catch (error) {
+ const message = formatUnknownError(error);
+ setToastApiError(message);
+ setErrors({
+ parameters: {},
+ general: message,
+ containsErrors: true,
+ });
+ }
+ };
+
+ const {
+ name,
+ description,
+ documentationUrl = 'https://unleash.github.io/docs/addons',
+ } = provider ? provider : ({} as Partial);
+
+ return (
+
+
+
+
+
+
+ }
+ label={formValues.enabled ? 'Enabled' : 'Disabled'}
+ />
+
+
+
+ What is your addon description?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {submitText}
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.test.tsx b/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.test.tsx
new file mode 100644
index 0000000000..186520ea37
--- /dev/null
+++ b/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.test.tsx
@@ -0,0 +1,143 @@
+import { vi } from 'vitest';
+import React from 'react';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from 'utils/testRenderer';
+import {
+ IAddonMultiSelectorProps,
+ AddonMultiSelector,
+} from './AddonMultiSelector';
+
+const onChange = vi.fn();
+const onFocus = vi.fn();
+
+const mockProps: IAddonMultiSelectorProps = {
+ options: [
+ { label: 'Project1', value: 'project1' },
+ { label: 'Project2', value: 'project2' },
+ { label: 'Project3', value: 'project3' },
+ ],
+ selectedItems: [],
+ onChange,
+ onFocus,
+ selectAllEnabled: true,
+ entityName: 'project',
+};
+
+describe('AddonMultiSelector', () => {
+ beforeEach(() => {
+ onChange.mockClear();
+ onFocus.mockClear();
+ });
+
+ it('renders with default state', () => {
+ render();
+
+ const checkbox = screen.getByLabelText(
+ /all current and future projects/i
+ );
+ expect(checkbox).toBeChecked();
+
+ const selectInputContainer = screen.getByTestId('select-project-input');
+ const input = within(selectInputContainer).getByRole('combobox');
+ expect(input).toBeDisabled();
+ });
+
+ it('can toggle "ALL" checkbox', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTestId('select-all-projects'));
+
+ expect(
+ screen.getByLabelText(/all current and future projects/i)
+ ).not.toBeChecked();
+
+ expect(screen.getByLabelText('Projects')).toBeEnabled();
+
+ await user.click(screen.getByTestId('select-all-projects'));
+
+ expect(
+ screen.getByLabelText(/all current and future projects/i)
+ ).toBeChecked();
+
+ expect(screen.getByLabelText('Projects')).toBeDisabled();
+ });
+
+ it('renders with autocomplete enabled if default value is not a wildcard', () => {
+ render(
+
+ );
+
+ const checkbox = screen.getByLabelText(
+ /all current and future projects/i
+ );
+ expect(checkbox).not.toBeChecked();
+
+ const selectInputContainer = screen.getByTestId('select-project-input');
+ const input = within(selectInputContainer).getByRole('combobox');
+ expect(input).toBeEnabled();
+ });
+
+ describe('Select/Deselect projects in dropdown', () => {
+ it("doesn't show up for less than 3 options", async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+ await user.click(screen.getByLabelText('Projects'));
+
+ const button = screen.queryByRole('button', {
+ name: /select all/i,
+ });
+ expect(button).not.toBeInTheDocument();
+ });
+ });
+
+ it('can filter options', async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+ const input = await screen.findByLabelText('Projects');
+ await user.type(input, 'alp');
+
+ await waitFor(() => {
+ expect(screen.getByText('Alpha')).toBeVisible();
+ });
+ await waitFor(() => {
+ expect(screen.queryByText('Bravo')).not.toBeInTheDocument();
+ });
+ await waitFor(() => {
+ expect(screen.queryByText('Charlie')).not.toBeInTheDocument();
+ });
+ await waitFor(() => {
+ expect(screen.getByText('Alpaca')).toBeVisible();
+ });
+
+ await user.clear(input);
+ await user.type(input, 'bravo');
+ await waitFor(() => {
+ expect(screen.getByText('Bravo')).toBeVisible();
+ });
+ await waitFor(() => {
+ expect(screen.queryByText('Alpha')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.tsx b/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.tsx
new file mode 100644
index 0000000000..e7c29889d5
--- /dev/null
+++ b/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.tsx
@@ -0,0 +1,189 @@
+import React, { ChangeEvent, Fragment, useState, VFC } from 'react';
+import { IAutocompleteBoxOption } from '../../../common/AutocompleteBox/AutocompleteBox';
+import { styles as themeStyles } from 'component/common';
+import {
+ AutocompleteRenderGroupParams,
+ AutocompleteRenderInputParams,
+ AutocompleteRenderOptionState,
+} from '@mui/material/Autocomplete';
+import { styled } from '@mui/system';
+import {
+ capitalize,
+ Checkbox,
+ Paper,
+ TextField,
+ Autocomplete,
+} from '@mui/material';
+import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
+import CheckBoxIcon from '@mui/icons-material/CheckBox';
+import { ConditionallyRender } from '../../../common/ConditionallyRender/ConditionallyRender';
+import { SelectAllButton } from '../../../admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton';
+import {
+ StyledHelpText,
+ StyledSelectAllFormControlLabel,
+ StyledTitle,
+} from '../AddonForm.styles';
+
+export interface IAddonMultiSelectorProps {
+ options: IAutocompleteBoxOption[];
+ selectedItems: string[];
+ onChange: (value: string[]) => void;
+ error?: string;
+ onFocus?: () => void;
+ entityName: string;
+ selectAllEnabled: boolean;
+ description?: string;
+}
+
+const ALL_OPTIONS = '*';
+
+const StyledCheckbox = styled(Checkbox)(() => ({
+ marginRight: '0.2em',
+}));
+
+const CustomPaper = ({ ...props }) => ;
+
+export const AddonMultiSelector: VFC = ({
+ options,
+ selectedItems,
+ onChange,
+ error,
+ onFocus,
+ entityName,
+ selectAllEnabled = true,
+ description,
+}) => {
+ const [isWildcardSelected, selectWildcard] = useState(
+ selectedItems.includes(ALL_OPTIONS)
+ );
+ const renderInput = (params: AutocompleteRenderInputParams) => (
+
+ );
+
+ const isAllSelected =
+ selectedItems.length > 0 &&
+ selectedItems.length === options.length &&
+ selectedItems[0] !== ALL_OPTIONS;
+
+ const onAllItemsChange = (
+ e: ChangeEvent,
+ checked: boolean
+ ) => {
+ if (checked) {
+ selectWildcard(true);
+ onChange([ALL_OPTIONS]);
+ } else {
+ selectWildcard(false);
+ onChange(selectedItems.includes(ALL_OPTIONS) ? [] : selectedItems);
+ }
+ };
+
+ const onSelectAllClick = () => {
+ const newItems = isAllSelected ? [] : options.map(({ value }) => value);
+ onChange(newItems);
+ };
+ const renderOption = (
+ props: object,
+ option: IAutocompleteBoxOption,
+ { selected }: AutocompleteRenderOptionState
+ ) => {
+ return (
+
+ }
+ checkedIcon={}
+ checked={selected}
+ />
+ {option.label}
+
+ );
+ };
+ const renderGroup = ({ key, children }: AutocompleteRenderGroupParams) => (
+
+ 2 && selectAllEnabled}
+ show={
+
+ }
+ />
+ {children}
+
+ );
+ const SelectAllFormControl = () => (
+
+ }
+ label={`ALL current and future ${entityName}s`}
+ />
+ );
+
+ const DefaultHelpText = () => (
+
+ Selecting {entityName}(s) here will filter events so that your addon
+ will only receive events that are tagged with one of your{' '}
+ {entityName}s.
+
+ );
+
+ return (
+
+ {capitalize(entityName)}s
+ {description}}
+ />
+ }
+ />
+ {error}
+ }
+ />
+ label}
+ fullWidth
+ groupBy={() => 'Select/Deselect all'}
+ renderGroup={renderGroup}
+ PaperComponent={CustomPaper}
+ renderOption={renderOption}
+ renderInput={renderInput}
+ value={
+ isWildcardSelected
+ ? options
+ : options.filter(option =>
+ selectedItems.includes(option.value)
+ )
+ }
+ onChange={(_, input) => {
+ const state = input.map(({ value }) => value);
+ onChange(state);
+ }}
+ />
+
+ );
+};
diff --git a/frontend/src/component/addons/AddonForm/AddonParameters/AddonParameter/AddonParameter.tsx b/frontend/src/component/addons/AddonForm/AddonParameters/AddonParameter/AddonParameter.tsx
new file mode 100644
index 0000000000..9a1f3b20f8
--- /dev/null
+++ b/frontend/src/component/addons/AddonForm/AddonParameters/AddonParameter/AddonParameter.tsx
@@ -0,0 +1,57 @@
+import { TextField } from '@mui/material';
+import { IAddonConfig, IAddonProviderParams } from 'interfaces/addons';
+import { ChangeEventHandler } from 'react';
+import { StyledAddonParameterContainer } from '../../AddonForm.styles';
+
+const resolveType = ({ type = 'text', sensitive = false }, value: string) => {
+ if (sensitive && value === MASKED_VALUE) {
+ return 'text';
+ }
+ if (type === 'textfield') {
+ return 'text';
+ }
+ return type;
+};
+
+const MASKED_VALUE = '*****';
+
+export interface IAddonParameterProps {
+ parametersErrors: Record;
+ definition: IAddonProviderParams;
+ setParameterValue: (param: string) => ChangeEventHandler;
+ config: IAddonConfig;
+}
+
+export const AddonParameter = ({
+ definition,
+ config,
+ parametersErrors,
+ setParameterValue,
+}: IAddonParameterProps) => {
+ const value = config.parameters[definition.name] || '';
+ const type = resolveType(definition, value);
+ const error = parametersErrors[definition.name];
+
+ return (
+
+
+
+ );
+};
diff --git a/frontend/src/component/addons/AddonForm/AddonParameters/AddonParameters.tsx b/frontend/src/component/addons/AddonForm/AddonParameters/AddonParameters.tsx
new file mode 100644
index 0000000000..fb62c6894f
--- /dev/null
+++ b/frontend/src/component/addons/AddonForm/AddonParameters/AddonParameters.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import { IAddonProvider } from 'interfaces/addons';
+import {
+ AddonParameter,
+ IAddonParameterProps,
+} from './AddonParameter/AddonParameter';
+import { StyledTitle } from '../AddonForm.styles';
+
+interface IAddonParametersProps {
+ provider?: IAddonProvider;
+ parametersErrors: IAddonParameterProps['parametersErrors'];
+ editMode: boolean;
+ setParameterValue: IAddonParameterProps['setParameterValue'];
+ config: IAddonParameterProps['config'];
+}
+
+export const AddonParameters = ({
+ provider,
+ config,
+ parametersErrors,
+ setParameterValue,
+ editMode,
+}: IAddonParametersProps) => {
+ if (!provider) return null;
+ return (
+
+ Parameters
+ {editMode ? (
+
+ Sensitive parameters will be masked with value "*****
+ ". If you don't change the value they will not be updated
+ when saving.
+
+ ) : null}
+ {provider.parameters.map(parameter => (
+
+ ))}
+
+ );
+};
diff --git a/frontend/src/component/addons/AddonList/AddonIcon/AddonIcon.tsx b/frontend/src/component/addons/AddonList/AddonIcon/AddonIcon.tsx
new file mode 100644
index 0000000000..2fb1ccfd5e
--- /dev/null
+++ b/frontend/src/component/addons/AddonList/AddonIcon/AddonIcon.tsx
@@ -0,0 +1,70 @@
+import { Avatar } from '@mui/material';
+import { DeviceHub } from '@mui/icons-material';
+import { formatAssetPath } from 'utils/formatPath';
+
+import slackIcon from 'assets/icons/slack.svg';
+import jiraIcon from 'assets/icons/jira.svg';
+import webhooksIcon from 'assets/icons/webhooks.svg';
+import teamsIcon from 'assets/icons/teams.svg';
+import dataDogIcon from 'assets/icons/datadog.svg';
+
+const style: React.CSSProperties = {
+ width: '32.5px',
+ height: '32.5px',
+ marginRight: '16px',
+};
+
+interface IAddonIconProps {
+ name: string;
+}
+
+export const AddonIcon = ({ name }: IAddonIconProps) => {
+ switch (name) {
+ case 'slack':
+ return (
+
+ );
+ case 'jira-comment':
+ return (
+
+ );
+ case 'webhook':
+ return (
+
+ );
+ case 'teams':
+ return (
+
+ );
+ case 'datadog':
+ return (
+
+ );
+ default:
+ return (
+
+
+
+ );
+ }
+};
diff --git a/frontend/src/component/addons/AddonList/AddonList.tsx b/frontend/src/component/addons/AddonList/AddonList.tsx
new file mode 100644
index 0000000000..5d4de0e8c8
--- /dev/null
+++ b/frontend/src/component/addons/AddonList/AddonList.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import { ConfiguredAddons } from './ConfiguredAddons/ConfiguredAddons';
+import { AvailableAddons } from './AvailableAddons/AvailableAddons';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import useAddons from 'hooks/api/getters/useAddons/useAddons';
+
+export const AddonList = () => {
+ const { providers, addons, loading } = useAddons();
+
+ return (
+ <>
+ 0}
+ show={}
+ />
+
+ >
+ );
+};
diff --git a/frontend/src/component/addons/AddonList/AvailableAddons/AvailableAddons.tsx b/frontend/src/component/addons/AddonList/AvailableAddons/AvailableAddons.tsx
new file mode 100644
index 0000000000..ed8d5486a4
--- /dev/null
+++ b/frontend/src/component/addons/AddonList/AvailableAddons/AvailableAddons.tsx
@@ -0,0 +1,181 @@
+import { useMemo } from 'react';
+import { PageContent } from 'component/common/PageContent/PageContent';
+
+import {
+ Table,
+ SortableTableHeader,
+ TableBody,
+ TableCell,
+ TableRow,
+ TablePlaceholder,
+} from 'component/common/Table';
+
+import { useTable, useSortBy } from 'react-table';
+import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { PageHeader } from 'component/common/PageHeader/PageHeader';
+import { sortTypes } from 'utils/sortTypes';
+import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
+import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
+import { ConfigureAddonButton } from './ConfigureAddonButton/ConfigureAddonButton';
+import { AddonIcon } from '../AddonIcon/AddonIcon';
+
+interface IProvider {
+ name: string;
+ displayName: string;
+ description: string;
+ documentationUrl: string;
+ parameters: object[];
+ events: string[];
+}
+
+interface IAvailableAddonsProps {
+ providers: IProvider[];
+ loading: boolean;
+}
+
+export const AvailableAddons = ({
+ providers,
+ loading,
+}: IAvailableAddonsProps) => {
+ const data = useMemo(() => {
+ if (loading) {
+ return Array(5).fill({
+ name: 'Provider name',
+ description: 'Provider description when loading',
+ });
+ }
+
+ return providers.map(({ name, displayName, description }) => ({
+ name,
+ displayName,
+ description,
+ }));
+ }, [providers, loading]);
+
+ const columns = useMemo(
+ () => [
+ {
+ id: 'Icon',
+ Cell: ({
+ row: {
+ original: { name },
+ },
+ }: any) => {
+ return (
+ } />
+ );
+ },
+ },
+ {
+ Header: 'Name',
+ accessor: 'name',
+ width: '90%',
+ Cell: ({
+ row: {
+ original: { name, description },
+ },
+ }: any) => {
+ return (
+
+ );
+ },
+ sortType: 'alphanumeric',
+ },
+ {
+ id: 'Actions',
+ align: 'center',
+ Cell: ({ row: { original } }: any) => (
+
+
+
+ ),
+ width: 150,
+ disableSortBy: true,
+ },
+ {
+ accessor: 'description',
+ disableSortBy: true,
+ },
+ ],
+ []
+ );
+
+ const initialState = useMemo(
+ () => ({
+ sortBy: [{ id: 'name', desc: false }],
+ hiddenColumns: ['description'],
+ }),
+ []
+ );
+
+ const {
+ getTableProps,
+ getTableBodyProps,
+ headerGroups,
+ rows,
+ prepareRow,
+ state: { globalFilter },
+ } = useTable(
+ {
+ columns: columns as any[], // TODO: fix after `react-table` v8 update
+ data,
+ initialState,
+ sortTypes,
+ autoResetGlobalFilter: false,
+ autoResetSortBy: false,
+ disableSortRemove: true,
+ },
+ useSortBy
+ );
+
+ return (
+ }
+ >
+
+
+
+ {rows.map(row => {
+ prepareRow(row);
+ return (
+
+ {row.cells.map(cell => (
+
+ {cell.render('Cell')}
+
+ ))}
+
+ );
+ })}
+
+
+
+ 0}
+ show={
+
+ No providers found matching “
+ {globalFilter}
+ ”
+
+ }
+ elseShow={
+
+ No providers available.
+
+ }
+ />
+ }
+ />
+
+ );
+};
diff --git a/frontend/src/component/addons/AddonList/AvailableAddons/ConfigureAddonButton/ConfigureAddonButton.tsx b/frontend/src/component/addons/AddonList/AvailableAddons/ConfigureAddonButton/ConfigureAddonButton.tsx
new file mode 100644
index 0000000000..10de7b0a56
--- /dev/null
+++ b/frontend/src/component/addons/AddonList/AvailableAddons/ConfigureAddonButton/ConfigureAddonButton.tsx
@@ -0,0 +1,21 @@
+import PermissionButton from 'component/common/PermissionButton/PermissionButton';
+import { CREATE_ADDON } from 'component/providers/AccessProvider/permissions';
+import { useNavigate } from 'react-router-dom';
+
+interface IConfigureAddonButtonProps {
+ name: string;
+}
+
+export const ConfigureAddonButton = ({ name }: IConfigureAddonButtonProps) => {
+ const navigate = useNavigate();
+
+ return (
+ navigate(`/addons/create/${name}`)}
+ >
+ Configure
+
+ );
+};
diff --git a/frontend/src/component/addons/AddonList/ConfiguredAddons/ConfiguredAddons.tsx b/frontend/src/component/addons/AddonList/ConfiguredAddons/ConfiguredAddons.tsx
new file mode 100644
index 0000000000..263d7d9d8f
--- /dev/null
+++ b/frontend/src/component/addons/AddonList/ConfiguredAddons/ConfiguredAddons.tsx
@@ -0,0 +1,228 @@
+import { Table, TableBody, TableCell, TableRow } from 'component/common/Table';
+import { useMemo, useState, useCallback } from 'react';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import useAddons from 'hooks/api/getters/useAddons/useAddons';
+import useToast from 'hooks/useToast';
+import useAddonsApi from 'hooks/api/actions/useAddonsApi/useAddonsApi';
+import { IAddon } from 'interfaces/addons';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
+import { sortTypes } from 'utils/sortTypes';
+import { useTable, useSortBy } from 'react-table';
+import { PageHeader } from 'component/common/PageHeader/PageHeader';
+import { SortableTableHeader, TablePlaceholder } from 'component/common/Table';
+import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
+import { AddonIcon } from '../AddonIcon/AddonIcon';
+import { ConfiguredAddonsActionsCell } from './ConfiguredAddonsActionCell/ConfiguredAddonsActionsCell';
+
+export const ConfiguredAddons = () => {
+ const { refetchAddons, addons, loading } = useAddons();
+ const { updateAddon, removeAddon } = useAddonsApi();
+ const { setToastData, setToastApiError } = useToast();
+ const [showDelete, setShowDelete] = useState(false);
+ const [deletedAddon, setDeletedAddon] = useState({
+ id: 0,
+ provider: '',
+ description: '',
+ enabled: false,
+ events: [],
+ parameters: {},
+ });
+
+ const data = useMemo(() => {
+ if (loading) {
+ return Array(5).fill({
+ name: 'Addon name',
+ description: 'Addon description when loading',
+ });
+ }
+
+ return addons.map(addon => ({
+ ...addon,
+ }));
+ }, [addons, loading]);
+
+ const toggleAddon = useCallback(
+ async (addon: IAddon) => {
+ try {
+ await updateAddon({ ...addon, enabled: !addon.enabled });
+ refetchAddons();
+ setToastData({
+ type: 'success',
+ title: 'Success',
+ text: !addon.enabled
+ ? 'Addon is now enabled'
+ : 'Addon is now disabled',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ throw error; // caught by optimistic update
+ }
+ },
+ [setToastApiError, refetchAddons, setToastData, updateAddon]
+ );
+
+ const columns = useMemo(
+ () => [
+ {
+ id: 'Icon',
+ Cell: ({
+ row: {
+ original: { provider },
+ },
+ }: any) => (
+ } />
+ ),
+ },
+ {
+ Header: 'Name',
+ accessor: 'provider',
+ width: '90%',
+ Cell: ({
+ row: {
+ original: { provider, description },
+ },
+ }: any) => {
+ return (
+
+ );
+ },
+ sortType: 'alphanumeric',
+ },
+ {
+ Header: 'Actions',
+ id: 'Actions',
+ align: 'center',
+ Cell: ({
+ row: { original },
+ }: {
+ row: { original: IAddon };
+ }) => (
+
+ ),
+ width: 150,
+ disableSortBy: true,
+ },
+ {
+ accessor: 'description',
+ disableSortBy: true,
+ },
+ ],
+ [toggleAddon]
+ );
+
+ const initialState = useMemo(
+ () => ({
+ sortBy: [{ id: 'provider', desc: false }],
+ hiddenColumns: ['description'],
+ }),
+ []
+ );
+
+ const {
+ getTableProps,
+ getTableBodyProps,
+ headerGroups,
+ rows,
+ prepareRow,
+ state: { globalFilter },
+ } = useTable(
+ {
+ columns: columns as any[], // TODO: fix after `react-table` v8 update
+ data,
+ initialState,
+ sortTypes,
+ autoResetGlobalFilter: false,
+ autoResetSortBy: false,
+ disableSortRemove: true,
+ },
+ useSortBy
+ );
+
+ const onRemoveAddon = async (addon: IAddon) => {
+ try {
+ await removeAddon(addon.id);
+ refetchAddons();
+ setToastData({
+ type: 'success',
+ title: 'Success',
+ text: 'Deleted addon successfully',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ return (
+ }
+ sx={theme => ({ marginBottom: theme.spacing(2) })}
+ >
+
+
+
+ {rows.map(row => {
+ prepareRow(row);
+ return (
+
+ {row.cells.map(cell => (
+
+ {cell.render('Cell')}
+
+ ))}
+
+ );
+ })}
+
+
+ 0}
+ show={
+
+ No addons found matching “
+ {globalFilter}
+ ”
+
+ }
+ elseShow={
+
+ No addons configured
+
+ }
+ />
+ }
+ />
+ {
+ onRemoveAddon(deletedAddon);
+ setShowDelete(false);
+ }}
+ onClose={() => {
+ setShowDelete(false);
+ }}
+ title="Confirm deletion"
+ >
+ Are you sure you want to delete this Addon?
+
+
+ );
+};
diff --git a/frontend/src/component/addons/AddonList/ConfiguredAddons/ConfiguredAddonsActionCell/ConfiguredAddonsActionsCell.tsx b/frontend/src/component/addons/AddonList/ConfiguredAddons/ConfiguredAddonsActionCell/ConfiguredAddonsActionsCell.tsx
new file mode 100644
index 0000000000..f2a9e21136
--- /dev/null
+++ b/frontend/src/component/addons/AddonList/ConfiguredAddons/ConfiguredAddonsActionCell/ConfiguredAddonsActionsCell.tsx
@@ -0,0 +1,73 @@
+import { Edit, Delete } from '@mui/icons-material';
+import { Tooltip } from '@mui/material';
+import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
+import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
+import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
+import { useOptimisticUpdate } from 'component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/hooks/useOptimisticUpdate';
+import {
+ UPDATE_ADDON,
+ DELETE_ADDON,
+} from 'component/providers/AccessProvider/permissions';
+import { IAddon } from 'interfaces/addons';
+import { useNavigate } from 'react-router-dom';
+
+interface IConfiguredAddonsActionsCellProps {
+ toggleAddon: (addon: IAddon) => Promise;
+ original: IAddon;
+ setShowDelete: React.Dispatch>;
+ setDeletedAddon: React.Dispatch>;
+}
+
+export const ConfiguredAddonsActionsCell = ({
+ toggleAddon,
+ setShowDelete,
+ setDeletedAddon,
+ original,
+}: IConfiguredAddonsActionsCellProps) => {
+ const navigate = useNavigate();
+ const [isEnabled, setIsEnabled, rollbackIsChecked] =
+ useOptimisticUpdate(original.enabled);
+
+ const onClick = () => {
+ setIsEnabled(!isEnabled);
+ toggleAddon(original).catch(rollbackIsChecked);
+ };
+
+ return (
+
+
+
+
+
+ navigate(`/addons/edit/${original.id}`)}
+ >
+
+
+ {
+ setDeletedAddon(original);
+ setShowDelete(true);
+ }}
+ >
+
+
+
+ );
+};
diff --git a/frontend/src/component/addons/CreateAddon/CreateAddon.tsx b/frontend/src/component/addons/CreateAddon/CreateAddon.tsx
new file mode 100644
index 0000000000..30548882f1
--- /dev/null
+++ b/frontend/src/component/addons/CreateAddon/CreateAddon.tsx
@@ -0,0 +1,39 @@
+import useAddons from 'hooks/api/getters/useAddons/useAddons';
+import { AddonForm } from '../AddonForm/AddonForm';
+import cloneDeep from 'lodash.clonedeep';
+import { IAddon } from 'interfaces/addons';
+import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
+
+export const DEFAULT_DATA = {
+ provider: '',
+ description: '',
+ enabled: true,
+ parameters: {},
+ events: [],
+ projects: [],
+ environments: [],
+} as unknown as IAddon; // TODO: improve type
+
+export const CreateAddon = () => {
+ const providerId = useRequiredPathParam('providerId');
+ const { providers, refetchAddons } = useAddons();
+
+ const editMode = false;
+ const provider = providers.find(
+ (providerItem: any) => providerItem.name === providerId
+ );
+
+ const defaultAddon = {
+ ...cloneDeep(DEFAULT_DATA),
+ provider: provider ? provider.name : '',
+ };
+
+ return (
+
+ );
+};
diff --git a/frontend/src/component/addons/EditAddon/EditAddon.tsx b/frontend/src/component/addons/EditAddon/EditAddon.tsx
new file mode 100644
index 0000000000..3fa6cfd4ce
--- /dev/null
+++ b/frontend/src/component/addons/EditAddon/EditAddon.tsx
@@ -0,0 +1,28 @@
+import useAddons from 'hooks/api/getters/useAddons/useAddons';
+import { AddonForm } from '../AddonForm/AddonForm';
+import cloneDeep from 'lodash.clonedeep';
+import { IAddon } from 'interfaces/addons';
+import { DEFAULT_DATA } from '../CreateAddon/CreateAddon';
+import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
+
+export const EditAddon = () => {
+ const addonId = useRequiredPathParam('addonId');
+ const { providers, addons, refetchAddons } = useAddons();
+
+ const editMode = true;
+ const addon = addons.find(
+ (addon: IAddon) => addon.id === Number(addonId)
+ ) || { ...cloneDeep(DEFAULT_DATA) };
+ const provider = addon
+ ? providers.find(provider => provider.name === addon.provider)
+ : undefined;
+
+ return (
+
+ );
+};
diff --git a/frontend/src/component/admin/api/index.tsx b/frontend/src/component/admin/api/index.tsx
new file mode 100644
index 0000000000..057133471e
--- /dev/null
+++ b/frontend/src/component/admin/api/index.tsx
@@ -0,0 +1,21 @@
+import { useLocation } from 'react-router-dom';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { ApiTokenPage } from 'component/admin/apiToken/ApiTokenPage/ApiTokenPage';
+import AdminMenu from '../menu/AdminMenu';
+
+const ApiPage = () => {
+ const { pathname } = useLocation();
+ const showAdminMenu = pathname.includes('/admin/');
+
+ return (
+
+ );
+};
+
+export default ApiPage;
diff --git a/frontend/src/component/admin/apiToken/ApiTokenDocs/ApiTokenDocs.tsx b/frontend/src/component/admin/apiToken/ApiTokenDocs/ApiTokenDocs.tsx
new file mode 100644
index 0000000000..f33c9a7282
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/ApiTokenDocs/ApiTokenDocs.tsx
@@ -0,0 +1,27 @@
+import { Alert } from '@mui/material';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+
+export const ApiTokenDocs = () => {
+ const { uiConfig } = useUiConfig();
+
+ return (
+
+
+ Read the{' '}
+
+ SDK overview
+ {' '}
+ to connect Unleash to your application. Please note it can take
+ up to 1 minute before a new API key is
+ activated.
+
+
+ API URL: {' '}
+ {uiConfig.unleashUrl}/api/
+
+ );
+};
diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.styles.ts b/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.styles.ts
new file mode 100644
index 0000000000..e54129a369
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.styles.ts
@@ -0,0 +1,61 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ maxWidth: '400px',
+ },
+ form: {
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ },
+ input: { width: '100%', marginBottom: '1rem' },
+ selectInput: {
+ marginBottom: '1rem',
+ minWidth: '400px',
+ [theme.breakpoints.down(600)]: {
+ minWidth: '379px',
+ },
+ },
+ radioGroup: {
+ marginBottom: theme.spacing(2),
+ },
+ radioItem: {
+ marginBottom: theme.spacing(1),
+ },
+ radio: {
+ marginLeft: theme.spacing(1.5),
+ },
+ label: {
+ minWidth: '300px',
+ [theme.breakpoints.down(600)]: {
+ minWidth: 'auto',
+ },
+ },
+ buttonContainer: {
+ marginTop: 'auto',
+ display: 'flex',
+ justifyContent: 'flex-end',
+ },
+ cancelButton: {
+ marginLeft: '1.5rem',
+ },
+ inputDescription: {
+ marginBottom: '0.5rem',
+ },
+ permissionErrorContainer: {
+ position: 'relative',
+ },
+ errorMessage: {
+ fontSize: theme.fontSizes.smallBody,
+ color: theme.palette.error.main,
+ position: 'absolute',
+ top: '-8px',
+ },
+ selectOptionsLink: {
+ cursor: 'pointer',
+ },
+ selectOptionCheckbox: {
+ marginRight: '0.2rem',
+ },
+}));
diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx
new file mode 100644
index 0000000000..5cce172e92
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx
@@ -0,0 +1,190 @@
+import {
+ Button,
+ FormControl,
+ FormControlLabel,
+ Radio,
+ RadioGroup,
+ Typography,
+ Box,
+} from '@mui/material';
+import { KeyboardArrowDownOutlined } from '@mui/icons-material';
+import React from 'react';
+import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
+import useProjects from 'hooks/api/getters/useProjects/useProjects';
+import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
+import Input from 'component/common/Input/Input';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import { SelectProjectInput } from './SelectProjectInput/SelectProjectInput';
+import { ApiTokenFormErrorType } from './useApiTokenForm';
+import { useStyles } from './ApiTokenForm.styles';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { TokenType } from 'interfaces/token';
+import { CorsTokenAlert } from 'component/admin/cors/CorsTokenAlert';
+
+interface IApiTokenFormProps {
+ username: string;
+ type: string;
+ projects: string[];
+ environment?: string;
+ setTokenType: (value: string) => void;
+ setUsername: React.Dispatch>;
+ setProjects: React.Dispatch>;
+ setEnvironment: React.Dispatch>;
+ handleSubmit: (e: any) => void;
+ handleCancel: () => void;
+ errors: { [key: string]: string };
+ mode: 'Create' | 'Edit';
+ clearErrors: (error?: ApiTokenFormErrorType) => void;
+}
+
+const ApiTokenForm: React.FC = ({
+ children,
+ username,
+ type,
+ projects,
+ environment,
+ setUsername,
+ setTokenType,
+ setProjects,
+ setEnvironment,
+ handleSubmit,
+ handleCancel,
+ errors,
+ clearErrors,
+}) => {
+ const { uiConfig } = useUiConfig();
+ const { classes: styles } = useStyles();
+ const { environments } = useEnvironments();
+ const { projects: availableProjects } = useProjects();
+
+ const selectableTypes = [
+ {
+ key: TokenType.CLIENT,
+ label: `Server-side SDK (${TokenType.CLIENT})`,
+ title: 'Connect server-side SDK or Unleash Proxy',
+ },
+ {
+ key: TokenType.ADMIN,
+ label: TokenType.ADMIN,
+ title: 'Full access for managing Unleash',
+ },
+ ];
+
+ if (uiConfig.embedProxy) {
+ selectableTypes.splice(1, 0, {
+ key: TokenType.FRONTEND,
+ label: `Client-side SDK (${TokenType.FRONTEND})`,
+ title: 'Connect web and mobile SDK directly to Unleash',
+ });
+ }
+
+ const selectableProjects = availableProjects.map(project => ({
+ value: project.id,
+ label: project.name,
+ }));
+
+ const selectableEnvs =
+ type === TokenType.ADMIN
+ ? [{ key: '*', label: 'ALL' }]
+ : environments.map(environment => ({
+ key: environment.name,
+ label: environment.name,
+ title: environment.name,
+ disabled: !environment.enabled,
+ }));
+
+ return (
+
+ );
+};
+
+export default ApiTokenForm;
diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.styles.ts b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.styles.ts
new file mode 100644
index 0000000000..5c1e706282
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.styles.ts
@@ -0,0 +1,8 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ selectOptionsLink: {
+ cursor: 'pointer',
+ fontSize: theme.fontSizes.bodySize,
+ },
+}));
diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.tsx
new file mode 100644
index 0000000000..10d8165223
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.tsx
@@ -0,0 +1,28 @@
+import React, { FC } from 'react';
+import { Box, Link } from '@mui/material';
+import { useStyles } from './SelectAllButton.styles';
+
+type SelectAllButtonProps = {
+ isAllSelected: boolean;
+ onClick: () => void;
+};
+
+export const SelectAllButton: FC = ({
+ isAllSelected,
+ onClick,
+}) => {
+ const { classes: styles } = useStyles();
+
+ return (
+
+
+ {isAllSelected ? 'Deselect all' : 'Select all'}
+
+
+ );
+};
diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.styles.ts b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.styles.ts
new file mode 100644
index 0000000000..7c29c9bd3c
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.styles.ts
@@ -0,0 +1,7 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ selectOptionCheckbox: {
+ marginRight: '0.2rem',
+ },
+}));
diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.test.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.test.tsx
new file mode 100644
index 0000000000..97ccfa906c
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.test.tsx
@@ -0,0 +1,166 @@
+import { vi } from 'vitest';
+import React from 'react';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from 'utils/testRenderer';
+import {
+ ISelectProjectInputProps,
+ SelectProjectInput,
+} from './SelectProjectInput';
+
+const onChange = vi.fn();
+const onFocus = vi.fn();
+
+const mockProps: ISelectProjectInputProps = {
+ options: [
+ { label: 'Project1', value: 'project1' },
+ { label: 'Project2', value: 'project2' },
+ { label: 'Project3', value: 'project3' },
+ ],
+ defaultValue: ['*'],
+ onChange,
+ onFocus,
+};
+
+describe('SelectProjectInput', () => {
+ beforeEach(() => {
+ onChange.mockClear();
+ onFocus.mockClear();
+ });
+
+ it('renders with default state', () => {
+ render();
+
+ const checkbox = screen.getByLabelText(
+ /all current and future projects/i
+ );
+ expect(checkbox).toBeChecked();
+
+ const selectInputContainer = screen.getByTestId('select-input');
+ const input = within(selectInputContainer).getByRole('combobox');
+ expect(input).toBeDisabled();
+ });
+
+ it('can toggle "ALL" checkbox', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTestId('select-all-projects'));
+
+ expect(
+ screen.getByLabelText(/all current and future projects/i)
+ ).not.toBeChecked();
+
+ expect(screen.getByLabelText('Projects')).toBeEnabled();
+
+ await user.click(screen.getByTestId('select-all-projects'));
+
+ expect(
+ screen.getByLabelText(/all current and future projects/i)
+ ).toBeChecked();
+
+ expect(screen.getByLabelText('Projects')).toBeDisabled();
+ });
+
+ it('renders with autocomplete enabled if default value is not a wildcard', () => {
+ render(
+
+ );
+
+ const checkbox = screen.getByLabelText(
+ /all current and future projects/i
+ );
+ expect(checkbox).not.toBeChecked();
+
+ const selectInputContainer = screen.getByTestId('select-input');
+ const input = within(selectInputContainer).getByRole('combobox');
+ expect(input).toBeEnabled();
+ });
+
+ describe('Select/Deselect projects in dropdown', () => {
+ it('selects and deselects all options', async () => {
+ const user = userEvent.setup();
+ render();
+ await user.click(screen.getByLabelText('Projects'));
+
+ let button = screen.getByRole('button', {
+ name: /select all/i,
+ });
+ expect(button).toBeInTheDocument();
+ await user.click(button);
+
+ expect(onChange).toHaveBeenCalledWith([
+ 'project1',
+ 'project2',
+ 'project3',
+ ]);
+
+ button = screen.getByRole('button', {
+ name: /deselect all/i,
+ });
+ expect(button).toBeInTheDocument();
+ await user.click(button);
+ expect(onChange).toHaveBeenCalledWith([]);
+ });
+
+ it("doesn't show up for less than 3 options", async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+ await user.click(screen.getByLabelText('Projects'));
+
+ const button = screen.queryByRole('button', {
+ name: /select all/i,
+ });
+ expect(button).not.toBeInTheDocument();
+ });
+ });
+
+ it('can filter options', async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+ const input = await screen.findByLabelText('Projects');
+ await user.type(input, 'alp');
+
+ await waitFor(() => {
+ expect(screen.getByText('Alpha')).toBeVisible();
+ });
+ await waitFor(() => {
+ expect(screen.queryByText('Bravo')).not.toBeInTheDocument();
+ });
+ await waitFor(() => {
+ expect(screen.queryByText('Charlie')).not.toBeInTheDocument();
+ });
+ await waitFor(() => {
+ expect(screen.getByText('Alpaca')).toBeVisible();
+ });
+
+ await user.clear(input);
+ await user.type(input, 'bravo');
+ await waitFor(() => {
+ expect(screen.getByText('Bravo')).toBeVisible();
+ });
+ await waitFor(() => {
+ expect(screen.queryByText('Alpha')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.tsx
new file mode 100644
index 0000000000..37e2c3c4ca
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.tsx
@@ -0,0 +1,166 @@
+import React, { Fragment, useState, ChangeEvent, VFC } from 'react';
+import {
+ Checkbox,
+ FormControlLabel,
+ TextField,
+ Box,
+ Paper,
+} from '@mui/material';
+import { Autocomplete } from '@mui/material';
+
+import {
+ AutocompleteRenderGroupParams,
+ AutocompleteRenderInputParams,
+ AutocompleteRenderOptionState,
+} from '@mui/material/Autocomplete';
+
+import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
+import CheckBoxIcon from '@mui/icons-material/CheckBox';
+import { IAutocompleteBoxOption } from 'component/common/AutocompleteBox/AutocompleteBox';
+import { useStyles } from '../ApiTokenForm.styles';
+import { SelectAllButton } from './SelectAllButton/SelectAllButton';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+
+const ALL_PROJECTS = '*';
+
+// Fix for shadow under Autocomplete - match with Select input
+const CustomPaper = ({ ...props }) => ;
+
+export interface ISelectProjectInputProps {
+ disabled?: boolean;
+ options: IAutocompleteBoxOption[];
+ defaultValue: string[];
+ onChange: (value: string[]) => void;
+ onFocus?: () => void;
+ error?: string;
+}
+
+export const SelectProjectInput: VFC = ({
+ options,
+ defaultValue = [ALL_PROJECTS],
+ onChange,
+ disabled,
+ error,
+ onFocus,
+}) => {
+ const { classes: styles } = useStyles();
+ const [projects, setProjects] = useState(
+ typeof defaultValue === 'string' ? [defaultValue] : defaultValue
+ );
+ const [isWildcardSelected, selectWildcard] = useState(
+ typeof defaultValue === 'string' || defaultValue.includes(ALL_PROJECTS)
+ );
+ const isAllSelected =
+ projects.length > 0 &&
+ projects.length === options.length &&
+ projects[0] !== ALL_PROJECTS;
+
+ const onAllProjectsChange = (
+ e: ChangeEvent,
+ checked: boolean
+ ) => {
+ if (checked) {
+ selectWildcard(true);
+ onChange([ALL_PROJECTS]);
+ } else {
+ selectWildcard(false);
+ onChange(projects.includes(ALL_PROJECTS) ? [] : projects);
+ }
+ };
+
+ const onSelectAllClick = () => {
+ const newProjects = isAllSelected
+ ? []
+ : options.map(({ value }) => value);
+ setProjects(newProjects);
+ onChange(newProjects);
+ };
+
+ const renderOption = (
+ props: object,
+ option: IAutocompleteBoxOption,
+ { selected }: AutocompleteRenderOptionState
+ ) => (
+
+ }
+ checkedIcon={}
+ checked={selected}
+ className={styles.selectOptionCheckbox}
+ />
+ {option.label}
+
+ );
+
+ const renderGroup = ({ key, children }: AutocompleteRenderGroupParams) => (
+
+ 2}
+ show={
+
+ }
+ />
+ {children}
+
+ );
+
+ const renderInput = (params: AutocompleteRenderInputParams) => (
+
+ );
+
+ return (
+
+
+
+ }
+ label="ALL current and future projects"
+ />
+
+ label}
+ groupBy={() => 'Select/Deselect all'}
+ renderGroup={renderGroup}
+ fullWidth
+ PaperComponent={CustomPaper}
+ renderOption={renderOption}
+ renderInput={renderInput}
+ value={
+ isWildcardSelected || disabled
+ ? options
+ : options.filter(option =>
+ projects.includes(option.value)
+ )
+ }
+ onChange={(_, input) => {
+ const state = input.map(({ value }) => value);
+ setProjects(state);
+ onChange(state);
+ }}
+ />
+
+ );
+};
diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/useApiTokenForm.ts b/frontend/src/component/admin/apiToken/ApiTokenForm/useApiTokenForm.ts
new file mode 100644
index 0000000000..7c28a3416c
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/ApiTokenForm/useApiTokenForm.ts
@@ -0,0 +1,82 @@
+import { useEffect, useState } from 'react';
+import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
+import { IApiTokenCreate } from 'hooks/api/actions/useApiTokensApi/useApiTokensApi';
+
+export type ApiTokenFormErrorType = 'username' | 'projects';
+
+export const useApiTokenForm = () => {
+ const { environments } = useEnvironments();
+ const initialEnvironment = environments?.find(e => e.enabled)?.name;
+
+ const [username, setUsername] = useState('');
+ const [type, setType] = useState('CLIENT');
+ const [projects, setProjects] = useState(['*']);
+ const [memorizedProjects, setMemorizedProjects] =
+ useState(projects);
+ const [environment, setEnvironment] = useState();
+ const [errors, setErrors] = useState<
+ Partial>
+ >({});
+
+ useEffect(() => {
+ setEnvironment(type === 'ADMIN' ? '*' : initialEnvironment);
+ }, [type, initialEnvironment]);
+
+ const setTokenType = (value: string) => {
+ if (value === 'ADMIN') {
+ setType(value);
+ setMemorizedProjects(projects);
+ setProjects(['*']);
+ setEnvironment('*');
+ } else {
+ setType(value);
+ setProjects(memorizedProjects);
+ setEnvironment(initialEnvironment);
+ }
+ };
+
+ const getApiTokenPayload = (): IApiTokenCreate => ({
+ username,
+ type,
+ environment,
+ projects,
+ });
+
+ const isValid = () => {
+ const newErrors: Partial> = {};
+ if (!username) {
+ newErrors['username'] = 'Username is required';
+ }
+ if (projects.length === 0) {
+ newErrors['projects'] = 'At least one project is required';
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const clearErrors = (error?: ApiTokenFormErrorType) => {
+ if (error) {
+ const newErrors = { ...errors };
+ delete newErrors[error];
+ setErrors(newErrors);
+ } else {
+ setErrors({});
+ }
+ };
+
+ return {
+ username,
+ type,
+ projects,
+ environment,
+ setUsername,
+ setTokenType,
+ setProjects,
+ setEnvironment,
+ getApiTokenPayload,
+ isValid,
+ clearErrors,
+ errors,
+ };
+};
diff --git a/frontend/src/component/admin/apiToken/ApiTokenPage/ApiTokenPage.tsx b/frontend/src/component/admin/apiToken/ApiTokenPage/ApiTokenPage.tsx
new file mode 100644
index 0000000000..f5d20b261d
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/ApiTokenPage/ApiTokenPage.tsx
@@ -0,0 +1,18 @@
+import { useContext } from 'react';
+import AccessContext from 'contexts/AccessContext';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { READ_API_TOKEN } from 'component/providers/AccessProvider/permissions';
+import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
+import { ApiTokenTable } from 'component/admin/apiToken/ApiTokenTable/ApiTokenTable';
+
+export const ApiTokenPage = () => {
+ const { hasAccess } = useContext(AccessContext);
+
+ return (
+ }
+ elseShow={() => }
+ />
+ );
+};
diff --git a/frontend/src/component/admin/apiToken/ApiTokenTable/ApiTokenTable.tsx b/frontend/src/component/admin/apiToken/ApiTokenTable/ApiTokenTable.tsx
new file mode 100644
index 0000000000..811b3316c8
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/ApiTokenTable/ApiTokenTable.tsx
@@ -0,0 +1,223 @@
+import { useApiTokens } from 'hooks/api/getters/useApiTokens/useApiTokens';
+import { useTable, useGlobalFilter, useSortBy } from 'react-table';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import {
+ SortableTableHeader,
+ TableCell,
+ TablePlaceholder,
+} from 'component/common/Table';
+import { Table, TableBody, Box, TableRow, useMediaQuery } from '@mui/material';
+import { PageHeader } from 'component/common/PageHeader/PageHeader';
+import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
+import { ApiTokenDocs } from 'component/admin/apiToken/ApiTokenDocs/ApiTokenDocs';
+import { CreateApiTokenButton } from 'component/admin/apiToken/CreateApiTokenButton/CreateApiTokenButton';
+import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
+import { Key } from '@mui/icons-material';
+import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
+import { CopyApiTokenButton } from 'component/admin/apiToken/CopyApiTokenButton/CopyApiTokenButton';
+import { RemoveApiTokenButton } from 'component/admin/apiToken/RemoveApiTokenButton/RemoveApiTokenButton';
+import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
+import { sortTypes } from 'utils/sortTypes';
+import { useMemo } from 'react';
+import theme from 'themes/theme';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import { ProjectsList } from 'component/admin/apiToken/ProjectsList/ProjectsList';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
+import { Search } from 'component/common/Search/Search';
+import useHiddenColumns from 'hooks/useHiddenColumns';
+
+const hiddenColumnsSmall = ['Icon', 'createdAt'];
+const hiddenColumnsFlagE = ['projects', 'environment'];
+
+export const ApiTokenTable = () => {
+ const { tokens, loading } = useApiTokens();
+ const initialState = useMemo(() => ({ sortBy: [{ id: 'createdAt' }] }), []);
+ const { uiConfig } = useUiConfig();
+ const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
+
+ const {
+ getTableProps,
+ getTableBodyProps,
+ headerGroups,
+ rows,
+ prepareRow,
+ state: { globalFilter },
+ setGlobalFilter,
+ setHiddenColumns,
+ } = useTable(
+ {
+ columns: COLUMNS as any,
+ data: tokens as any,
+ initialState,
+ sortTypes,
+ disableSortRemove: true,
+ },
+ useGlobalFilter,
+ useSortBy
+ );
+
+ useHiddenColumns(setHiddenColumns, hiddenColumnsSmall, isSmallScreen);
+ useHiddenColumns(setHiddenColumns, hiddenColumnsFlagE, !uiConfig.flags.E);
+
+ return (
+
+
+
+
+ >
+ }
+ />
+ }
+ >
+ 0}
+ show={
+
+
+
+ }
+ />
+
+
+
+
+
+ {rows.map(row => {
+ prepareRow(row);
+ return (
+
+ {row.cells.map(cell => (
+
+ {cell.render('Cell')}
+
+ ))}
+
+ );
+ })}
+
+
+
+
+ 0}
+ show={
+
+ No tokens found matching “
+ {globalFilter}
+ ”
+
+ }
+ elseShow={
+
+
+ {'No tokens available. Read '}
+
+ API How-to guides
+ {' '}
+ {' to learn more.'}
+
+
+ }
+ />
+ }
+ />
+
+ );
+};
+
+const tokenDescriptions = {
+ client: {
+ label: 'CLIENT',
+ title: 'Connect server-side SDK or Unleash Proxy',
+ },
+ frontend: {
+ label: 'FRONTEND',
+ title: 'Connect web and mobile SDK',
+ },
+ admin: {
+ label: 'ADMIN',
+ title: 'Full access for managing Unleash',
+ },
+};
+
+const COLUMNS = [
+ {
+ id: 'Icon',
+ width: '1%',
+ Cell: () => } />,
+ disableSortBy: true,
+ disableGlobalFilter: true,
+ },
+ {
+ Header: 'Username',
+ accessor: 'username',
+ Cell: HighlightCell,
+ },
+ {
+ Header: 'Type',
+ accessor: 'type',
+ Cell: ({ value }: { value: 'admin' | 'client' | 'frontend' }) => (
+
+ ),
+ minWidth: 280,
+ },
+ {
+ Header: 'Project',
+ accessor: 'project',
+ Cell: (props: any) => (
+
+ ),
+ minWidth: 120,
+ },
+ {
+ Header: 'Environment',
+ accessor: 'environment',
+ Cell: HighlightCell,
+ minWidth: 120,
+ },
+ {
+ Header: 'Created',
+ accessor: 'createdAt',
+ Cell: DateCell,
+ minWidth: 150,
+ disableGlobalFilter: true,
+ },
+ {
+ Header: 'Actions',
+ id: 'Actions',
+ align: 'center',
+ width: '1%',
+ disableSortBy: true,
+ disableGlobalFilter: true,
+ Cell: (props: any) => (
+
+
+
+
+ ),
+ },
+];
diff --git a/frontend/src/component/admin/apiToken/ConfirmToken/ConfirmToken.tsx b/frontend/src/component/admin/apiToken/ConfirmToken/ConfirmToken.tsx
new file mode 100644
index 0000000000..7526b9dffc
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/ConfirmToken/ConfirmToken.tsx
@@ -0,0 +1,34 @@
+import { Typography } from '@mui/material';
+import { useThemeStyles } from 'themes/themeStyles';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import { UserToken } from './UserToken/UserToken';
+
+interface IConfirmUserLink {
+ open: boolean;
+ closeConfirm: () => void;
+ token: string;
+}
+
+export const ConfirmToken = ({
+ open,
+ closeConfirm,
+ token,
+}: IConfirmUserLink) => {
+ const { classes: themeStyles } = useThemeStyles();
+
+ return (
+
+
+
+ Your new token has been created successfully.
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/apiToken/ConfirmToken/UserToken/UserToken.tsx b/frontend/src/component/admin/apiToken/ConfirmToken/UserToken/UserToken.tsx
new file mode 100644
index 0000000000..2125bf8684
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/ConfirmToken/UserToken/UserToken.tsx
@@ -0,0 +1,47 @@
+import { IconButton, Tooltip } from '@mui/material';
+import CopyIcon from '@mui/icons-material/FileCopy';
+import copy from 'copy-to-clipboard';
+import useToast from 'hooks/useToast';
+
+interface IUserTokenProps {
+ token: string;
+}
+
+export const UserToken = ({ token }: IUserTokenProps) => {
+ const { setToastData } = useToast();
+
+ const copyToken = () => {
+ if (copy(token)) {
+ setToastData({
+ type: 'success',
+ title: 'Token copied to clipboard',
+ });
+ } else
+ setToastData({
+ type: 'error',
+ title: 'Could not copy token',
+ });
+ };
+
+ return (
+
+ {token}
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/apiToken/CopyApiTokenButton/CopyApiTokenButton.tsx b/frontend/src/component/admin/apiToken/CopyApiTokenButton/CopyApiTokenButton.tsx
new file mode 100644
index 0000000000..e34e3501fb
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/CopyApiTokenButton/CopyApiTokenButton.tsx
@@ -0,0 +1,30 @@
+import { IconButton, Tooltip } from '@mui/material';
+import { IApiToken } from 'hooks/api/getters/useApiTokens/useApiTokens';
+import useToast from 'hooks/useToast';
+import copy from 'copy-to-clipboard';
+import { FileCopy } from '@mui/icons-material';
+
+interface ICopyApiTokenButtonProps {
+ token: IApiToken;
+}
+
+export const CopyApiTokenButton = ({ token }: ICopyApiTokenButtonProps) => {
+ const { setToastData } = useToast();
+
+ const copyToken = (value: string) => {
+ if (copy(value)) {
+ setToastData({
+ type: 'success',
+ title: `Token copied to clipboard`,
+ });
+ }
+ };
+
+ return (
+
+ copyToken(token.secret)} size="large">
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx b/frontend/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx
new file mode 100644
index 0000000000..203f1ac1fb
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx
@@ -0,0 +1,115 @@
+import FormTemplate from 'component/common/FormTemplate/FormTemplate';
+import { useNavigate } from 'react-router-dom';
+import ApiTokenForm from '../ApiTokenForm/ApiTokenForm';
+import { CreateButton } from 'component/common/CreateButton/CreateButton';
+import useApiTokensApi from 'hooks/api/actions/useApiTokensApi/useApiTokensApi';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import useToast from 'hooks/useToast';
+import { useApiTokenForm } from 'component/admin/apiToken/ApiTokenForm/useApiTokenForm';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import { ConfirmToken } from '../ConfirmToken/ConfirmToken';
+import { useState } from 'react';
+import { scrollToTop } from 'component/common/util';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { usePageTitle } from 'hooks/usePageTitle';
+import { GO_BACK } from 'constants/navigate';
+
+const pageTitle = 'Create API token';
+
+export const CreateApiToken = () => {
+ const { setToastApiError } = useToast();
+ const { uiConfig } = useUiConfig();
+ const navigate = useNavigate();
+ const [showConfirm, setShowConfirm] = useState(false);
+ const [token, setToken] = useState('');
+
+ const {
+ getApiTokenPayload,
+ username,
+ type,
+ projects,
+ environment,
+ setUsername,
+ setTokenType,
+ setProjects,
+ setEnvironment,
+ isValid,
+ errors,
+ clearErrors,
+ } = useApiTokenForm();
+
+ const { createToken, loading } = useApiTokensApi();
+
+ usePageTitle(pageTitle);
+
+ const handleSubmit = async (e: Event) => {
+ e.preventDefault();
+ if (!isValid()) {
+ return;
+ }
+ try {
+ const payload = getApiTokenPayload();
+ await createToken(payload)
+ .then(res => res.json())
+ .then(api => {
+ scrollToTop();
+ setToken(api.secret);
+ setShowConfirm(true);
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ const closeConfirm = () => {
+ setShowConfirm(false);
+ navigate('/admin/api');
+ };
+
+ const formatApiCode = () => {
+ return `curl --location --request POST '${
+ uiConfig.unleashUrl
+ }/api/admin/api-tokens' \\
+--header 'Authorization: INSERT_API_KEY' \\
+--header 'Content-Type: application/json' \\
+--data-raw '${JSON.stringify(getApiTokenPayload(), undefined, 2)}'`;
+ };
+
+ const handleCancel = () => {
+ navigate(GO_BACK);
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/apiToken/CreateApiTokenButton/CreateApiTokenButton.tsx b/frontend/src/component/admin/apiToken/CreateApiTokenButton/CreateApiTokenButton.tsx
new file mode 100644
index 0000000000..f1236fec3b
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/CreateApiTokenButton/CreateApiTokenButton.tsx
@@ -0,0 +1,21 @@
+import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
+import { CREATE_API_TOKEN } from 'component/providers/AccessProvider/permissions';
+import { CREATE_API_TOKEN_BUTTON } from 'utils/testIds';
+import { useNavigate } from 'react-router-dom';
+import { Add } from '@mui/icons-material';
+
+export const CreateApiTokenButton = () => {
+ const navigate = useNavigate();
+
+ return (
+ navigate('/admin/api/create-token')}
+ data-testid={CREATE_API_TOKEN_BUTTON}
+ permission={CREATE_API_TOKEN}
+ maxWidth="700px"
+ >
+ New API token
+
+ );
+};
diff --git a/frontend/src/component/admin/apiToken/ProjectsList/ProjectsList.test.tsx b/frontend/src/component/admin/apiToken/ProjectsList/ProjectsList.test.tsx
new file mode 100644
index 0000000000..5341609dc7
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/ProjectsList/ProjectsList.test.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import { render } from 'utils/testRenderer';
+import { screen } from '@testing-library/react';
+import { ProjectsList } from 'component/admin/apiToken/ProjectsList/ProjectsList';
+
+describe('ProjectsList', () => {
+ it('should prioritize new "projects" array over deprecated "project"', async () => {
+ render(
+
+ );
+
+ const links = await screen.findAllByRole('link');
+ expect(links).toHaveLength(2);
+ expect(links[0]).toHaveTextContent('project1');
+ expect(links[1]).toHaveTextContent('project2');
+ expect(links[0]).toHaveAttribute('href', '/projects/project1');
+ expect(links[1]).toHaveAttribute('href', '/projects/project2');
+ });
+
+ it('should render correctly with single "project"', async () => {
+ render();
+
+ const links = await screen.findAllByRole('link');
+ expect(links).toHaveLength(1);
+ expect(links[0]).toHaveTextContent('project');
+ });
+
+ it('should have comma between project links', async () => {
+ const { container } = render();
+
+ expect(container.textContent).toContain(', ');
+ });
+
+ it('should render asterisk if no projects are passed', async () => {
+ const { container } = render();
+
+ expect(container.textContent).toEqual('*');
+ });
+
+ it('should render asterisk if empty projects array is passed', async () => {
+ const { container } = render();
+
+ expect(container.textContent).toEqual('*');
+ });
+});
diff --git a/frontend/src/component/admin/apiToken/ProjectsList/ProjectsList.tsx b/frontend/src/component/admin/apiToken/ProjectsList/ProjectsList.tsx
new file mode 100644
index 0000000000..c18918e88f
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/ProjectsList/ProjectsList.tsx
@@ -0,0 +1,59 @@
+import { styled } from '@mui/material';
+import { Highlighter } from 'component/common/Highlighter/Highlighter';
+import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
+import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
+import { Fragment, VFC } from 'react';
+import { Link } from 'react-router-dom';
+
+const StyledLink = styled(Link)(() => ({
+ textDecoration: 'none',
+ '&:hover, &:focus': {
+ textDecoration: 'underline',
+ },
+}));
+
+interface IProjectsListProps {
+ project?: string;
+ projects?: string | string[];
+}
+
+export const ProjectsList: VFC = ({
+ projects,
+ project,
+}) => {
+ const { searchQuery } = useSearchHighlightContext();
+
+ let fields: string[] =
+ projects && Array.isArray(projects)
+ ? projects
+ : project
+ ? [project]
+ : [];
+
+ if (fields.length === 0) {
+ return (
+
+ *
+
+ );
+ }
+
+ return (
+
+ {fields.map((item, index) => (
+
+ {index > 0 && ', '}
+ {!item || item === '*' ? (
+ *
+ ) : (
+
+
+ {item}
+
+
+ )}
+
+ ))}
+
+ );
+};
diff --git a/frontend/src/component/admin/apiToken/RemoveApiTokenButton/RemoveApiTokenButton.tsx b/frontend/src/component/admin/apiToken/RemoveApiTokenButton/RemoveApiTokenButton.tsx
new file mode 100644
index 0000000000..38d47099b6
--- /dev/null
+++ b/frontend/src/component/admin/apiToken/RemoveApiTokenButton/RemoveApiTokenButton.tsx
@@ -0,0 +1,70 @@
+import { DELETE_API_TOKEN } from 'component/providers/AccessProvider/permissions';
+import { Delete } from '@mui/icons-material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { IconButton, Tooltip } from '@mui/material';
+import {
+ IApiToken,
+ useApiTokens,
+} from 'hooks/api/getters/useApiTokens/useApiTokens';
+import AccessContext from 'contexts/AccessContext';
+import { useContext, useState } from 'react';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import useToast from 'hooks/useToast';
+import useApiTokensApi from 'hooks/api/actions/useApiTokensApi/useApiTokensApi';
+
+interface IRemoveApiTokenButtonProps {
+ token: IApiToken;
+}
+
+export const RemoveApiTokenButton = ({ token }: IRemoveApiTokenButtonProps) => {
+ const { hasAccess } = useContext(AccessContext);
+ const { deleteToken } = useApiTokensApi();
+ const [open, setOpen] = useState(false);
+ const { setToastData } = useToast();
+ const { refetch } = useApiTokens();
+
+ const onRemove = async () => {
+ await deleteToken(token.secret);
+ setOpen(false);
+ refetch();
+ setToastData({
+ type: 'success',
+ title: 'API token removed',
+ });
+ };
+
+ return (
+ <>
+
+ setOpen(true)} size="large">
+
+
+
+ }
+ />
+ setOpen(false)}
+ title="Confirm deletion"
+ >
+
+ Are you sure you want to delete the following API token?
+
+
+ -
+ username:{' '}
+
{token.username}
+
+ -
+ type:
{token.type}
+
+
+
+
+ >
+ );
+};
diff --git a/frontend/src/component/admin/auth/AuthSettings.tsx b/frontend/src/component/admin/auth/AuthSettings.tsx
new file mode 100644
index 0000000000..e95d9e8f6b
--- /dev/null
+++ b/frontend/src/component/admin/auth/AuthSettings.tsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import AdminMenu from '../menu/AdminMenu';
+import { Alert } from '@mui/material';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import { OidcAuth } from './OidcAuth/OidcAuth';
+import { SamlAuth } from './SamlAuth/SamlAuth';
+import { PasswordAuth } from './PasswordAuth/PasswordAuth';
+import { GoogleAuth } from './GoogleAuth/GoogleAuth';
+import { TabNav } from 'component/common/TabNav/TabNav/TabNav';
+
+export const AuthSettings = () => {
+ const { authenticationType } = useUiConfig().uiConfig;
+
+ const tabs = [
+ {
+ label: 'OpenID Connect',
+ component: ,
+ },
+ {
+ label: 'SAML 2.0',
+ component: ,
+ },
+ {
+ label: 'Password',
+ component: ,
+ },
+ {
+ label: 'Google',
+ component: ,
+ },
+ ];
+
+ return (
+
+
+
+ }
+ />
+
+ You are running the open-source version of Unleash.
+ You have to use the Enterprise edition in order
+ configure Single Sign-on.
+
+ }
+ />
+
+ You are running Unleash in demo mode. You have to
+ use the Enterprise edition in order configure Single
+ Sign-on.
+
+ }
+ />
+
+ You have decided to use custom authentication type.
+ You have to use the Enterprise edition in order
+ configure Single Sign-on from the user interface.
+
+ }
+ />
+
+ Your Unleash instance is managed by the Unleash
+ team.
+
+ }
+ />
+
+
+ );
+};
diff --git a/frontend/src/component/admin/auth/AutoCreateForm/AutoCreateForm.tsx b/frontend/src/component/admin/auth/AutoCreateForm/AutoCreateForm.tsx
new file mode 100644
index 0000000000..bc89340ad6
--- /dev/null
+++ b/frontend/src/component/admin/auth/AutoCreateForm/AutoCreateForm.tsx
@@ -0,0 +1,119 @@
+import React, { ChangeEvent, Fragment } from 'react';
+import {
+ FormControl,
+ FormControlLabel,
+ Grid,
+ InputLabel,
+ MenuItem,
+ Select,
+ Switch,
+ TextField,
+ SelectChangeEvent,
+} from '@mui/material';
+
+interface IAutoCreateFormProps {
+ data?: {
+ enabled: boolean;
+ autoCreate: boolean;
+ defaultRootRole?: string;
+ emailDomains?: string;
+ };
+ setValue: (name: string, value: string | boolean) => void;
+}
+
+export const AutoCreateForm = ({
+ data = { enabled: false, autoCreate: false },
+ setValue,
+}: IAutoCreateFormProps) => {
+ const updateAutoCreate = () => {
+ setValue('autoCreate', !data.autoCreate);
+ };
+
+ const updateDefaultRootRole = (evt: SelectChangeEvent) => {
+ setValue('defaultRootRole', evt.target.value);
+ };
+
+ const updateField = (e: ChangeEvent) => {
+ setValue(e.target.name, e.target.value);
+ };
+
+ return (
+
+
+
+ Auto-create users
+
+ Enable automatic creation of new users when signing in.
+
+
+
+
+ }
+ label="Auto-create users"
+ />
+
+
+
+
+ Default Root Role
+
+ Choose which root role the user should get when no
+ explicit role mapping exists.
+
+
+
+
+
+ Default Role
+
+
+
+
+
+
+
+ Email domains
+
+ Comma separated list of email domains that should be
+ allowed to sign in.
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/auth/GoogleAuth/GoogleAuth.tsx b/frontend/src/component/admin/auth/GoogleAuth/GoogleAuth.tsx
new file mode 100644
index 0000000000..fb23b99aa3
--- /dev/null
+++ b/frontend/src/component/admin/auth/GoogleAuth/GoogleAuth.tsx
@@ -0,0 +1,248 @@
+import React, { useContext, useEffect, useState } from 'react';
+import {
+ Button,
+ FormControlLabel,
+ Grid,
+ Switch,
+ TextField,
+} from '@mui/material';
+import { Alert } from '@mui/material';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import AccessContext from 'contexts/AccessContext';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings';
+import useAuthSettingsApi from 'hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi';
+import useToast from 'hooks/useToast';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { removeEmptyStringFields } from 'utils/removeEmptyStringFields';
+
+const initialState = {
+ enabled: false,
+ autoCreate: false,
+ unleashHostname: location.hostname,
+ clientId: '',
+ clientSecret: '',
+ emailDomains: '',
+};
+
+export const GoogleAuth = () => {
+ const { setToastData, setToastApiError } = useToast();
+ const { uiConfig } = useUiConfig();
+ const [data, setData] = useState(initialState);
+ const { hasAccess } = useContext(AccessContext);
+ const { config } = useAuthSettings('google');
+ const { updateSettings, errors, loading } = useAuthSettingsApi('google');
+
+ useEffect(() => {
+ if (config.clientId) {
+ setData(config);
+ }
+ }, [config]);
+
+ if (!hasAccess(ADMIN)) {
+ return You need admin privileges to access this section.;
+ }
+
+ const updateField = (event: React.ChangeEvent) => {
+ setData({
+ ...data,
+ [event.target.name]: event.target.value,
+ });
+ };
+
+ const updateEnabled = () => {
+ setData({ ...data, enabled: !data.enabled });
+ };
+
+ const updateAutoCreate = () => {
+ setData({ ...data, autoCreate: !data.autoCreate });
+ };
+
+ const onSubmit = async (event: React.SyntheticEvent) => {
+ event.preventDefault();
+
+ try {
+ await updateSettings(removeEmptyStringFields(data));
+ setToastData({
+ title: 'Settings stored',
+ type: 'success',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ return (
+
+
+
+
+ Please read the{' '}
+
+ documentation
+ {' '}
+ to learn how to integrate with Google OAuth 2.0.
+ Callback URL:{' '}
+ {uiConfig.unleashUrl}/auth/google/callback
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/auth/OidcAuth/OidcAuth.tsx b/frontend/src/component/admin/auth/OidcAuth/OidcAuth.tsx
new file mode 100644
index 0000000000..721f65beff
--- /dev/null
+++ b/frontend/src/component/admin/auth/OidcAuth/OidcAuth.tsx
@@ -0,0 +1,261 @@
+import React, { useContext, useEffect, useState } from 'react';
+import {
+ Button,
+ FormControlLabel,
+ Grid,
+ Switch,
+ TextField,
+} from '@mui/material';
+import { Alert } from '@mui/material';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import AccessContext from 'contexts/AccessContext';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import { AutoCreateForm } from '../AutoCreateForm/AutoCreateForm';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import useAuthSettingsApi from 'hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi';
+import useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings';
+import useToast from 'hooks/useToast';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { removeEmptyStringFields } from 'utils/removeEmptyStringFields';
+
+const initialState = {
+ enabled: false,
+ enableSingleSignOut: false,
+ autoCreate: false,
+ unleashHostname: location.hostname,
+ clientId: '',
+ discoverUrl: '',
+ secret: '',
+ acrValues: '',
+};
+
+export const OidcAuth = () => {
+ const { setToastData, setToastApiError } = useToast();
+ const { uiConfig } = useUiConfig();
+ const [data, setData] = useState(initialState);
+ const { hasAccess } = useContext(AccessContext);
+ const { config } = useAuthSettings('oidc');
+ const { updateSettings, errors, loading } = useAuthSettingsApi('oidc');
+
+ useEffect(() => {
+ if (config.discoverUrl) {
+ setData(config);
+ }
+ }, [config]);
+
+ if (!hasAccess(ADMIN)) {
+ return (
+
+ You need to be a root admin to access this section.
+
+ );
+ }
+
+ const updateField = (event: React.ChangeEvent) => {
+ setValue(event.target.name, event.target.value);
+ };
+
+ const updateEnabled = () => {
+ setData({ ...data, enabled: !data.enabled });
+ };
+
+ const updateSingleSignOut = () => {
+ setData({ ...data, enableSingleSignOut: !data.enableSingleSignOut });
+ };
+
+ const setValue = (name: string, value: string | boolean) => {
+ setData({
+ ...data,
+ [name]: value,
+ });
+ };
+
+ const onSubmit = async (event: React.SyntheticEvent) => {
+ event.preventDefault();
+
+ try {
+ await updateSettings(removeEmptyStringFields(data));
+ setToastData({
+ title: 'Settings stored',
+ type: 'success',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ return (
+
+
+
+
+ Please read the{' '}
+
+ documentation
+ {' '}
+ to learn how to integrate with specific Open Id Connect
+ providers (Okta, Keycloak, Google, etc).
+ Callback URL:{' '}
+ {uiConfig.unleashUrl}/auth/oidc/callback
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/auth/PasswordAuth/PasswordAuth.tsx b/frontend/src/component/admin/auth/PasswordAuth/PasswordAuth.tsx
new file mode 100644
index 0000000000..2aef86c5cd
--- /dev/null
+++ b/frontend/src/component/admin/auth/PasswordAuth/PasswordAuth.tsx
@@ -0,0 +1,103 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { Button, FormControlLabel, Grid, Switch } from '@mui/material';
+import { Alert } from '@mui/material';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import AccessContext from 'contexts/AccessContext';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings';
+import useAuthSettingsApi, {
+ ISimpleAuthSettings,
+} from 'hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi';
+import useToast from 'hooks/useToast';
+import { formatUnknownError } from 'utils/formatUnknownError';
+
+export const PasswordAuth = () => {
+ const { setToastData, setToastApiError } = useToast();
+ const { config } = useAuthSettings('simple');
+ const [disablePasswordAuth, setDisablePasswordAuth] =
+ useState(false);
+ const { updateSettings, errors, loading } =
+ useAuthSettingsApi('simple');
+ const { hasAccess } = useContext(AccessContext);
+
+ useEffect(() => {
+ setDisablePasswordAuth(!!config.disabled);
+ }, [config.disabled]);
+
+ if (!hasAccess(ADMIN)) {
+ return (
+
+ You need to be a root admin to access this section.
+
+ );
+ }
+
+ const updateDisabled = () => {
+ setDisablePasswordAuth(!disablePasswordAuth);
+ };
+
+ const onSubmit = async (event: React.SyntheticEvent) => {
+ event.preventDefault();
+
+ try {
+ const settings: ISimpleAuthSettings = {
+ disabled: disablePasswordAuth,
+ };
+ await updateSettings(settings);
+ setToastData({
+ title: 'Successfully saved',
+ text: 'Password authentication settings stored.',
+ autoHideDuration: 4000,
+ type: 'success',
+ show: true,
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ setDisablePasswordAuth(config.disabled);
+ }
+ };
+ return (
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/auth/SamlAuth/SamlAuth.tsx b/frontend/src/component/admin/auth/SamlAuth/SamlAuth.tsx
new file mode 100644
index 0000000000..5e1da6f385
--- /dev/null
+++ b/frontend/src/component/admin/auth/SamlAuth/SamlAuth.tsx
@@ -0,0 +1,270 @@
+import React, { useContext, useEffect, useState } from 'react';
+import {
+ Button,
+ FormControlLabel,
+ Grid,
+ Switch,
+ TextField,
+} from '@mui/material';
+import { Alert } from '@mui/material';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import AccessContext from 'contexts/AccessContext';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import { AutoCreateForm } from '../AutoCreateForm/AutoCreateForm';
+import useToast from 'hooks/useToast';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings';
+import useAuthSettingsApi from 'hooks/api/actions/useAuthSettingsApi/useAuthSettingsApi';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { removeEmptyStringFields } from 'utils/removeEmptyStringFields';
+
+const initialState = {
+ enabled: false,
+ autoCreate: false,
+ unleashHostname: location.hostname,
+ entityId: '',
+ signOnUrl: '',
+ certificate: '',
+ signOutUrl: '',
+ spCertificate: '',
+};
+
+export const SamlAuth = () => {
+ const { setToastData, setToastApiError } = useToast();
+ const { uiConfig } = useUiConfig();
+ const [data, setData] = useState(initialState);
+ const { hasAccess } = useContext(AccessContext);
+ const { config } = useAuthSettings('saml');
+ const { updateSettings, errors, loading } = useAuthSettingsApi('saml');
+
+ useEffect(() => {
+ if (config.entityId) {
+ setData(config);
+ }
+ }, [config]);
+
+ if (!hasAccess(ADMIN)) {
+ return (
+
+ You need to be a root admin to access this section.
+
+ );
+ }
+
+ const updateField = (event: React.ChangeEvent) => {
+ setValue(event.target.name, event.target.value);
+ };
+
+ const updateEnabled = () => {
+ setData({ ...data, enabled: !data.enabled });
+ };
+
+ const setValue = (name: string, value: string | boolean) => {
+ setData({
+ ...data,
+ [name]: value,
+ });
+ };
+
+ const onSubmit = async (event: React.SyntheticEvent) => {
+ event.preventDefault();
+
+ try {
+ await updateSettings(removeEmptyStringFields(data));
+ setToastData({
+ title: 'Settings stored',
+ type: 'success',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ return (
+
+
+
+
+ Please read the{' '}
+
+ documentation
+ {' '}
+ to learn how to integrate with specific SAML 2.0
+ providers (Okta, Keycloak, etc).
+ Callback URL:{' '}
+ {uiConfig.unleashUrl}/auth/saml/callback
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/billing/Billing.tsx b/frontend/src/component/admin/billing/Billing.tsx
new file mode 100644
index 0000000000..2e82f5d8aa
--- /dev/null
+++ b/frontend/src/component/admin/billing/Billing.tsx
@@ -0,0 +1,62 @@
+import AdminMenu from '../menu/AdminMenu';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import { useContext, useEffect } from 'react';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import AccessContext from 'contexts/AccessContext';
+import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
+import { useInstanceStatus } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
+import { Alert } from '@mui/material';
+import { BillingDashboard } from './BillingDashboard/BillingDashboard';
+import { BillingHistory } from './BillingHistory/BillingHistory';
+import useInvoices from 'hooks/api/getters/useInvoices/useInvoices';
+
+export const Billing = () => {
+ const {
+ instanceStatus,
+ isBilling,
+ refetchInstanceStatus,
+ refresh,
+ loading,
+ } = useInstanceStatus();
+ const { invoices } = useInvoices();
+ const { hasAccess } = useContext(AccessContext);
+
+ useEffect(() => {
+ const hardRefresh = async () => {
+ await refresh();
+ refetchInstanceStatus();
+ };
+ hardRefresh();
+ }, [refetchInstanceStatus, refresh]);
+
+ return (
+
+
+
+ (
+ <>
+
+
+ >
+ )}
+ elseShow={() => }
+ />
+ }
+ elseShow={
+
+ Billing is not enabled for this instance.
+
+ }
+ />
+
+
+ );
+};
diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingDashboard.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingDashboard.tsx
new file mode 100644
index 0000000000..179f1d2bb3
--- /dev/null
+++ b/frontend/src/component/admin/billing/BillingDashboard/BillingDashboard.tsx
@@ -0,0 +1,20 @@
+import { Grid } from '@mui/material';
+import { IInstanceStatus } from 'interfaces/instance';
+import { VFC } from 'react';
+import { BillingInformation } from './BillingInformation/BillingInformation';
+import { BillingPlan } from './BillingPlan/BillingPlan';
+
+interface IBillingDashboardProps {
+ instanceStatus: IInstanceStatus;
+}
+
+export const BillingDashboard: VFC = ({
+ instanceStatus,
+}) => {
+ return (
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingInformation/BillingInformation.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingInformation/BillingInformation.tsx
new file mode 100644
index 0000000000..6564441ad1
--- /dev/null
+++ b/frontend/src/component/admin/billing/BillingDashboard/BillingInformation/BillingInformation.tsx
@@ -0,0 +1,69 @@
+import { FC } from 'react';
+import { Alert, Divider, Grid, styled, Typography } from '@mui/material';
+import { BillingInformationButton } from './BillingInformationButton/BillingInformationButton';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { IInstanceStatus, InstanceState } from 'interfaces/instance';
+
+const StyledInfoBox = styled('aside')(({ theme }) => ({
+ padding: theme.spacing(4),
+ height: '100%',
+ borderRadius: theme.shape.borderRadiusLarge,
+ backgroundColor: theme.palette.secondaryContainer,
+}));
+
+const StyledTitle = styled(Typography)(({ theme }) => ({
+ marginBottom: theme.spacing(4),
+}));
+
+const StyledAlert = styled(Alert)(({ theme }) => ({
+ marginBottom: theme.spacing(4),
+}));
+
+const StyledInfoLabel = styled(Typography)(({ theme }) => ({
+ fontSize: theme.fontSizes.smallBody,
+ color: theme.palette.text.secondary,
+}));
+
+const StyledDivider = styled(Divider)(({ theme }) => ({
+ margin: `${theme.spacing(2.5)} 0`,
+ borderColor: theme.palette.dividerAlternative,
+}));
+interface IBillingInformationProps {
+ instanceStatus: IInstanceStatus;
+}
+
+export const BillingInformation: FC = ({
+ instanceStatus,
+}) => {
+ const inactive = instanceStatus.state !== InstanceState.ACTIVE;
+
+ return (
+
+
+ Billing information
+
+ In order to Upgrade trial you need
+ to provide us your billing information.
+
+ }
+ />
+
+
+ {inactive
+ ? 'Once we have received your billing information we will upgrade your trial within 1 business day'
+ : 'Update your credit card and business information and change which email address we send invoices to'}
+
+
+
+
+ Get in touch with us
+ {' '}
+ for any clarification
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingInformation/BillingInformationButton/BillingInformationButton.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingInformation/BillingInformationButton/BillingInformationButton.tsx
new file mode 100644
index 0000000000..dd70f6a04d
--- /dev/null
+++ b/frontend/src/component/admin/billing/BillingDashboard/BillingInformation/BillingInformationButton/BillingInformationButton.tsx
@@ -0,0 +1,25 @@
+import { Button, styled } from '@mui/material';
+import { VFC } from 'react';
+import { formatApiPath } from 'utils/formatPath';
+
+const PORTAL_URL = formatApiPath('api/admin/invoices');
+
+const StyledButton = styled(Button)(({ theme }) => ({
+ width: '100%',
+ marginBottom: theme.spacing(1.5),
+}));
+
+interface IBillingInformationButtonProps {
+ update?: boolean;
+}
+
+export const BillingInformationButton: VFC = ({
+ update,
+}) => (
+
+ {update ? 'Update billing information' : 'Add billing information'}
+
+);
diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx
new file mode 100644
index 0000000000..e32726c912
--- /dev/null
+++ b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/BillingPlan.tsx
@@ -0,0 +1,241 @@
+import { FC } from 'react';
+import { Alert, Divider, Grid, styled, Typography } from '@mui/material';
+import { Link } from 'react-router-dom';
+import CheckIcon from '@mui/icons-material/Check';
+import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import {
+ IInstanceStatus,
+ InstanceState,
+ InstancePlan,
+} from 'interfaces/instance';
+import { trialHasExpired, isTrialInstance } from 'utils/instanceTrial';
+import { GridRow } from 'component/common/GridRow/GridRow';
+import { GridCol } from 'component/common/GridCol/GridCol';
+import { GridColLink } from './GridColLink/GridColLink';
+import { STRIPE } from 'component/admin/billing/flags';
+import { Badge } from 'component/common/Badge/Badge';
+
+const StyledPlanBox = styled('aside')(({ theme }) => ({
+ padding: theme.spacing(2.5),
+ height: '100%',
+ borderRadius: theme.shape.borderRadiusLarge,
+ boxShadow: theme.boxShadows.elevated,
+ [theme.breakpoints.up('md')]: {
+ padding: theme.spacing(6.5),
+ },
+}));
+
+const StyledInfoLabel = styled(Typography)(({ theme }) => ({
+ fontSize: theme.fontSizes.smallBody,
+ color: theme.palette.text.secondary,
+}));
+
+const StyledPlanSpan = styled('span')(({ theme }) => ({
+ fontSize: '3.25rem',
+ lineHeight: 1,
+ color: theme.palette.primary.main,
+ fontWeight: 800,
+}));
+
+const StyledTrialSpan = styled('span')(({ theme }) => ({
+ marginLeft: theme.spacing(1.5),
+ fontWeight: theme.fontWeight.bold,
+}));
+
+const StyledPriceSpan = styled('span')(({ theme }) => ({
+ color: theme.palette.primary.main,
+ fontSize: theme.fontSizes.mainHeader,
+ fontWeight: theme.fontWeight.bold,
+}));
+
+const StyledAlert = styled(Alert)(({ theme }) => ({
+ fontSize: theme.fontSizes.smallerBody,
+ marginBottom: theme.spacing(3),
+ marginTop: theme.spacing(-1.5),
+ [theme.breakpoints.up('md')]: {
+ marginTop: theme.spacing(-4.5),
+ },
+}));
+
+const StyledCheckIcon = styled(CheckIcon)(({ theme }) => ({
+ fontSize: '1rem',
+ marginRight: theme.spacing(1),
+}));
+
+const StyledDivider = styled(Divider)(({ theme }) => ({
+ margin: `${theme.spacing(3)} 0`,
+}));
+
+interface IBillingPlanProps {
+ instanceStatus: IInstanceStatus;
+}
+
+export const BillingPlan: FC = ({ instanceStatus }) => {
+ const { users } = useUsers();
+ const expired = trialHasExpired(instanceStatus);
+
+ const price = {
+ [InstancePlan.PRO]: 80,
+ [InstancePlan.COMPANY]: 0,
+ [InstancePlan.TEAM]: 0,
+ [InstancePlan.UNKNOWN]: 0,
+ user: 15,
+ };
+
+ const planPrice = price[instanceStatus.plan];
+ const seats = instanceStatus.seats ?? 5;
+ const freeAssigned = Math.min(users.length, seats);
+ const paidAssigned = users.length - freeAssigned;
+ const paidAssignedPrice = price.user * paidAssigned;
+ const finalPrice = planPrice + paidAssignedPrice;
+ const inactive = instanceStatus.state !== InstanceState.ACTIVE;
+
+ return (
+
+
+
+ After you have sent your billing information, your
+ instance will be upgraded - you don't have to do
+ anything.{' '}
+
+ Get in touch with us
+ {' '}
+ for any clarification
+
+ }
+ />
+ Current plan
+
+ ({ marginBottom: theme.spacing(3) })}>
+
+
+ {instanceStatus.plan}
+
+ ({
+ color: expired
+ ? theme.palette.error.dark
+ : theme.palette.warning.dark,
+ })}
+ >
+ {expired
+ ? 'Trial expired'
+ : instanceStatus.trialExtended
+ ? 'Extended Trial'
+ : 'Trial'}
+
+ }
+ />
+
+
+ 0}
+ show={
+
+ ${planPrice.toFixed(2)}
+
+ }
+ />
+
+
+
+
+
+ ({
+ marginBottom: theme.spacing(1.5),
+ })}
+ >
+
+
+ {seats} team
+ members
+
+
+ {freeAssigned} assigned
+
+
+
+
+
+
+
+ included
+
+
+
+
+
+
+ Paid members
+
+
+ {paidAssigned} assigned
+
+
+
+
+ Add up to 15 extra paid members - $
+ {price.user}
+ /month per member
+
+
+
+ ({
+ fontSize:
+ theme.fontSizes.mainHeader,
+ })}
+ >
+ ${paidAssignedPrice.toFixed(2)}
+
+
+
+
+
+
+
+
+ ({
+ fontWeight:
+ theme.fontWeight.bold,
+ fontSize:
+ theme.fontSizes.mainHeader,
+ })}
+ >
+ Total per month
+
+
+
+ ({
+ fontWeight:
+ theme.fontWeight.bold,
+ fontSize: '2rem',
+ })}
+ >
+ ${finalPrice.toFixed(2)}
+
+
+
+
+ >
+ }
+ />
+
+
+ );
+};
diff --git a/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/GridColLink/GridColLink.tsx b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/GridColLink/GridColLink.tsx
new file mode 100644
index 0000000000..d077a58c8b
--- /dev/null
+++ b/frontend/src/component/admin/billing/BillingDashboard/BillingPlan/GridColLink/GridColLink.tsx
@@ -0,0 +1,11 @@
+import { styled } from '@mui/material';
+import { FC } from 'react';
+
+const StyledSpan = styled('span')(({ theme }) => ({
+ fontSize: theme.fontSizes.smallBody,
+ marginLeft: theme.spacing(1),
+}));
+
+export const GridColLink: FC = ({ children }) => {
+ return ({children});
+};
diff --git a/frontend/src/component/admin/billing/BillingHistory/BillingHistory.tsx b/frontend/src/component/admin/billing/BillingHistory/BillingHistory.tsx
new file mode 100644
index 0000000000..373f8cf4c6
--- /dev/null
+++ b/frontend/src/component/admin/billing/BillingHistory/BillingHistory.tsx
@@ -0,0 +1,120 @@
+import {
+ Table,
+ SortableTableHeader,
+ TableBody,
+ TableCell,
+ TableRow,
+ TablePlaceholder,
+} from 'component/common/Table';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
+import { useMemo, VFC } from 'react';
+import { useGlobalFilter, useSortBy, useTable } from 'react-table';
+import { sortTypes } from 'utils/sortTypes';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { Box, IconButton, styled, Typography } from '@mui/material';
+import FileDownload from '@mui/icons-material/FileDownload';
+import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
+
+const StyledTitle = styled(Typography)(({ theme }) => ({
+ marginTop: theme.spacing(6),
+ marginBottom: theme.spacing(2.5),
+ fontSize: theme.fontSizes.mainHeader,
+}));
+interface IBillingHistoryProps {
+ data: Record[];
+ isLoading?: boolean;
+}
+
+const columns = [
+ {
+ Header: 'Amount',
+ accessor: 'amountFormatted',
+ },
+ {
+ Header: 'Status',
+ accessor: 'status',
+ disableGlobalFilter: true,
+ },
+ {
+ Header: 'Due date',
+ accessor: 'dueDate',
+ Cell: DateCell,
+ sortType: 'date',
+ disableGlobalFilter: true,
+ },
+ {
+ Header: 'Download',
+ accessor: 'invoicePDF',
+ align: 'center',
+ Cell: ({ value }: { value: string }) => (
+
+
+
+
+
+ ),
+ width: 100,
+ disableGlobalFilter: true,
+ disableSortBy: true,
+ },
+];
+
+export const BillingHistory: VFC = ({
+ data,
+ isLoading = false,
+}) => {
+ const initialState = useMemo(
+ () => ({
+ sortBy: [{ id: 'dueDate' }],
+ }),
+ []
+ );
+
+ const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
+ useTable(
+ {
+ columns,
+ data,
+ initialState,
+ sortTypes,
+ autoResetGlobalFilter: false,
+ disableSortRemove: true,
+ defaultColumn: {
+ Cell: TextCell,
+ },
+ },
+ useGlobalFilter,
+ useSortBy
+ );
+
+ return (
+
+ Payment history
+
+
+
+ {rows.map(row => {
+ prepareRow(row);
+ return (
+
+ {row.cells.map(cell => (
+
+ {cell.render('Cell')}
+
+ ))}
+
+ );
+ })}
+
+
+ No invoices to show.}
+ />
+
+ );
+};
diff --git a/frontend/src/component/admin/billing/FlaggedBillingRedirect/FlaggedBillingRedirect.tsx b/frontend/src/component/admin/billing/FlaggedBillingRedirect/FlaggedBillingRedirect.tsx
new file mode 100644
index 0000000000..0a9d17683e
--- /dev/null
+++ b/frontend/src/component/admin/billing/FlaggedBillingRedirect/FlaggedBillingRedirect.tsx
@@ -0,0 +1,19 @@
+import { Navigate } from 'react-router-dom';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import InvoiceAdminPage from 'component/admin/invoice/InvoiceAdminPage';
+
+const FlaggedBillingRedirect = () => {
+ const { uiConfig, loading } = useUiConfig();
+
+ if (loading) {
+ return null;
+ }
+
+ if (!uiConfig.flags.UNLEASH_CLOUD) {
+ return ;
+ }
+
+ return ;
+};
+
+export default FlaggedBillingRedirect;
diff --git a/frontend/src/component/admin/billing/flags.ts b/frontend/src/component/admin/billing/flags.ts
new file mode 100644
index 0000000000..3ae06e3aea
--- /dev/null
+++ b/frontend/src/component/admin/billing/flags.ts
@@ -0,0 +1 @@
+export const STRIPE = false;
diff --git a/frontend/src/component/admin/cors/CorsForm.test.tsx b/frontend/src/component/admin/cors/CorsForm.test.tsx
new file mode 100644
index 0000000000..93b48176e8
--- /dev/null
+++ b/frontend/src/component/admin/cors/CorsForm.test.tsx
@@ -0,0 +1,23 @@
+import {
+ parseInputValue,
+ formatInputValue,
+} from 'component/admin/cors/CorsForm';
+
+test('parseInputValue', () => {
+ const fn = parseInputValue;
+ expect(fn('')).toEqual([]);
+ expect(fn('a')).toEqual(['a']);
+ expect(fn('a\nb,,c,d,')).toEqual(['a', 'b', 'c', 'd']);
+ expect(fn('http://localhost:8080')).toEqual(['http://localhost:8080']);
+ expect(fn('https://example.com')).toEqual(['https://example.com']);
+ expect(fn('https://example.com/')).toEqual(['https://example.com']);
+ expect(fn('https://example.com/')).toEqual(['https://example.com']);
+});
+
+test('formatInputValue', () => {
+ const fn = formatInputValue;
+ expect(fn(undefined)).toEqual('');
+ expect(fn([])).toEqual('');
+ expect(fn(['a'])).toEqual('a');
+ expect(fn(['a', 'b', 'c', 'd'])).toEqual('a\nb\nc\nd');
+});
diff --git a/frontend/src/component/admin/cors/CorsForm.tsx b/frontend/src/component/admin/cors/CorsForm.tsx
new file mode 100644
index 0000000000..10dfd80459
--- /dev/null
+++ b/frontend/src/component/admin/cors/CorsForm.tsx
@@ -0,0 +1,74 @@
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import React, { useState } from 'react';
+import { TextField, Box } from '@mui/material';
+import { UpdateButton } from 'component/common/UpdateButton/UpdateButton';
+import { useUiConfigApi } from 'hooks/api/actions/useUiConfigApi/useUiConfigApi';
+import useToast from 'hooks/useToast';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { useId } from 'hooks/useId';
+
+interface ICorsFormProps {
+ frontendApiOrigins: string[] | undefined;
+}
+
+export const CorsForm = ({ frontendApiOrigins }: ICorsFormProps) => {
+ const { setFrontendSettings } = useUiConfigApi();
+ const { setToastData, setToastApiError } = useToast();
+ const [value, setValue] = useState(formatInputValue(frontendApiOrigins));
+ const inputFieldId = useId();
+ const helpTextId = useId();
+
+ const onSubmit = async (event: React.FormEvent) => {
+ try {
+ const split = parseInputValue(value);
+ event.preventDefault();
+ await setFrontendSettings(split);
+ setValue(formatInputValue(split));
+ setToastData({ title: 'Settings saved', type: 'success' });
+ } catch (error) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ return (
+
+ );
+};
+
+export const parseInputValue = (value: string): string[] => {
+ return value
+ .split(/[,\n\s]+/) // Split by commas/newlines/spaces.
+ .map(value => value.replace(/\/$/, '')) // Remove trailing slashes.
+ .filter(Boolean); // Remove empty values from (e.g.) double newlines.
+};
+
+export const formatInputValue = (values: string[] | undefined): string => {
+ return values?.join('\n') ?? '';
+};
+
+const textareaDomainsPlaceholder = [
+ 'https://example.com',
+ 'https://example.org',
+].join('\n');
diff --git a/frontend/src/component/admin/cors/CorsHelpAlert.tsx b/frontend/src/component/admin/cors/CorsHelpAlert.tsx
new file mode 100644
index 0000000000..05c3623ba1
--- /dev/null
+++ b/frontend/src/component/admin/cors/CorsHelpAlert.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { Alert } from '@mui/material';
+
+export const CorsHelpAlert = () => {
+ return (
+
+
+ Use this page to configure allowed CORS origins for the Frontend
+ API (/api/frontend
).
+
+
+ This configuration will not affect the Admin API (
+ /api/admin
) nor the Client API (
+ /api/client
).
+
+
+ An asterisk (*
) may be used to allow API calls from
+ any origin.
+
+
+ );
+};
diff --git a/frontend/src/component/admin/cors/CorsTokenAlert.tsx b/frontend/src/component/admin/cors/CorsTokenAlert.tsx
new file mode 100644
index 0000000000..bac1ebfba8
--- /dev/null
+++ b/frontend/src/component/admin/cors/CorsTokenAlert.tsx
@@ -0,0 +1,17 @@
+import { TokenType } from 'interfaces/token';
+import { Link } from 'react-router-dom';
+import { Alert } from '@mui/material';
+
+export const CorsTokenAlert = () => {
+ return (
+
+ By default, all {TokenType.FRONTEND} tokens may be used from any
+ CORS origin. If you'd like to configure a strict set of origins,
+ please use the{' '}
+
+ CORS origins configuration page
+
+ .
+
+ );
+};
diff --git a/frontend/src/component/admin/cors/index.tsx b/frontend/src/component/admin/cors/index.tsx
new file mode 100644
index 0000000000..802696b383
--- /dev/null
+++ b/frontend/src/component/admin/cors/index.tsx
@@ -0,0 +1,50 @@
+import { useLocation } from 'react-router-dom';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import AdminMenu from '../menu/AdminMenu';
+import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import AccessContext from 'contexts/AccessContext';
+import React, { useContext } from 'react';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import { PageHeader } from 'component/common/PageHeader/PageHeader';
+import { Box } from '@mui/material';
+import { CorsHelpAlert } from 'component/admin/cors/CorsHelpAlert';
+import { CorsForm } from 'component/admin/cors/CorsForm';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+
+export const CorsAdmin = () => {
+ const { pathname } = useLocation();
+ const showAdminMenu = pathname.includes('/admin/');
+ const { hasAccess } = useContext(AccessContext);
+
+ return (
+
+
}
+ />
+
}
+ elseShow={
}
+ />
+
+ );
+};
+
+const CorsPage = () => {
+ const { uiConfig, loading } = useUiConfig();
+
+ if (loading) {
+ return null;
+ }
+
+ return (
+ }>
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx b/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx
new file mode 100644
index 0000000000..12661a7d51
--- /dev/null
+++ b/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx
@@ -0,0 +1,116 @@
+import FormTemplate from 'component/common/FormTemplate/FormTemplate';
+import { useNavigate } from 'react-router-dom';
+import { GroupForm } from '../GroupForm/GroupForm';
+import { useGroupForm } from '../hooks/useGroupForm';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import useToast from 'hooks/useToast';
+import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { UG_CREATE_BTN_ID } from 'utils/testIds';
+import { Button } from '@mui/material';
+import { CREATE } from 'constants/misc';
+import { GO_BACK } from 'constants/navigate';
+import { useGroups } from 'hooks/api/getters/useGroups/useGroups';
+
+export const CreateGroup = () => {
+ const { setToastData, setToastApiError } = useToast();
+ const { uiConfig } = useUiConfig();
+ const navigate = useNavigate();
+
+ const {
+ name,
+ setName,
+ description,
+ setDescription,
+ users,
+ setUsers,
+ getGroupPayload,
+ clearErrors,
+ errors,
+ setErrors,
+ } = useGroupForm();
+
+ const { groups } = useGroups();
+ const { createGroup, loading } = useGroupApi();
+
+ const handleSubmit = async (e: Event) => {
+ e.preventDefault();
+ clearErrors();
+
+ if (!isValid) return;
+
+ const payload = getGroupPayload();
+ try {
+ const group = await createGroup(payload);
+ navigate(`/admin/groups/${group.id}`);
+ setToastData({
+ title: 'Group created successfully',
+ text: 'Now you can start using your group.',
+ confetti: true,
+ type: 'success',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ const formatApiCode = () => {
+ return `curl --location --request POST '${
+ uiConfig.unleashUrl
+ }/api/admin/groups' \\
+ --header 'Authorization: INSERT_API_KEY' \\
+ --header 'Content-Type: application/json' \\
+ --data-raw '${JSON.stringify(getGroupPayload(), undefined, 2)}'`;
+ };
+
+ const handleCancel = () => {
+ navigate(GO_BACK);
+ };
+
+ const isNameEmpty = (name: string) => name.length;
+ const isNameUnique = (name: string) =>
+ !groups?.filter(group => group.name === name).length;
+ const isValid = isNameEmpty(name) && isNameUnique(name);
+
+ const onSetName = (name: string) => {
+ clearErrors();
+ if (!isNameUnique(name)) {
+ setErrors({ name: 'A group with that name already exists.' });
+ }
+ setName(name);
+ };
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx b/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx
new file mode 100644
index 0000000000..a7b5285305
--- /dev/null
+++ b/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx
@@ -0,0 +1,120 @@
+import FormTemplate from 'component/common/FormTemplate/FormTemplate';
+import { useNavigate } from 'react-router-dom';
+import { GroupForm } from '../GroupForm/GroupForm';
+import { useGroupForm } from '../hooks/useGroupForm';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import useToast from 'hooks/useToast';
+import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { Button } from '@mui/material';
+import { EDIT } from 'constants/misc';
+import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
+import { useGroup } from 'hooks/api/getters/useGroup/useGroup';
+import { UG_SAVE_BTN_ID } from 'utils/testIds';
+import { GO_BACK } from 'constants/navigate';
+import { useGroups } from 'hooks/api/getters/useGroups/useGroups';
+
+export const EditGroup = () => {
+ const groupId = Number(useRequiredPathParam('groupId'));
+ const { group, refetchGroup } = useGroup(groupId);
+ const { refetchGroups } = useGroups();
+ const { setToastData, setToastApiError } = useToast();
+ const { uiConfig } = useUiConfig();
+ const navigate = useNavigate();
+
+ const {
+ name,
+ setName,
+ description,
+ setDescription,
+ users,
+ setUsers,
+ getGroupPayload,
+ clearErrors,
+ errors,
+ setErrors,
+ } = useGroupForm(group?.name, group?.description, group?.users);
+
+ const { groups } = useGroups();
+ const { updateGroup, loading } = useGroupApi();
+
+ const handleSubmit = async (e: Event) => {
+ e.preventDefault();
+ clearErrors();
+
+ const payload = getGroupPayload();
+ try {
+ await updateGroup(groupId, payload);
+ refetchGroup();
+ refetchGroups();
+ navigate(GO_BACK);
+ setToastData({
+ title: 'Group updated successfully',
+ type: 'success',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ const formatApiCode = () => {
+ return `curl --location --request PUT '${
+ uiConfig.unleashUrl
+ }/api/admin/groups/${groupId}' \\
+ --header 'Authorization: INSERT_API_KEY' \\
+ --header 'Content-Type: application/json' \\
+ --data-raw '${JSON.stringify(getGroupPayload(), undefined, 2)}'`;
+ };
+
+ const handleCancel = () => {
+ navigate(GO_BACK);
+ };
+
+ const isNameEmpty = (name: string) => name.length;
+ const isNameUnique = (name: string) =>
+ !groups?.filter(group => group.name === name && group.id !== groupId)
+ .length;
+ const isValid = isNameEmpty(name) && isNameUnique(name);
+
+ const onSetName = (name: string) => {
+ clearErrors();
+ if (!isNameUnique(name)) {
+ setErrors({ name: 'A group with that name already exists.' });
+ }
+ setName(name);
+ };
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/groups/Group/EditGroupUsers/EditGroupUsers.tsx b/frontend/src/component/admin/groups/Group/EditGroupUsers/EditGroupUsers.tsx
new file mode 100644
index 0000000000..7fbdb57ac5
--- /dev/null
+++ b/frontend/src/component/admin/groups/Group/EditGroupUsers/EditGroupUsers.tsx
@@ -0,0 +1,144 @@
+import { Button, styled } from '@mui/material';
+import FormTemplate from 'component/common/FormTemplate/FormTemplate';
+import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
+import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi';
+import { useGroup } from 'hooks/api/getters/useGroup/useGroup';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import useToast from 'hooks/useToast';
+import { IGroup } from 'interfaces/group';
+import { FC, FormEvent, useEffect } from 'react';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { GroupFormUsersSelect } from 'component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect';
+import { GroupFormUsersTable } from 'component/admin/groups/GroupForm/GroupFormUsersTable/GroupFormUsersTable';
+import { UG_SAVE_BTN_ID } from 'utils/testIds';
+import { useGroupForm } from 'component/admin/groups/hooks/useGroupForm';
+import { useGroups } from 'hooks/api/getters/useGroups/useGroups';
+
+const StyledForm = styled('form')(() => ({
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+}));
+
+const StyledInputDescription = styled('p')(({ theme }) => ({
+ color: theme.palette.text.secondary,
+ marginBottom: theme.spacing(1),
+}));
+
+const StyledButtonContainer = styled('div')(() => ({
+ marginTop: 'auto',
+ display: 'flex',
+ justifyContent: 'flex-end',
+}));
+
+const StyledCancelButton = styled(Button)(({ theme }) => ({
+ marginLeft: theme.spacing(3),
+}));
+
+interface IEditGroupUsersProps {
+ open: boolean;
+ setOpen: React.Dispatch>;
+ group: IGroup;
+}
+
+export const EditGroupUsers: FC = ({
+ open,
+ setOpen,
+ group,
+}) => {
+ const { refetchGroup } = useGroup(group.id);
+ const { refetchGroups } = useGroups();
+ const { updateGroup, loading } = useGroupApi();
+ const { setToastData, setToastApiError } = useToast();
+ const { uiConfig } = useUiConfig();
+
+ const { users, setUsers, getGroupPayload } = useGroupForm(
+ group.name,
+ group.description,
+ group.users
+ );
+
+ useEffect(() => {
+ setUsers(group.users);
+ }, [group.users, open, setUsers]);
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+
+ try {
+ await updateGroup(group.id, getGroupPayload());
+ refetchGroup();
+ refetchGroups();
+ setOpen(false);
+ setToastData({
+ title: 'Group users saved successfully',
+ type: 'success',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ const formatApiCode = () => {
+ return `curl --location --request PUT '${
+ uiConfig.unleashUrl
+ }/api/admin/groups/${group.id}' \\
+ --header 'Authorization: INSERT_API_KEY' \\
+ --header 'Content-Type: application/json' \\
+ --data-raw '${JSON.stringify(getGroupPayload(), undefined, 2)}'`;
+ };
+
+ return (
+ {
+ setOpen(false);
+ }}
+ label="Edit users"
+ >
+
+
+
+
+ Edit users in this group
+
+
+
+
+
+
+
+ {
+ setOpen(false);
+ }}
+ >
+ Cancel
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/groups/Group/Group.tsx b/frontend/src/component/admin/groups/Group/Group.tsx
new file mode 100644
index 0000000000..231fca285c
--- /dev/null
+++ b/frontend/src/component/admin/groups/Group/Group.tsx
@@ -0,0 +1,366 @@
+import { useEffect, useMemo, useState, VFC } from 'react';
+import {
+ IconButton,
+ styled,
+ Tooltip,
+ useMediaQuery,
+ useTheme,
+} from '@mui/material';
+import { useSearchParams, Link } from 'react-router-dom';
+import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
+import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
+import { useGroup } from 'hooks/api/getters/useGroup/useGroup';
+import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
+import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import { PageHeader } from 'component/common/PageHeader/PageHeader';
+import { sortTypes } from 'utils/sortTypes';
+import { createLocalStorage } from 'utils/createLocalStorage';
+import { IGroupUser } from 'interfaces/group';
+import { useSearch } from 'hooks/useSearch';
+import { Search } from 'component/common/Search/Search';
+import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
+import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
+import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
+import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
+import { Add, Delete, Edit } from '@mui/icons-material';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import { MainHeader } from 'component/common/MainHeader/MainHeader';
+import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
+import { RemoveGroup } from 'component/admin/groups/RemoveGroup/RemoveGroup';
+import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
+import { EditGroupUsers } from './EditGroupUsers/EditGroupUsers';
+import { RemoveGroupUser } from './RemoveGroupUser/RemoveGroupUser';
+import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
+import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
+import {
+ UG_EDIT_BTN_ID,
+ UG_DELETE_BTN_ID,
+ UG_EDIT_USERS_BTN_ID,
+ UG_REMOVE_USER_BTN_ID,
+} from 'utils/testIds';
+
+const StyledEdit = styled(Edit)(({ theme }) => ({
+ fontSize: theme.fontSizes.mainHeader,
+}));
+
+const StyledDelete = styled(Delete)(({ theme }) => ({
+ fontSize: theme.fontSizes.mainHeader,
+}));
+
+export const groupUsersPlaceholder: IGroupUser[] = Array(15).fill({
+ name: 'Name of the user',
+ username: 'Username of the user',
+});
+
+export type PageQueryType = Partial<
+ Record<'sort' | 'order' | 'search', string>
+>;
+
+const defaultSort: SortingRule = { id: 'joinedAt' };
+
+const { value: storedParams, setValue: setStoredParams } = createLocalStorage(
+ 'Group:v1',
+ defaultSort
+);
+
+export const Group: VFC = () => {
+ const groupId = Number(useRequiredPathParam('groupId'));
+ const theme = useTheme();
+ const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
+ const { group, loading } = useGroup(groupId);
+ const [removeOpen, setRemoveOpen] = useState(false);
+ const [editUsersOpen, setEditUsersOpen] = useState(false);
+ const [removeUserOpen, setRemoveUserOpen] = useState(false);
+ const [selectedUser, setSelectedUser] = useState();
+
+ const columns = useMemo(
+ () => [
+ {
+ Header: 'Avatar',
+ accessor: 'imageUrl',
+ Cell: ({ row: { original: user } }: any) => (
+
+
+
+ ),
+ maxWidth: 85,
+ disableSortBy: true,
+ },
+ {
+ id: 'name',
+ Header: 'Name',
+ accessor: (row: IGroupUser) => row.name || '',
+ Cell: HighlightCell,
+ minWidth: 100,
+ searchable: true,
+ },
+ {
+ id: 'username',
+ Header: 'Username',
+ accessor: (row: IGroupUser) => row.username || row.email,
+ Cell: HighlightCell,
+ minWidth: 100,
+ searchable: true,
+ },
+ {
+ Header: 'Joined',
+ accessor: 'joinedAt',
+ Cell: DateCell,
+ sortType: 'date',
+ maxWidth: 150,
+ },
+ {
+ Header: 'Last login',
+ accessor: (row: IGroupUser) => row.seenAt || '',
+ Cell: ({ row: { original: user } }: any) => (
+
+ ),
+ sortType: 'date',
+ maxWidth: 150,
+ },
+ {
+ Header: 'Actions',
+ id: 'Actions',
+ align: 'center',
+ Cell: ({ row: { original: rowUser } }: any) => (
+
+
+
+ {
+ setSelectedUser(rowUser);
+ setRemoveUserOpen(true);
+ }}
+ >
+
+
+
+
+
+ ),
+ maxWidth: 100,
+ disableSortBy: true,
+ },
+ ],
+ [setSelectedUser, setRemoveUserOpen]
+ );
+
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [initialState] = useState(() => ({
+ sortBy: [
+ {
+ id: searchParams.get('sort') || storedParams.id,
+ desc: searchParams.has('order')
+ ? searchParams.get('order') === 'desc'
+ : storedParams.desc,
+ },
+ ],
+ hiddenColumns: ['description'],
+ globalFilter: searchParams.get('search') || '',
+ }));
+ const [searchValue, setSearchValue] = useState(initialState.globalFilter);
+
+ const {
+ data: searchedData,
+ getSearchText,
+ getSearchContext,
+ } = useSearch(columns, searchValue, group?.users ?? []);
+
+ const data = useMemo(
+ () =>
+ searchedData?.length === 0 && loading
+ ? groupUsersPlaceholder
+ : searchedData,
+ [searchedData, loading]
+ );
+
+ const {
+ headerGroups,
+ rows,
+ prepareRow,
+ state: { sortBy },
+ } = useTable(
+ {
+ columns: columns as any[],
+ data,
+ initialState,
+ sortTypes,
+ autoResetSortBy: false,
+ disableSortRemove: true,
+ disableMultiSort: true,
+ },
+ useSortBy,
+ useFlexLayout
+ );
+
+ useEffect(() => {
+ const tableState: PageQueryType = {};
+ tableState.sort = sortBy[0].id;
+ if (sortBy[0].desc) {
+ tableState.order = 'desc';
+ }
+ if (searchValue) {
+ tableState.search = searchValue;
+ }
+
+ setSearchParams(tableState, {
+ replace: true,
+ });
+ setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false });
+ }, [sortBy, searchValue, setSearchParams]);
+
+ return (
+
+
+
+
+
+ setRemoveOpen(true)}
+ permission={ADMIN}
+ tooltipProps={{
+ title: 'Delete group',
+ }}
+ >
+
+
+ >
+ }
+ />
+
+
+
+
+ >
+ }
+ />
+ {
+ setEditUsersOpen(true);
+ }}
+ maxWidth="700px"
+ Icon={Add}
+ permission={ADMIN}
+ >
+ Edit users
+
+ >
+ }
+ >
+
+ }
+ />
+
+ }
+ >
+
+
+
+ 0}
+ show={
+
+ No users found matching “
+ {searchValue}
+ ” in this group.
+
+ }
+ elseShow={
+
+ This group is empty. Get started by
+ adding a user to the group.
+
+ }
+ />
+ }
+ />
+
+
+
+
+ >
+ }
+ />
+ );
+};
diff --git a/frontend/src/component/admin/groups/Group/RemoveGroupUser/RemoveGroupUser.tsx b/frontend/src/component/admin/groups/Group/RemoveGroupUser/RemoveGroupUser.tsx
new file mode 100644
index 0000000000..1a970e8dd5
--- /dev/null
+++ b/frontend/src/component/admin/groups/Group/RemoveGroupUser/RemoveGroupUser.tsx
@@ -0,0 +1,68 @@
+import { Typography } from '@mui/material';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi';
+import { useGroup } from 'hooks/api/getters/useGroup/useGroup';
+import useToast from 'hooks/useToast';
+import { IGroup, IGroupUser } from 'interfaces/group';
+import { FC } from 'react';
+import { formatUnknownError } from 'utils/formatUnknownError';
+
+interface IRemoveGroupUserProps {
+ open: boolean;
+ setOpen: React.Dispatch>;
+ user?: IGroupUser;
+ group: IGroup;
+}
+
+export const RemoveGroupUser: FC = ({
+ open,
+ setOpen,
+ user,
+ group,
+}) => {
+ const { refetchGroup } = useGroup(group.id);
+ const { updateGroup } = useGroupApi();
+ const { setToastData, setToastApiError } = useToast();
+
+ const onRemoveClick = async () => {
+ try {
+ const groupPayload = {
+ ...group,
+ users: group.users
+ .filter(({ id }) => id !== user?.id)
+ .map(({ id }) => ({
+ user: { id },
+ })),
+ };
+ await updateGroup(group.id, groupPayload);
+ refetchGroup();
+ setOpen(false);
+ setToastData({
+ title: 'User removed from group successfully',
+ type: 'success',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ const userName = user?.name || user?.username || user?.email;
+ return (
+ {
+ setOpen(false);
+ }}
+ title="Remove user from group?"
+ >
+
+ Do you really want to remove {userName} from{' '}
+ {group.name}? {userName} will
+ lose all access rights granted by this group.
+
+
+ );
+};
diff --git a/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx b/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx
new file mode 100644
index 0000000000..704583770b
--- /dev/null
+++ b/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx
@@ -0,0 +1,124 @@
+import { FC } from 'react';
+import { Button, styled } from '@mui/material';
+import { UG_DESC_ID, UG_NAME_ID } from 'utils/testIds';
+import Input from 'component/common/Input/Input';
+import { IGroupUser } from 'interfaces/group';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { GroupFormUsersSelect } from './GroupFormUsersSelect/GroupFormUsersSelect';
+import { GroupFormUsersTable } from './GroupFormUsersTable/GroupFormUsersTable';
+
+const StyledForm = styled('form')(() => ({
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+}));
+
+const StyledInputDescription = styled('p')(({ theme }) => ({
+ color: theme.palette.text.secondary,
+ marginBottom: theme.spacing(1),
+}));
+
+const StyledInput = styled(Input)(({ theme }) => ({
+ width: '100%',
+ maxWidth: theme.spacing(50),
+ marginBottom: theme.spacing(2),
+}));
+
+const StyledGroupFormUsersTableWrapper = styled('div')(({ theme }) => ({
+ marginBottom: theme.spacing(6),
+}));
+
+const StyledButtonContainer = styled('div')(() => ({
+ marginTop: 'auto',
+ display: 'flex',
+ justifyContent: 'flex-end',
+}));
+
+const StyledCancelButton = styled(Button)(({ theme }) => ({
+ marginLeft: theme.spacing(3),
+}));
+
+interface IGroupForm {
+ name: string;
+ description: string;
+ users: IGroupUser[];
+ setName: (name: string) => void;
+ setDescription: React.Dispatch>;
+ setUsers: React.Dispatch>;
+ handleSubmit: (e: any) => void;
+ handleCancel: () => void;
+ errors: { [key: string]: string };
+ mode: 'Create' | 'Edit';
+}
+
+export const GroupForm: FC = ({
+ name,
+ description,
+ users,
+ setName,
+ setDescription,
+ setUsers,
+ handleSubmit,
+ handleCancel,
+ errors,
+ mode,
+ children,
+}) => (
+
+
+
+ What would you like to call your group?
+
+ setName(e.target.value)}
+ data-testid={UG_NAME_ID}
+ required
+ />
+
+ How would you describe your group?
+
+ setDescription(e.target.value)}
+ data-testid={UG_DESC_ID}
+ />
+
+
+ Add users to this group
+
+
+
+
+
+ >
+ }
+ />
+
+
+
+ {children}
+
+ Cancel
+
+
+
+);
diff --git a/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx
new file mode 100644
index 0000000000..e17f558b4e
--- /dev/null
+++ b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect.tsx
@@ -0,0 +1,109 @@
+import { Autocomplete, Checkbox, styled, TextField } from '@mui/material';
+import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
+import CheckBoxIcon from '@mui/icons-material/CheckBox';
+import { IUser } from 'interfaces/user';
+import { VFC } from 'react';
+import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
+import { IGroupUser } from 'interfaces/group';
+import { UG_USERS_ID } from 'utils/testIds';
+
+const StyledOption = styled('div')(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ '& > span:first-of-type': {
+ color: theme.palette.text.secondary,
+ },
+}));
+
+const StyledTags = styled('div')(({ theme }) => ({
+ paddingLeft: theme.spacing(1),
+}));
+
+const StyledGroupFormUsersSelect = styled('div')(({ theme }) => ({
+ display: 'flex',
+ marginBottom: theme.spacing(3),
+ '& > div:first-of-type': {
+ width: '100%',
+ maxWidth: theme.spacing(50),
+ marginRight: theme.spacing(1),
+ },
+}));
+
+const renderOption = (
+ props: React.HTMLAttributes,
+ option: IUser,
+ selected: boolean
+) => (
+
+ }
+ checkedIcon={}
+ style={{ marginRight: 8 }}
+ checked={selected}
+ />
+
+ {option.name || option.username}
+ {option.email}
+
+
+);
+
+const renderTags = (value: IGroupUser[]) => (
+
+ {value.length > 1
+ ? `${value.length} users selected`
+ : value[0].name || value[0].username || value[0].email}
+
+);
+
+interface IGroupFormUsersSelectProps {
+ users: IGroupUser[];
+ setUsers: React.Dispatch>;
+}
+
+export const GroupFormUsersSelect: VFC = ({
+ users,
+ setUsers,
+}) => {
+ const { users: usersAll } = useUsers();
+
+ return (
+
+ {
+ if (
+ event.type === 'keydown' &&
+ (event as React.KeyboardEvent).key === 'Backspace' &&
+ reason === 'removeOption'
+ ) {
+ return;
+ }
+ setUsers(newValue);
+ }}
+ options={[...usersAll].sort((a, b) => {
+ const aName = a.name || a.username || '';
+ const bName = b.name || b.username || '';
+ return aName.localeCompare(bName);
+ })}
+ renderOption={(props, option, { selected }) =>
+ renderOption(props, option as IUser, selected)
+ }
+ isOptionEqualToValue={(option, value) => option.id === value.id}
+ getOptionLabel={(option: IUser) =>
+ option.email || option.name || option.username || ''
+ }
+ renderInput={params => (
+
+ )}
+ renderTags={value => renderTags(value)}
+ />
+
+ );
+};
diff --git a/frontend/src/component/admin/groups/GroupForm/GroupFormUsersTable/GroupFormUsersTable.tsx b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersTable/GroupFormUsersTable.tsx
new file mode 100644
index 0000000000..64aff7d552
--- /dev/null
+++ b/frontend/src/component/admin/groups/GroupForm/GroupFormUsersTable/GroupFormUsersTable.tsx
@@ -0,0 +1,117 @@
+import { useMemo, VFC } from 'react';
+import { IconButton, Tooltip, useMediaQuery } from '@mui/material';
+import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
+import { IGroupUser } from 'interfaces/group';
+import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
+import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
+import { Delete } from '@mui/icons-material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { VirtualizedTable } from 'component/common/Table';
+import { useFlexLayout, useSortBy, useTable } from 'react-table';
+import { sortTypes } from 'utils/sortTypes';
+import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
+import theme from 'themes/theme';
+import useHiddenColumns from 'hooks/useHiddenColumns';
+
+const hiddenColumnsSmall = ['imageUrl', 'name'];
+
+interface IGroupFormUsersTableProps {
+ users: IGroupUser[];
+ setUsers: React.Dispatch>;
+}
+
+export const GroupFormUsersTable: VFC = ({
+ users,
+ setUsers,
+}) => {
+ const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
+
+ const columns = useMemo(
+ () => [
+ {
+ Header: 'Avatar',
+ accessor: 'imageUrl',
+ Cell: ({ row: { original: user } }: any) => (
+
+
+
+ ),
+ maxWidth: 85,
+ disableSortBy: true,
+ },
+ {
+ id: 'name',
+ Header: 'Name',
+ accessor: (row: IGroupUser) => row.name || '',
+ Cell: HighlightCell,
+ minWidth: 100,
+ searchable: true,
+ },
+ {
+ id: 'username',
+ Header: 'Username',
+ accessor: (row: IGroupUser) => row.username || row.email,
+ Cell: HighlightCell,
+ minWidth: 100,
+ searchable: true,
+ },
+ {
+ Header: 'Action',
+ id: 'Action',
+ align: 'center',
+ Cell: ({ row: { original: rowUser } }: any) => (
+
+
+
+ setUsers((users: IGroupUser[]) =>
+ users.filter(
+ user => user.id !== rowUser.id
+ )
+ )
+ }
+ >
+
+
+
+
+ ),
+ maxWidth: 100,
+ disableSortBy: true,
+ },
+ ],
+ [setUsers]
+ );
+
+ const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable(
+ {
+ columns: columns as any[],
+ data: users as any[],
+ sortTypes,
+ autoResetSortBy: false,
+ disableSortRemove: true,
+ disableMultiSort: true,
+ },
+ useSortBy,
+ useFlexLayout
+ );
+
+ useHiddenColumns(setHiddenColumns, hiddenColumnsSmall, isSmallScreen);
+
+ return (
+ 0}
+ show={
+
+ }
+ />
+ );
+};
diff --git a/frontend/src/component/admin/groups/GroupsAdmin.tsx b/frontend/src/component/admin/groups/GroupsAdmin.tsx
new file mode 100644
index 0000000000..ffe8b39814
--- /dev/null
+++ b/frontend/src/component/admin/groups/GroupsAdmin.tsx
@@ -0,0 +1,11 @@
+import { GroupsList } from './GroupsList/GroupsList';
+import AdminMenu from '../menu/AdminMenu';
+
+export const GroupsAdmin = () => {
+ return (
+
+ );
+};
diff --git a/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCard.tsx b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCard.tsx
new file mode 100644
index 0000000000..b9f0cc5d13
--- /dev/null
+++ b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCard.tsx
@@ -0,0 +1,165 @@
+import { styled, Tooltip } from '@mui/material';
+import { IGroup } from 'interfaces/group';
+import { Link, useNavigate } from 'react-router-dom';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { GroupCardAvatars } from './GroupCardAvatars/GroupCardAvatars';
+import { Badge } from 'component/common/Badge/Badge';
+import { GroupCardActions } from './GroupCardActions/GroupCardActions';
+import { RemoveGroup } from 'component/admin/groups/RemoveGroup/RemoveGroup';
+import { useState } from 'react';
+import TopicOutlinedIcon from '@mui/icons-material/TopicOutlined';
+import { EditGroupUsers } from 'component/admin/groups/Group/EditGroupUsers/EditGroupUsers';
+
+const StyledLink = styled(Link)(({ theme }) => ({
+ textDecoration: 'none',
+ color: theme.palette.text.primary,
+}));
+
+const StyledGroupCard = styled('aside')(({ theme }) => ({
+ padding: theme.spacing(2.5),
+ height: '100%',
+ border: `1px solid ${theme.palette.dividerAlternative}`,
+ borderRadius: theme.shape.borderRadiusLarge,
+ boxShadow: theme.boxShadows.card,
+ display: 'flex',
+ flexDirection: 'column',
+ [theme.breakpoints.up('md')]: {
+ padding: theme.spacing(4),
+ },
+ '&:hover': {
+ transition: 'background-color 0.2s ease-in-out',
+ backgroundColor: theme.palette.neutral.light,
+ },
+}));
+
+const StyledRow = styled('div')(() => ({
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+}));
+
+const StyledTitleRow = styled(StyledRow)(() => ({
+ alignItems: 'flex-start',
+}));
+
+const StyledBottomRow = styled(StyledRow)(() => ({
+ marginTop: 'auto',
+}));
+
+const StyledHeaderTitle = styled('h2')(({ theme }) => ({
+ fontSize: theme.fontSizes.mainHeader,
+ fontWeight: theme.fontWeight.medium,
+}));
+
+const StyledHeaderActions = styled('div')(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'center',
+ color: theme.palette.text.secondary,
+ fontSize: theme.fontSizes.smallBody,
+}));
+
+const StyledDescription = styled('p')(({ theme }) => ({
+ color: theme.palette.text.secondary,
+ fontSize: theme.fontSizes.smallBody,
+ marginTop: theme.spacing(1),
+ marginBottom: theme.spacing(4),
+}));
+
+const StyledCounterDescription = styled('span')(({ theme }) => ({
+ color: theme.palette.text.secondary,
+ marginLeft: theme.spacing(1),
+}));
+
+const ProjectBadgeContainer = styled('div')(() => ({
+ maxWidth: '50%',
+}));
+
+const StyledBadge = styled(Badge)(() => ({
+ marginRight: 0.5,
+}));
+
+interface IGroupCardProps {
+ group: IGroup;
+}
+
+export const GroupCard = ({ group }: IGroupCardProps) => {
+ const [editUsersOpen, setEditUsersOpen] = useState(false);
+ const [removeOpen, setRemoveOpen] = useState(false);
+ const navigate = useNavigate();
+ return (
+ <>
+
+
+
+ {group.name}
+
+ setEditUsersOpen(true)}
+ onRemove={() => setRemoveOpen(true)}
+ />
+
+
+ {group.description}
+
+ 0}
+ show={}
+ elseShow={
+
+ This group has no users.
+
+ }
+ />
+
+ 0}
+ show={group.projects.map(project => (
+
+ {
+ e.preventDefault();
+ navigate(
+ `/projects/${project}/access`
+ );
+ }}
+ color="secondary"
+ icon={}
+ >
+ {project}
+
+
+ ))}
+ elseShow={
+
+ Not used
+
+ }
+ />
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardActions/GroupCardActions.tsx b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardActions/GroupCardActions.tsx
new file mode 100644
index 0000000000..c4a277bb54
--- /dev/null
+++ b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardActions/GroupCardActions.tsx
@@ -0,0 +1,125 @@
+import { FC, useState } from 'react';
+import {
+ IconButton,
+ ListItemIcon,
+ ListItemText,
+ MenuItem,
+ MenuList,
+ Popover,
+ styled,
+ Tooltip,
+ Typography,
+} from '@mui/material';
+import { Delete, Edit, GroupRounded, MoreVert } from '@mui/icons-material';
+import { Link } from 'react-router-dom';
+
+const StyledActions = styled('div')(({ theme }) => ({
+ display: 'flex',
+ justifyContent: 'center',
+}));
+
+const StyledPopover = styled(Popover)(({ theme }) => ({
+ borderRadius: theme.shape.borderRadiusLarge,
+ padding: theme.spacing(1, 1.5),
+}));
+
+interface IGroupCardActions {
+ groupId: number;
+ onEditUsers: () => void;
+ onRemove: () => void;
+}
+
+export const GroupCardActions: FC = ({
+ groupId,
+ onEditUsers,
+ onRemove,
+}) => {
+ const [anchorEl, setAnchorEl] = useState(null);
+
+ const open = Boolean(anchorEl);
+ const handleClick = (event: React.MouseEvent) => {
+ setAnchorEl(event.currentTarget);
+ };
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
+
+ const id = `feature-${groupId}-actions`;
+ const menuId = `${id}-menu`;
+
+ return (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ >
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/GroupCardAvatars.tsx b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/GroupCardAvatars.tsx
new file mode 100644
index 0000000000..3b13b4c190
--- /dev/null
+++ b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/GroupCardAvatars.tsx
@@ -0,0 +1,78 @@
+import { styled } from '@mui/material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { IGroupUser } from 'interfaces/group';
+import React, { useMemo, useState } from 'react';
+import { GroupPopover } from './GroupPopover/GroupPopover';
+import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
+
+const StyledAvatars = styled('div')(({ theme }) => ({
+ display: 'inline-flex',
+ alignItems: 'center',
+ flexWrap: 'wrap',
+ marginLeft: theme.spacing(1),
+}));
+
+const StyledAvatar = styled(UserAvatar)(({ theme }) => ({
+ outline: `${theme.spacing(0.25)} solid ${theme.palette.background.paper}`,
+ marginLeft: theme.spacing(-1),
+ '&:hover': {
+ outlineColor: theme.palette.primary.main,
+ },
+}));
+
+interface IGroupCardAvatarsProps {
+ users: IGroupUser[];
+}
+
+export const GroupCardAvatars = ({ users }: IGroupCardAvatarsProps) => {
+ const shownUsers = useMemo(
+ () =>
+ users
+ .sort((a, b) => b?.joinedAt!.getTime() - a?.joinedAt!.getTime())
+ .slice(0, 9),
+ [users]
+ );
+
+ const [anchorEl, setAnchorEl] = useState(null);
+ const [popupUser, setPopupUser] = useState();
+
+ const onPopoverOpen = (event: React.MouseEvent) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ const onPopoverClose = () => {
+ setAnchorEl(null);
+ };
+
+ const avatarOpen = Boolean(anchorEl);
+
+ return (
+
+ {shownUsers.map(user => (
+ {
+ onPopoverOpen(event);
+ setPopupUser(user);
+ }}
+ onMouseLeave={onPopoverClose}
+ />
+ ))}
+ 9}
+ show={
+
+ +{users.length - shownUsers.length}
+
+ }
+ />
+
+
+ );
+};
diff --git a/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/GroupPopover/GroupPopover.tsx b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/GroupPopover/GroupPopover.tsx
new file mode 100644
index 0000000000..b9a50d1442
--- /dev/null
+++ b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCardAvatars/GroupPopover/GroupPopover.tsx
@@ -0,0 +1,50 @@
+import { Popover, styled } from '@mui/material';
+import { IGroupUser } from 'interfaces/group';
+
+const StyledPopover = styled(Popover)(({ theme }) => ({
+ pointerEvents: 'none',
+ '.MuiPaper-root': {
+ padding: theme.spacing(2),
+ },
+}));
+
+const StyledName = styled('div')(({ theme }) => ({
+ color: theme.palette.text.secondary,
+ fontSize: theme.fontSizes.smallBody,
+ marginTop: theme.spacing(1),
+}));
+
+interface IGroupPopoverProps {
+ user: IGroupUser | undefined;
+
+ open: boolean;
+ anchorEl: HTMLElement | null;
+
+ onPopoverClose(event: React.MouseEvent): void;
+}
+
+export const GroupPopover = ({
+ user,
+ open,
+ anchorEl,
+ onPopoverClose,
+}: IGroupPopoverProps) => {
+ return (
+
+ {user?.name || user?.username}
+ {user?.email}
+
+ );
+};
diff --git a/frontend/src/component/admin/groups/GroupsList/GroupEmpty/GroupEmpty.tsx b/frontend/src/component/admin/groups/GroupsList/GroupEmpty/GroupEmpty.tsx
new file mode 100644
index 0000000000..768127474d
--- /dev/null
+++ b/frontend/src/component/admin/groups/GroupsList/GroupEmpty/GroupEmpty.tsx
@@ -0,0 +1,35 @@
+import { Button, styled, Typography } from '@mui/material';
+import { Link } from 'react-router-dom';
+
+export const GroupEmpty = () => {
+ const StyledContainerDiv = styled('div')(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ margin: theme.spacing(6),
+ marginLeft: 'auto',
+ marginRight: 'auto',
+ }));
+
+ const StyledTitle = styled(Typography)(({ theme }) => ({
+ fontSize: theme.fontSizes.bodySize,
+ marginBottom: theme.spacing(2.5),
+ }));
+
+ return (
+
+
+ No groups available. Get started by adding a new group.
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx b/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx
new file mode 100644
index 0000000000..d47238784f
--- /dev/null
+++ b/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx
@@ -0,0 +1,141 @@
+import { useEffect, useMemo, useState, VFC } from 'react';
+import { useGroups } from 'hooks/api/getters/useGroups/useGroups';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { IGroup } from 'interfaces/group';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import { PageHeader } from 'component/common/PageHeader/PageHeader';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { Search } from 'component/common/Search/Search';
+import { Grid, useMediaQuery } from '@mui/material';
+import theme from 'themes/theme';
+import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
+import { TablePlaceholder } from 'component/common/Table';
+import { GroupCard } from './GroupCard/GroupCard';
+import { GroupEmpty } from './GroupEmpty/GroupEmpty';
+import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import { Add } from '@mui/icons-material';
+import { NAVIGATE_TO_CREATE_GROUP } from 'utils/testIds';
+
+type PageQueryType = Partial>;
+
+const groupsSearch = (group: IGroup, searchValue: string) => {
+ const search = searchValue.toLowerCase();
+ const users = {
+ names: group.users?.map(user => user.name?.toLowerCase() || ''),
+ usernames: group.users?.map(user => user.username?.toLowerCase() || ''),
+ emails: group.users?.map(user => user.email?.toLowerCase() || ''),
+ };
+ return (
+ group.name.toLowerCase().includes(search) ||
+ group.description.toLowerCase().includes(search) ||
+ users.names?.some(name => name.includes(search)) ||
+ users.usernames?.some(username => username.includes(search)) ||
+ users.emails?.some(email => email.includes(search))
+ );
+};
+
+export const GroupsList: VFC = () => {
+ const navigate = useNavigate();
+ const { groups = [], loading } = useGroups();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [searchValue, setSearchValue] = useState(
+ searchParams.get('search') || ''
+ );
+
+ const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
+
+ useEffect(() => {
+ const tableState: PageQueryType = {};
+ if (searchValue) {
+ tableState.search = searchValue;
+ }
+
+ setSearchParams(tableState, {
+ replace: true,
+ });
+ }, [searchValue, setSearchParams]);
+
+ const data = useMemo(() => {
+ const sortedGroups = groups.sort((a, b) =>
+ a.name.localeCompare(b.name)
+ );
+ return searchValue
+ ? sortedGroups.filter(group => groupsSearch(group, searchValue))
+ : sortedGroups;
+ }, [groups, searchValue]);
+
+ return (
+
+
+
+
+ >
+ }
+ />
+
+ navigate('/admin/groups/create-group')
+ }
+ maxWidth="700px"
+ Icon={Add}
+ permission={ADMIN}
+ data-testid={NAVIGATE_TO_CREATE_GROUP}
+ >
+ New group
+
+ >
+ }
+ >
+
+ }
+ />
+
+ }
+ >
+
+
+ {data.map(group => (
+
+
+
+ ))}
+
+
+ 0}
+ show={
+
+ No groups found matching “
+ {searchValue}
+ ”
+
+ }
+ elseShow={}
+ />
+ }
+ />
+
+ );
+};
diff --git a/frontend/src/component/admin/groups/RemoveGroup/RemoveGroup.tsx b/frontend/src/component/admin/groups/RemoveGroup/RemoveGroup.tsx
new file mode 100644
index 0000000000..d6f1544697
--- /dev/null
+++ b/frontend/src/component/admin/groups/RemoveGroup/RemoveGroup.tsx
@@ -0,0 +1,60 @@
+import { Typography } from '@mui/material';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi';
+import { useGroups } from 'hooks/api/getters/useGroups/useGroups';
+import useToast from 'hooks/useToast';
+import { IGroup } from 'interfaces/group';
+import { FC } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { formatUnknownError } from 'utils/formatUnknownError';
+
+interface IRemoveGroupProps {
+ open: boolean;
+ setOpen: React.Dispatch>;
+ group: IGroup;
+}
+
+export const RemoveGroup: FC = ({
+ open,
+ setOpen,
+ group,
+}) => {
+ const { refetchGroups } = useGroups();
+ const { removeGroup } = useGroupApi();
+ const { setToastData, setToastApiError } = useToast();
+ const navigate = useNavigate();
+
+ const onRemoveClick = async () => {
+ try {
+ await removeGroup(group.id);
+ refetchGroups();
+ setOpen(false);
+ navigate('/admin/groups');
+ setToastData({
+ title: 'Group removed successfully',
+ type: 'success',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ return (
+ {
+ setOpen(false);
+ }}
+ title="Delete group?"
+ >
+
+ Do you really want to delete {group.name}?
+ Users who are granted access to projects only via this group
+ will lose access to those projects.
+
+
+ );
+};
diff --git a/frontend/src/component/admin/groups/hooks/useGroupForm.ts b/frontend/src/component/admin/groups/hooks/useGroupForm.ts
new file mode 100644
index 0000000000..da4b5c4adf
--- /dev/null
+++ b/frontend/src/component/admin/groups/hooks/useGroupForm.ts
@@ -0,0 +1,43 @@
+import { useState } from 'react';
+import useQueryParams from 'hooks/useQueryParams';
+import { IGroupUser } from 'interfaces/group';
+
+export const useGroupForm = (
+ initialName = '',
+ initialDescription = '',
+ initialUsers: IGroupUser[] = []
+) => {
+ const params = useQueryParams();
+ const groupQueryName = params.get('name');
+ const [name, setName] = useState(groupQueryName || initialName);
+ const [description, setDescription] = useState(initialDescription);
+ const [users, setUsers] = useState(initialUsers);
+ const [errors, setErrors] = useState({});
+
+ const getGroupPayload = () => {
+ return {
+ name,
+ description,
+ users: users.map(({ id }) => ({
+ user: { id },
+ })),
+ };
+ };
+
+ const clearErrors = () => {
+ setErrors({});
+ };
+
+ return {
+ name,
+ setName,
+ description,
+ setDescription,
+ users,
+ setUsers,
+ getGroupPayload,
+ clearErrors,
+ errors,
+ setErrors,
+ };
+};
diff --git a/frontend/src/component/admin/index.tsx b/frontend/src/component/admin/index.tsx
new file mode 100644
index 0000000000..6605f52f9f
--- /dev/null
+++ b/frontend/src/component/admin/index.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Navigate } from 'react-router-dom';
+
+const render = () => ;
+
+render.propTypes = {
+ match: PropTypes.object.isRequired,
+ history: PropTypes.object.isRequired,
+};
+
+export default render;
diff --git a/frontend/src/component/admin/invoice/InvoiceAdminPage.tsx b/frontend/src/component/admin/invoice/InvoiceAdminPage.tsx
new file mode 100644
index 0000000000..f9197c0df6
--- /dev/null
+++ b/frontend/src/component/admin/invoice/InvoiceAdminPage.tsx
@@ -0,0 +1,25 @@
+import { useContext } from 'react';
+import InvoiceList from './InvoiceList';
+import AccessContext from 'contexts/AccessContext';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { Alert } from '@mui/material';
+
+const InvoiceAdminPage = () => {
+ const { hasAccess } = useContext(AccessContext);
+ return (
+
+
}
+ elseShow={
+
+ You need to be instance admin to access this section.
+
+ }
+ />
+
+ );
+};
+
+export default InvoiceAdminPage;
diff --git a/frontend/src/component/admin/invoice/InvoiceList.tsx b/frontend/src/component/admin/invoice/InvoiceList.tsx
new file mode 100644
index 0000000000..136706ca32
--- /dev/null
+++ b/frontend/src/component/admin/invoice/InvoiceList.tsx
@@ -0,0 +1,122 @@
+import { useEffect, useState } from 'react';
+import {
+ Table,
+ TableHead,
+ TableBody,
+ TableRow,
+ TableCell,
+ Button,
+} from '@mui/material';
+import OpenInNew from '@mui/icons-material/OpenInNew';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import { PageHeader } from 'component/common/PageHeader/PageHeader';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { formatApiPath } from 'utils/formatPath';
+import useInvoices from 'hooks/api/getters/useInvoices/useInvoices';
+import { IInvoice } from 'interfaces/invoice';
+import { useLocationSettings } from 'hooks/useLocationSettings';
+import { formatDateYMD } from 'utils/formatDate';
+
+const PORTAL_URL = formatApiPath('api/admin/invoices/portal');
+
+const InvoiceList = () => {
+ const { refetchInvoices, invoices } = useInvoices();
+ const [isLoaded, setLoaded] = useState(false);
+ const { locationSettings } = useLocationSettings();
+
+ useEffect(() => {
+ refetchInvoices();
+ setLoaded(true);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+ 0}
+ show={
+ }
+ >
+ Billing portal
+
+ }
+ />
+ }
+ >
+
+
+
+
+ Amount
+ Status
+ Due date
+ PDF
+ Link
+
+
+
+ {invoices.map((item: IInvoice) => (
+
+
+ {item.amountFormatted}
+
+
+ {item.status}
+
+
+ {item.dueDate &&
+ formatDateYMD(
+ item.dueDate,
+ locationSettings.locale
+ )}
+
+
+ PDF
+
+
+
+ Payment link
+
+
+
+ ))}
+
+
+
+
+ }
+ elseShow={{isLoaded && 'No invoices to show.'}
}
+ />
+ );
+};
+export default InvoiceList;
diff --git a/frontend/src/component/admin/menu/AdminMenu.tsx b/frontend/src/component/admin/menu/AdminMenu.tsx
new file mode 100644
index 0000000000..f85102eb9c
--- /dev/null
+++ b/frontend/src/component/admin/menu/AdminMenu.tsx
@@ -0,0 +1,127 @@
+import React from 'react';
+import { NavLink, useLocation } from 'react-router-dom';
+import { Paper, Tab, Tabs } from '@mui/material';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import { useInstanceStatus } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
+
+const navLinkStyle = {
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ width: '100%',
+ textDecoration: 'none',
+ color: 'inherit',
+ padding: '0.8rem 1.5rem',
+};
+
+const activeNavLinkStyle: React.CSSProperties = {
+ fontWeight: 'bold',
+ borderRadius: '3px',
+ padding: '0.8rem 1.5rem',
+};
+
+const createNavLinkStyle = (props: {
+ isActive: boolean;
+}): React.CSSProperties => {
+ return props.isActive
+ ? { ...navLinkStyle, ...activeNavLinkStyle }
+ : navLinkStyle;
+};
+
+function AdminMenu() {
+ const { uiConfig } = useUiConfig();
+ const { pathname } = useLocation();
+ const { isBilling } = useInstanceStatus();
+ const { flags } = uiConfig;
+
+ return (
+
+
+
+ Users
+
+ }
+ />
+ {flags.UG && (
+
+ Groups
+
+ }
+ />
+ )}
+ {flags.RE && (
+
+ Project roles
+
+ }
+ />
+ )}
+
+ API access
+
+ }
+ />
+ {uiConfig.embedProxy && (
+
+ CORS origins
+
+ }
+ />
+ )}
+
+ Single sign-on
+
+ }
+ />
+ {isBilling && (
+
+ Billing
+
+ }
+ />
+ )}
+
+
+ );
+}
+
+export default AdminMenu;
diff --git a/frontend/src/component/admin/projectRoles/CreateProjectRole/CreateProjectRole.tsx b/frontend/src/component/admin/projectRoles/CreateProjectRole/CreateProjectRole.tsx
new file mode 100644
index 0000000000..cb0b547901
--- /dev/null
+++ b/frontend/src/component/admin/projectRoles/CreateProjectRole/CreateProjectRole.tsx
@@ -0,0 +1,107 @@
+import FormTemplate from 'component/common/FormTemplate/FormTemplate';
+import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi';
+import { useNavigate } from 'react-router-dom';
+import ProjectRoleForm from '../ProjectRoleForm/ProjectRoleForm';
+import useProjectRoleForm from '../hooks/useProjectRoleForm';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import useToast from 'hooks/useToast';
+import { CreateButton } from 'component/common/CreateButton/CreateButton';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { GO_BACK } from 'constants/navigate';
+
+const CreateProjectRole = () => {
+ const { setToastData, setToastApiError } = useToast();
+ const { uiConfig } = useUiConfig();
+ const navigate = useNavigate();
+ const {
+ roleName,
+ roleDesc,
+ setRoleName,
+ setRoleDesc,
+ checkedPermissions,
+ handlePermissionChange,
+ checkAllProjectPermissions,
+ checkAllEnvironmentPermissions,
+ getProjectRolePayload,
+ validatePermissions,
+ validateName,
+ validateNameUniqueness,
+ errors,
+ clearErrors,
+ getRoleKey,
+ } = useProjectRoleForm();
+
+ const { createRole, loading } = useProjectRolesApi();
+
+ const handleSubmit = async (e: Event) => {
+ e.preventDefault();
+ clearErrors();
+ const validName = validateName();
+ const validPermissions = validatePermissions();
+
+ if (validName && validPermissions) {
+ const payload = getProjectRolePayload();
+ try {
+ await createRole(payload);
+ navigate('/admin/roles');
+ setToastData({
+ title: 'Project role created',
+ text: 'Now you can start assigning your project roles to project members.',
+ confetti: true,
+ type: 'success',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ }
+ };
+
+ const formatApiCode = () => {
+ return `curl --location --request POST '${
+ uiConfig.unleashUrl
+ }/api/admin/roles' \\
+--header 'Authorization: INSERT_API_KEY' \\
+--header 'Content-Type: application/json' \\
+--data-raw '${JSON.stringify(getProjectRolePayload(), undefined, 2)}'`;
+ };
+
+ const handleCancel = () => {
+ navigate(GO_BACK);
+ };
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default CreateProjectRole;
diff --git a/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx b/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx
new file mode 100644
index 0000000000..f00d947fe7
--- /dev/null
+++ b/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx
@@ -0,0 +1,133 @@
+import { useEffect } from 'react';
+import FormTemplate from 'component/common/FormTemplate/FormTemplate';
+import { UpdateButton } from 'component/common/UpdateButton/UpdateButton';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi';
+import useProjectRole from 'hooks/api/getters/useProjectRole/useProjectRole';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import useToast from 'hooks/useToast';
+import { IPermission } from 'interfaces/project';
+import { useNavigate } from 'react-router-dom';
+import useProjectRoleForm from '../hooks/useProjectRoleForm';
+import ProjectRoleForm from '../ProjectRoleForm/ProjectRoleForm';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
+import { GO_BACK } from 'constants/navigate';
+
+const EditProjectRole = () => {
+ const { uiConfig } = useUiConfig();
+ const { setToastData, setToastApiError } = useToast();
+ const projectId = useRequiredPathParam('id');
+ const { role } = useProjectRole(projectId);
+
+ const navigate = useNavigate();
+ const {
+ roleName,
+ roleDesc,
+ setRoleName,
+ setRoleDesc,
+ checkedPermissions,
+ handlePermissionChange,
+ checkAllProjectPermissions,
+ checkAllEnvironmentPermissions,
+ getProjectRolePayload,
+ validatePermissions,
+ validateName,
+ errors,
+ clearErrors,
+ getRoleKey,
+ handleInitialCheckedPermissions,
+ permissions,
+ } = useProjectRoleForm(role.name, role.description);
+
+ useEffect(() => {
+ const initialCheckedPermissions = role?.permissions?.reduce(
+ (acc: { [key: string]: IPermission }, curr: IPermission) => {
+ acc[getRoleKey(curr)] = curr;
+ return acc;
+ },
+ {}
+ );
+
+ handleInitialCheckedPermissions(initialCheckedPermissions || {});
+ /* eslint-disable-next-line */
+ }, [
+ role?.permissions?.length,
+ permissions?.project?.length,
+ permissions?.environments?.length,
+ ]);
+
+ const formatApiCode = () => {
+ return `curl --location --request PUT '${
+ uiConfig.unleashUrl
+ }/api/admin/roles/${role.id}' \\
+--header 'Authorization: INSERT_API_KEY' \\
+--header 'Content-Type: application/json' \\
+--data-raw '${JSON.stringify(getProjectRolePayload(), undefined, 2)}'`;
+ };
+
+ const { refetch } = useProjectRole(projectId);
+ const { editRole, loading } = useProjectRolesApi();
+
+ const handleSubmit = async (e: Event) => {
+ e.preventDefault();
+ const payload = getProjectRolePayload();
+
+ const validName = validateName();
+ const validPermissions = validatePermissions();
+
+ if (validName && validPermissions) {
+ try {
+ await editRole(projectId, payload);
+ refetch();
+ navigate('/admin/roles');
+ setToastData({
+ type: 'success',
+ title: 'Project role updated',
+ text: 'Your role changes will automatically be applied to the users with this role.',
+ confetti: true,
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ }
+ };
+
+ const handleCancel = () => {
+ navigate(GO_BACK);
+ };
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default EditProjectRole;
diff --git a/frontend/src/component/admin/projectRoles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.styles.ts b/frontend/src/component/admin/projectRoles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.styles.ts
new file mode 100644
index 0000000000..eaac54cee8
--- /dev/null
+++ b/frontend/src/component/admin/projectRoles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.styles.ts
@@ -0,0 +1,35 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ environmentPermissionContainer: {
+ marginBottom: '1.25rem',
+ },
+ accordionSummary: {
+ boxShadow: 'none',
+ padding: '0',
+ },
+ label: {
+ minWidth: '300px',
+ [theme.breakpoints.down(600)]: {
+ minWidth: 'auto',
+ },
+ },
+ accordionHeader: {
+ display: 'flex',
+ alignItems: 'center',
+ [theme.breakpoints.down(500)]: {
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ },
+ },
+ accordionBody: {
+ padding: '0',
+ flexWrap: 'wrap',
+ },
+ header: {
+ color: theme.palette.primary.main,
+ },
+ icon: {
+ fill: theme.palette.primary.main,
+ },
+}));
diff --git a/frontend/src/component/admin/projectRoles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.tsx b/frontend/src/component/admin/projectRoles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.tsx
new file mode 100644
index 0000000000..95f715952b
--- /dev/null
+++ b/frontend/src/component/admin/projectRoles/ProjectRoleForm/EnvironmentPermissionAccordion/EnvironmentPermissionAccordion.tsx
@@ -0,0 +1,153 @@
+import {
+ Accordion,
+ AccordionDetails,
+ AccordionSummary,
+ Checkbox,
+ FormControlLabel,
+} from '@mui/material';
+import { ExpandMore } from '@mui/icons-material';
+import { useEffect, useState } from 'react';
+import {
+ IPermission,
+ IProjectEnvironmentPermissions,
+} from 'interfaces/project';
+import StringTruncator from 'component/common/StringTruncator/StringTruncator';
+import { ICheckedPermission } from 'component/admin/projectRoles/hooks/useProjectRoleForm';
+import { useStyles } from './EnvironmentPermissionAccordion.styles';
+
+type PermissionMap = { [key: string]: boolean };
+
+interface IEnvironmentPermissionAccordionProps {
+ environment: IProjectEnvironmentPermissions;
+ handlePermissionChange: (permission: IPermission, type: string) => void;
+ checkAllEnvironmentPermissions: (envName: string) => void;
+ checkedPermissions: ICheckedPermission;
+ getRoleKey: (permission: { id: number; environment?: string }) => string;
+}
+
+const EnvironmentPermissionAccordion = ({
+ environment,
+ handlePermissionChange,
+ checkAllEnvironmentPermissions,
+ checkedPermissions,
+ getRoleKey,
+}: IEnvironmentPermissionAccordionProps) => {
+ const [permissionMap, setPermissionMap] = useState({});
+ const [permissionCount, setPermissionCount] = useState(0);
+ const { classes: styles } = useStyles();
+
+ useEffect(() => {
+ const permissionMap = environment?.permissions?.reduce(
+ (acc: PermissionMap, curr: IPermission) => {
+ acc[getRoleKey(curr)] = true;
+ return acc;
+ },
+ {}
+ );
+
+ setPermissionMap(permissionMap);
+ /* eslint-disable-next-line */
+ }, [environment?.permissions?.length]);
+
+ useEffect(() => {
+ let count = 0;
+ Object.keys(checkedPermissions).forEach(key => {
+ if (permissionMap[key]) {
+ count = count + 1;
+ }
+ });
+
+ setPermissionCount(count);
+ /* eslint-disable-next-line */
+ }, [checkedPermissions]);
+
+ const renderPermissions = () => {
+ const envPermissions = environment?.permissions?.map(
+ (permission: IPermission) => {
+ return (
+
+ handlePermissionChange(
+ permission,
+ environment.name
+ )
+ }
+ color="primary"
+ />
+ }
+ label={permission.displayName}
+ />
+ );
+ }
+ );
+
+ envPermissions.push(
+
+ checkAllEnvironmentPermissions(environment?.name)
+ }
+ color="primary"
+ />
+ }
+ label={'Select all permissions for this env'}
+ />
+ );
+
+ return envPermissions;
+ };
+
+ return (
+
+
+
+ }
+ >
+
+
+
+
+ ({permissionCount} /{' '}
+ {environment?.permissions?.length} permissions)
+
+
+
+
+ {renderPermissions()}
+
+
+
+ );
+};
+
+export default EnvironmentPermissionAccordion;
diff --git a/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.styles.ts b/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.styles.ts
new file mode 100644
index 0000000000..55eca794a7
--- /dev/null
+++ b/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.styles.ts
@@ -0,0 +1,41 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ maxWidth: '400px',
+ },
+ input: { width: '100%', marginBottom: '1rem' },
+ label: {
+ minWidth: '300px',
+ [theme.breakpoints.down(600)]: {
+ minWidth: 'auto',
+ },
+ },
+ buttonContainer: {
+ marginTop: 'auto',
+ display: 'flex',
+ justifyContent: 'flex-end',
+ },
+ cancelButton: {
+ marginLeft: '1.5rem',
+ },
+ inputDescription: {
+ marginBottom: '0.5rem',
+ },
+ formHeader: {
+ fontWeight: 'normal',
+ marginTop: '0',
+ },
+ header: {
+ fontWeight: 'normal',
+ },
+ permissionErrorContainer: {
+ position: 'relative',
+ },
+ errorMessage: {
+ fontSize: theme.fontSizes.smallBody,
+ color: theme.palette.error.main,
+ position: 'absolute',
+ top: '-8px',
+ },
+}));
diff --git a/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.tsx b/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.tsx
new file mode 100644
index 0000000000..c1244c38fa
--- /dev/null
+++ b/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.tsx
@@ -0,0 +1,179 @@
+import Input from 'component/common/Input/Input';
+import EnvironmentPermissionAccordion from './EnvironmentPermissionAccordion/EnvironmentPermissionAccordion';
+import { Button, Checkbox, FormControlLabel, TextField } from '@mui/material';
+import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions';
+
+import { useStyles } from './ProjectRoleForm.styles';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import React, { ReactNode } from 'react';
+import { IPermission } from 'interfaces/project';
+import {
+ ICheckedPermission,
+ PROJECT_CHECK_ALL_KEY,
+} from '../hooks/useProjectRoleForm';
+
+interface IProjectRoleForm {
+ roleName: string;
+ roleDesc: string;
+ setRoleName: React.Dispatch>;
+ setRoleDesc: React.Dispatch>;
+ checkedPermissions: ICheckedPermission;
+ handlePermissionChange: (permission: IPermission, type: string) => void;
+ checkAllProjectPermissions: () => void;
+ checkAllEnvironmentPermissions: (envName: string) => void;
+ handleSubmit: (e: any) => void;
+ handleCancel: () => void;
+ errors: { [key: string]: string };
+ mode?: string;
+ clearErrors: () => void;
+ validateNameUniqueness?: () => void;
+ getRoleKey: (permission: { id: number; environment?: string }) => string;
+ children: ReactNode;
+}
+
+const ProjectRoleForm: React.FC = ({
+ children,
+ handleSubmit,
+ handleCancel,
+ roleName,
+ roleDesc,
+ setRoleName,
+ setRoleDesc,
+ checkedPermissions,
+ handlePermissionChange,
+ checkAllProjectPermissions,
+ checkAllEnvironmentPermissions,
+ errors,
+ mode,
+ validateNameUniqueness,
+ clearErrors,
+ getRoleKey,
+}: IProjectRoleForm) => {
+ const { classes: styles } = useStyles();
+ const { permissions } = useProjectRolePermissions({
+ revalidateIfStale: false,
+ revalidateOnReconnect: false,
+ revalidateOnFocus: false,
+ });
+
+ const { project, environments } = permissions;
+
+ const renderProjectPermissions = () => {
+ const projectPermissions = project.map(permission => {
+ return (
+
+ handlePermissionChange(permission, 'project')
+ }
+ color="primary"
+ />
+ }
+ label={permission.displayName}
+ />
+ );
+ });
+
+ projectPermissions.push(
+ checkAllProjectPermissions()}
+ color="primary"
+ />
+ }
+ label={'Select all project permissions'}
+ />
+ );
+
+ return projectPermissions;
+ };
+
+ const renderEnvironmentPermissions = () => {
+ return environments.map(environment => {
+ return (
+
+ );
+ });
+ };
+
+ return (
+
+ );
+};
+
+export default ProjectRoleForm;
diff --git a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.styles.ts b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.styles.ts
new file mode 100644
index 0000000000..7937593eb6
--- /dev/null
+++ b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.styles.ts
@@ -0,0 +1,10 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ deleteParagraph: {
+ marginTop: '2rem',
+ },
+ roleDeleteInput: {
+ marginTop: '1rem',
+ },
+}));
diff --git a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.tsx b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.tsx
new file mode 100644
index 0000000000..ed719e68f0
--- /dev/null
+++ b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.tsx
@@ -0,0 +1,70 @@
+import { Alert } from '@mui/material';
+import React from 'react';
+import { IProjectRole } from 'interfaces/role';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import Input from 'component/common/Input/Input';
+import { useStyles } from './ProjectRoleDeleteConfirm.styles';
+
+interface IProjectRoleDeleteConfirmProps {
+ role: IProjectRole;
+ open: boolean;
+ setDeldialogue: React.Dispatch>;
+ handleDeleteRole: (id: number) => Promise;
+ confirmName: string;
+ setConfirmName: React.Dispatch>;
+}
+
+const ProjectRoleDeleteConfirm = ({
+ role,
+ open,
+ setDeldialogue,
+ handleDeleteRole,
+ confirmName,
+ setConfirmName,
+}: IProjectRoleDeleteConfirmProps) => {
+ const { classes: styles } = useStyles();
+
+ const handleChange = (e: React.ChangeEvent) =>
+ setConfirmName(e.currentTarget.value);
+
+ const handleCancel = () => {
+ setDeldialogue(false);
+ setConfirmName('');
+ };
+ const formId = 'delete-project-role-confirmation-form';
+ return (
+ handleDeleteRole(role.id)}
+ disabledPrimaryButton={role?.name !== confirmName}
+ onClose={handleCancel}
+ formId={formId}
+ >
+
+ Danger. Deleting this role will result in removing all
+ permissions that are active in this environment across all
+ feature toggles.
+
+
+
+ In order to delete this role, please enter the name of the role
+ in the textfield below: {role?.name}
+
+
+
+
+ );
+};
+
+export default ProjectRoleDeleteConfirm;
diff --git a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx
new file mode 100644
index 0000000000..bf088cdb8d
--- /dev/null
+++ b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx
@@ -0,0 +1,265 @@
+import { useEffect, useMemo, useState } from 'react';
+import {
+ Table,
+ SortableTableHeader,
+ TableBody,
+ TableCell,
+ TableRow,
+ TablePlaceholder,
+} from 'component/common/Table';
+import { useTable, useGlobalFilter, useSortBy } from 'react-table';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import useProjectRoles from 'hooks/api/getters/useProjectRoles/useProjectRoles';
+import IRole, { IProjectRole } from 'interfaces/role';
+import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi';
+import useToast from 'hooks/useToast';
+import ProjectRoleDeleteConfirm from '../ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { Box, Button, useMediaQuery } from '@mui/material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
+import { Delete, Edit, SupervisedUserCircle } from '@mui/icons-material';
+import { useNavigate } from 'react-router-dom';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
+import { PageHeader } from 'component/common/PageHeader/PageHeader';
+import { sortTypes } from 'utils/sortTypes';
+import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
+import theme from 'themes/theme';
+import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
+import { Search } from 'component/common/Search/Search';
+
+const ROOTROLE = 'root';
+const BUILTIN_ROLE_TYPE = 'project';
+
+const ProjectRoleList = () => {
+ const navigate = useNavigate();
+ const { roles, refetch, loading } = useProjectRoles();
+
+ const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
+
+ const paginationFilter = (role: IRole) => role?.type !== ROOTROLE;
+ const data = roles.filter(paginationFilter);
+
+ const { deleteRole } = useProjectRolesApi();
+ const [currentRole, setCurrentRole] = useState(null);
+ const [delDialog, setDelDialog] = useState(false);
+ const [confirmName, setConfirmName] = useState('');
+ const { setToastData, setToastApiError } = useToast();
+
+ const deleteProjectRole = async () => {
+ if (!currentRole?.id) return;
+ try {
+ await deleteRole(currentRole?.id);
+ refetch();
+ setToastData({
+ type: 'success',
+ title: 'Successfully deleted role',
+ text: 'Your role is now deleted',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ setDelDialog(false);
+ setConfirmName('');
+ };
+
+ const columns = useMemo(
+ () => [
+ {
+ id: 'Icon',
+ Cell: () => (
+ }
+ />
+ ),
+ disableGlobalFilter: true,
+ },
+ {
+ Header: 'Project role',
+ accessor: 'name',
+ },
+ {
+ Header: 'Description',
+ accessor: 'description',
+ width: '90%',
+ },
+ {
+ Header: 'Actions',
+ id: 'Actions',
+ align: 'center',
+ Cell: ({
+ row: {
+ original: { id, type, name, description },
+ },
+ }: any) => (
+
+ {
+ navigate(`/admin/roles/${id}/edit`);
+ }}
+ permission={ADMIN}
+ tooltipProps={{
+ title:
+ type === BUILTIN_ROLE_TYPE
+ ? 'You cannot edit role'
+ : 'Edit role',
+ }}
+ >
+
+
+ {
+ setCurrentRole({
+ id,
+ name,
+ description,
+ } as IProjectRole);
+ setDelDialog(true);
+ }}
+ permission={ADMIN}
+ tooltipProps={{
+ title:
+ type === BUILTIN_ROLE_TYPE
+ ? 'You cannot remove role'
+ : 'Remove role',
+ }}
+ >
+
+
+
+ ),
+ width: 100,
+ disableGlobalFilter: true,
+ disableSortBy: true,
+ },
+ ],
+ [navigate]
+ );
+
+ const initialState = useMemo(
+ () => ({
+ sortBy: [{ id: 'name', desc: false }],
+ }),
+ []
+ );
+
+ const {
+ getTableProps,
+ getTableBodyProps,
+ headerGroups,
+ rows,
+ prepareRow,
+ state: { globalFilter },
+ setGlobalFilter,
+ setHiddenColumns,
+ } = useTable(
+ {
+ columns: columns as any[], // TODO: fix after `react-table` v8 update
+ data,
+ initialState,
+ sortTypes,
+ autoResetGlobalFilter: false,
+ autoResetSortBy: false,
+ disableSortRemove: true,
+ defaultColumn: {
+ Cell: HighlightCell,
+ },
+ },
+ useGlobalFilter,
+ useSortBy
+ );
+
+ useEffect(() => {
+ const hiddenColumns = [];
+ if (isExtraSmallScreen) {
+ hiddenColumns.push('Icon');
+ }
+ setHiddenColumns(hiddenColumns);
+ }, [setHiddenColumns, isExtraSmallScreen]);
+
+ return (
+
+
+
+
+ >
+ }
+ />
+ }
+ >
+
+
+
+
+ {rows.map(row => {
+ prepareRow(row);
+ return (
+
+ {row.cells.map(cell => (
+
+ {cell.render('Cell')}
+
+ ))}
+
+ );
+ })}
+
+
+
+ 0}
+ show={
+
+ No project roles found matching “
+ {globalFilter}
+ ”
+
+ }
+ elseShow={
+
+ No project roles available. Get started by
+ adding one.
+
+ }
+ />
+ }
+ />
+
+
+
+ );
+};
+
+export default ProjectRoleList;
diff --git a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.styles.ts b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.styles.ts
new file mode 100644
index 0000000000..fb25df6238
--- /dev/null
+++ b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.styles.ts
@@ -0,0 +1,10 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ rolesListBody: {
+ padding: theme.spacing(4),
+ paddingBottom: '4rem',
+ minHeight: '50vh',
+ position: 'relative',
+ },
+}));
diff --git a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.tsx b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.tsx
new file mode 100644
index 0000000000..8b538acac8
--- /dev/null
+++ b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.tsx
@@ -0,0 +1,24 @@
+import { useContext } from 'react';
+import AccessContext from 'contexts/AccessContext';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import AdminMenu from 'component/admin/menu/AdminMenu';
+import ProjectRoleList from './ProjectRoleList/ProjectRoleList';
+import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
+
+const ProjectRoles = () => {
+ const { hasAccess } = useContext(AccessContext);
+
+ return (
+
+
+
}
+ elseShow={
}
+ />
+
+ );
+};
+
+export default ProjectRoles;
diff --git a/frontend/src/component/admin/projectRoles/hooks/useProjectRoleForm.ts b/frontend/src/component/admin/projectRoles/hooks/useProjectRoleForm.ts
new file mode 100644
index 0000000000..3b364813dc
--- /dev/null
+++ b/frontend/src/component/admin/projectRoles/hooks/useProjectRoleForm.ts
@@ -0,0 +1,271 @@
+import { useEffect, useState } from 'react';
+import { IPermission } from 'interfaces/project';
+import cloneDeep from 'lodash.clonedeep';
+import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions';
+import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi';
+import { formatUnknownError } from 'utils/formatUnknownError';
+
+export interface ICheckedPermission {
+ [key: string]: IPermission;
+}
+
+export const PROJECT_CHECK_ALL_KEY = 'check-all-project';
+export const ENVIRONMENT_CHECK_ALL_KEY = 'check-all-environment';
+
+const useProjectRoleForm = (
+ initialRoleName = '',
+ initialRoleDesc = '',
+ initialCheckedPermissions = {}
+) => {
+ const { permissions } = useProjectRolePermissions({
+ revalidateIfStale: false,
+ revalidateOnReconnect: false,
+ revalidateOnFocus: false,
+ });
+
+ const [roleName, setRoleName] = useState(initialRoleName);
+ const [roleDesc, setRoleDesc] = useState(initialRoleDesc);
+ const [checkedPermissions, setCheckedPermissions] =
+ useState(initialCheckedPermissions);
+ const [errors, setErrors] = useState({});
+
+ const { validateRole } = useProjectRolesApi();
+
+ useEffect(() => {
+ setRoleName(initialRoleName);
+ }, [initialRoleName]);
+
+ useEffect(() => {
+ setRoleDesc(initialRoleDesc);
+ }, [initialRoleDesc]);
+
+ const handleInitialCheckedPermissions = (
+ initialCheckedPermissions: ICheckedPermission
+ ) => {
+ const formattedInitialCheckedPermissions =
+ isAllEnvironmentPermissionsChecked(
+ // @ts-expect-error
+ isAllProjectPermissionsChecked(initialCheckedPermissions)
+ );
+
+ setCheckedPermissions(formattedInitialCheckedPermissions || {});
+ };
+
+ const isAllProjectPermissionsChecked = (
+ initialCheckedPermissions: ICheckedPermission
+ ) => {
+ const { project } = permissions;
+ if (!project || project.length === 0) return;
+ const isAllChecked = project.every((permission: IPermission) => {
+ return initialCheckedPermissions[getRoleKey(permission)];
+ });
+
+ if (isAllChecked) {
+ // @ts-expect-error
+ initialCheckedPermissions[PROJECT_CHECK_ALL_KEY] = true;
+ } else {
+ delete initialCheckedPermissions[PROJECT_CHECK_ALL_KEY];
+ }
+
+ return initialCheckedPermissions;
+ };
+
+ const isAllEnvironmentPermissionsChecked = (
+ initialCheckedPermissions: ICheckedPermission
+ ) => {
+ const { environments } = permissions;
+ if (!environments || environments.length === 0) return;
+ environments.forEach(env => {
+ const isAllChecked = env.permissions.every(
+ (permission: IPermission) => {
+ return initialCheckedPermissions[getRoleKey(permission)];
+ }
+ );
+
+ const key = `${ENVIRONMENT_CHECK_ALL_KEY}-${env.name}`;
+
+ if (isAllChecked) {
+ // @ts-expect-error
+ initialCheckedPermissions[key] = true;
+ } else {
+ delete initialCheckedPermissions[key];
+ }
+ });
+ return initialCheckedPermissions;
+ };
+
+ const getCheckAllKeys = () => {
+ const { environments } = permissions;
+ const envKeys = environments.map(env => {
+ return `${ENVIRONMENT_CHECK_ALL_KEY}-${env.name}`;
+ });
+
+ return [...envKeys, PROJECT_CHECK_ALL_KEY];
+ };
+
+ const handlePermissionChange = (permission: IPermission, type: string) => {
+ let checkedPermissionsCopy = cloneDeep(checkedPermissions);
+
+ if (checkedPermissionsCopy[getRoleKey(permission)]) {
+ delete checkedPermissionsCopy[getRoleKey(permission)];
+ } else {
+ checkedPermissionsCopy[getRoleKey(permission)] = { ...permission };
+ }
+
+ if (type === 'project') {
+ // @ts-expect-error
+ checkedPermissionsCopy = isAllProjectPermissionsChecked(
+ checkedPermissionsCopy
+ );
+ } else {
+ // @ts-expect-error
+ checkedPermissionsCopy = isAllEnvironmentPermissionsChecked(
+ checkedPermissionsCopy
+ );
+ }
+
+ setCheckedPermissions(checkedPermissionsCopy);
+ };
+
+ const checkAllProjectPermissions = () => {
+ const { project } = permissions;
+ const checkedPermissionsCopy = cloneDeep(checkedPermissions);
+ const checkedAll = checkedPermissionsCopy[PROJECT_CHECK_ALL_KEY];
+ project.forEach((permission: IPermission, index: number) => {
+ const lastItem = project.length - 1 === index;
+ if (checkedAll) {
+ if (checkedPermissionsCopy[getRoleKey(permission)]) {
+ delete checkedPermissionsCopy[getRoleKey(permission)];
+ }
+
+ if (lastItem) {
+ delete checkedPermissionsCopy[PROJECT_CHECK_ALL_KEY];
+ }
+ } else {
+ checkedPermissionsCopy[getRoleKey(permission)] = {
+ ...permission,
+ };
+
+ if (lastItem) {
+ // @ts-expect-error
+ checkedPermissionsCopy[PROJECT_CHECK_ALL_KEY] = true;
+ }
+ }
+ });
+
+ setCheckedPermissions(checkedPermissionsCopy);
+ };
+
+ const checkAllEnvironmentPermissions = (envName: string) => {
+ const { environments } = permissions;
+ const checkedPermissionsCopy = cloneDeep(checkedPermissions);
+ const environmentCheckAllKey = `${ENVIRONMENT_CHECK_ALL_KEY}-${envName}`;
+ const env = environments.find(env => env.name === envName);
+ if (!env) return;
+ const checkedAll = checkedPermissionsCopy[environmentCheckAllKey];
+
+ env.permissions.forEach((permission: IPermission, index: number) => {
+ const lastItem = env.permissions.length - 1 === index;
+ if (checkedAll) {
+ if (checkedPermissionsCopy[getRoleKey(permission)]) {
+ delete checkedPermissionsCopy[getRoleKey(permission)];
+ }
+
+ if (lastItem) {
+ delete checkedPermissionsCopy[environmentCheckAllKey];
+ }
+ } else {
+ checkedPermissionsCopy[getRoleKey(permission)] = {
+ ...permission,
+ };
+
+ if (lastItem) {
+ // @ts-expect-error
+ checkedPermissionsCopy[environmentCheckAllKey] = true;
+ }
+ }
+ });
+
+ setCheckedPermissions(checkedPermissionsCopy);
+ };
+
+ const getProjectRolePayload = () => {
+ const checkAllKeys = getCheckAllKeys();
+ const permissions = Object.keys(checkedPermissions)
+ .filter(key => {
+ return !checkAllKeys.includes(key);
+ })
+ .map(permission => {
+ return checkedPermissions[permission];
+ });
+ return {
+ name: roleName,
+ description: roleDesc,
+ permissions,
+ };
+ };
+
+ const validateNameUniqueness = async () => {
+ const payload = getProjectRolePayload();
+
+ try {
+ await validateRole(payload);
+ } catch (error: unknown) {
+ setErrors(prev => ({ ...prev, name: formatUnknownError(error) }));
+ }
+ };
+
+ const validateName = () => {
+ if (roleName.length === 0) {
+ setErrors(prev => ({ ...prev, name: 'Name can not be empty.' }));
+ return false;
+ }
+ return true;
+ };
+
+ const validatePermissions = () => {
+ if (Object.keys(checkedPermissions).length === 0) {
+ setErrors(prev => ({
+ ...prev,
+ permissions: 'You must include at least one permission.',
+ }));
+ return false;
+ }
+ return true;
+ };
+
+ const clearErrors = () => {
+ setErrors({});
+ };
+
+ const getRoleKey = (permission: {
+ id: number;
+ environment?: string;
+ }): string => {
+ return permission.environment
+ ? `${permission.id}-${permission.environment}`
+ : `${permission.id}`;
+ };
+
+ return {
+ roleName,
+ roleDesc,
+ setRoleName,
+ setRoleDesc,
+ handlePermissionChange,
+ checkAllProjectPermissions,
+ checkAllEnvironmentPermissions,
+ checkedPermissions,
+ getProjectRolePayload,
+ validatePermissions,
+ validateName,
+ handleInitialCheckedPermissions,
+ clearErrors,
+ validateNameUniqueness,
+ errors,
+ getRoleKey,
+ permissions,
+ };
+};
+
+export default useProjectRoleForm;
diff --git a/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserAdded.tsx b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserAdded.tsx
new file mode 100644
index 0000000000..5142eb3a12
--- /dev/null
+++ b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserAdded.tsx
@@ -0,0 +1,36 @@
+import ConfirmUserEmail from './ConfirmUserEmail/ConfirmUserEmail';
+import ConfirmUserLink from './ConfirmUserLink/ConfirmUserLink';
+
+interface IConfirmUserAddedProps {
+ open: boolean;
+ closeConfirm: () => void;
+ inviteLink: string;
+ emailSent: boolean;
+}
+
+const ConfirmUserAdded = ({
+ open,
+ closeConfirm,
+ emailSent,
+ inviteLink,
+}: IConfirmUserAddedProps) => {
+ if (emailSent) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default ConfirmUserAdded;
diff --git a/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.styles.ts b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.styles.ts
new file mode 100644
index 0000000000..5245049494
--- /dev/null
+++ b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.styles.ts
@@ -0,0 +1,11 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()({
+ iconContainer: {
+ width: '100%',
+ textAlign: 'center',
+ },
+ emailIcon: {
+ margin: '2rem auto',
+ },
+});
diff --git a/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.tsx b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.tsx
new file mode 100644
index 0000000000..0bae5c0514
--- /dev/null
+++ b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserEmail/ConfirmUserEmail.tsx
@@ -0,0 +1,46 @@
+import { Typography } from '@mui/material';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+
+import { ReactComponent as EmailIcon } from 'assets/icons/email.svg';
+import { useStyles } from './ConfirmUserEmail.styles';
+import UserInviteLink from '../ConfirmUserLink/UserInviteLink/UserInviteLink';
+
+interface IConfirmUserEmailProps {
+ open: boolean;
+ closeConfirm: () => void;
+ inviteLink: string;
+}
+
+const ConfirmUserEmail = ({
+ open,
+ closeConfirm,
+ inviteLink,
+}: IConfirmUserEmailProps) => {
+ const { classes: styles } = useStyles();
+ return (
+
+
+ A new team member has been added. We’ve sent an email on your
+ behalf to inform them of their new account and role. No further
+ steps are required.
+
+
+
+
+
+ In a rush?
+
+
+ You may also copy the invite link and send it to the user.
+
+
+
+ );
+};
+
+export default ConfirmUserEmail;
diff --git a/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/ConfirmUserLink.tsx b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/ConfirmUserLink.tsx
new file mode 100644
index 0000000000..7471d056a9
--- /dev/null
+++ b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/ConfirmUserLink.tsx
@@ -0,0 +1,58 @@
+import { Typography } from '@mui/material';
+import { Alert } from '@mui/material';
+import { useThemeStyles } from 'themes/themeStyles';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import UserInviteLink from './UserInviteLink/UserInviteLink';
+
+interface IConfirmUserLink {
+ open: boolean;
+ closeConfirm: () => void;
+ inviteLink: string;
+}
+
+const ConfirmUserLink = ({
+ open,
+ closeConfirm,
+ inviteLink,
+}: IConfirmUserLink) => {
+ const { classes: themeStyles } = useThemeStyles();
+ return (
+
+
+
+ A new team member has been added. Please provide them with
+ the following link to get started:
+
+
+
+
+ Copy the link and send it to the user. This will allow them
+ to set up their password and get started with their Unleash
+ account.
+
+
+
+ Want to avoid this step in the future?{' '}
+ {/* TODO - ADD LINK HERE ONCE IT EXISTS*/}
+
+ If you configure an email server for Unleash
+ {' '}
+ we'll automatically send informational getting started
+ emails to new users once you add them.
+
+
+
+
+ );
+};
+
+export default ConfirmUserLink;
diff --git a/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/UserInviteLink/UserInviteLink.tsx b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/UserInviteLink/UserInviteLink.tsx
new file mode 100644
index 0000000000..391a436da1
--- /dev/null
+++ b/frontend/src/component/admin/users/ConfirmUserAdded/ConfirmUserLink/UserInviteLink/UserInviteLink.tsx
@@ -0,0 +1,59 @@
+import { IconButton, Tooltip } from '@mui/material';
+import CopyIcon from '@mui/icons-material/FileCopy';
+import useToast from 'hooks/useToast';
+
+interface IInviteLinkProps {
+ inviteLink: string;
+}
+
+const UserInviteLink = ({ inviteLink }: IInviteLinkProps) => {
+ const { setToastData } = useToast();
+
+ const handleCopy = () => {
+ try {
+ return navigator.clipboard
+ .writeText(inviteLink)
+ .then(() => {
+ setToastData({
+ type: 'success',
+ title: 'Successfully copied invite link.',
+ });
+ })
+ .catch(() => {
+ setError();
+ });
+ } catch (e) {
+ setError();
+ }
+ };
+
+ const setError = () =>
+ setToastData({
+ type: 'error',
+ title: 'Could not copy invite link.',
+ });
+
+ return (
+
+ {inviteLink}
+
+
+
+
+
+
+ );
+};
+
+export default UserInviteLink;
diff --git a/frontend/src/component/admin/users/CreateUser/CreateUser.tsx b/frontend/src/component/admin/users/CreateUser/CreateUser.tsx
new file mode 100644
index 0000000000..41ce786603
--- /dev/null
+++ b/frontend/src/component/admin/users/CreateUser/CreateUser.tsx
@@ -0,0 +1,115 @@
+import FormTemplate from 'component/common/FormTemplate/FormTemplate';
+import { useNavigate } from 'react-router-dom';
+import UserForm from '../UserForm/UserForm';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import useAdminUsersApi from 'hooks/api/actions/useAdminUsersApi/useAdminUsersApi';
+import useToast from 'hooks/useToast';
+import useAddUserForm from '../hooks/useAddUserForm';
+import ConfirmUserAdded from '../ConfirmUserAdded/ConfirmUserAdded';
+import { useState } from 'react';
+import { scrollToTop } from 'component/common/util';
+import { CreateButton } from 'component/common/CreateButton/CreateButton';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { GO_BACK } from 'constants/navigate';
+
+const CreateUser = () => {
+ const { setToastApiError } = useToast();
+ const { uiConfig } = useUiConfig();
+ const navigate = useNavigate();
+ const {
+ name,
+ setName,
+ email,
+ setEmail,
+ sendEmail,
+ setSendEmail,
+ rootRole,
+ setRootRole,
+ getAddUserPayload,
+ validateName,
+ validateEmail,
+ errors,
+ clearErrors,
+ } = useAddUserForm();
+ const [showConfirm, setShowConfirm] = useState(false);
+ const [inviteLink, setInviteLink] = useState('');
+
+ const { addUser, userLoading: loading } = useAdminUsersApi();
+
+ const handleSubmit = async (e: Event) => {
+ e.preventDefault();
+ clearErrors();
+ const validName = validateName();
+ const validEmail = validateEmail();
+
+ if (validName && validEmail) {
+ const payload = getAddUserPayload();
+ try {
+ await addUser(payload)
+ .then(res => res.json())
+ .then(user => {
+ scrollToTop();
+ setInviteLink(user.inviteLink);
+ setShowConfirm(true);
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ }
+ };
+ const closeConfirm = () => {
+ setShowConfirm(false);
+ navigate('/admin/users');
+ };
+
+ const formatApiCode = () => {
+ return `curl --location --request POST '${
+ uiConfig.unleashUrl
+ }/api/admin/user-admin' \\
+--header 'Authorization: INSERT_API_KEY' \\
+--header 'Content-Type: application/json' \\
+--data-raw '${JSON.stringify(getAddUserPayload(), undefined, 2)}'`;
+ };
+
+ const handleCancel = () => {
+ navigate(GO_BACK);
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default CreateUser;
diff --git a/frontend/src/component/admin/users/EditUser/EditUser.tsx b/frontend/src/component/admin/users/EditUser/EditUser.tsx
new file mode 100644
index 0000000000..482f6bfe24
--- /dev/null
+++ b/frontend/src/component/admin/users/EditUser/EditUser.tsx
@@ -0,0 +1,107 @@
+import { useNavigate } from 'react-router-dom';
+import UserForm from '../UserForm/UserForm';
+import useAddUserForm from '../hooks/useAddUserForm';
+import { scrollToTop } from 'component/common/util';
+import { useEffect } from 'react';
+import { UpdateButton } from 'component/common/UpdateButton/UpdateButton';
+import FormTemplate from 'component/common/FormTemplate/FormTemplate';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import { EDIT } from 'constants/misc';
+import useAdminUsersApi from 'hooks/api/actions/useAdminUsersApi/useAdminUsersApi';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import useUserInfo from 'hooks/api/getters/useUserInfo/useUserInfo';
+import useToast from 'hooks/useToast';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
+import { GO_BACK } from 'constants/navigate';
+
+const EditUser = () => {
+ useEffect(() => {
+ scrollToTop();
+ }, []);
+ const { uiConfig } = useUiConfig();
+ const { setToastData, setToastApiError } = useToast();
+ const id = useRequiredPathParam('id');
+ const { user, refetch } = useUserInfo(id);
+ const { updateUser, userLoading: loading } = useAdminUsersApi();
+ const navigate = useNavigate();
+ const {
+ name,
+ setName,
+ email,
+ setEmail,
+ sendEmail,
+ setSendEmail,
+ rootRole,
+ setRootRole,
+ getAddUserPayload,
+ validateName,
+ errors,
+ clearErrors,
+ } = useAddUserForm(user?.name, user?.email, user?.rootRole);
+
+ const formatApiCode = () => {
+ return `curl --location --request PUT '${
+ uiConfig.unleashUrl
+ }/api/admin/user-admin/${id}' \\
+--header 'Authorization: INSERT_API_KEY' \\
+--header 'Content-Type: application/json' \\
+--data-raw '${JSON.stringify(getAddUserPayload(), undefined, 2)}'`;
+ };
+
+ const handleSubmit = async (e: Event) => {
+ e.preventDefault();
+ const payload = getAddUserPayload();
+ const validName = validateName();
+
+ if (validName) {
+ try {
+ await updateUser({ ...payload, id });
+ refetch();
+ navigate('/admin/users');
+ setToastData({
+ title: 'User information updated',
+ type: 'success',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ }
+ };
+
+ const handleCancel = () => {
+ navigate(GO_BACK);
+ };
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default EditUser;
diff --git a/frontend/src/component/admin/users/UserForm/UserForm.styles.ts b/frontend/src/component/admin/users/UserForm/UserForm.styles.ts
new file mode 100644
index 0000000000..2b4106a89d
--- /dev/null
+++ b/frontend/src/component/admin/users/UserForm/UserForm.styles.ts
@@ -0,0 +1,60 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ maxWidth: '400px',
+ },
+ form: {
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ },
+ input: { width: '100%', marginBottom: '1rem' },
+ label: {
+ minWidth: '300px',
+ [theme.breakpoints.down(600)]: {
+ minWidth: 'auto',
+ },
+ },
+ buttonContainer: {
+ marginTop: 'auto',
+ display: 'flex',
+ justifyContent: 'flex-end',
+ },
+ cancelButton: {
+ marginLeft: '1.5rem',
+ },
+ inputDescription: {
+ marginBottom: '0.5rem',
+ },
+ permissionErrorContainer: {
+ position: 'relative',
+ },
+ errorMessage: {
+ fontSize: theme.fontSizes.smallBody,
+ color: theme.palette.error.main,
+ position: 'absolute',
+ top: '-8px',
+ },
+ roleBox: {
+ margin: '3px 0',
+ border: '1px solid #EFEFEF',
+ padding: '1rem',
+ },
+ userInfoContainer: {
+ margin: '-20px 0',
+ },
+ roleRadio: {
+ marginRight: '15px',
+ },
+ roleSubtitle: {
+ margin: '0.5rem 0',
+ },
+ errorAlert: {
+ marginBottom: '1rem',
+ },
+ flexRow: {
+ display: 'flex',
+ alignItems: 'center',
+ },
+}));
diff --git a/frontend/src/component/admin/users/UserForm/UserForm.tsx b/frontend/src/component/admin/users/UserForm/UserForm.tsx
new file mode 100644
index 0000000000..f3678f489d
--- /dev/null
+++ b/frontend/src/component/admin/users/UserForm/UserForm.tsx
@@ -0,0 +1,164 @@
+import Input from 'component/common/Input/Input';
+import {
+ FormControlLabel,
+ Button,
+ RadioGroup,
+ FormControl,
+ Typography,
+ Radio,
+ Switch,
+} from '@mui/material';
+import { useStyles } from './UserForm.styles';
+import React from 'react';
+import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { EDIT } from 'constants/misc';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+
+interface IUserForm {
+ email: string;
+ name: string;
+ rootRole: number;
+ sendEmail: boolean;
+ setEmail: React.Dispatch>;
+ setName: React.Dispatch>;
+ setSendEmail: React.Dispatch>;
+ setRootRole: React.Dispatch>;
+ handleSubmit: (e: any) => void;
+ handleCancel: () => void;
+ errors: { [key: string]: string };
+ clearErrors: () => void;
+ mode?: string;
+}
+
+const UserForm: React.FC = ({
+ children,
+ email,
+ name,
+ rootRole,
+ sendEmail,
+ setEmail,
+ setName,
+ setSendEmail,
+ setRootRole,
+ handleSubmit,
+ handleCancel,
+ errors,
+ clearErrors,
+ mode,
+}) => {
+ const { classes: styles } = useStyles();
+ const { roles } = useUsers();
+ const { uiConfig } = useUiConfig();
+
+ // @ts-expect-error
+ const sortRoles = (a, b) => {
+ if (b.name[0] < a.name[0]) {
+ return 1;
+ } else if (a.name[0] < b.name[0]) {
+ return -1;
+ }
+ return 0;
+ };
+
+ return (
+
+ );
+};
+
+export default UserForm;
diff --git a/frontend/src/component/admin/users/UsersAdmin.tsx b/frontend/src/component/admin/users/UsersAdmin.tsx
new file mode 100644
index 0000000000..e34f238537
--- /dev/null
+++ b/frontend/src/component/admin/users/UsersAdmin.tsx
@@ -0,0 +1,24 @@
+import { useContext } from 'react';
+import UsersList from './UsersList/UsersList';
+import AdminMenu from '../menu/AdminMenu';
+import AccessContext from 'contexts/AccessContext';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
+
+const UsersAdmin = () => {
+ const { hasAccess } = useContext(AccessContext);
+
+ return (
+
+
+
}
+ elseShow={
}
+ />
+
+ );
+};
+
+export default UsersAdmin;
diff --git a/frontend/src/component/admin/users/UsersList/ChangePassword/ChangePassword.tsx b/frontend/src/component/admin/users/UsersList/ChangePassword/ChangePassword.tsx
new file mode 100644
index 0000000000..251e9f7d43
--- /dev/null
+++ b/frontend/src/component/admin/users/UsersList/ChangePassword/ChangePassword.tsx
@@ -0,0 +1,136 @@
+import React, { useState } from 'react';
+import classnames from 'classnames';
+import { styled, TextField, Typography } from '@mui/material';
+import { trim } from 'component/common/util';
+import { modalStyles } from 'component/admin/users/util';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import PasswordChecker, {
+ PASSWORD_FORMAT_MESSAGE,
+} from 'component/user/common/ResetPasswordForm/PasswordChecker/PasswordChecker';
+import { useThemeStyles } from 'themes/themeStyles';
+import PasswordMatcher from 'component/user/common/ResetPasswordForm/PasswordMatcher/PasswordMatcher';
+import { IUser } from 'interfaces/user';
+import useAdminUsersApi from 'hooks/api/actions/useAdminUsersApi/useAdminUsersApi';
+import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
+
+const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({
+ width: theme.spacing(5),
+ height: theme.spacing(5),
+ margin: 0,
+}));
+
+interface IChangePasswordProps {
+ showDialog: boolean;
+ closeDialog: () => void;
+ user: IUser;
+}
+
+const ChangePassword = ({
+ showDialog,
+ closeDialog,
+ user,
+}: IChangePasswordProps) => {
+ const [data, setData] = useState>({});
+ const [error, setError] = useState();
+ const [validPassword, setValidPassword] = useState(false);
+ const { classes: themeStyles } = useThemeStyles();
+ const { changePassword } = useAdminUsersApi();
+
+ const updateField: React.ChangeEventHandler = event => {
+ setError(undefined);
+ setData({ ...data, [event.target.name]: trim(event.target.value) });
+ };
+
+ const submit = async (event: React.SyntheticEvent) => {
+ event.preventDefault();
+
+ if (data.password !== data.confirm) {
+ return;
+ }
+
+ if (!validPassword) {
+ setError(PASSWORD_FORMAT_MESSAGE);
+ return;
+ }
+
+ try {
+ await changePassword(user.id, data.password);
+ setData({});
+ closeDialog();
+ } catch (error: unknown) {
+ console.warn(error);
+ setError(PASSWORD_FORMAT_MESSAGE);
+ }
+ };
+
+ const onCancel = (event: React.SyntheticEvent) => {
+ event.preventDefault();
+ setData({});
+ setError(undefined);
+ closeDialog();
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default ChangePassword;
diff --git a/frontend/src/component/admin/users/UsersList/DeleteUser/DeleteUser.tsx b/frontend/src/component/admin/users/UsersList/DeleteUser/DeleteUser.tsx
new file mode 100644
index 0000000000..783d81f764
--- /dev/null
+++ b/frontend/src/component/admin/users/UsersList/DeleteUser/DeleteUser.tsx
@@ -0,0 +1,87 @@
+import React from 'react';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { REMOVE_USER_ERROR } from 'hooks/api/actions/useAdminUsersApi/useAdminUsersApi';
+import { Alert, styled } from '@mui/material';
+import useLoading from 'hooks/useLoading';
+import { Typography } from '@mui/material';
+import { useThemeStyles } from 'themes/themeStyles';
+import { IUser } from 'interfaces/user';
+import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
+
+const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({
+ width: theme.spacing(5),
+ height: theme.spacing(5),
+ margin: 0,
+}));
+
+interface IDeleteUserProps {
+ showDialog: boolean;
+ closeDialog: () => void;
+ user: IUser;
+ userLoading: boolean;
+ removeUser: () => void;
+ userApiErrors: Record;
+}
+
+const DeleteUser = ({
+ showDialog,
+ closeDialog,
+ user,
+ userLoading,
+ removeUser,
+ userApiErrors,
+}: IDeleteUserProps) => {
+ const ref = useLoading(userLoading);
+ const { classes: themeStyles } = useThemeStyles();
+
+ return (
+
+
+
+ {userApiErrors[REMOVE_USER_ERROR]}
+
+ }
+ />
+
+
+
+ {user.username || user.email}
+
+
+
+ Are you sure you want to delete{' '}
+ {user
+ ? `${user.name || 'user'} (${
+ user.email || user.username
+ })`
+ : ''}
+ ?
+
+
+
+ );
+};
+
+export default DeleteUser;
diff --git a/frontend/src/component/admin/users/UsersList/UserTypeCell/UserTypeCell.tsx b/frontend/src/component/admin/users/UsersList/UserTypeCell/UserTypeCell.tsx
new file mode 100644
index 0000000000..d27d5b205d
--- /dev/null
+++ b/frontend/src/component/admin/users/UsersList/UserTypeCell/UserTypeCell.tsx
@@ -0,0 +1,29 @@
+import { MonetizationOn } from '@mui/icons-material';
+import { styled, Tooltip } from '@mui/material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
+
+const StyledMonetizationOn = styled(MonetizationOn)(({ theme }) => ({
+ color: theme.palette.primary.light,
+ fontSize: '1.75rem',
+}));
+
+interface IUserTypeCellProps {
+ value: boolean;
+}
+
+export const UserTypeCell = ({ value }: IUserTypeCellProps) => {
+ return (
+
+
+
+
+ }
+ elseShow="Free"
+ />
+
+ );
+};
diff --git a/frontend/src/component/admin/users/UsersList/UsersActionsCell/UsersActionsCell.tsx b/frontend/src/component/admin/users/UsersList/UsersActionsCell/UsersActionsCell.tsx
new file mode 100644
index 0000000000..720b2679ea
--- /dev/null
+++ b/frontend/src/component/admin/users/UsersList/UsersActionsCell/UsersActionsCell.tsx
@@ -0,0 +1,57 @@
+import { Delete, Edit, Lock } from '@mui/icons-material';
+import { Box, styled } from '@mui/material';
+import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import { VFC } from 'react';
+
+const StyledBox = styled(Box)(() => ({
+ display: 'flex',
+ justifyContent: 'center',
+}));
+
+interface IUsersActionsCellProps {
+ onEdit: (event: React.SyntheticEvent) => void;
+ onChangePassword: (event: React.SyntheticEvent) => void;
+ onDelete: (event: React.SyntheticEvent) => void;
+}
+
+export const UsersActionsCell: VFC = ({
+ onEdit,
+ onChangePassword,
+ onDelete,
+}) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/admin/users/UsersList/UsersList.tsx b/frontend/src/component/admin/users/UsersList/UsersList.tsx
new file mode 100644
index 0000000000..7876760811
--- /dev/null
+++ b/frontend/src/component/admin/users/UsersList/UsersList.tsx
@@ -0,0 +1,330 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import {
+ Table,
+ SortableTableHeader,
+ TableBody,
+ TableCell,
+ TableRow,
+ TablePlaceholder,
+} from 'component/common/Table';
+import ChangePassword from './ChangePassword/ChangePassword';
+import DeleteUser from './DeleteUser/DeleteUser';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import ConfirmUserAdded from '../ConfirmUserAdded/ConfirmUserAdded';
+import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
+import useAdminUsersApi from 'hooks/api/actions/useAdminUsersApi/useAdminUsersApi';
+import { IUser } from 'interfaces/user';
+import IRole from 'interfaces/role';
+import useToast from 'hooks/useToast';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { useUsersPlan } from 'hooks/useUsersPlan';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import { PageHeader } from 'component/common/PageHeader/PageHeader';
+import { Button, useMediaQuery } from '@mui/material';
+import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
+import { UserTypeCell } from './UserTypeCell/UserTypeCell';
+import { useGlobalFilter, useSortBy, useTable } from 'react-table';
+import { sortTypes } from 'utils/sortTypes';
+import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
+import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
+import { useNavigate } from 'react-router-dom';
+import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
+import theme from 'themes/theme';
+import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
+import { UsersActionsCell } from './UsersActionsCell/UsersActionsCell';
+import { Search } from 'component/common/Search/Search';
+import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
+
+const UsersList = () => {
+ const navigate = useNavigate();
+ const { users, roles, refetch, loading } = useUsers();
+ const { setToastData, setToastApiError } = useToast();
+ const { removeUser, userLoading, userApiErrors } = useAdminUsersApi();
+ const [pwDialog, setPwDialog] = useState<{ open: boolean; user?: IUser }>({
+ open: false,
+ });
+ const [delDialog, setDelDialog] = useState(false);
+ const [showConfirm, setShowConfirm] = useState(false);
+ const [emailSent, setEmailSent] = useState(false);
+ const [inviteLink, setInviteLink] = useState('');
+ const [delUser, setDelUser] = useState();
+ const { planUsers, isBillingUsers } = useUsersPlan(users);
+
+ const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
+ const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
+
+ const closeDelDialog = () => {
+ setDelDialog(false);
+ setDelUser(undefined);
+ };
+
+ const openDelDialog =
+ (user: IUser) => (e: React.SyntheticEvent) => {
+ e.preventDefault();
+ setDelDialog(true);
+ setDelUser(user);
+ };
+ const openPwDialog =
+ (user: IUser) => (e: React.SyntheticEvent) => {
+ e.preventDefault();
+ setPwDialog({ open: true, user });
+ };
+
+ const closePwDialog = () => {
+ setPwDialog({ open: false });
+ };
+
+ const onDeleteUser = async (user: IUser) => {
+ try {
+ await removeUser(user.id);
+ setToastData({
+ title: `${user.name} has been deleted`,
+ type: 'success',
+ });
+ refetch();
+ closeDelDialog();
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ const closeConfirm = () => {
+ setShowConfirm(false);
+ setEmailSent(false);
+ setInviteLink('');
+ };
+
+ const columns = useMemo(
+ () => [
+ {
+ id: 'type',
+ Header: 'Type',
+ accessor: 'paid',
+ Cell: ({ row: { original: user } }: any) => (
+
+ ),
+ disableGlobalFilter: true,
+ sortType: 'boolean',
+ },
+ {
+ Header: 'Created',
+ accessor: 'createdAt',
+ Cell: DateCell,
+ disableGlobalFilter: true,
+ sortType: 'date',
+ minWidth: 120,
+ },
+ {
+ Header: 'Avatar',
+ accessor: 'imageUrl',
+ Cell: ({ row: { original: user } }: any) => (
+
+
+
+ ),
+ disableGlobalFilter: true,
+ disableSortBy: true,
+ },
+ {
+ id: 'name',
+ Header: 'Name',
+ accessor: (row: any) => row.name || '',
+ width: '40%',
+ Cell: HighlightCell,
+ },
+ {
+ id: 'username',
+ Header: 'Username',
+ accessor: (row: any) => row.username || row.email,
+ width: '40%',
+ Cell: HighlightCell,
+ },
+ {
+ id: 'role',
+ Header: 'Role',
+ accessor: (row: any) =>
+ roles.find((role: IRole) => role.id === row.rootRole)
+ ?.name || '',
+ disableGlobalFilter: true,
+ },
+ {
+ id: 'last-login',
+ Header: 'Last login',
+ accessor: (row: any) => row.seenAt || '',
+ Cell: ({ row: { original: user } }: any) => (
+
+ ),
+ disableGlobalFilter: true,
+ sortType: 'date',
+ minWidth: 150,
+ },
+ {
+ Header: 'Actions',
+ id: 'Actions',
+ align: 'center',
+ Cell: ({ row: { original: user } }: any) => (
+ {
+ navigate(`/admin/users/${user.id}/edit`);
+ }}
+ onChangePassword={openPwDialog(user)}
+ onDelete={openDelDialog(user)}
+ />
+ ),
+ width: 100,
+ disableGlobalFilter: true,
+ disableSortBy: true,
+ },
+ ],
+ [roles, navigate, isBillingUsers]
+ );
+
+ const initialState = useMemo(() => {
+ return {
+ sortBy: [{ id: 'createdAt' }],
+ hiddenColumns: isBillingUsers ? [] : ['type'],
+ };
+ }, [isBillingUsers]);
+
+ const data = isBillingUsers ? planUsers : users;
+
+ const {
+ getTableProps,
+ getTableBodyProps,
+ headerGroups,
+ rows,
+ prepareRow,
+ state: { globalFilter },
+ setGlobalFilter,
+ setHiddenColumns,
+ } = useTable(
+ {
+ columns: columns,
+ data,
+ initialState,
+ sortTypes,
+ autoResetGlobalFilter: false,
+ autoResetSortBy: false,
+ disableSortRemove: true,
+ defaultColumn: {
+ Cell: TextCell,
+ },
+ },
+ useGlobalFilter,
+ useSortBy
+ );
+
+ useEffect(() => {
+ const hiddenColumns = [];
+ if (!isBillingUsers || isSmallScreen) {
+ hiddenColumns.push('type');
+ }
+ if (isSmallScreen) {
+ hiddenColumns.push('createdAt', 'username');
+ }
+ if (isExtraSmallScreen) {
+ hiddenColumns.push('imageUrl', 'role', 'last-login');
+ }
+ setHiddenColumns(hiddenColumns);
+ }, [setHiddenColumns, isExtraSmallScreen, isSmallScreen, isBillingUsers]);
+
+ return (
+
+
+
+
+ >
+ }
+ />
+ }
+ >
+
+
+
+
+ {rows.map(row => {
+ prepareRow(row);
+ return (
+
+ {row.cells.map(cell => (
+
+ {cell.render('Cell')}
+
+ ))}
+
+ );
+ })}
+
+
+
+ 0}
+ show={
+
+ No users found matching “
+ {globalFilter}
+ ”
+
+ }
+ elseShow={
+
+ No users available. Get started by adding one.
+
+ }
+ />
+ }
+ />
+
+
+
+ (
+
+ )}
+ />
+ onDeleteUser(delUser!)}
+ userLoading={userLoading}
+ userApiErrors={userApiErrors}
+ />
+ }
+ />
+
+ );
+};
+
+export default UsersList;
diff --git a/frontend/src/component/admin/users/hooks/useAddUserForm.ts b/frontend/src/component/admin/users/hooks/useAddUserForm.ts
new file mode 100644
index 0000000000..607f1e30e1
--- /dev/null
+++ b/frontend/src/component/admin/users/hooks/useAddUserForm.ts
@@ -0,0 +1,86 @@
+import { useEffect, useState } from 'react';
+import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+
+const useCreateUserForm = (
+ initialName = '',
+ initialEmail = '',
+ initialRootRole = 1
+) => {
+ const { uiConfig } = useUiConfig();
+ const [name, setName] = useState(initialName);
+ const [email, setEmail] = useState(initialEmail);
+ const [sendEmail, setSendEmail] = useState(false);
+ const [rootRole, setRootRole] = useState(initialRootRole);
+ const [errors, setErrors] = useState({});
+
+ const { users } = useUsers();
+
+ useEffect(() => {
+ setName(initialName);
+ }, [initialName]);
+
+ useEffect(() => {
+ setEmail(initialEmail);
+ }, [initialEmail]);
+
+ useEffect(() => {
+ setSendEmail(uiConfig?.emailEnabled || false);
+ }, [uiConfig?.emailEnabled]);
+
+ useEffect(() => {
+ setRootRole(initialRootRole);
+ }, [initialRootRole]);
+
+ const getAddUserPayload = () => {
+ return {
+ name: name,
+ email: email,
+ sendEmail: sendEmail,
+ rootRole: rootRole,
+ };
+ };
+
+ const validateName = () => {
+ if (name.length === 0) {
+ setErrors(prev => ({ ...prev, name: 'Name can not be empty.' }));
+ return false;
+ }
+ if (email.length === 0) {
+ setErrors(prev => ({ ...prev, email: 'Email can not be empty.' }));
+ return false;
+ }
+ return true;
+ };
+
+ const validateEmail = () => {
+ // @ts-expect-error
+ if (users.some(user => user['email'] === email)) {
+ setErrors(prev => ({ ...prev, email: 'Email already exists' }));
+ return false;
+ }
+ return true;
+ };
+
+ const clearErrors = () => {
+ setErrors({});
+ };
+
+ return {
+ name,
+ setName,
+ email,
+ setEmail,
+ sendEmail,
+ setSendEmail,
+ rootRole,
+ setRootRole,
+ getAddUserPayload,
+ validateName,
+ validateEmail,
+ clearErrors,
+ errors,
+ };
+};
+
+export default useCreateUserForm;
diff --git a/frontend/src/component/admin/users/util.ts b/frontend/src/component/admin/users/util.ts
new file mode 100644
index 0000000000..027e93284e
--- /dev/null
+++ b/frontend/src/component/admin/users/util.ts
@@ -0,0 +1,21 @@
+export const modalStyles = {
+ overlay: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: 'rgba(0, 0, 0, 0.25)',
+ zIndex: 5,
+ },
+ content: {
+ width: '500px',
+ maxWidth: '90%',
+ margin: '0',
+ top: '50%',
+ left: '50%',
+ right: 'auto',
+ bottom: 'auto',
+ transform: 'translate(-50%, -50%)',
+ },
+};
diff --git a/frontend/src/component/application/ApplicationEdit/ApplicationEdit.tsx b/frontend/src/component/application/ApplicationEdit/ApplicationEdit.tsx
new file mode 100644
index 0000000000..0bbacd33d7
--- /dev/null
+++ b/frontend/src/component/application/ApplicationEdit/ApplicationEdit.tsx
@@ -0,0 +1,155 @@
+/* eslint react/no-multi-comp:off */
+import React, { useContext, useState } from 'react';
+import {
+ Avatar,
+ Icon,
+ IconButton,
+ LinearProgress,
+ Link,
+ Typography,
+} from '@mui/material';
+import { Link as LinkIcon } from '@mui/icons-material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { UPDATE_APPLICATION } from 'component/providers/AccessProvider/permissions';
+import { ApplicationView } from '../ApplicationView/ApplicationView';
+import { ApplicationUpdate } from '../ApplicationUpdate/ApplicationUpdate';
+import { TabNav } from 'component/common/TabNav/TabNav/TabNav';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import { PageHeader } from 'component/common/PageHeader/PageHeader';
+import AccessContext from 'contexts/AccessContext';
+import useApplicationsApi from 'hooks/api/actions/useApplicationsApi/useApplicationsApi';
+import useApplication from 'hooks/api/getters/useApplication/useApplication';
+import { useNavigate } from 'react-router-dom';
+import { useLocationSettings } from 'hooks/useLocationSettings';
+import useToast from 'hooks/useToast';
+import PermissionButton from 'component/common/PermissionButton/PermissionButton';
+import { formatDateYMD } from 'utils/formatDate';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
+
+export const ApplicationEdit = () => {
+ const navigate = useNavigate();
+ const name = useRequiredPathParam('name');
+ const { application, loading } = useApplication(name);
+ const { appName, url, description, icon = 'apps', createdAt } = application;
+ const { hasAccess } = useContext(AccessContext);
+ const { deleteApplication } = useApplicationsApi();
+ const { locationSettings } = useLocationSettings();
+ const { setToastData, setToastApiError } = useToast();
+
+ const [showDialog, setShowDialog] = useState(false);
+
+ const toggleModal = () => {
+ setShowDialog(!showDialog);
+ };
+
+ const formatDate = (v: string) => formatDateYMD(v, locationSettings.locale);
+
+ const onDeleteApplication = async (evt: React.SyntheticEvent) => {
+ evt.preventDefault();
+ try {
+ await deleteApplication(appName);
+ setToastData({
+ title: 'Deleted Successfully',
+ text: 'Application deleted successfully',
+ type: 'success',
+ });
+ navigate('/applications');
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ const renderModal = () => (
+
+ );
+ const tabData = [
+ {
+ label: 'Application overview',
+ component: ,
+ },
+ {
+ label: 'Edit application',
+ component: ,
+ },
+ ];
+
+ if (loading) {
+ return (
+
+ );
+ } else if (!application) {
+ return Application ({appName}) not found
;
+ }
+ return (
+
+
+ {icon || 'apps'}
+
+ {appName}
+
+ }
+ title={appName}
+ actions={
+ <>
+
+
+
+ }
+ />
+
+
+ Delete
+
+ >
+ }
+ />
+ }
+ >
+
+ {description || ''}
+
+ Created: {formatDate(createdAt)}
+
+
+
+ {renderModal()}
+
+
+ }
+ />
+
+ );
+};
diff --git a/frontend/src/component/application/ApplicationList/ApplicationList.tsx b/frontend/src/component/application/ApplicationList/ApplicationList.tsx
new file mode 100644
index 0000000000..983a9aaf09
--- /dev/null
+++ b/frontend/src/component/application/ApplicationList/ApplicationList.tsx
@@ -0,0 +1,93 @@
+import { useEffect, useMemo, useState } from 'react';
+import { CircularProgress } from '@mui/material';
+import { Warning } from '@mui/icons-material';
+import { AppsLinkList, styles as themeStyles } from 'component/common';
+import { PageContent } from 'component/common/PageContent/PageContent';
+import { PageHeader } from 'component/common/PageHeader/PageHeader';
+import useApplications from 'hooks/api/getters/useApplications/useApplications';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { useSearchParams } from 'react-router-dom';
+import { Search } from 'component/common/Search/Search';
+
+type PageQueryType = Partial>;
+
+export const ApplicationList = () => {
+ const { applications, loading } = useApplications();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [searchValue, setSearchValue] = useState(
+ searchParams.get('search') || ''
+ );
+
+ useEffect(() => {
+ const tableState: PageQueryType = {};
+ if (searchValue) {
+ tableState.search = searchValue;
+ }
+
+ setSearchParams(tableState, {
+ replace: true,
+ });
+ }, [searchValue, setSearchParams]);
+
+ const filteredApplications = useMemo(() => {
+ const regExp = new RegExp(searchValue, 'i');
+ return searchValue
+ ? applications?.filter(a => regExp.test(a.appName))
+ : applications;
+ }, [applications, searchValue]);
+
+ const renderNoApplications = () => (
+ <>
+
+
+
+ Oh snap, it does not seem like you have connected any
+ applications. To connect your application to Unleash you will
+ require a Client SDK.
+
+
+ You can read more about how to use Unleash in your application
+ in the{' '}
+
+ documentation.
+
+
+ >
+ );
+
+ if (!filteredApplications) {
+ return ;
+ }
+
+ return (
+ <>
+
+ }
+ />
+ }
+ >
+
+
0}
+ show={}
+ elseShow={
+ ...loading }
+ elseShow={renderNoApplications()}
+ />
+ }
+ />
+
+
+ >
+ );
+};
diff --git a/frontend/src/component/application/ApplicationUpdate/ApplicationUpdate.tsx b/frontend/src/component/application/ApplicationUpdate/ApplicationUpdate.tsx
new file mode 100644
index 0000000000..da4793dbc9
--- /dev/null
+++ b/frontend/src/component/application/ApplicationUpdate/ApplicationUpdate.tsx
@@ -0,0 +1,85 @@
+import { ChangeEvent, useState } from 'react';
+import { Grid, TextField } from '@mui/material';
+import { useThemeStyles } from 'themes/themeStyles';
+import icons from 'component/application/iconNames';
+import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
+import useApplicationsApi from 'hooks/api/actions/useApplicationsApi/useApplicationsApi';
+import useToast from 'hooks/useToast';
+import { IApplication } from 'interfaces/application';
+import useApplication from 'hooks/api/getters/useApplication/useApplication';
+import { formatUnknownError } from 'utils/formatUnknownError';
+
+interface IApplicationUpdateProps {
+ application: IApplication;
+}
+
+export const ApplicationUpdate = ({ application }: IApplicationUpdateProps) => {
+ const { storeApplicationMetaData } = useApplicationsApi();
+ const { appName, icon, url, description } = application;
+ const { refetchApplication } = useApplication(appName);
+ const [localUrl, setLocalUrl] = useState(url || '');
+ const [localDescription, setLocalDescription] = useState(description || '');
+ const { setToastData, setToastApiError } = useToast();
+ const { classes: themeStyles } = useThemeStyles();
+
+ const onChange = async (
+ field: string,
+ value: string,
+ event?: ChangeEvent
+ ) => {
+ event?.preventDefault();
+ try {
+ await storeApplicationMetaData(appName, field, value);
+ refetchApplication();
+ setToastData({
+ type: 'success',
+ title: 'Updated Successfully',
+ text: `${field} successfully updated`,
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ return (
+
+
+
+ ({ key: v, label: v }))}
+ value={icon || 'apps'}
+ onChange={key => onChange('icon', key)}
+ />
+
+
+ setLocalUrl(e.target.value)}
+ label="Application URL"
+ placeholder="https://example.com"
+ type="url"
+ variant="outlined"
+ size="small"
+ onBlur={e => onChange('url', localUrl, e)}
+ />
+
+
+ setLocalDescription(e.target.value)}
+ onBlur={e =>
+ onChange('description', localDescription, e)
+ }
+ />
+
+
+
+ );
+};
diff --git a/frontend/src/component/application/ApplicationView/ApplicationView.tsx b/frontend/src/component/application/ApplicationView/ApplicationView.tsx
new file mode 100644
index 0000000000..b6c014aa2f
--- /dev/null
+++ b/frontend/src/component/application/ApplicationView/ApplicationView.tsx
@@ -0,0 +1,217 @@
+import { useContext } from 'react';
+import { Link } from 'react-router-dom';
+import {
+ Grid,
+ List,
+ ListItem,
+ ListItemAvatar,
+ ListItemText,
+ Typography,
+ Divider,
+} from '@mui/material';
+import {
+ Extension,
+ FlagRounded,
+ Report,
+ SvgIconComponent,
+ Timeline,
+} from '@mui/icons-material';
+import {
+ CREATE_FEATURE,
+ CREATE_STRATEGY,
+} from 'component/providers/AccessProvider/permissions';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { getTogglePath } from 'utils/routePathHelpers';
+import useApplication from 'hooks/api/getters/useApplication/useApplication';
+import AccessContext from 'contexts/AccessContext';
+import { formatDateYMDHMS } from 'utils/formatDate';
+import { useLocationSettings } from 'hooks/useLocationSettings';
+import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
+
+export const ApplicationView = () => {
+ const { hasAccess } = useContext(AccessContext);
+ const name = useRequiredPathParam('name');
+ const { application } = useApplication(name);
+ const { locationSettings } = useLocationSettings();
+ const { instances, strategies, seenToggles } = application;
+
+ const notFoundListItem = ({
+ createUrl,
+ name,
+ permission,
+ }: {
+ createUrl: string;
+ name: string;
+ permission: string;
+ }) => (
+
+
+
+
+ {name}}
+ secondary={'Missing, want to create?'}
+ />
+
+ }
+ elseShow={
+
+
+
+
+
+
+ }
+ />
+ );
+
+ const foundListItem = ({
+ viewUrl,
+ name,
+ description,
+ Icon,
+ i,
+ }: {
+ viewUrl: string;
+ name: string;
+ description: string;
+ Icon: SvgIconComponent;
+ i: number;
+ }) => (
+
+
+
+
+
+ {name}
+
+ }
+ secondary={description}
+ />
+
+ );
+ return (
+
+
+
+ Toggles
+
+
+
+ {seenToggles.map(
+ ({ name, description, notFound, project }, i) => (
+
+ )
+ )}
+
+
+
+
+ Implemented strategies
+
+
+
+ {strategies.map(
+ ({ name, description, notFound }, i: number) => (
+
+ )
+ )}
+
+
+
+
+ {instances.length} Instances registered
+
+
+
+ {instances.map(
+ ({
+ instanceId,
+ clientIp,
+ lastSeen,
+ sdkVersion,
+ }: {
+ instanceId: string;
+ clientIp: string;
+ lastSeen: string;
+ sdkVersion: string;
+ }) => (
+
+
+
+
+
+ {instanceId} {sdkVersion}
+
+ }
+ elseShow={{instanceId}}
+ />
+ }
+ secondary={
+
+ {clientIp} last seen at{' '}
+
+ {formatDateYMDHMS(
+ lastSeen,
+ locationSettings.locale
+ )}
+
+
+ }
+ />
+
+ )
+ )}
+
+
+
+ );
+};
diff --git a/frontend/src/component/application/iconNames.ts b/frontend/src/component/application/iconNames.ts
new file mode 100644
index 0000000000..90d5ac720e
--- /dev/null
+++ b/frontend/src/component/application/iconNames.ts
@@ -0,0 +1,934 @@
+export default [
+ '3d_rotation',
+ 'ac_unit',
+ 'access_alarm',
+ 'access_alarms',
+ 'access_time',
+ 'accessibility',
+ 'accessible',
+ 'account_balance',
+ 'account_balance_wallet',
+ 'account_box',
+ 'account_circle',
+ 'adb',
+ 'add',
+ 'add_a_photo',
+ 'add_alarm',
+ 'add_alert',
+ 'add_box',
+ 'add_circle',
+ 'add_circle_outline',
+ 'add_location',
+ 'add_shopping_cart',
+ 'add_to_photos',
+ 'add_to_queue',
+ 'adjust',
+ 'airline_seat_flat',
+ 'airline_seat_flat_angled',
+ 'airline_seat_individual_suite',
+ 'airline_seat_legroom_extra',
+ 'airline_seat_legroom_normal',
+ 'airline_seat_legroom_reduced',
+ 'airline_seat_recline_extra',
+ 'airline_seat_recline_normal',
+ 'airplanemode_active',
+ 'airplanemode_inactive',
+ 'airplay',
+ 'airport_shuttle',
+ 'alarm',
+ 'alarm_add',
+ 'alarm_off',
+ 'alarm_on',
+ 'album',
+ 'all_inclusive',
+ 'all_out',
+ 'android',
+ 'announcement',
+ 'apps',
+ 'archive',
+ 'arrow_back',
+ 'arrow_downward',
+ 'arrow_drop_down',
+ 'arrow_drop_down_circle',
+ 'arrow_drop_up',
+ 'arrow_forward',
+ 'arrow_upward',
+ 'art_track',
+ 'aspect_ratio',
+ 'assessment',
+ 'assignment',
+ 'assignment_ind',
+ 'assignment_late',
+ 'assignment_return',
+ 'assignment_returned',
+ 'assignment_turned_in',
+ 'assistant',
+ 'assistant_photo',
+ 'attach_file',
+ 'attach_money',
+ 'attachment',
+ 'audiotrack',
+ 'autorenew',
+ 'av_timer',
+ 'backspace',
+ 'backup',
+ 'battery_alert',
+ 'battery_charging_full',
+ 'battery_full',
+ 'battery_std',
+ 'battery_unknown',
+ 'beach_access',
+ 'beenhere',
+ 'block',
+ 'bluetooth',
+ 'bluetooth_audio',
+ 'bluetooth_connected',
+ 'bluetooth_disabled',
+ 'bluetooth_searching',
+ 'blur_circular',
+ 'blur_linear',
+ 'blur_off',
+ 'blur_on',
+ 'book',
+ 'bookmark',
+ 'bookmark_border',
+ 'border_all',
+ 'border_bottom',
+ 'border_clear',
+ 'border_color',
+ 'border_horizontal',
+ 'border_inner',
+ 'border_left',
+ 'border_outer',
+ 'border_right',
+ 'border_style',
+ 'border_top',
+ 'border_vertical',
+ 'branding_watermark',
+ 'brightness_1',
+ 'brightness_2',
+ 'brightness_3',
+ 'brightness_4',
+ 'brightness_5',
+ 'brightness_6',
+ 'brightness_7',
+ 'brightness_auto',
+ 'brightness_high',
+ 'brightness_low',
+ 'brightness_medium',
+ 'broken_image',
+ 'brush',
+ 'bubble_chart',
+ 'bug_report',
+ 'build',
+ 'burst_mode',
+ 'business',
+ 'business_center',
+ 'cached',
+ 'cake',
+ 'call',
+ 'call_end',
+ 'call_made',
+ 'call_merge',
+ 'call_missed',
+ 'call_missed_outgoing',
+ 'call_received',
+ 'call_split',
+ 'call_to_action',
+ 'camera',
+ 'camera_alt',
+ 'camera_enhance',
+ 'camera_front',
+ 'camera_rear',
+ 'camera_roll',
+ 'cancel',
+ 'card_giftcard',
+ 'card_membership',
+ 'card_travel',
+ 'casino',
+ 'cast',
+ 'cast_connected',
+ 'center_focus_strong',
+ 'center_focus_weak',
+ 'change_history',
+ 'chat',
+ 'chat_bubble',
+ 'chat_bubble_outline',
+ 'check',
+ 'check_box',
+ 'check_box_outline_blank',
+ 'check_circle',
+ 'chevron_left',
+ 'chevron_right',
+ 'child_care',
+ 'child_friendly',
+ 'chrome_reader_mode',
+ 'class',
+ 'clear',
+ 'clear_all',
+ 'close',
+ 'closed_caption',
+ 'cloud',
+ 'cloud_circle',
+ 'cloud_done',
+ 'cloud_download',
+ 'cloud_off',
+ 'cloud_queue',
+ 'cloud_upload',
+ 'code',
+ 'collections',
+ 'collections_bookmark',
+ 'color_lens',
+ 'colorize',
+ 'comment',
+ 'compare',
+ 'compare_arrows',
+ 'computer',
+ 'confirmation_number',
+ 'contact_mail',
+ 'contact_phone',
+ 'contacts',
+ 'content_copy',
+ 'content_cut',
+ 'content_paste',
+ 'control_point',
+ 'control_point_duplicate',
+ 'copyright',
+ 'create',
+ 'create_new_folder',
+ 'credit_card',
+ 'crop',
+ 'crop_16_9',
+ 'crop_3_2',
+ 'crop_5_4',
+ 'crop_7_5',
+ 'crop_din',
+ 'crop_free',
+ 'crop_landscape',
+ 'crop_original',
+ 'crop_portrait',
+ 'crop_rotate',
+ 'crop_square',
+ 'dashboard',
+ 'data_usage',
+ 'date_range',
+ 'dehaze',
+ 'delete',
+ 'delete_forever',
+ 'delete_sweep',
+ 'description',
+ 'desktop_mac',
+ 'desktop_windows',
+ 'details',
+ 'developer_board',
+ 'developer_mode',
+ 'device_hub',
+ 'devices',
+ 'devices_other',
+ 'dialer_sip',
+ 'dialpad',
+ 'directions',
+ 'directions_bike',
+ 'directions_boat',
+ 'directions_bus',
+ 'directions_car',
+ 'directions_railway',
+ 'directions_run',
+ 'directions_subway',
+ 'directions_transit',
+ 'directions_walk',
+ 'disc_full',
+ 'dns',
+ 'do_not_disturb',
+ 'do_not_disturb_alt',
+ 'do_not_disturb_off',
+ 'do_not_disturb_on',
+ 'dock',
+ 'domain',
+ 'done',
+ 'done_all',
+ 'donut_large',
+ 'donut_small',
+ 'drafts',
+ 'drag_handle',
+ 'drive_eta',
+ 'dvr',
+ 'edit',
+ 'edit_location',
+ 'eject',
+ 'email',
+ 'enhanced_encryption',
+ 'equalizer',
+ 'error',
+ 'error_outline',
+ 'euro_symbol',
+ 'ev_station',
+ 'event',
+ 'event_available',
+ 'event_busy',
+ 'event_note',
+ 'event_seat',
+ 'exit_to_app',
+ 'expand_less',
+ 'expand_more',
+ 'explicit',
+ 'explore',
+ 'exposure',
+ 'exposure_neg_1',
+ 'exposure_neg_2',
+ 'exposure_plus_1',
+ 'exposure_plus_2',
+ 'exposure_zero',
+ 'extension',
+ 'face',
+ 'fast_forward',
+ 'fast_rewind',
+ 'favorite',
+ 'favorite_border',
+ 'featured_play_list',
+ 'featured_video',
+ 'feedback',
+ 'fiber_dvr',
+ 'fiber_manual_record',
+ 'fiber_new',
+ 'fiber_pin',
+ 'fiber_smart_record',
+ 'file_download',
+ 'file_upload',
+ 'filter',
+ 'filter_1',
+ 'filter_2',
+ 'filter_3',
+ 'filter_4',
+ 'filter_5',
+ 'filter_6',
+ 'filter_7',
+ 'filter_8',
+ 'filter_9',
+ 'filter_9_plus',
+ 'filter_b_and_w',
+ 'filter_center_focus',
+ 'filter_drama',
+ 'filter_frames',
+ 'filter_hdr',
+ 'filter_list',
+ 'filter_none',
+ 'filter_tilt_shift',
+ 'filter_vintage',
+ 'find_in_page',
+ 'find_replace',
+ 'fingerprint',
+ 'first_page',
+ 'fitness_center',
+ 'flag',
+ 'flare',
+ 'flash_auto',
+ 'flash_off',
+ 'flash_on',
+ 'flight',
+ 'flight_land',
+ 'flight_takeoff',
+ 'flip',
+ 'flip_to_back',
+ 'flip_to_front',
+ 'folder',
+ 'folder_open',
+ 'folder_shared',
+ 'folder_special',
+ 'font_download',
+ 'format_align_center',
+ 'format_align_justify',
+ 'format_align_left',
+ 'format_align_right',
+ 'format_bold',
+ 'format_clear',
+ 'format_color_fill',
+ 'format_color_reset',
+ 'format_color_text',
+ 'format_indent_decrease',
+ 'format_indent_increase',
+ 'format_italic',
+ 'format_line_spacing',
+ 'format_list_bulleted',
+ 'format_list_numbered',
+ 'format_paint',
+ 'format_quote',
+ 'format_shapes',
+ 'format_size',
+ 'format_strikethrough',
+ 'format_textdirection_l_to_r',
+ 'format_textdirection_r_to_l',
+ 'format_underlined',
+ 'forum',
+ 'forward',
+ 'forward_10',
+ 'forward_30',
+ 'forward_5',
+ 'free_breakfast',
+ 'fullscreen',
+ 'fullscreen_exit',
+ 'functions',
+ 'g_translate',
+ 'gamepad',
+ 'games',
+ 'gavel',
+ 'gesture',
+ 'get_app',
+ 'gif',
+ 'golf_course',
+ 'gps_fixed',
+ 'gps_not_fixed',
+ 'gps_off',
+ 'grade',
+ 'gradient',
+ 'grain',
+ 'graphic_eq',
+ 'grid_off',
+ 'grid_on',
+ 'group',
+ 'group_add',
+ 'group_work',
+ 'hd',
+ 'hdr_off',
+ 'hdr_on',
+ 'hdr_strong',
+ 'hdr_weak',
+ 'headset',
+ 'headset_mic',
+ 'healing',
+ 'hearing',
+ 'help',
+ 'help_outline',
+ 'high_quality',
+ 'highlight',
+ 'highlight_off',
+ 'history',
+ 'home',
+ 'hot_tub',
+ 'hotel',
+ 'hourglass_empty',
+ 'hourglass_full',
+ 'http',
+ 'https',
+ 'image',
+ 'image_aspect_ratio',
+ 'import_contacts',
+ 'import_export',
+ 'important_devices',
+ 'inbox',
+ 'indeterminate_check_box',
+ 'info',
+ 'info_outline',
+ 'input',
+ 'insert_chart',
+ 'insert_comment',
+ 'insert_drive_file',
+ 'insert_emoticon',
+ 'insert_invitation',
+ 'insert_link',
+ 'insert_photo',
+ 'invert_colors',
+ 'invert_colors_off',
+ 'iso',
+ 'keyboard',
+ 'keyboard_arrow_down',
+ 'keyboard_arrow_left',
+ 'keyboard_arrow_right',
+ 'keyboard_arrow_up',
+ 'keyboard_backspace',
+ 'keyboard_capslock',
+ 'keyboard_hide',
+ 'keyboard_return',
+ 'keyboard_tab',
+ 'keyboard_voice',
+ 'kitchen',
+ 'label',
+ 'label_outline',
+ 'landscape',
+ 'language',
+ 'laptop',
+ 'laptop_chromebook',
+ 'laptop_mac',
+ 'laptop_windows',
+ 'last_page',
+ 'launch',
+ 'layers',
+ 'layers_clear',
+ 'leak_add',
+ 'leak_remove',
+ 'lens',
+ 'library_add',
+ 'library_books',
+ 'library_music',
+ 'lightbulb_outline',
+ 'line_style',
+ 'line_weight',
+ 'linear_scale',
+ 'link',
+ 'linked_camera',
+ 'list',
+ 'live_help',
+ 'live_tv',
+ 'local_activity',
+ 'local_airport',
+ 'local_atm',
+ 'local_bar',
+ 'local_cafe',
+ 'local_car_wash',
+ 'local_convenience_store',
+ 'local_dining',
+ 'local_drink',
+ 'local_florist',
+ 'local_gas_station',
+ 'local_grocery_store',
+ 'local_hospital',
+ 'local_hotel',
+ 'local_laundry_service',
+ 'local_library',
+ 'local_mall',
+ 'local_movies',
+ 'local_offer',
+ 'local_parking',
+ 'local_pharmacy',
+ 'local_phone',
+ 'local_pizza',
+ 'local_play',
+ 'local_post_office',
+ 'local_printshop',
+ 'local_see',
+ 'local_shipping',
+ 'local_taxi',
+ 'location_city',
+ 'location_disabled',
+ 'location_off',
+ 'location_on',
+ 'location_searching',
+ 'lock',
+ 'lock_open',
+ 'lock_outline',
+ 'looks',
+ 'looks_3',
+ 'looks_4',
+ 'looks_5',
+ 'looks_6',
+ 'looks_one',
+ 'looks_two',
+ 'loop',
+ 'loupe',
+ 'low_priority',
+ 'loyalty',
+ 'mail',
+ 'mail_outline',
+ 'map',
+ 'markunread',
+ 'markunread_mailbox',
+ 'memory',
+ 'menu',
+ 'merge_type',
+ 'message',
+ 'mic',
+ 'mic_none',
+ 'mic_off',
+ 'mms',
+ 'mode_comment',
+ 'mode_edit',
+ 'monetization_on',
+ 'money_off',
+ 'monochrome_photos',
+ 'mood',
+ 'mood_bad',
+ 'more',
+ 'more_horiz',
+ 'more_vert',
+ 'motorcycle',
+ 'mouse',
+ 'move_to_inbox',
+ 'movie',
+ 'movie_creation',
+ 'movie_filter',
+ 'multiline_chart',
+ 'music_note',
+ 'music_video',
+ 'my_location',
+ 'nature',
+ 'nature_people',
+ 'navigate_before',
+ 'navigate_next',
+ 'navigation',
+ 'near_me',
+ 'network_cell',
+ 'network_check',
+ 'network_locked',
+ 'network_wifi',
+ 'new_releases',
+ 'next_week',
+ 'nfc',
+ 'no_encryption',
+ 'no_sim',
+ 'not_interested',
+ 'note',
+ 'note_add',
+ 'notifications',
+ 'notifications_active',
+ 'notifications_none',
+ 'notifications_off',
+ 'notifications_paused',
+ 'offline_pin',
+ 'ondemand_video',
+ 'opacity',
+ 'open_in_browser',
+ 'open_in_new',
+ 'open_with',
+ 'pages',
+ 'pageview',
+ 'palette',
+ 'pan_tool',
+ 'panorama',
+ 'panorama_fish_eye',
+ 'panorama_horizontal',
+ 'panorama_vertical',
+ 'panorama_wide_angle',
+ 'party_mode',
+ 'pause',
+ 'pause_circle_filled',
+ 'pause_circle_outline',
+ 'payment',
+ 'people',
+ 'people_outline',
+ 'perm_camera_mic',
+ 'perm_contact_calendar',
+ 'perm_data_setting',
+ 'perm_device_information',
+ 'perm_identity',
+ 'perm_media',
+ 'perm_phone_msg',
+ 'perm_scan_wifi',
+ 'person',
+ 'person_add',
+ 'person_outline',
+ 'person_pin',
+ 'person_pin_circle',
+ 'personal_video',
+ 'pets',
+ 'phone',
+ 'phone_android',
+ 'phone_bluetooth_speaker',
+ 'phone_forwarded',
+ 'phone_in_talk',
+ 'phone_iphone',
+ 'phone_locked',
+ 'phone_missed',
+ 'phone_paused',
+ 'phonelink',
+ 'phonelink_erase',
+ 'phonelink_lock',
+ 'phonelink_off',
+ 'phonelink_ring',
+ 'phonelink_setup',
+ 'photo',
+ 'photo_album',
+ 'photo_camera',
+ 'photo_filter',
+ 'photo_library',
+ 'photo_size_select_actual',
+ 'photo_size_select_large',
+ 'photo_size_select_small',
+ 'picture_as_pdf',
+ 'picture_in_picture',
+ 'picture_in_picture_alt',
+ 'pie_chart',
+ 'pie_chart_outlined',
+ 'pin_drop',
+ 'place',
+ 'play_arrow',
+ 'play_circle_filled',
+ 'play_circle_outline',
+ 'play_for_work',
+ 'playlist_add',
+ 'playlist_add_check',
+ 'playlist_play',
+ 'plus_one',
+ 'poll',
+ 'polymer',
+ 'pool',
+ 'portable_wifi_off',
+ 'portrait',
+ 'power',
+ 'power_input',
+ 'power_settings_new',
+ 'pregnant_woman',
+ 'present_to_all',
+ 'print',
+ 'priority_high',
+ 'public',
+ 'publish',
+ 'query_builder',
+ 'question_answer',
+ 'queue',
+ 'queue_music',
+ 'queue_play_next',
+ 'radio',
+ 'radio_button_checked',
+ 'radio_button_unchecked',
+ 'rate_review',
+ 'receipt',
+ 'recent_actors',
+ 'record_voice_over',
+ 'redeem',
+ 'redo',
+ 'refresh',
+ 'remove',
+ 'remove_circle',
+ 'remove_circle_outline',
+ 'remove_from_queue',
+ 'remove_red_eye',
+ 'remove_shopping_cart',
+ 'reorder',
+ 'repeat',
+ 'repeat_one',
+ 'replay',
+ 'replay_10',
+ 'replay_30',
+ 'replay_5',
+ 'reply',
+ 'reply_all',
+ 'report',
+ 'report_problem',
+ 'restaurant',
+ 'restaurant_menu',
+ 'restore',
+ 'restore_page',
+ 'ring_volume',
+ 'room',
+ 'room_service',
+ 'rotate_90_degrees_ccw',
+ 'rotate_left',
+ 'rotate_right',
+ 'rounded_corner',
+ 'router',
+ 'rowing',
+ 'rss_feed',
+ 'rv_hookup',
+ 'satellite',
+ 'save',
+ 'scanner',
+ 'schedule',
+ 'school',
+ 'screen_lock_landscape',
+ 'screen_lock_portrait',
+ 'screen_lock_rotation',
+ 'screen_rotation',
+ 'screen_share',
+ 'sd_card',
+ 'sd_storage',
+ 'search',
+ 'security',
+ 'select_all',
+ 'send',
+ 'sentiment_dissatisfied',
+ 'sentiment_neutral',
+ 'sentiment_satisfied',
+ 'sentiment_very_dissatisfied',
+ 'sentiment_very_satisfied',
+ 'settings',
+ 'settings_applications',
+ 'settings_backup_restore',
+ 'settings_bluetooth',
+ 'settings_brightness',
+ 'settings_cell',
+ 'settings_ethernet',
+ 'settings_input_antenna',
+ 'settings_input_component',
+ 'settings_input_composite',
+ 'settings_input_hdmi',
+ 'settings_input_svideo',
+ 'settings_overscan',
+ 'settings_phone',
+ 'settings_power',
+ 'settings_remote',
+ 'settings_system_daydream',
+ 'settings_voice',
+ 'share',
+ 'shop',
+ 'shop_two',
+ 'shopping_basket',
+ 'shopping_cart',
+ 'short_text',
+ 'show_chart',
+ 'shuffle',
+ 'signal_cellular_4_bar',
+ 'signal_cellular_connected_no_internet_4_bar',
+ 'signal_cellular_no_sim',
+ 'signal_cellular_null',
+ 'signal_cellular_off',
+ 'signal_wifi_4_bar',
+ 'signal_wifi_4_bar_lock',
+ 'signal_wifi_off',
+ 'sim_card',
+ 'sim_card_alert',
+ 'skip_next',
+ 'skip_previous',
+ 'slideshow',
+ 'slow_motion_video',
+ 'smartphone',
+ 'smoke_free',
+ 'smoking_rooms',
+ 'sms',
+ 'sms_failed',
+ 'snooze',
+ 'sort',
+ 'sort_by_alpha',
+ 'spa',
+ 'space_bar',
+ 'speaker',
+ 'speaker_group',
+ 'speaker_notes',
+ 'speaker_notes_off',
+ 'speaker_phone',
+ 'spellcheck',
+ 'star',
+ 'star_border',
+ 'star_half',
+ 'stars',
+ 'stay_current_landscape',
+ 'stay_current_portrait',
+ 'stay_primary_landscape',
+ 'stay_primary_portrait',
+ 'stop',
+ 'stop_screen_share',
+ 'storage',
+ 'store',
+ 'store_mall_directory',
+ 'straighten',
+ 'streetview',
+ 'strikethrough_s',
+ 'style',
+ 'subdirectory_arrow_left',
+ 'subdirectory_arrow_right',
+ 'subject',
+ 'subscriptions',
+ 'subtitles',
+ 'subway',
+ 'supervisor_account',
+ 'surround_sound',
+ 'swap_calls',
+ 'swap_horiz',
+ 'swap_vert',
+ 'swap_vertical_circle',
+ 'switch_camera',
+ 'switch_video',
+ 'sync',
+ 'sync_disabled',
+ 'sync_problem',
+ 'system_update',
+ 'system_update_alt',
+ 'tab',
+ 'tab_unselected',
+ 'tablet',
+ 'tablet_android',
+ 'tablet_mac',
+ 'tag_faces',
+ 'tap_and_play',
+ 'terrain',
+ 'text_fields',
+ 'text_format',
+ 'textsms',
+ 'texture',
+ 'theaters',
+ 'thumb_down',
+ 'thumb_up',
+ 'thumbs_up_down',
+ 'time_to_leave',
+ 'timelapse',
+ 'timeline',
+ 'timer',
+ 'timer_10',
+ 'timer_3',
+ 'timer_off',
+ 'title',
+ 'toc',
+ 'today',
+ 'toll',
+ 'tonality',
+ 'touch_app',
+ 'toys',
+ 'track_changes',
+ 'traffic',
+ 'train',
+ 'tram',
+ 'transfer_within_a_station',
+ 'transform',
+ 'translate',
+ 'trending_down',
+ 'trending_flat',
+ 'trending_up',
+ 'tune',
+ 'turned_in',
+ 'turned_in_not',
+ 'tv',
+ 'unarchive',
+ 'undo',
+ 'unfold_less',
+ 'unfold_more',
+ 'update',
+ 'usb',
+ 'verified_user',
+ 'vertical_align_bottom',
+ 'vertical_align_center',
+ 'vertical_align_top',
+ 'vibration',
+ 'video_call',
+ 'video_label',
+ 'video_library',
+ 'videocam',
+ 'videocam_off',
+ 'videogame_asset',
+ 'view_agenda',
+ 'view_array',
+ 'view_carousel',
+ 'view_column',
+ 'view_comfy',
+ 'view_compact',
+ 'view_day',
+ 'view_headline',
+ 'view_list',
+ 'view_module',
+ 'view_quilt',
+ 'view_stream',
+ 'view_week',
+ 'vignette',
+ 'visibility',
+ 'visibility_off',
+ 'voice_chat',
+ 'voicemail',
+ 'volume_down',
+ 'volume_mute',
+ 'volume_off',
+ 'volume_up',
+ 'vpn_key',
+ 'vpn_lock',
+ 'wallpaper',
+ 'warning',
+ 'watch',
+ 'watch_later',
+ 'wb_auto',
+ 'wb_cloudy',
+ 'wb_incandescent',
+ 'wb_iridescent',
+ 'wb_sunny',
+ 'wc',
+ 'web',
+ 'web_asset',
+ 'weekend',
+ 'whatshot',
+ 'widgets',
+ 'wifi',
+ 'wifi_lock',
+ 'wifi_tethering',
+ 'work',
+ 'wrap_text',
+ 'youtube_searched_for',
+ 'zoom_in',
+ 'zoom_out',
+ 'zoom_out_map',
+];
diff --git a/frontend/src/component/archive/ArchiveTable/ArchiveTable.tsx b/frontend/src/component/archive/ArchiveTable/ArchiveTable.tsx
new file mode 100644
index 0000000000..714e799804
--- /dev/null
+++ b/frontend/src/component/archive/ArchiveTable/ArchiveTable.tsx
@@ -0,0 +1,310 @@
+import { PageContent } from 'component/common/PageContent/PageContent';
+import { PageHeader } from 'component/common/PageHeader/PageHeader';
+import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
+import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
+import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
+import { useMediaQuery } from '@mui/material';
+import { sortTypes } from 'utils/sortTypes';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
+import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { Search } from 'component/common/Search/Search';
+import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell';
+import { FeatureSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureSeenCell';
+import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
+import { FeatureStaleCell } from 'component/feature/FeatureToggleList/FeatureStaleCell/FeatureStaleCell';
+import { ArchivedFeatureActionCell } from 'component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureActionCell';
+import { featuresPlaceholder } from 'component/feature/FeatureToggleList/FeatureToggleListTable';
+import theme from 'themes/theme';
+import { FeatureSchema } from 'openapi';
+import { useFeatureArchiveApi } from 'hooks/api/actions/useFeatureArchiveApi/useReviveFeatureApi';
+import useToast from 'hooks/useToast';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { useSearch } from 'hooks/useSearch';
+import { FeatureArchivedCell } from './FeatureArchivedCell/FeatureArchivedCell';
+import { useSearchParams } from 'react-router-dom';
+import { ArchivedFeatureDeleteConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureDeleteConfirm/ArchivedFeatureDeleteConfirm';
+import { IFeatureToggle } from 'interfaces/featureToggle';
+
+export interface IFeaturesArchiveTableProps {
+ archivedFeatures: FeatureSchema[];
+ title: string;
+ refetch: () => void;
+ loading: boolean;
+ storedParams: SortingRule;
+ setStoredParams: (
+ newValue:
+ | SortingRule
+ | ((prev: SortingRule) => SortingRule)
+ ) => SortingRule;
+ projectId?: string;
+}
+
+export const ArchiveTable = ({
+ archivedFeatures = [],
+ loading,
+ refetch,
+ storedParams,
+ setStoredParams,
+ title,
+ projectId,
+}: IFeaturesArchiveTableProps) => {
+ const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
+ const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
+ const { setToastData, setToastApiError } = useToast();
+
+ const [deleteModalOpen, setDeleteModalOpen] = useState(false);
+ const [deletedFeature, setDeletedFeature] = useState();
+
+ const [searchParams, setSearchParams] = useSearchParams();
+ const { reviveFeature } = useFeatureArchiveApi();
+
+ const [searchValue, setSearchValue] = useState(
+ searchParams.get('search') || ''
+ );
+
+ const onRevive = useCallback(
+ async (feature: string) => {
+ try {
+ await reviveFeature(feature);
+ await refetch();
+ setToastData({
+ type: 'success',
+ title: "And we're back!",
+ text: 'The feature toggle has been revived.',
+ });
+ } catch (e: unknown) {
+ setToastApiError(formatUnknownError(e));
+ }
+ },
+ [refetch, reviveFeature, setToastApiError, setToastData]
+ );
+
+ const columns = useMemo(
+ () => [
+ {
+ id: 'Seen',
+ Header: 'Seen',
+ width: 85,
+ canSort: true,
+ Cell: FeatureSeenCell,
+ accessor: 'lastSeenAt',
+ align: 'center',
+ },
+ {
+ Header: 'Type',
+ accessor: 'type',
+ width: 85,
+ canSort: true,
+ Cell: FeatureTypeCell,
+ align: 'center',
+ },
+ {
+ Header: 'Name',
+ accessor: 'name',
+ searchable: true,
+ minWidth: 100,
+ Cell: ({ value, row: { original } }: any) => (
+
+ ),
+ sortType: 'alphanumeric',
+ },
+ {
+ Header: 'Created',
+ accessor: 'createdAt',
+ width: 150,
+ Cell: DateCell,
+ sortType: 'date',
+ },
+ {
+ Header: 'Archived',
+ accessor: 'archivedAt',
+ width: 150,
+ Cell: FeatureArchivedCell,
+ sortType: 'date',
+ },
+ ...(!projectId
+ ? [
+ {
+ Header: 'Project ID',
+ accessor: 'project',
+ sortType: 'alphanumeric',
+ filterName: 'project',
+ searchable: true,
+ maxWidth: 170,
+ Cell: ({ value }: any) => (
+
+ ),
+ },
+ ]
+ : []),
+ {
+ Header: 'State',
+ accessor: 'stale',
+ Cell: FeatureStaleCell,
+ sortType: 'boolean',
+ maxWidth: 120,
+ filterName: 'state',
+ filterParsing: (value: any) => (value ? 'stale' : 'active'),
+ },
+ {
+ Header: 'Actions',
+ id: 'Actions',
+ align: 'center',
+ maxWidth: 120,
+ canSort: false,
+ Cell: ({ row: { original: feature } }: any) => (
+ onRevive(feature.name)}
+ onDelete={() => {
+ setDeletedFeature(feature);
+ setDeleteModalOpen(true);
+ }}
+ />
+ ),
+ },
+ // Always hidden -- for search
+ {
+ accessor: 'description',
+ },
+ ],
+ //eslint-disable-next-line
+ [projectId]
+ );
+
+ const {
+ data: searchedData,
+ getSearchText,
+ getSearchContext,
+ } = useSearch(columns, searchValue, archivedFeatures);
+
+ const data = useMemo(
+ () => (loading ? featuresPlaceholder : searchedData),
+ [searchedData, loading]
+ );
+
+ const [initialState] = useState(() => ({
+ sortBy: [
+ {
+ id: searchParams.get('sort') || storedParams.id,
+ desc: searchParams.has('order')
+ ? searchParams.get('order') === 'desc'
+ : storedParams.desc,
+ },
+ ],
+ hiddenColumns: ['description'],
+ }));
+
+ const {
+ headerGroups,
+ rows,
+ state: { sortBy },
+ prepareRow,
+ setHiddenColumns,
+ } = useTable(
+ {
+ columns: columns as any[], // TODO: fix after `react-table` v8 update
+ data,
+ initialState,
+ sortTypes,
+ disableSortRemove: true,
+ autoResetSortBy: false,
+ },
+ useFlexLayout,
+ useSortBy
+ );
+
+ useEffect(() => {
+ const hiddenColumns = ['description'];
+ if (isMediumScreen) {
+ hiddenColumns.push('lastSeenAt', 'status');
+ }
+ if (isSmallScreen) {
+ hiddenColumns.push('type', 'createdAt');
+ }
+ setHiddenColumns(hiddenColumns);
+ }, [setHiddenColumns, isSmallScreen, isMediumScreen]);
+
+ useEffect(() => {
+ if (loading) {
+ return;
+ }
+ const tableState: Record = {};
+ tableState.sort = sortBy[0].id;
+ if (sortBy[0].desc) {
+ tableState.order = 'desc';
+ }
+ if (searchValue) {
+ tableState.search = searchValue;
+ }
+
+ setSearchParams(tableState, {
+ replace: true,
+ });
+ setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false });
+ }, [loading, sortBy, searchValue]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return (
+
+ }
+ />
+ }
+ >
+
+
+
+ (
+ 0}
+ show={
+
+ No feature toggles found matching “
+ {searchValue}”
+
+ }
+ elseShow={
+
+ None of the feature toggles were archived yet.
+
+ }
+ />
+ )}
+ />
+
+
+ );
+};
diff --git a/frontend/src/component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureActionCell.tsx b/frontend/src/component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureActionCell.tsx
new file mode 100644
index 0000000000..f536af806f
--- /dev/null
+++ b/frontend/src/component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureActionCell.tsx
@@ -0,0 +1,41 @@
+import { VFC } from 'react';
+import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
+import { Delete, Undo } from '@mui/icons-material';
+import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
+import {
+ DELETE_FEATURE,
+ UPDATE_FEATURE,
+} from 'component/providers/AccessProvider/permissions';
+
+interface IReviveArchivedFeatureCell {
+ onRevive: () => void;
+ onDelete: () => void;
+ project: string;
+}
+
+export const ArchivedFeatureActionCell: VFC = ({
+ onRevive,
+ onDelete,
+ project,
+}) => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureDeleteConfirm/ArchivedFeatureDeleteConfirm.tsx b/frontend/src/component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureDeleteConfirm/ArchivedFeatureDeleteConfirm.tsx
new file mode 100644
index 0000000000..0ab88b915f
--- /dev/null
+++ b/frontend/src/component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureDeleteConfirm/ArchivedFeatureDeleteConfirm.tsx
@@ -0,0 +1,100 @@
+import { Alert, styled } from '@mui/material';
+import React, { useState } from 'react';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import Input from 'component/common/Input/Input';
+import { IFeatureToggle } from 'interfaces/featureToggle';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { useFeatureArchiveApi } from 'hooks/api/actions/useFeatureArchiveApi/useReviveFeatureApi';
+import useToast from 'hooks/useToast';
+
+interface IArchivedFeatureDeleteConfirmProps {
+ deletedFeature?: IFeatureToggle;
+ open: boolean;
+ setOpen: React.Dispatch>;
+ refetch: () => void;
+}
+
+const StyledDeleteParagraph = styled('p')(({ theme }) => ({
+ marginTop: theme.spacing(4),
+}));
+
+const StyledFormInput = styled(Input)(({ theme }) => ({
+ marginTop: theme.spacing(2),
+ width: '100%',
+}));
+
+export const ArchivedFeatureDeleteConfirm = ({
+ deletedFeature,
+ open,
+ setOpen,
+ refetch,
+}: IArchivedFeatureDeleteConfirmProps) => {
+ const [confirmName, setConfirmName] = useState('');
+ const { setToastData, setToastApiError } = useToast();
+ const { deleteFeature } = useFeatureArchiveApi();
+
+ const onDeleteFeatureToggle = async () => {
+ try {
+ if (!deletedFeature) {
+ return;
+ }
+ await deleteFeature(deletedFeature.name);
+ await refetch();
+ setToastData({
+ type: 'success',
+ title: 'Feature toggle deleted',
+ text: `You have successfully deleted the ${deletedFeature.name} feature toggle.`,
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ } finally {
+ clearModal();
+ }
+ };
+
+ const clearModal = () => {
+ setOpen(false);
+ setConfirmName('');
+ };
+
+ const formId = 'delete-feature-toggle-confirmation-form';
+
+ return (
+
+
+ Warning! Before you delete a feature toggle, make sure
+ all in-code references to that feature toggle have been removed.
+ Otherwise, a new feature toggle with the same name could
+ activate the old code paths.
+
+
+
+ In order to delete this feature toggle, please enter its name in
+ the text field below:
+
+ {deletedFeature?.name}
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/archive/ArchiveTable/FeatureArchivedCell/FeatureArchivedCell.tsx b/frontend/src/component/archive/ArchiveTable/FeatureArchivedCell/FeatureArchivedCell.tsx
new file mode 100644
index 0000000000..7cd1177199
--- /dev/null
+++ b/frontend/src/component/archive/ArchiveTable/FeatureArchivedCell/FeatureArchivedCell.tsx
@@ -0,0 +1,49 @@
+import { VFC } from 'react';
+import TimeAgo from 'react-timeago';
+import { Tooltip, Typography, useTheme } from '@mui/material';
+import { formatDateYMD } from 'utils/formatDate';
+import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
+import { useLocationSettings } from 'hooks/useLocationSettings';
+
+interface IFeatureArchivedCellProps {
+ value?: string | Date | null;
+}
+
+export const FeatureArchivedCell: VFC = ({
+ value: archivedAt,
+}) => {
+ const { locationSettings } = useLocationSettings();
+ const theme = useTheme();
+
+ if (!archivedAt)
+ return (
+
+
+ not available
+
+
+ );
+
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/archive/FeaturesArchiveTable.tsx b/frontend/src/component/archive/FeaturesArchiveTable.tsx
new file mode 100644
index 0000000000..1fde18d557
--- /dev/null
+++ b/frontend/src/component/archive/FeaturesArchiveTable.tsx
@@ -0,0 +1,32 @@
+import { useFeaturesArchive } from 'hooks/api/getters/useFeaturesArchive/useFeaturesArchive';
+import { ArchiveTable } from './ArchiveTable/ArchiveTable';
+import { SortingRule } from 'react-table';
+import { usePageTitle } from 'hooks/usePageTitle';
+import { createLocalStorage } from 'utils/createLocalStorage';
+
+const defaultSort: SortingRule = { id: 'createdAt' };
+const { value, setValue } = createLocalStorage(
+ 'FeaturesArchiveTable:v1',
+ defaultSort
+);
+
+export const FeaturesArchiveTable = () => {
+ usePageTitle('Archive');
+
+ const {
+ archivedFeatures = [],
+ loading,
+ refetchArchived,
+ } = useFeaturesArchive();
+
+ return (
+
+ );
+};
diff --git a/frontend/src/component/archive/ProjectFeaturesArchiveTable.tsx b/frontend/src/component/archive/ProjectFeaturesArchiveTable.tsx
new file mode 100644
index 0000000000..5f200dfa00
--- /dev/null
+++ b/frontend/src/component/archive/ProjectFeaturesArchiveTable.tsx
@@ -0,0 +1,37 @@
+import { ArchiveTable } from './ArchiveTable/ArchiveTable';
+import { SortingRule } from 'react-table';
+import { useProjectFeaturesArchive } from 'hooks/api/getters/useProjectFeaturesArchive/useProjectFeaturesArchive';
+import { createLocalStorage } from 'utils/createLocalStorage';
+
+const defaultSort: SortingRule = { id: 'archivedAt' };
+
+interface IProjectFeaturesTable {
+ projectId: string;
+}
+
+export const ProjectFeaturesArchiveTable = ({
+ projectId,
+}: IProjectFeaturesTable) => {
+ const {
+ archivedFeatures = [],
+ refetchArchived,
+ loading,
+ } = useProjectFeaturesArchive(projectId);
+
+ const { value, setValue } = createLocalStorage(
+ `${projectId}:ProjectFeaturesArchiveTable`,
+ defaultSort
+ );
+
+ return (
+
+ );
+};
diff --git a/frontend/src/component/archive/RedirectArchive.tsx b/frontend/src/component/archive/RedirectArchive.tsx
new file mode 100644
index 0000000000..e0cbfe0f31
--- /dev/null
+++ b/frontend/src/component/archive/RedirectArchive.tsx
@@ -0,0 +1,7 @@
+import { Navigate } from 'react-router-dom';
+
+const RedirectArchive = () => {
+ return ;
+};
+
+export default RedirectArchive;
diff --git a/frontend/src/component/common/AdminAlert/AdminAlert.tsx b/frontend/src/component/common/AdminAlert/AdminAlert.tsx
new file mode 100644
index 0000000000..71a5b84253
--- /dev/null
+++ b/frontend/src/component/common/AdminAlert/AdminAlert.tsx
@@ -0,0 +1,9 @@
+import { Alert } from '@mui/material';
+
+export const AdminAlert = () => {
+ return (
+
+ You need instance admin to access this section.
+
+ );
+};
diff --git a/frontend/src/component/common/AnimateOnMount/AnimateOnMount.tsx b/frontend/src/component/common/AnimateOnMount/AnimateOnMount.tsx
new file mode 100644
index 0000000000..e05f212a43
--- /dev/null
+++ b/frontend/src/component/common/AnimateOnMount/AnimateOnMount.tsx
@@ -0,0 +1,72 @@
+import React, { useEffect, useState, useRef, FC } from 'react';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+
+interface IAnimateOnMountProps {
+ mounted: boolean;
+ enter: string;
+ start: string;
+ leave: string;
+ container?: string;
+ style?: React.CSSProperties;
+ onStart?: () => void;
+ onEnd?: () => void;
+}
+
+const AnimateOnMount: FC = ({
+ mounted,
+ enter,
+ start,
+ leave,
+ container,
+ children,
+ style,
+ onStart,
+ onEnd,
+}) => {
+ const [show, setShow] = useState(mounted);
+ const [styles, setStyles] = useState('');
+ const mountedRef = useRef(null);
+
+ useEffect(() => {
+ if (mountedRef.current !== mounted || mountedRef === null) {
+ if (mounted) {
+ setShow(true);
+ onStart?.();
+ setTimeout(() => {
+ setStyles(enter);
+ }, 50);
+ } else {
+ if (!leave) {
+ setShow(false);
+ }
+ setStyles(leave);
+ }
+ }
+ }, [mounted, enter, onStart, leave]);
+
+ const onTransitionEnd = () => {
+ if (!mounted) {
+ setShow(false);
+ onEnd?.();
+ }
+ };
+
+ return (
+
+ {children}
+
+ }
+ />
+ );
+};
+
+export default AnimateOnMount;
diff --git a/frontend/src/component/common/Announcer/AnnouncerContext/AnnouncerContext.test.tsx b/frontend/src/component/common/Announcer/AnnouncerContext/AnnouncerContext.test.tsx
new file mode 100644
index 0000000000..97851c3547
--- /dev/null
+++ b/frontend/src/component/common/Announcer/AnnouncerContext/AnnouncerContext.test.tsx
@@ -0,0 +1,24 @@
+import { render } from 'utils/testRenderer';
+import { AnnouncerContext } from 'component/common/Announcer/AnnouncerContext/AnnouncerContext';
+import { useContext, useEffect } from 'react';
+import { screen } from '@testing-library/react';
+import { ANNOUNCER_ELEMENT_TEST_ID } from 'utils/testIds';
+
+test('AnnouncerContext', async () => {
+ const TestComponent = () => {
+ const { setAnnouncement } = useContext(AnnouncerContext);
+
+ useEffect(() => {
+ setAnnouncement('Foo');
+ setAnnouncement('Bar');
+ }, [setAnnouncement]);
+
+ return null;
+ };
+
+ render();
+
+ const el = screen.getByTestId(ANNOUNCER_ELEMENT_TEST_ID);
+ expect(el).not.toHaveTextContent('Foo');
+ expect(el).toHaveTextContent('Bar');
+});
diff --git a/frontend/src/component/common/Announcer/AnnouncerContext/AnnouncerContext.tsx b/frontend/src/component/common/Announcer/AnnouncerContext/AnnouncerContext.tsx
new file mode 100644
index 0000000000..5d8e4f429a
--- /dev/null
+++ b/frontend/src/component/common/Announcer/AnnouncerContext/AnnouncerContext.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+
+export interface IAnnouncerContext {
+ setAnnouncement: React.Dispatch>;
+}
+
+const setAnnouncementPlaceholder = () => {
+ throw new Error('setAnnouncement called outside AnnouncerContext');
+};
+
+// AnnouncerContext announces messages to screen readers through a live region.
+// Call setAnnouncement to broadcast a new message to the screen reader.
+export const AnnouncerContext = React.createContext({
+ setAnnouncement: setAnnouncementPlaceholder,
+});
diff --git a/frontend/src/component/common/Announcer/AnnouncerElement/AnnouncerElement.styles.ts b/frontend/src/component/common/Announcer/AnnouncerElement/AnnouncerElement.styles.ts
new file mode 100644
index 0000000000..5e55220bdb
--- /dev/null
+++ b/frontend/src/component/common/Announcer/AnnouncerElement/AnnouncerElement.styles.ts
@@ -0,0 +1,14 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()({
+ container: {
+ clip: 'rect(0 0 0 0)',
+ clipPath: 'inset(50%)',
+ zIndex: -1,
+ width: 1,
+ height: 1,
+ margin: -1,
+ padding: 0,
+ overflow: 'hidden',
+ },
+});
diff --git a/frontend/src/component/common/Announcer/AnnouncerElement/AnnouncerElement.tsx b/frontend/src/component/common/Announcer/AnnouncerElement/AnnouncerElement.tsx
new file mode 100644
index 0000000000..b58321b075
--- /dev/null
+++ b/frontend/src/component/common/Announcer/AnnouncerElement/AnnouncerElement.tsx
@@ -0,0 +1,25 @@
+import React, { ReactElement } from 'react';
+import { useStyles } from 'component/common/Announcer/AnnouncerElement/AnnouncerElement.styles';
+import { ANNOUNCER_ELEMENT_TEST_ID } from 'utils/testIds';
+
+interface IAnnouncerElementProps {
+ announcement?: string;
+}
+
+export const AnnouncerElement = ({
+ announcement,
+}: IAnnouncerElementProps): ReactElement => {
+ const { classes: styles } = useStyles();
+
+ return (
+
+ {announcement}
+
+ );
+};
diff --git a/frontend/src/component/common/Announcer/AnnouncerProvider/AnnouncerProvider.tsx b/frontend/src/component/common/Announcer/AnnouncerProvider/AnnouncerProvider.tsx
new file mode 100644
index 0000000000..a2fdc63e4f
--- /dev/null
+++ b/frontend/src/component/common/Announcer/AnnouncerProvider/AnnouncerProvider.tsx
@@ -0,0 +1,27 @@
+import React, { ReactElement, useMemo, useState, ReactNode } from 'react';
+import { AnnouncerContext } from '../AnnouncerContext/AnnouncerContext';
+import { AnnouncerElement } from 'component/common/Announcer/AnnouncerElement/AnnouncerElement';
+
+interface IAnnouncerProviderProps {
+ children: ReactNode;
+}
+
+export const AnnouncerProvider = ({
+ children,
+}: IAnnouncerProviderProps): ReactElement => {
+ const [announcement, setAnnouncement] = useState();
+
+ const value = useMemo(
+ () => ({
+ setAnnouncement,
+ }),
+ [setAnnouncement]
+ );
+
+ return (
+
+ {children}
+
+
+ );
+};
diff --git a/frontend/src/component/common/ApiError/ApiError.tsx b/frontend/src/component/common/ApiError/ApiError.tsx
new file mode 100644
index 0000000000..379b0172d6
--- /dev/null
+++ b/frontend/src/component/common/ApiError/ApiError.tsx
@@ -0,0 +1,34 @@
+import { Button } from '@mui/material';
+import { Alert } from '@mui/material';
+import React from 'react';
+
+interface IApiErrorProps {
+ className?: string;
+ onClick: () => void;
+ text: string;
+ style?: React.CSSProperties;
+}
+
+const ApiError: React.FC = ({
+ className,
+ onClick,
+ text,
+ ...rest
+}) => {
+ return (
+
+ TRY AGAIN
+
+ }
+ severity="error"
+ {...rest}
+ >
+ {text}
+
+ );
+};
+
+export default ApiError;
diff --git a/frontend/src/component/common/AutocompleteBox/AutocompleteBox.styles.ts b/frontend/src/component/common/AutocompleteBox/AutocompleteBox.styles.ts
new file mode 100644
index 0000000000..d7b20bf80e
--- /dev/null
+++ b/frontend/src/component/common/AutocompleteBox/AutocompleteBox.styles.ts
@@ -0,0 +1,44 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ display: 'flex',
+ alignItems: 'center',
+ borderRadius: '1rem',
+ '& .MuiInputLabel-root[data-shrink="false"]': {
+ top: 3,
+ },
+ },
+ icon: {
+ background: theme.palette.featureSegmentSearchBackground,
+ height: 48,
+ width: 48,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingLeft: 6,
+ borderTopLeftRadius: 40,
+ borderBottomLeftRadius: 40,
+ color: '#fff',
+ },
+ iconDisabled: {
+ background: theme.palette.primary.light,
+ },
+ autocomplete: {
+ flex: 1,
+ },
+ inputRoot: {
+ height: 48,
+ borderTopLeftRadius: 0,
+ borderBottomLeftRadius: 0,
+ borderTopRightRadius: 50,
+ borderBottomRightRadius: 50,
+ '& fieldset': {
+ borderColor: theme.palette.grey[300],
+ borderLeftColor: 'transparent',
+ },
+ '&.Mui-focused .MuiOutlinedInput-notchedOutline': {
+ borderWidth: 1,
+ },
+ },
+}));
diff --git a/frontend/src/component/common/AutocompleteBox/AutocompleteBox.tsx b/frontend/src/component/common/AutocompleteBox/AutocompleteBox.tsx
new file mode 100644
index 0000000000..e43e4123a4
--- /dev/null
+++ b/frontend/src/component/common/AutocompleteBox/AutocompleteBox.tsx
@@ -0,0 +1,60 @@
+import { useStyles } from 'component/common/AutocompleteBox/AutocompleteBox.styles';
+import { Search, ArrowDropDown } from '@mui/icons-material';
+import { Autocomplete } from '@mui/material';
+import { AutocompleteRenderInputParams } from '@mui/material/Autocomplete';
+import { TextField } from '@mui/material';
+import classNames from 'classnames';
+
+interface IAutocompleteBoxProps {
+ label: string;
+ options: IAutocompleteBoxOption[];
+ value?: IAutocompleteBoxOption[];
+ onChange: (value: IAutocompleteBoxOption[]) => void;
+ disabled?: boolean;
+}
+
+export interface IAutocompleteBoxOption {
+ value: string;
+ label: string;
+}
+
+export const AutocompleteBox = ({
+ label,
+ options,
+ value = [],
+ onChange,
+ disabled,
+}: IAutocompleteBoxProps) => {
+ const { classes: styles } = useStyles();
+
+ const renderInput = (params: AutocompleteRenderInputParams) => {
+ return ;
+ };
+
+ return (
+
+
+
+
+
}
+ onChange={(event, value) => onChange(value || [])}
+ renderInput={renderInput}
+ getOptionLabel={value => value.label}
+ disabled={disabled}
+ size="small"
+ multiple
+ />
+
+ );
+};
diff --git a/frontend/src/component/common/Badge/Badge.tsx b/frontend/src/component/common/Badge/Badge.tsx
new file mode 100644
index 0000000000..b944542e25
--- /dev/null
+++ b/frontend/src/component/common/Badge/Badge.tsx
@@ -0,0 +1,90 @@
+import { styled, SxProps, Theme } from '@mui/material';
+import React, {
+ cloneElement,
+ FC,
+ ForwardedRef,
+ forwardRef,
+ ReactElement,
+ ReactNode,
+} from 'react';
+import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender';
+
+type Color = 'info' | 'success' | 'warning' | 'error' | 'secondary' | 'neutral';
+
+interface IBadgeProps {
+ color?: Color;
+ icon?: ReactElement;
+ className?: string;
+ sx?: SxProps;
+ children?: ReactNode;
+ title?: string;
+ onClick?: (event: React.SyntheticEvent) => void;
+}
+
+interface IBadgeIconProps {
+ color?: Color;
+}
+
+const StyledBadge = styled('div')(
+ ({ theme, color = 'neutral', icon }) => ({
+ display: 'inline-flex',
+ alignItems: 'center',
+ padding: theme.spacing(icon ? 0.375 : 0.625, 1),
+ borderRadius: theme.shape.borderRadius,
+ fontSize: theme.fontSizes.smallerBody,
+ fontWeight: theme.fontWeight.bold,
+ lineHeight: 1,
+ backgroundColor: theme.palette[color].light,
+ color: theme.palette[color].dark,
+ border: `1px solid ${theme.palette[color].border}`,
+ })
+);
+
+const StyledBadgeIcon = styled('div')(
+ ({ theme, color = 'neutral' }) => ({
+ display: 'flex',
+ color: theme.palette[color].main,
+ marginRight: theme.spacing(0.5),
+ })
+);
+
+export const Badge: FC = forwardRef(
+ (
+ {
+ color = 'neutral',
+ icon,
+ className,
+ sx,
+ children,
+ ...props
+ }: IBadgeProps,
+ ref: ForwardedRef
+ ) => (
+
+
+
+ cloneElement(icon!, {
+ sx: { fontSize: '16px' },
+ })
+ }
+ />
+
+ }
+ />
+ {children}
+
+ )
+);
diff --git a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.styles.ts b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.styles.ts
new file mode 100644
index 0000000000..9706b056fd
--- /dev/null
+++ b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.styles.ts
@@ -0,0 +1,21 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ breadcrumbNav: {
+ position: 'absolute',
+ top: '4px',
+ },
+ breadcrumbNavParagraph: {
+ color: 'inherit',
+ '& > *': {
+ verticalAlign: 'middle',
+ },
+ },
+ breadcrumbLink: {
+ textDecoration: 'none',
+ '& > *': {
+ verticalAlign: 'middle',
+ },
+ color: theme.palette.primary.main,
+ },
+}));
diff --git a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx
new file mode 100644
index 0000000000..4f598bd2a1
--- /dev/null
+++ b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx
@@ -0,0 +1,97 @@
+import Breadcrumbs from '@mui/material/Breadcrumbs';
+import { Link, useLocation } from 'react-router-dom';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { useStyles } from './BreadcrumbNav.styles';
+import AccessContext from 'contexts/AccessContext';
+import { useContext } from 'react';
+import StringTruncator from '../StringTruncator/StringTruncator';
+
+const BreadcrumbNav = () => {
+ const { isAdmin } = useContext(AccessContext);
+ const { classes: styles } = useStyles();
+ const location = useLocation();
+
+ const paths = location.pathname
+ .split('/')
+ .filter(item => item)
+ .filter(
+ item =>
+ item !== 'create' &&
+ item !== 'edit' &&
+ item !== 'view' &&
+ item !== 'variants' &&
+ item !== 'logs' &&
+ item !== 'metrics' &&
+ item !== 'copy' &&
+ item !== 'features' &&
+ item !== 'features2' &&
+ item !== 'create-toggle' &&
+ item !== 'settings'
+ );
+
+ return (
+ 1}
+ show={
+
+ {paths.map((path, index) => {
+ const lastItem = index === paths.length - 1;
+ if (lastItem) {
+ return (
+
+
+
+ );
+ }
+
+ let link = '/';
+
+ paths.forEach((path, i) => {
+ if (i !== index && i < index) {
+ link += path + '/';
+ } else if (i === index) {
+ link += path;
+ }
+ });
+
+ return (
+
+
+
+ );
+ })}
+
+ }
+ />
+ }
+ />
+ );
+};
+
+export default BreadcrumbNav;
diff --git a/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.styles.ts b/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.styles.ts
new file mode 100644
index 0000000000..33e9bdf2be
--- /dev/null
+++ b/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.styles.ts
@@ -0,0 +1,22 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ badge: {
+ backgroundColor: theme.palette.checkmarkBadge,
+ width: '75px',
+ height: '75px',
+ borderRadius: '50px',
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ [theme.breakpoints.down('sm')]: {
+ width: '50px',
+ height: '50px',
+ },
+ },
+ check: {
+ color: '#fff',
+ width: '35px',
+ height: '35px',
+ },
+}));
diff --git a/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.tsx b/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.tsx
new file mode 100644
index 0000000000..deb6c7dc1d
--- /dev/null
+++ b/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.tsx
@@ -0,0 +1,23 @@
+import { Check, Close } from '@mui/icons-material';
+import { useStyles } from './CheckMarkBadge.styles';
+import classnames from 'classnames';
+
+interface ICheckMarkBadgeProps {
+ className: string;
+ type?: string;
+}
+
+const CheckMarkBadge = ({ type, className }: ICheckMarkBadgeProps) => {
+ const { classes: styles } = useStyles();
+ return (
+
+ {type === 'error' ? (
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default CheckMarkBadge;
diff --git a/frontend/src/component/common/Codebox/Codebox.styles.ts b/frontend/src/component/common/Codebox/Codebox.styles.ts
new file mode 100644
index 0000000000..f1bbb2e8a2
--- /dev/null
+++ b/frontend/src/component/common/Codebox/Codebox.styles.ts
@@ -0,0 +1,27 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ backgroundColor: theme.palette.codebox,
+ padding: '1rem',
+ borderRadius: theme.shape.borderRadiusMedium,
+ position: 'relative',
+ maxHeight: '500px',
+ overflow: 'auto',
+ },
+ code: {
+ margin: 0,
+ wordBreak: 'break-all',
+ whiteSpace: 'pre-wrap',
+ color: theme.palette.formSidebarTextColor,
+ fontSize: 14,
+ },
+ icon: {
+ fill: '#fff',
+ },
+ iconButton: {
+ position: 'absolute',
+ bottom: '10px',
+ right: '20px',
+ },
+}));
diff --git a/frontend/src/component/common/Codebox/Codebox.tsx b/frontend/src/component/common/Codebox/Codebox.tsx
new file mode 100644
index 0000000000..76257cfb0e
--- /dev/null
+++ b/frontend/src/component/common/Codebox/Codebox.tsx
@@ -0,0 +1,17 @@
+import { useStyles } from './Codebox.styles';
+
+interface ICodeboxProps {
+ text: string;
+}
+
+const Codebox = ({ text }: ICodeboxProps) => {
+ const { classes: styles } = useStyles();
+
+ return (
+
+ );
+};
+
+export default Codebox;
diff --git a/frontend/src/component/common/ConditionallyRender/ConditionallyRender.tsx b/frontend/src/component/common/ConditionallyRender/ConditionallyRender.tsx
new file mode 100644
index 0000000000..b87028d087
--- /dev/null
+++ b/frontend/src/component/common/ConditionallyRender/ConditionallyRender.tsx
@@ -0,0 +1,53 @@
+import { ReactNode } from 'react';
+
+interface IConditionallyRenderProps {
+ condition: boolean;
+ show: TargetElement;
+ elseShow?: TargetElement;
+}
+
+type TargetElement =
+ | JSX.Element
+ | JSX.Element[]
+ | RenderFunc
+ | ReactNode
+ | null;
+
+type RenderFunc = () => JSX.Element;
+
+export const ConditionallyRender = ({
+ condition,
+ show,
+ elseShow,
+}: IConditionallyRenderProps): JSX.Element | null => {
+ const handleFunction = (renderFunc: RenderFunc): JSX.Element | null => {
+ const result = renderFunc();
+ if (!result) {
+ /* eslint-disable-next-line */
+ console.warn(
+ 'Nothing was returned from your render function. Verify that you are returning a valid react component'
+ );
+ return null;
+ }
+ return result;
+ };
+
+ const isFunc = (param: TargetElement): boolean => {
+ return typeof param === 'function';
+ };
+
+ if (condition) {
+ if (isFunc(show)) {
+ return handleFunction(show as RenderFunc);
+ }
+
+ return show as JSX.Element;
+ }
+ if (!condition && elseShow) {
+ if (isFunc(elseShow)) {
+ return handleFunction(elseShow as RenderFunc);
+ }
+ return elseShow as JSX.Element;
+ }
+ return null;
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.styles.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.styles.ts
new file mode 100644
index 0000000000..af577bd40e
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.styles.ts
@@ -0,0 +1,166 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ constraintIconContainer: {
+ backgroundColor: theme.palette.background.paper,
+ borderRadius: '50%',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: theme.spacing(1),
+ [theme.breakpoints.down(650)]: {
+ marginBottom: '1rem',
+ marginRight: 0,
+ },
+ },
+ constraintIcon: {
+ fill: '#fff',
+ },
+ accordion: {
+ border: `1px solid ${theme.palette.dividerAlternative}`,
+ borderRadius: theme.shape.borderRadiusMedium,
+ backgroundColor: theme.palette.constraintAccordion.background,
+ boxShadow: 'none',
+ margin: 0,
+ },
+ accordionRoot: {
+ '&:before': {
+ opacity: '0 !important',
+ },
+ },
+ accordionEdit: {
+ backgroundColor: theme.palette.constraintAccordion.editBackground,
+ },
+ headerMetaInfo: {
+ display: 'flex',
+ alignItems: 'stretch',
+ marginLeft: theme.spacing(1),
+ [theme.breakpoints.down(710)]: {
+ marginLeft: 0,
+ flexDirection: 'column',
+ alignItems: 'center',
+ width: '100%',
+ },
+ },
+ headerContainer: {
+ display: 'flex',
+ alignItems: 'center',
+ width: '100%',
+ [theme.breakpoints.down(710)]: {
+ flexDirection: 'column',
+ alignItems: 'center',
+ position: 'relative',
+ },
+ },
+ headerValuesContainerWrapper: {
+ display: 'flex',
+ alignItems: 'stretch',
+ margin: 'auto 0',
+ },
+ headerValuesContainer: {
+ display: 'flex',
+ justifyContent: 'stretch',
+ margin: 'auto 0',
+ flexDirection: 'column',
+ marginLeft: theme.spacing(1),
+ [theme.breakpoints.down(710)]: {
+ marginLeft: 0,
+ },
+ },
+ headerValues: {
+ fontSize: theme.fontSizes.smallBody,
+ },
+ headerValuesExpand: {
+ fontSize: theme.fontSizes.smallBody,
+ marginTop: '4px',
+ color: theme.palette.primary.dark,
+ [theme.breakpoints.down(710)]: {
+ textAlign: 'center',
+ },
+ },
+ headerConstraintContainer: {
+ minWidth: '152px',
+ position: 'relative',
+ [theme.breakpoints.down(710)]: {
+ paddingRight: 0,
+ },
+ },
+ editingBadge: {
+ borderRadius: theme.shape.borderRadiusExtraLarge,
+ padding: '0.25rem 0.5rem',
+ backgroundColor: '#635DC5',
+ color: '#fff',
+ marginLeft: 'auto',
+ fontSize: '0.9rem',
+ [theme.breakpoints.down(650)]: {
+ position: 'absolute',
+ right: 0,
+ top: '-10px',
+ },
+ },
+ headerText: {
+ maxWidth: '400px',
+ fontSize: theme.fontSizes.smallBody,
+ [theme.breakpoints.down('xl')]: {
+ display: 'none',
+ },
+ },
+ selectContainer: {
+ display: 'flex',
+ alignItems: 'center',
+ [theme.breakpoints.down(770)]: {
+ flexDirection: 'column',
+ },
+ },
+ bottomSelect: {
+ [theme.breakpoints.down(770)]: {
+ marginTop: '1rem',
+ },
+ display: 'inline-flex',
+ },
+ headerSelect: {
+ marginRight: '1rem',
+ width: '200px',
+ [theme.breakpoints.between(1101, 1365)]: {
+ width: '170px',
+ marginRight: '8px',
+ },
+ },
+ chip: {
+ margin: '0 0.5rem 0.5rem 0',
+ },
+ chipValue: {
+ whiteSpace: 'pre',
+ },
+ headerActions: {
+ marginLeft: 'auto',
+ whiteSpace: 'nowrap',
+ [theme.breakpoints.down(710)]: {
+ display: 'none',
+ },
+ },
+ accordionDetails: {
+ borderTop: `1px dashed ${theme.palette.grey[300]}`,
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ valuesContainer: {
+ padding: '1rem 0rem',
+ maxHeight: '400px',
+ overflowY: 'auto',
+ },
+ summary: {
+ border: 'none',
+ padding: theme.spacing(0.5, 3),
+ '&:hover .valuesExpandLabel': {
+ textDecoration: 'underline',
+ },
+ },
+ settingsIcon: {
+ height: '32.5px',
+ width: '32.5px',
+ marginRight: '0.5rem',
+ fill: theme.palette.inactiveIcon,
+ },
+ form: { padding: 0, margin: 0, width: '100%' },
+}));
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.tsx
new file mode 100644
index 0000000000..ffb54cc4a1
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordion.tsx
@@ -0,0 +1,49 @@
+import { IConstraint } from 'interfaces/strategy';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+
+import { ConstraintAccordionEdit } from './ConstraintAccordionEdit/ConstraintAccordionEdit';
+import { ConstraintAccordionView } from './ConstraintAccordionView/ConstraintAccordionView';
+
+interface IConstraintAccordionProps {
+ compact: boolean;
+ editing: boolean;
+ constraint: IConstraint;
+ onCancel: () => void;
+ onEdit?: () => void;
+ onDelete?: () => void;
+ onSave?: (constraint: IConstraint) => void;
+}
+
+export const ConstraintAccordion = ({
+ constraint,
+ compact = false,
+ editing,
+ onEdit,
+ onCancel,
+ onDelete,
+ onSave,
+}: IConstraintAccordionProps) => {
+ if (!constraint) return null;
+
+ return (
+
+ }
+ elseShow={
+
+ }
+ />
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEdit.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEdit.tsx
new file mode 100644
index 0000000000..73b30eb261
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEdit.tsx
@@ -0,0 +1,241 @@
+import { useCallback, useEffect, useState } from 'react';
+import classnames from 'classnames';
+import { IConstraint } from 'interfaces/strategy';
+import { useStyles } from '../ConstraintAccordion.styles';
+import { ConstraintAccordionEditBody } from './ConstraintAccordionEditBody/ConstraintAccordionEditBody';
+import { ConstraintAccordionEditHeader } from './ConstraintAccordionEditHeader/ConstraintAccordionEditHeader';
+import { Accordion, AccordionDetails, AccordionSummary } from '@mui/material';
+import { cleanConstraint } from 'utils/cleanConstraint';
+import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
+import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { IUnleashContextDefinition } from 'interfaces/context';
+import { useConstraintInput } from './ConstraintAccordionEditBody/useConstraintInput/useConstraintInput';
+import { Operator } from 'constants/operators';
+import { ResolveInput } from './ConstraintAccordionEditBody/ResolveInput/ResolveInput';
+
+interface IConstraintAccordionEditProps {
+ constraint: IConstraint;
+ onCancel: () => void;
+ onSave: (constraint: IConstraint) => void;
+ compact: boolean;
+ onDelete?: () => void;
+}
+
+export const CANCEL = 'cancel';
+export const SAVE = 'save';
+
+const resolveContextDefinition = (
+ context: IUnleashContextDefinition[],
+ contextName: string
+): IUnleashContextDefinition => {
+ const definition = context.find(
+ contextDef => contextDef.name === contextName
+ );
+
+ return (
+ definition || {
+ name: '',
+ description: '',
+ createdAt: '',
+ sortOrder: 1,
+ stickiness: false,
+ }
+ );
+};
+
+export const ConstraintAccordionEdit = ({
+ constraint,
+ compact,
+ onCancel,
+ onSave,
+ onDelete,
+}: IConstraintAccordionEditProps) => {
+ const [localConstraint, setLocalConstraint] = useState(
+ cleanConstraint(constraint)
+ );
+
+ const { context } = useUnleashContext();
+ const [contextDefinition, setContextDefinition] = useState(
+ resolveContextDefinition(context, localConstraint.contextName)
+ );
+ const { validateConstraint } = useFeatureApi();
+ const [expanded, setExpanded] = useState(false);
+ const [action, setAction] = useState('');
+ const { classes: styles } = useStyles();
+
+ useEffect(() => {
+ // Setting expanded to true on mount will cause the accordion
+ // animation to take effect and transition the expanded accordion in
+ setExpanded(true);
+ }, []);
+
+ useEffect(() => {
+ setContextDefinition(
+ resolveContextDefinition(context, localConstraint.contextName)
+ );
+ }, [localConstraint.contextName, context]);
+
+ const setContextName = useCallback((contextName: string) => {
+ setLocalConstraint(prev => ({
+ ...prev,
+ contextName,
+ values: [],
+ value: '',
+ }));
+ }, []);
+
+ const setOperator = useCallback((operator: Operator) => {
+ setLocalConstraint(prev => ({
+ ...prev,
+ operator,
+ values: [],
+ value: '',
+ }));
+ }, []);
+
+ const setValues = useCallback((values: string[]) => {
+ setLocalConstraint(prev => ({
+ ...prev,
+ values,
+ }));
+ }, []);
+
+ const setValue = useCallback((value: string) => {
+ setLocalConstraint(prev => ({ ...prev, value }));
+ }, []);
+
+ const setInvertedOperator = () => {
+ setLocalConstraint(prev => ({ ...prev, inverted: !prev.inverted }));
+ };
+
+ const setCaseInsensitive = useCallback(() => {
+ setLocalConstraint(prev => ({
+ ...prev,
+ caseInsensitive: !prev.caseInsensitive,
+ }));
+ }, []);
+
+ const removeValue = useCallback(
+ (index: number) => {
+ const valueCopy = [...localConstraint.values!];
+ valueCopy.splice(index, 1);
+
+ setValues(valueCopy);
+ },
+ [localConstraint, setValues]
+ );
+
+ const triggerTransition = () => {
+ setExpanded(false);
+ };
+
+ const validateConstraintValues = () => {
+ const hasValues =
+ Array.isArray(localConstraint.values) &&
+ Boolean(localConstraint.values.length > 0);
+ const hasValue = Boolean(localConstraint.value);
+
+ if (hasValues || hasValue) {
+ setError('');
+ return true;
+ }
+ setError('You must provide a value for the constraint');
+ return false;
+ };
+
+ const onSubmit = async () => {
+ const hasValues = validateConstraintValues();
+ if (!hasValues) return;
+ const [typeValidatorResult, err] = validator();
+
+ if (!typeValidatorResult) {
+ setError(err);
+ }
+
+ if (typeValidatorResult) {
+ try {
+ await validateConstraint(localConstraint);
+ setError('');
+ setAction(SAVE);
+ triggerTransition();
+ return;
+ } catch (error: unknown) {
+ setError(formatUnknownError(error));
+ }
+ }
+ };
+
+ const { input, validator, setError, error } = useConstraintInput({
+ contextDefinition,
+ localConstraint,
+ });
+
+ useEffect(() => {
+ setError('');
+ setLocalConstraint(localConstraint => cleanConstraint(localConstraint));
+ }, [localConstraint.operator, localConstraint.contextName, setError]);
+
+ return (
+
+
{
+ if (action === CANCEL) {
+ setAction('');
+ onCancel();
+ } else if (action === SAVE) {
+ setAction('');
+ onSave(localConstraint);
+ }
+ },
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintAccordionEditBody.styles.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintAccordionEditBody.styles.ts
new file mode 100644
index 0000000000..82c2efa82a
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintAccordionEditBody.styles.ts
@@ -0,0 +1,27 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ inputContainer: {
+ padding: '1rem',
+ backgroundColor: theme.palette.neutral.light,
+ },
+ buttonContainer: {
+ display: 'flex',
+ alignItems: 'center',
+ marginTop: '1rem',
+ borderTop: `1px solid ${theme.palette.grey[300]}`,
+ width: '100%',
+ padding: '1rem',
+ },
+ innerButtonContainer: {
+ marginLeft: 'auto',
+ },
+ leftButton: {
+ marginRight: '0.5rem',
+ minWidth: '125px',
+ },
+ rightButton: {
+ marginLeft: '0.5rem',
+ minWidth: '125px',
+ },
+}));
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintAccordionEditBody.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintAccordionEditBody.tsx
new file mode 100644
index 0000000000..ebfcb4d3a9
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintAccordionEditBody.tsx
@@ -0,0 +1,59 @@
+import { Button } from '@mui/material';
+import { IConstraint } from 'interfaces/strategy';
+import { CANCEL } from '../ConstraintAccordionEdit';
+
+import { useStyles } from './ConstraintAccordionEditBody.styles';
+import React from 'react';
+import { newOperators } from 'constants/operators';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { oneOf } from 'utils/oneOf';
+import { OperatorUpgradeAlert } from 'component/common/OperatorUpgradeAlert/OperatorUpgradeAlert';
+
+interface IConstraintAccordionBody {
+ localConstraint: IConstraint;
+ setValues: (values: string[]) => void;
+ triggerTransition: () => void;
+ setValue: (value: string) => void;
+ setAction: React.Dispatch>;
+ onSubmit: () => void;
+}
+
+export const ConstraintAccordionEditBody: React.FC<
+ IConstraintAccordionBody
+> = ({ localConstraint, children, triggerTransition, setAction, onSubmit }) => {
+ const { classes: styles } = useStyles();
+
+ return (
+ <>
+
+ }
+ />
+ {children}
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintFormHeader/ConstraintFormHeader.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintFormHeader/ConstraintFormHeader.tsx
new file mode 100644
index 0000000000..be2ab9b64c
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ConstraintFormHeader/ConstraintFormHeader.tsx
@@ -0,0 +1,22 @@
+import { makeStyles } from 'tss-react/mui';
+import React from 'react';
+
+const useStyles = makeStyles()(theme => ({
+ header: {
+ fontSize: theme.fontSizes.bodySize,
+ fontWeight: 'normal',
+ marginTop: '1rem',
+ marginBottom: '0.25rem',
+ },
+}));
+
+export const ConstraintFormHeader: React.FC<
+ React.HTMLAttributes
+> = ({ children, ...rest }) => {
+ const { classes: styles } = useStyles();
+ return (
+
+ {children}
+
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.test.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.test.tsx
new file mode 100644
index 0000000000..ed6f46a166
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.test.tsx
@@ -0,0 +1,18 @@
+import { parseDateValue } from 'component/common/util';
+
+test(`Date component is able to parse midnight when it's 00`, () => {
+ let f = parseDateValue('2022-03-15T12:27');
+ let midnight = parseDateValue('2022-03-15T00:27');
+ expect(f).toEqual('2022-03-15T12:27');
+ expect(midnight).toEqual('2022-03-15T00:27');
+});
+
+test(`Date component - snapshot matching`, () => {
+ let midnight = '2022-03-15T00:00';
+ let midday = '2022-03-15T12:00';
+ let obj = {
+ midnight: parseDateValue(midnight),
+ midday: parseDateValue(midday),
+ };
+ expect(obj).toMatchSnapshot();
+});
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.tsx
new file mode 100644
index 0000000000..39d1f900c0
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue.tsx
@@ -0,0 +1,42 @@
+import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader';
+import Input from 'component/common/Input/Input';
+import { parseDateValue, parseValidDate } from 'component/common/util';
+interface IDateSingleValueProps {
+ setValue: (value: string) => void;
+ value?: string;
+ error: string;
+ setError: React.Dispatch>;
+}
+
+export const DateSingleValue = ({
+ setValue,
+ value,
+ error,
+ setError,
+}: IDateSingleValueProps) => {
+ if (!value) return null;
+
+ return (
+ <>
+ Select a date
+ {
+ setError('');
+ const parsedDate = parseValidDate(e.target.value);
+ const dateString = parsedDate?.toISOString();
+ dateString && setValue(dateString);
+ }}
+ InputLabelProps={{
+ shrink: true,
+ }}
+ error={Boolean(error)}
+ errorText={error}
+ required
+ />
+ >
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/__snapshots__/DateSingleValue.test.tsx.snap b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/__snapshots__/DateSingleValue.test.tsx.snap
new file mode 100644
index 0000000000..b7338e170a
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/__snapshots__/DateSingleValue.test.tsx.snap
@@ -0,0 +1,8 @@
+// Vitest Snapshot v1
+
+exports[`Date component - snapshot matching 1`] = `
+{
+ "midday": "2022-03-15T12:00",
+ "midnight": "2022-03-15T00:00",
+}
+`;
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput.tsx
new file mode 100644
index 0000000000..91c9e2b190
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput.tsx
@@ -0,0 +1,167 @@
+import { Button, Chip } from '@mui/material';
+import { makeStyles } from 'tss-react/mui';
+import Input from 'component/common/Input/Input';
+import StringTruncator from 'component/common/StringTruncator/StringTruncator';
+import React, { useState } from 'react';
+import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader';
+import { parseParameterStrings } from 'utils/parseParameter';
+
+interface IFreeTextInputProps {
+ values: string[];
+ removeValue: (index: number) => void;
+ setValues: (values: string[]) => void;
+ beforeValues?: JSX.Element;
+ error: string;
+ setError: React.Dispatch>;
+}
+
+const useStyles = makeStyles()(theme => ({
+ valueChip: {
+ margin: '0 0.5rem 0.5rem 0',
+ },
+ chipValue: {
+ whiteSpace: 'pre',
+ },
+ inputContainer: {
+ display: 'flex',
+ alignItems: 'center',
+ [theme.breakpoints.down(700)]: {
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ },
+ },
+ inputInnerContainer: {
+ minWidth: '300px',
+ [theme.breakpoints.down(700)]: {
+ minWidth: '100%',
+ },
+ },
+ input: {
+ width: '100%',
+ margin: '1rem 0',
+ },
+ button: {
+ marginLeft: '1rem',
+ [theme.breakpoints.down(700)]: {
+ marginLeft: 0,
+ marginBottom: '0.5rem',
+ },
+ },
+ valuesContainer: { marginTop: '1rem' },
+}));
+
+const ENTER = 'Enter';
+
+export const FreeTextInput = ({
+ values,
+ removeValue,
+ setValues,
+ error,
+ setError,
+}: IFreeTextInputProps) => {
+ const [inputValues, setInputValues] = useState('');
+ const { classes: styles } = useStyles();
+
+ const onKeyPress = (event: React.KeyboardEvent) => {
+ if (event.key === ENTER) {
+ event.preventDefault();
+ addValues();
+ }
+ };
+
+ const addValues = () => {
+ const newValues = uniqueValues([
+ ...values,
+ ...parseParameterStrings(inputValues),
+ ]);
+
+ if (newValues.length === 0) {
+ setError('values cannot be empty');
+ } else if (newValues.some(v => v.length > 100)) {
+ setError('values cannot be longer than 100 characters');
+ } else {
+ setError('');
+ setInputValues('');
+ setValues(newValues);
+ }
+ };
+
+ return (
+
+
+ Set values (maximum 100 char length per value)
+
+
+
+ {
+ setError('');
+ }}
+ onChange={e => setInputValues(e.target.value)}
+ placeholder="value1, value2, value3..."
+ className={styles.input}
+ error={Boolean(error)}
+ errorText={error}
+ />
+
+
+
+
+
+
+
+ );
+};
+
+interface IConstraintValueChipsProps {
+ values: string[];
+ removeValue: (index: number) => void;
+}
+
+const ConstraintValueChips = ({
+ values,
+ removeValue,
+}: IConstraintValueChipsProps) => {
+ const { classes: styles } = useStyles();
+ return (
+ <>
+ {values.map((value, index) => {
+ // Key is not ideal, but we don't have anything guaranteed to
+ // be unique here.
+ return (
+
+ }
+ key={`${value}-${index}`}
+ onDelete={() => removeValue(index)}
+ className={styles.valueChip}
+ />
+ );
+ })}
+ >
+ );
+};
+
+const uniqueValues = (values: T[]): T[] => {
+ return Array.from(new Set(values));
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.styles.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.styles.ts
new file mode 100644
index 0000000000..890e09c7b2
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.styles.ts
@@ -0,0 +1,17 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ display: 'inline-block',
+ wordBreak: 'break-word',
+ },
+ value: {
+ lineHeight: 1.33,
+ fontSize: theme.fontSizes.smallBody,
+ },
+ description: {
+ lineHeight: 1.33,
+ fontSize: theme.fontSizes.smallerBody,
+ color: theme.palette.grey[700],
+ },
+}));
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.tsx
new file mode 100644
index 0000000000..0c637119a8
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel.tsx
@@ -0,0 +1,39 @@
+import { ILegalValue } from 'interfaces/context';
+import { useStyles } from './LegalValueLabel.styles';
+import React from 'react';
+import { FormControlLabel } from '@mui/material';
+
+interface ILegalValueTextProps {
+ legal: ILegalValue;
+ control: React.ReactElement;
+}
+
+export const LegalValueLabel = ({ legal, control }: ILegalValueTextProps) => {
+ const { classes: styles } = useStyles();
+
+ return (
+
+
+ {legal.value}
+
+ {legal.description}
+
+ >
+ }
+ />
+
+ );
+};
+
+export const filterLegalValues = (
+ legalValues: ILegalValue[],
+ filter: string
+): ILegalValue[] => {
+ return legalValues.filter(legalValue => {
+ return legalValue.value.includes(filter);
+ });
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ResolveInput/ResolveInput.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ResolveInput/ResolveInput.tsx
new file mode 100644
index 0000000000..a957d1df4e
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ResolveInput/ResolveInput.tsx
@@ -0,0 +1,152 @@
+import { IUnleashContextDefinition } from 'interfaces/context';
+import { IConstraint } from 'interfaces/strategy';
+import { DateSingleValue } from '../DateSingleValue/DateSingleValue';
+import { FreeTextInput } from '../FreeTextInput/FreeTextInput';
+import { RestrictiveLegalValues } from '../RestrictiveLegalValues/RestrictiveLegalValues';
+import { SingleLegalValue } from '../SingleLegalValue/SingleLegalValue';
+import { SingleValue } from '../SingleValue/SingleValue';
+import {
+ IN_OPERATORS_LEGAL_VALUES,
+ STRING_OPERATORS_FREETEXT,
+ STRING_OPERATORS_LEGAL_VALUES,
+ SEMVER_OPERATORS_SINGLE_VALUE,
+ NUM_OPERATORS_LEGAL_VALUES,
+ NUM_OPERATORS_SINGLE_VALUE,
+ SEMVER_OPERATORS_LEGAL_VALUES,
+ DATE_OPERATORS_SINGLE_VALUE,
+ IN_OPERATORS_FREETEXT,
+ Input,
+} from '../useConstraintInput/useConstraintInput';
+import React from 'react';
+
+interface IResolveInputProps {
+ contextDefinition: IUnleashContextDefinition;
+ localConstraint: IConstraint;
+ setValue: (value: string) => void;
+ setValues: (values: string[]) => void;
+ setError: React.Dispatch>;
+ removeValue: (index: number) => void;
+ input: Input;
+ error: string;
+}
+
+export const ResolveInput = ({
+ input,
+ contextDefinition,
+ localConstraint,
+ setValue,
+ setValues,
+ setError,
+ removeValue,
+ error,
+}: IResolveInputProps) => {
+ const resolveInput = () => {
+ switch (input) {
+ case IN_OPERATORS_LEGAL_VALUES:
+ return (
+
+ );
+ case STRING_OPERATORS_LEGAL_VALUES:
+ return (
+ <>
+
+ >
+ );
+ case NUM_OPERATORS_LEGAL_VALUES:
+ return (
+ <>
+ Number(legalValue.value)
+ ) || []
+ }
+ error={error}
+ setError={setError}
+ />
+ >
+ );
+ case SEMVER_OPERATORS_LEGAL_VALUES:
+ return (
+ <>
+
+ >
+ );
+ case DATE_OPERATORS_SINGLE_VALUE:
+ return (
+
+ );
+ case IN_OPERATORS_FREETEXT:
+ return (
+
+ );
+ case STRING_OPERATORS_FREETEXT:
+ return (
+ <>
+
+ >
+ );
+ case NUM_OPERATORS_SINGLE_VALUE:
+ return (
+
+ );
+ case SEMVER_OPERATORS_SINGLE_VALUE:
+ return (
+
+ );
+ }
+ };
+
+ return <>{resolveInput()}>;
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/RestrictiveLegalValues/RestrictiveLegalValues.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/RestrictiveLegalValues/RestrictiveLegalValues.tsx
new file mode 100644
index 0000000000..930db206da
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/RestrictiveLegalValues/RestrictiveLegalValues.tsx
@@ -0,0 +1,100 @@
+import { useEffect, useState } from 'react';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { Checkbox } from '@mui/material';
+import { useThemeStyles } from 'themes/themeStyles';
+import { ConstraintValueSearch } from 'component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch';
+import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader';
+import { ILegalValue } from 'interfaces/context';
+import {
+ LegalValueLabel,
+ filterLegalValues,
+} from '../LegalValueLabel/LegalValueLabel';
+
+interface IRestrictiveLegalValuesProps {
+ legalValues: ILegalValue[];
+ values: string[];
+ setValues: (values: string[]) => void;
+ beforeValues?: JSX.Element;
+ error: string;
+ setError: React.Dispatch>;
+}
+
+interface IValuesMap {
+ [key: string]: boolean;
+}
+
+const createValuesMap = (values: string[]): IValuesMap => {
+ return values.reduce((result: IValuesMap, currentValue: string) => {
+ if (!result[currentValue]) {
+ result[currentValue] = true;
+ }
+ return result;
+ }, {});
+};
+
+export const RestrictiveLegalValues = ({
+ legalValues,
+ values,
+ setValues,
+ error,
+ setError,
+}: IRestrictiveLegalValuesProps) => {
+ const [filter, setFilter] = useState('');
+ const filteredValues = filterLegalValues(legalValues, filter);
+
+ // Lazily initialise the values because there might be a lot of them.
+ const [valuesMap, setValuesMap] = useState(() => createValuesMap(values));
+ const { classes: styles } = useThemeStyles();
+
+ useEffect(() => {
+ setValuesMap(createValuesMap(values));
+ }, [values, setValuesMap]);
+
+ const onChange = (legalValue: string) => {
+ setError('');
+ if (valuesMap[legalValue]) {
+ const index = values.findIndex(value => value === legalValue);
+ const newValues = [...values];
+ newValues.splice(index, 1);
+ setValues(newValues);
+ return;
+ }
+
+ setValues([...values, legalValue]);
+ };
+
+ return (
+ <>
+
+ Select values from a predefined set
+
+ 100}
+ show={
+
+ }
+ />
+ {filteredValues.map(match => (
+ onChange(match.value)}
+ name={match.value}
+ color="primary"
+ />
+ }
+ />
+ ))}
+ {error}
}
+ />
+ >
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleLegalValue/SingleLegalValue.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleLegalValue/SingleLegalValue.tsx
new file mode 100644
index 0000000000..b83db6fa1e
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleLegalValue/SingleLegalValue.tsx
@@ -0,0 +1,81 @@
+import React, { useState } from 'react';
+import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader';
+import { FormControl, RadioGroup, Radio } from '@mui/material';
+import { ConstraintValueSearch } from 'component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { useThemeStyles } from 'themes/themeStyles';
+import { ILegalValue } from 'interfaces/context';
+import {
+ LegalValueLabel,
+ filterLegalValues,
+} from '../LegalValueLabel/LegalValueLabel';
+
+interface ISingleLegalValueProps {
+ setValue: (value: string) => void;
+ value?: string;
+ type: string;
+ legalValues: ILegalValue[];
+ error: string;
+ setError: React.Dispatch>;
+}
+
+export const SingleLegalValue = ({
+ setValue,
+ value,
+ type,
+ legalValues,
+ error,
+ setError,
+}: ISingleLegalValueProps) => {
+ const [filter, setFilter] = useState('');
+ const { classes: styles } = useThemeStyles();
+ const filteredValues = filterLegalValues(legalValues, filter);
+
+ return (
+ <>
+
+ Add a single {type.toLowerCase()} value
+
+ 100)}
+ show={
+
+ }
+ />
+
+ {
+ setError('');
+ setValue(e.target.value);
+ }}
+ >
+ {filteredValues.map(match => (
+ }
+ />
+ ))}
+
+
+ }
+ elseShow={
+ No valid legal values available for this operator.
+ }
+ />
+ {error}}
+ />
+ >
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleValue/SingleValue.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleValue/SingleValue.tsx
new file mode 100644
index 0000000000..38573b7dd5
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleValue/SingleValue.tsx
@@ -0,0 +1,52 @@
+import Input from 'component/common/Input/Input';
+import { makeStyles } from 'tss-react/mui';
+import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader';
+
+interface ISingleValueProps {
+ setValue: (value: string) => void;
+ value?: string;
+ type: string;
+ error: string;
+ setError: React.Dispatch>;
+}
+
+const useStyles = makeStyles()(theme => ({
+ singleValueContainer: { maxWidth: '300px', marginTop: '-1rem' },
+ singleValueInput: {
+ width: '100%',
+ margin: '1rem 0',
+ },
+}));
+
+export const SingleValue = ({
+ setValue,
+ value,
+ type,
+ error,
+ setError,
+}: ISingleValueProps) => {
+ const { classes: styles } = useStyles();
+ return (
+ <>
+
+ Add a single {type.toLowerCase()} value
+
+
+ {
+ setError('');
+ setValue(e.target.value.trim());
+ }}
+ onFocus={() => setError('')}
+ placeholder={`Enter a single ${type} value`}
+ className={styles.singleValueInput}
+ error={Boolean(error)}
+ errorText={error}
+ />
+
+ >
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.test.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.test.ts
new file mode 100644
index 0000000000..0dcd5870f0
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.test.ts
@@ -0,0 +1,110 @@
+import {
+ numberValidatorGenerator,
+ semVerValidatorGenerator,
+ dateValidatorGenerator,
+ stringValidatorGenerator,
+} from './constraintValidators';
+
+test('numbervalidator should accept 0', () => {
+ const numValidator = numberValidatorGenerator(0);
+ const [result, err] = numValidator();
+
+ expect(result).toBe(true);
+ expect(err).toBe('');
+});
+
+test('number validator should reject value that cannot be parsed to number', () => {
+ const numValidator = numberValidatorGenerator('testa31');
+ const [result, err] = numValidator();
+
+ expect(result).toBe(false);
+ expect(err).toBe('Value must be a number');
+});
+
+test('number validator should reject NaN', () => {
+ const numValidator = numberValidatorGenerator(NaN);
+ const [result, err] = numValidator();
+
+ expect(result).toBe(false);
+ expect(err).toBe('Value must be a number');
+});
+
+test('number validator should accept value that can be parsed to number', () => {
+ const numValidator = numberValidatorGenerator('31');
+ const [result, err] = numValidator();
+
+ expect(result).toBe(true);
+ expect(err).toBe('');
+});
+
+test('number validator should accept float values', () => {
+ const numValidator = numberValidatorGenerator('31.12');
+ const [result, err] = numValidator();
+
+ expect(result).toBe(true);
+ expect(err).toBe('');
+});
+
+test('semver validator should reject prefixed values', () => {
+ const semVerValidator = semVerValidatorGenerator('v1.4.2');
+ const [result, err] = semVerValidator();
+
+ expect(result).toBe(false);
+ expect(err).toBe('Value is not a valid semver. For example 1.2.4');
+});
+
+test('semver validator should reject partial semver values', () => {
+ const semVerValidator = semVerValidatorGenerator('4.2');
+ const [result, err] = semVerValidator();
+
+ expect(result).toBe(false);
+ expect(err).toBe('Value is not a valid semver. For example 1.2.4');
+});
+
+test('semver validator should accept semver complient values', () => {
+ const semVerValidator = semVerValidatorGenerator('1.4.2');
+ const [result, err] = semVerValidator();
+
+ expect(result).toBe(true);
+ expect(err).toBe('');
+});
+
+test('date validator should reject invalid date', () => {
+ const dateValidator = dateValidatorGenerator('114mydate2005');
+ const [result, err] = dateValidator();
+
+ expect(result).toBe(false);
+ expect(err).toBe('Value must be a valid date matching RFC3339');
+});
+
+test('date validator should accept valid date', () => {
+ const dateValidator = dateValidatorGenerator('2022-03-03T10:15:23.262Z');
+ const [result, err] = dateValidator();
+
+ expect(result).toBe(true);
+ expect(err).toBe('');
+});
+
+test('string validator should accept a list of strings', () => {
+ const stringValidator = stringValidatorGenerator(['1234', '4121']);
+ const [result, err] = stringValidator();
+
+ expect(result).toBe(true);
+ expect(err).toBe('');
+});
+
+test('string validator should reject values that are not arrays', () => {
+ const stringValidator = stringValidatorGenerator(4);
+ const [result, err] = stringValidator();
+
+ expect(result).toBe(false);
+ expect(err).toBe('Values must be a list of strings');
+});
+
+test('string validator should reject arrays that are not arrays of strings', () => {
+ const stringValidator = stringValidatorGenerator(['test', NaN, 5]);
+ const [result, err] = stringValidator();
+
+ expect(result).toBe(false);
+ expect(err).toBe('Values must be a list of strings');
+});
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.ts
new file mode 100644
index 0000000000..cf23d3e420
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/constraintValidators.ts
@@ -0,0 +1,55 @@
+import { isValid, parseISO } from 'date-fns';
+import semver from 'semver';
+
+export type ConstraintValidatorOutput = [boolean, string];
+
+export const numberValidatorGenerator = (value: unknown) => {
+ return (): ConstraintValidatorOutput => {
+ const converted = Number(value);
+
+ if (typeof converted !== 'number' || Number.isNaN(converted)) {
+ return [false, 'Value must be a number'];
+ }
+
+ return [true, ''];
+ };
+};
+
+export const stringValidatorGenerator = (values: unknown) => {
+ return (): ConstraintValidatorOutput => {
+ const error: ConstraintValidatorOutput = [
+ false,
+ 'Values must be a list of strings',
+ ];
+ if (!Array.isArray(values)) {
+ return error;
+ }
+
+ if (!values.every(value => typeof value === 'string')) {
+ return error;
+ }
+
+ return [true, ''];
+ };
+};
+
+export const semVerValidatorGenerator = (value: string) => {
+ return (): ConstraintValidatorOutput => {
+ const isCleanValue = semver.clean(value) === value;
+
+ if (!semver.valid(value) || !isCleanValue) {
+ return [false, 'Value is not a valid semver. For example 1.2.4'];
+ }
+
+ return [true, ''];
+ };
+};
+
+export const dateValidatorGenerator = (value: string) => {
+ return (): ConstraintValidatorOutput => {
+ if (!isValid(parseISO(value))) {
+ return [false, 'Value must be a valid date matching RFC3339'];
+ }
+ return [true, ''];
+ };
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/useConstraintInput.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/useConstraintInput.tsx
new file mode 100644
index 0000000000..3503c69ce5
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/useConstraintInput.tsx
@@ -0,0 +1,158 @@
+import {
+ inOperators,
+ stringOperators,
+ numOperators,
+ semVerOperators,
+ dateOperators,
+} from 'constants/operators';
+import { IUnleashContextDefinition } from 'interfaces/context';
+import { IConstraint } from 'interfaces/strategy';
+import React, { useCallback, useEffect, useState } from 'react';
+import { oneOf } from 'utils/oneOf';
+
+import {
+ numberValidatorGenerator,
+ stringValidatorGenerator,
+ semVerValidatorGenerator,
+ dateValidatorGenerator,
+ ConstraintValidatorOutput,
+} from './constraintValidators';
+import { nonEmptyArray } from 'utils/nonEmptyArray';
+
+interface IUseConstraintInputProps {
+ contextDefinition: IUnleashContextDefinition;
+ localConstraint: IConstraint;
+}
+
+interface IUseConstraintOutput {
+ input: Input;
+ error: string;
+ validator: () => ConstraintValidatorOutput;
+ setError: React.Dispatch>;
+}
+
+export const IN_OPERATORS_LEGAL_VALUES = 'IN_OPERATORS_LEGAL_VALUES';
+export const STRING_OPERATORS_LEGAL_VALUES = 'STRING_OPERATORS_LEGAL_VALUES';
+export const NUM_OPERATORS_LEGAL_VALUES = 'NUM_OPERATORS_LEGAL_VALUES';
+export const SEMVER_OPERATORS_LEGAL_VALUES = 'SEMVER_OPERATORS_LEGAL_VALUES';
+export const DATE_OPERATORS_SINGLE_VALUE = 'DATE_OPERATORS_SINGLE_VALUE';
+export const IN_OPERATORS_FREETEXT = 'IN_OPERATORS_FREETEXT';
+export const STRING_OPERATORS_FREETEXT = 'STRING_OPERATORS_FREETEXT';
+export const NUM_OPERATORS_SINGLE_VALUE = 'NUM_OPERATORS_SINGLE_VALUE';
+export const SEMVER_OPERATORS_SINGLE_VALUE = 'SEMVER_OPERATORS_SINGLE_VALUE';
+
+export type Input =
+ | 'IN_OPERATORS_LEGAL_VALUES'
+ | 'STRING_OPERATORS_LEGAL_VALUES'
+ | 'NUM_OPERATORS_LEGAL_VALUES'
+ | 'SEMVER_OPERATORS_LEGAL_VALUES'
+ | 'DATE_OPERATORS_SINGLE_VALUE'
+ | 'IN_OPERATORS_FREETEXT'
+ | 'STRING_OPERATORS_FREETEXT'
+ | 'NUM_OPERATORS_SINGLE_VALUE'
+ | 'SEMVER_OPERATORS_SINGLE_VALUE';
+
+const NUMBER_VALIDATOR = 'NUMBER_VALIDATOR';
+const SEMVER_VALIDATOR = 'SEMVER_VALIDATOR';
+const STRING_ARRAY_VALIDATOR = 'STRING_ARRAY_VALIDATOR';
+const DATE_VALIDATOR = 'DATE_VALIDATOR';
+
+type Validator =
+ | 'NUMBER_VALIDATOR'
+ | 'SEMVER_VALIDATOR'
+ | 'STRING_ARRAY_VALIDATOR'
+ | 'DATE_VALIDATOR';
+
+export const useConstraintInput = ({
+ contextDefinition,
+ localConstraint,
+}: IUseConstraintInputProps): IUseConstraintOutput => {
+ const [input, setInput] = useState(IN_OPERATORS_LEGAL_VALUES);
+ const [validator, setValidator] = useState(
+ STRING_ARRAY_VALIDATOR
+ );
+ const [error, setError] = useState('');
+
+ const resolveInputType = useCallback(() => {
+ if (
+ nonEmptyArray(contextDefinition.legalValues) &&
+ oneOf(inOperators, localConstraint.operator)
+ ) {
+ setInput(IN_OPERATORS_LEGAL_VALUES);
+ } else if (
+ nonEmptyArray(contextDefinition.legalValues) &&
+ oneOf(stringOperators, localConstraint.operator)
+ ) {
+ setInput(STRING_OPERATORS_LEGAL_VALUES);
+ } else if (
+ nonEmptyArray(contextDefinition.legalValues) &&
+ oneOf(numOperators, localConstraint.operator)
+ ) {
+ setInput(NUM_OPERATORS_LEGAL_VALUES);
+ } else if (
+ nonEmptyArray(contextDefinition.legalValues) &&
+ oneOf(semVerOperators, localConstraint.operator)
+ ) {
+ setInput(SEMVER_OPERATORS_LEGAL_VALUES);
+ } else if (oneOf(dateOperators, localConstraint.operator)) {
+ setInput(DATE_OPERATORS_SINGLE_VALUE);
+ } else if (oneOf(inOperators, localConstraint.operator)) {
+ setInput(IN_OPERATORS_FREETEXT);
+ } else if (oneOf(stringOperators, localConstraint.operator)) {
+ setInput(STRING_OPERATORS_FREETEXT);
+ } else if (oneOf(numOperators, localConstraint.operator)) {
+ setInput(NUM_OPERATORS_SINGLE_VALUE);
+ } else if (oneOf(semVerOperators, localConstraint.operator)) {
+ setInput(SEMVER_OPERATORS_SINGLE_VALUE);
+ }
+ }, [localConstraint, contextDefinition]);
+
+ const resolveValidator = () => {
+ switch (validator) {
+ case NUMBER_VALIDATOR:
+ return numberValidatorGenerator(localConstraint.value);
+ case STRING_ARRAY_VALIDATOR:
+ return stringValidatorGenerator(localConstraint.values || []);
+ case SEMVER_VALIDATOR:
+ return semVerValidatorGenerator(localConstraint.value || '');
+ case DATE_VALIDATOR:
+ return dateValidatorGenerator(localConstraint.value || '');
+ }
+ };
+
+ const resolveValidatorType = useCallback(
+ (operator: string) => {
+ if (oneOf(numOperators, operator)) {
+ setValidator(NUMBER_VALIDATOR);
+ }
+
+ if (oneOf([...stringOperators, ...inOperators], operator)) {
+ setValidator(STRING_ARRAY_VALIDATOR);
+ }
+
+ if (oneOf(semVerOperators, operator)) {
+ setValidator(SEMVER_VALIDATOR);
+ }
+
+ if (oneOf(dateOperators, operator)) {
+ setValidator(DATE_VALIDATOR);
+ }
+ },
+ [setValidator]
+ );
+
+ useEffect(() => {
+ resolveValidatorType(localConstraint.operator);
+ }, [
+ localConstraint.operator,
+ localConstraint.value,
+ localConstraint.values,
+ resolveValidatorType,
+ ]);
+
+ useEffect(() => {
+ resolveInputType();
+ }, [contextDefinition, localConstraint, resolveInputType]);
+
+ return { input, error, validator: resolveValidator(), setError };
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/ConstraintAccordionEditHeader.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/ConstraintAccordionEditHeader.tsx
new file mode 100644
index 0000000000..3e474a34f9
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/ConstraintAccordionEditHeader.tsx
@@ -0,0 +1,153 @@
+import { IConstraint } from 'interfaces/strategy';
+
+import { useStyles } from 'component/common/ConstraintAccordion/ConstraintAccordion.styles';
+import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
+import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
+import { ConstraintIcon } from 'component/common/ConstraintAccordion/ConstraintIcon';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import {
+ dateOperators,
+ DATE_AFTER,
+ IN,
+ stringOperators,
+} from 'constants/operators';
+import { resolveText } from './helpers';
+import { oneOf } from 'utils/oneOf';
+import React, { useEffect, useState } from 'react';
+import { Operator } from 'constants/operators';
+import { ConstraintOperatorSelect } from 'component/common/ConstraintAccordion/ConstraintOperatorSelect/ConstraintOperatorSelect';
+import {
+ operatorsForContext,
+ CURRENT_TIME_CONTEXT_FIELD,
+} from 'utils/operatorsForContext';
+import { InvertedOperatorButton } from '../StyledToggleButton/InvertedOperatorButton/InvertedOperatorButton';
+import { CaseSensitiveButton } from '../StyledToggleButton/CaseSensitiveButton/CaseSensitiveButton';
+import { ConstraintAccordionHeaderActions } from '../../ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions';
+
+interface IConstraintAccordionViewHeader {
+ localConstraint: IConstraint;
+ setContextName: (contextName: string) => void;
+ setOperator: (operator: Operator) => void;
+ setLocalConstraint: React.Dispatch>;
+ action: string;
+ compact: boolean;
+ onDelete?: () => void;
+ setInvertedOperator: () => void;
+ setCaseInsensitive: () => void;
+}
+
+export const ConstraintAccordionEditHeader = ({
+ compact,
+ localConstraint,
+ setLocalConstraint,
+ setContextName,
+ setOperator,
+ onDelete,
+ setInvertedOperator,
+ setCaseInsensitive,
+}: IConstraintAccordionViewHeader) => {
+ const { classes: styles } = useStyles();
+ const { context } = useUnleashContext();
+ const { contextName, operator } = localConstraint;
+ const [showCaseSensitiveButton, setShowCaseSensitiveButton] =
+ useState(false);
+
+ /* We need a special case to handle the currenTime context field. Since
+ this field will be the only one to allow DATE_BEFORE and DATE_AFTER operators
+ this will check if the context field is the current time context field AND check
+ if it is not already using one of the date operators (to not overwrite if there is existing
+ data). */
+ useEffect(() => {
+ if (
+ contextName === CURRENT_TIME_CONTEXT_FIELD &&
+ !oneOf(dateOperators, operator)
+ ) {
+ setLocalConstraint(prev => ({
+ ...prev,
+ operator: DATE_AFTER,
+ value: new Date().toISOString(),
+ }));
+ } else if (
+ contextName !== CURRENT_TIME_CONTEXT_FIELD &&
+ oneOf(dateOperators, operator)
+ ) {
+ setOperator(IN);
+ } else if (oneOf(stringOperators, operator)) {
+ setShowCaseSensitiveButton(true);
+ }
+ }, [contextName, setOperator, operator, setLocalConstraint]);
+
+ if (!context) {
+ return null;
+ }
+
+ const constraintNameOptions = context.map(context => {
+ return { key: context.name, label: context.name };
+ });
+
+ const onOperatorChange = (operator: Operator) => {
+ if (oneOf(dateOperators, operator)) {
+ setLocalConstraint(prev => ({
+ ...prev,
+ operator: operator,
+ value: new Date().toISOString(),
+ }));
+ } else {
+ if (oneOf(stringOperators, operator)) {
+ setShowCaseSensitiveButton(true);
+ }
+ setOperator(operator);
+ }
+ };
+
+ return (
+
+
+
+
+ {resolveText(operator, contextName)}
+
+ }
+ />
+
+
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/helpers.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/helpers.ts
new file mode 100644
index 0000000000..f0d2b13384
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/helpers.ts
@@ -0,0 +1,82 @@
+import {
+ DATE_BEFORE,
+ DATE_AFTER,
+ IN,
+ NOT_IN,
+ NUM_EQ,
+ NUM_GT,
+ NUM_GTE,
+ NUM_LT,
+ NUM_LTE,
+ STR_CONTAINS,
+ STR_ENDS_WITH,
+ STR_STARTS_WITH,
+ SEMVER_EQ,
+ SEMVER_GT,
+ SEMVER_LT,
+ Operator,
+} from 'constants/operators';
+
+export const resolveText = (operator: Operator, contextName: string) => {
+ const base = `To satisfy this constraint, values passed into the SDK as ${contextName} must`;
+
+ if (operator === IN) {
+ return `${base} include:`;
+ }
+
+ if (operator === NOT_IN) {
+ return `${base} not include:`;
+ }
+
+ if (operator === STR_ENDS_WITH) {
+ return `${base} end with:`;
+ }
+
+ if (operator === STR_STARTS_WITH) {
+ return `${base} start with:`;
+ }
+
+ if (operator === STR_CONTAINS) {
+ return `${base} contain:`;
+ }
+
+ if (operator === NUM_EQ) {
+ return `${base} match:`;
+ }
+
+ if (operator === NUM_GT) {
+ return `${base} be greater than:`;
+ }
+
+ if (operator === NUM_GTE) {
+ return `${base} be greater than or equal to:`;
+ }
+
+ if (operator === NUM_LT) {
+ return `${base} be less than:`;
+ }
+
+ if (operator === NUM_LTE) {
+ return `${base} be less than or equal to:`;
+ }
+
+ if (operator === DATE_AFTER) {
+ return `${base} be after the following date`;
+ }
+
+ if (operator === DATE_BEFORE) {
+ return `${base} be before the following date:`;
+ }
+
+ if (operator === SEMVER_EQ) {
+ return `${base} match the following version:`;
+ }
+
+ if (operator === SEMVER_GT) {
+ return `${base} be greater than the following version:`;
+ }
+
+ if (operator === SEMVER_LT) {
+ return `${base} be less than the following version:`;
+ }
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/CaseSensitiveButton/CaseSensitiveButton.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/CaseSensitiveButton/CaseSensitiveButton.tsx
new file mode 100644
index 0000000000..8a82b7ef5c
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/CaseSensitiveButton/CaseSensitiveButton.tsx
@@ -0,0 +1,51 @@
+import { Tooltip, Box } from '@mui/material';
+import { ReactComponent as CaseSensitive } from 'assets/icons/24_Text format.svg';
+import { ReactComponent as CaseSensitiveOff } from 'assets/icons/24_Text format off.svg';
+import React from 'react';
+import {
+ StyledToggleButtonOff,
+ StyledToggleButtonOn,
+} from '../StyledToggleButton';
+import { ConditionallyRender } from '../../../../ConditionallyRender/ConditionallyRender';
+import { IConstraint } from 'interfaces/strategy';
+
+interface CaseSensitiveButtonProps {
+ localConstraint: IConstraint;
+ setCaseInsensitive: () => void;
+}
+
+export const CaseSensitiveButton = ({
+ localConstraint,
+ setCaseInsensitive,
+}: CaseSensitiveButtonProps) => (
+
+
+
+
+
+ }
+ elseShow={
+
+
+
+ }
+ />
+
+
+);
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/InvertedOperatorButton/InvertedOperatorButton.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/InvertedOperatorButton/InvertedOperatorButton.tsx
new file mode 100644
index 0000000000..28dad13936
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/InvertedOperatorButton/InvertedOperatorButton.tsx
@@ -0,0 +1,75 @@
+import { Box, Tooltip, useTheme } from '@mui/material';
+import { ReactComponent as NegatedIcon } from 'assets/icons/24_Negator.svg';
+import { ReactComponent as NegatedIconOff } from 'assets/icons/24_Negator off.svg';
+import { IConstraint } from 'interfaces/strategy';
+import {
+ StyledToggleButtonOff,
+ StyledToggleButtonOn,
+} from '../StyledToggleButton';
+import { ConditionallyRender } from '../../../../ConditionallyRender/ConditionallyRender';
+import { ThemeMode } from 'component/common/ThemeMode/ThemeMode';
+
+interface InvertedOperatorButtonProps {
+ localConstraint: IConstraint;
+ setInvertedOperator: () => void;
+}
+
+export const InvertedOperatorButton = ({
+ localConstraint,
+ setInvertedOperator,
+}: InvertedOperatorButtonProps) => {
+ const theme = useTheme();
+
+ return (
+
+
+
+
+ }
+ lightmode={}
+ />
+
+ }
+ elseShow={
+
+
+ }
+ lightmode={}
+ />
+
+ }
+ />
+
+
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/StyledToggleButton.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/StyledToggleButton.tsx
new file mode 100644
index 0000000000..e4cef7dae5
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/StyledToggleButton.tsx
@@ -0,0 +1,39 @@
+import { styled } from '@mui/system';
+import { IconButton } from '@mui/material';
+
+export const StyledToggleButtonOff = styled(IconButton)(({ theme }) => ({
+ width: '28px',
+ minWidth: '28px',
+ maxWidth: '28px',
+ height: 'auto',
+ backgroundColor: theme.palette.tertiary.background,
+ borderRadius: theme.shape.borderRadius,
+ padding: '0 1px 0',
+ marginRight: '1rem',
+ '&:hover': {
+ background: theme.palette.tertiary.contrast[300],
+ },
+ [theme.breakpoints.between(1101, 1365)]: {
+ marginRight: '0.5rem',
+ alignItems: 'center',
+ },
+}));
+
+export const StyledToggleButtonOn = styled(IconButton)(({ theme }) => ({
+ width: '28px',
+ minWidth: '28px',
+ maxWidth: '28px',
+ color: theme.palette.primary.contrastText,
+ backgroundColor: theme.palette.primary.main,
+ borderRadius: theme.shape.borderRadius,
+ marginRight: '1rem',
+ padding: '0 1px 0',
+ '&:hover': {
+ color: theme.palette.primary.contrastText,
+ backgroundColor: theme.palette.primary.main,
+ },
+ [theme.breakpoints.between(1101, 1365)]: {
+ marginRight: '0.5rem',
+ alignItems: 'center',
+ },
+}));
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions.tsx
new file mode 100644
index 0000000000..810a3b9e63
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import { IconButton, Tooltip } from '@mui/material';
+import { Delete, Edit } from '@mui/icons-material';
+import { useStyles } from '../ConstraintAccordion.styles';
+import { ConditionallyRender } from '../../ConditionallyRender/ConditionallyRender';
+
+interface ConstraintAccordionHeaderActionsProps {
+ onDelete?: () => void;
+ onEdit?: () => void;
+ disableEdit?: boolean;
+ disableDelete?: boolean;
+}
+
+export const ConstraintAccordionHeaderActions = ({
+ onEdit,
+ onDelete,
+ disableDelete = false,
+ disableEdit = false,
+}: ConstraintAccordionHeaderActionsProps) => {
+ const { classes: styles } = useStyles();
+ const onEditClick =
+ onEdit &&
+ ((event: React.SyntheticEvent) => {
+ event.stopPropagation();
+ onEdit();
+ });
+
+ const onDeleteClick =
+ onDelete &&
+ ((event: React.SyntheticEvent) => {
+ event.stopPropagation();
+ onDelete();
+ });
+
+ return (
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+ }
+ />
+
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList.styles.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList.styles.ts
new file mode 100644
index 0000000000..571a8c663b
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList.styles.ts
@@ -0,0 +1,29 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ help: {
+ fill: theme.palette.grey[600],
+ [theme.breakpoints.down(860)]: {
+ display: 'none',
+ },
+ },
+ helpWrapper: {
+ marginLeft: '12px',
+ height: '24px',
+ },
+ addCustomLabel: {
+ display: 'flex',
+ flexDirection: 'row',
+ justifyContent: 'start',
+ margin: '0.75rem 0 ',
+ },
+ customConstraintLabel: {
+ marginBottom: theme.spacing(1),
+ color: theme.palette.text.secondary,
+ },
+}));
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList.tsx
new file mode 100644
index 0000000000..85b3c33772
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList.tsx
@@ -0,0 +1,175 @@
+import React, { forwardRef, Fragment, useImperativeHandle } from 'react';
+import { Button, Tooltip } from '@mui/material';
+import { HelpOutline } from '@mui/icons-material';
+import { IConstraint } from 'interfaces/strategy';
+import { ConstraintAccordion } from 'component/common/ConstraintAccordion/ConstraintAccordion';
+import produce from 'immer';
+import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
+import { useWeakMap } from 'hooks/useWeakMap';
+import { objectId } from 'utils/objectId';
+import { useStyles } from './ConstraintAccordionList.styles';
+import { createEmptyConstraint } from 'component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
+
+interface IConstraintAccordionListProps {
+ constraints: IConstraint[];
+ setConstraints?: React.Dispatch>;
+ showCreateButton?: boolean;
+ /* Add "Custom constraints" title on the top - default `true` */
+ showLabel?: boolean;
+}
+
+// Ref methods exposed by this component.
+export interface IConstraintAccordionListRef {
+ addConstraint?: (contextName: string) => void;
+}
+
+// Extra form state for each constraint.
+interface IConstraintAccordionListItemState {
+ // Is the constraint new (never been saved)?
+ new?: boolean;
+ // Is the constraint currently being edited?
+ editing?: boolean;
+}
+
+export const constraintAccordionListId = 'constraintAccordionListId';
+
+export const ConstraintAccordionList = forwardRef<
+ IConstraintAccordionListRef | undefined,
+ IConstraintAccordionListProps
+>(
+ (
+ { constraints, setConstraints, showCreateButton, showLabel = true },
+ ref
+ ) => {
+ const state = useWeakMap<
+ IConstraint,
+ IConstraintAccordionListItemState
+ >();
+ const { context } = useUnleashContext();
+ const { classes: styles } = useStyles();
+
+ const addConstraint =
+ setConstraints &&
+ ((contextName: string) => {
+ const constraint = createEmptyConstraint(contextName);
+ state.set(constraint, { editing: true, new: true });
+ setConstraints(prev => [...prev, constraint]);
+ });
+
+ useImperativeHandle(ref, () => ({
+ addConstraint,
+ }));
+
+ const onAdd =
+ addConstraint &&
+ (() => {
+ addConstraint(context[0].name);
+ });
+
+ const onEdit =
+ setConstraints &&
+ ((constraint: IConstraint) => {
+ state.set(constraint, { editing: true });
+ });
+
+ const onRemove =
+ setConstraints &&
+ ((index: number) => {
+ const constraint = constraints[index];
+ state.set(constraint, {});
+ setConstraints(
+ produce(draft => {
+ draft.splice(index, 1);
+ })
+ );
+ });
+
+ const onSave =
+ setConstraints &&
+ ((index: number, constraint: IConstraint) => {
+ state.set(constraint, {});
+ setConstraints(
+ produce(draft => {
+ draft[index] = constraint;
+ })
+ );
+ });
+
+ const onCancel = (index: number) => {
+ const constraint = constraints[index];
+ state.get(constraint)?.new && onRemove?.(index);
+ state.set(constraint, {});
+ };
+
+ if (context.length === 0) {
+ return null;
+ }
+
+ return (
+
+
0 && showLabel
+ }
+ show={
+
+ Custom constraints
+
+ }
+ />
+ {constraints.map((constraint, index) => (
+
+ 0}
+ show={}
+ />
+
+
+ ))}
+
+
+
Add any number of custom constraints
+
+
+
+
+
+
+
+
+ }
+ />
+
+ );
+ }
+);
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint.ts
new file mode 100644
index 0000000000..5ad8f8edfa
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint.ts
@@ -0,0 +1,21 @@
+import { dateOperators } from 'constants/operators';
+import { IConstraint } from 'interfaces/strategy';
+import { oneOf } from 'utils/oneOf';
+import { operatorsForContext } from 'utils/operatorsForContext';
+
+export const createEmptyConstraint = (contextName: string): IConstraint => {
+ const operator = operatorsForContext(contextName)[0];
+
+ const value = oneOf(dateOperators, operator)
+ ? new Date().toISOString()
+ : '';
+
+ return {
+ contextName,
+ operator,
+ value,
+ values: [],
+ caseInsensitive: false,
+ inverted: false,
+ };
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView.tsx
new file mode 100644
index 0000000000..382aea7266
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView.tsx
@@ -0,0 +1,94 @@
+import { useState } from 'react';
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ SxProps,
+ Theme,
+} from '@mui/material';
+import { IConstraint } from 'interfaces/strategy';
+import { ConstraintAccordionViewBody } from './ConstraintAccordionViewBody/ConstraintAccordionViewBody';
+import { ConstraintAccordionViewHeader } from './ConstraintAccordionViewHeader/ConstraintAccordionViewHeader';
+import { oneOf } from 'utils/oneOf';
+import {
+ dateOperators,
+ numOperators,
+ semVerOperators,
+} from 'constants/operators';
+import { useStyles } from '../ConstraintAccordion.styles';
+
+interface IConstraintAccordionViewProps {
+ constraint: IConstraint;
+ onDelete?: () => void;
+ onEdit?: () => void;
+ sx?: SxProps;
+ compact?: boolean;
+ renderAfter?: JSX.Element;
+}
+
+export const ConstraintAccordionView = ({
+ constraint,
+ onEdit,
+ onDelete,
+ sx = undefined,
+ compact = false,
+ renderAfter,
+}: IConstraintAccordionViewProps) => {
+ const { classes: styles } = useStyles();
+ const [expandable, setExpandable] = useState(true);
+ const [expanded, setExpanded] = useState(false);
+
+ const singleValue = oneOf(
+ [...semVerOperators, ...numOperators, ...dateOperators],
+ constraint.operator
+ );
+ const handleClick = () => {
+ if (expandable) {
+ setExpanded(!expanded);
+ }
+ };
+
+ return (
+
+
+
+
+ {renderAfter}
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/ConstraintAccordionViewBody.style.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/ConstraintAccordionViewBody.style.ts
new file mode 100644
index 0000000000..7d0cc2d8b8
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/ConstraintAccordionViewBody.style.ts
@@ -0,0 +1,27 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ chip: {
+ margin: '0 0.5rem 0.5rem 0',
+ },
+ chipValue: {
+ whiteSpace: 'pre',
+ },
+ singleValueView: {
+ display: 'flex',
+ alignItems: 'center',
+ [theme.breakpoints.down(600)]: { flexDirection: 'column' },
+ },
+ singleValueText: {
+ marginRight: '0.75rem',
+ [theme.breakpoints.down(600)]: {
+ marginBottom: '0.75rem',
+ marginRight: 0,
+ },
+ },
+ settingsParagraph: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '0.5rem 0',
+ },
+}));
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/ConstraintAccordionViewBody.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/ConstraintAccordionViewBody.tsx
new file mode 100644
index 0000000000..703f4eb746
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/ConstraintAccordionViewBody.tsx
@@ -0,0 +1,29 @@
+import { IConstraint } from 'interfaces/strategy';
+import { useStyles } from 'component/common/ConstraintAccordion/ConstraintAccordion.styles';
+import { formatConstraintValue } from 'utils/formatConstraintValue';
+import { useLocationSettings } from 'hooks/useLocationSettings';
+import { MultipleValues } from './MultipleValues/MultipleValues';
+import { SingleValue } from './SingleValue/SingleValue';
+
+interface IConstraintAccordionViewBodyProps {
+ constraint: IConstraint;
+}
+
+export const ConstraintAccordionViewBody = ({
+ constraint,
+}: IConstraintAccordionViewBodyProps) => {
+ const { classes: styles } = useStyles();
+ const { locationSettings } = useLocationSettings();
+
+ return (
+
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/MultipleValues/MultipleValues.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/MultipleValues/MultipleValues.tsx
new file mode 100644
index 0000000000..cdcb925720
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/MultipleValues/MultipleValues.tsx
@@ -0,0 +1,47 @@
+import { useState } from 'react';
+import { Chip } from '@mui/material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import StringTruncator from 'component/common/StringTruncator/StringTruncator';
+import { ConstraintValueSearch } from '../../../ConstraintValueSearch/ConstraintValueSearch';
+import { useStyles } from '../ConstraintAccordionViewBody.style';
+
+interface IMultipleValuesProps {
+ values: string[] | undefined;
+}
+
+export const MultipleValues = ({ values }: IMultipleValuesProps) => {
+ const [filter, setFilter] = useState('');
+ const { classes: styles } = useStyles();
+
+ if (!values || values.length === 0) return null;
+
+ return (
+ <>
+ 20}
+ show={
+
+ }
+ />
+ {values
+ .filter(value => value.includes(filter))
+ .map((value, index) => (
+
+ }
+ className={styles.chip}
+ />
+ ))}
+ >
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/SingleValue/SingleValue.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/SingleValue/SingleValue.tsx
new file mode 100644
index 0000000000..f34ef16d46
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewBody/SingleValue/SingleValue.tsx
@@ -0,0 +1,29 @@
+import { Chip } from '@mui/material';
+import StringTruncator from 'component/common/StringTruncator/StringTruncator';
+import { useStyles } from '../ConstraintAccordionViewBody.style';
+
+interface ISingleValueProps {
+ value: string | undefined;
+ operator: string;
+}
+
+export const SingleValue = ({ value, operator }: ISingleValueProps) => {
+ const { classes: styles } = useStyles();
+ if (!value) return null;
+
+ return (
+
+
Value must be {operator}
{' '}
+
+ }
+ className={styles.chip}
+ />
+
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeader.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeader.tsx
new file mode 100644
index 0000000000..bf09761625
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeader.tsx
@@ -0,0 +1,43 @@
+import { ConstraintIcon } from 'component/common/ConstraintAccordion/ConstraintIcon';
+import { IConstraint } from 'interfaces/strategy';
+import { ConstraintAccordionViewHeaderInfo } from './ConstraintAccordionViewHeaderInfo/ConstraintAccordionViewHeaderInfo';
+import { ConstraintAccordionHeaderActions } from '../../ConstraintAccordionHeaderActions/ConstraintAccordionHeaderActions';
+import { useStyles } from 'component/common/ConstraintAccordion/ConstraintAccordion.styles';
+
+interface IConstraintAccordionViewHeaderProps {
+ constraint: IConstraint;
+ onDelete?: () => void;
+ onEdit?: () => void;
+ singleValue: boolean;
+ expanded: boolean;
+ allowExpand: (shouldExpand: boolean) => void;
+ compact?: boolean;
+}
+
+export const ConstraintAccordionViewHeader = ({
+ constraint,
+ onEdit,
+ onDelete,
+ singleValue,
+ allowExpand,
+ expanded,
+ compact,
+}: IConstraintAccordionViewHeaderProps) => {
+ const { classes: styles } = useStyles();
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeaderInfo/ConstraintAccordionViewHeaderInfo.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeaderInfo/ConstraintAccordionViewHeaderInfo.tsx
new file mode 100644
index 0000000000..f8298b0fa5
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintAccordionViewHeaderInfo/ConstraintAccordionViewHeaderInfo.tsx
@@ -0,0 +1,83 @@
+import { styled, Tooltip } from '@mui/material';
+import { ConstraintViewHeaderOperator } from '../ConstraintViewHeaderOperator/ConstraintViewHeaderOperator';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { ConstraintAccordionViewHeaderSingleValue } from '../ContraintAccordionViewHeaderSingleValue/ConstraintAccordionViewHeaderSingleValue';
+import { ConstraintAccordionViewHeaderMultipleValues } from '../ContraintAccordionViewHeaderMultipleValues/ConstraintAccordionViewHeaderMultipleValues';
+import React from 'react';
+import { IConstraint } from 'interfaces/strategy';
+import { useStyles } from '../../../ConstraintAccordion.styles';
+
+const StyledHeaderText = styled('span')(({ theme }) => ({
+ display: '-webkit-box',
+ WebkitLineClamp: 3,
+ WebkitBoxOrient: 'vertical',
+ overflow: 'hidden',
+ maxWidth: '100px',
+ minWidth: '100px',
+ marginRight: '10px',
+ marginTop: 'auto',
+ marginBottom: 'auto',
+ wordBreak: 'break-word',
+ fontSize: theme.fontSizes.smallBody,
+ [theme.breakpoints.down(710)]: {
+ textAlign: 'center',
+ padding: theme.spacing(1, 0),
+ marginRight: 'inherit',
+ maxWidth: 'inherit',
+ },
+}));
+
+const StyledHeaderWrapper = styled('div')(({ theme }) => ({
+ display: 'flex',
+ width: '100%',
+ justifyContent: 'space-between',
+ borderRadius: theme.spacing(1),
+}));
+
+interface ConstraintAccordionViewHeaderMetaInfoProps {
+ constraint: IConstraint;
+ singleValue: boolean;
+ expanded: boolean;
+ allowExpand: (shouldExpand: boolean) => void;
+ maxLength?: number;
+}
+
+export const ConstraintAccordionViewHeaderInfo = ({
+ constraint,
+ singleValue,
+ allowExpand,
+ expanded,
+ maxLength = 112, //The max number of characters in the values text for NOT allowing expansion
+}: ConstraintAccordionViewHeaderMetaInfoProps) => {
+ const { classes: styles } = useStyles();
+
+ return (
+
+
+
+
+ {constraint.contextName}
+
+
+
+
+ }
+ elseShow={
+
+ }
+ />
+
+
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintViewHeaderOperator/ConstraintViewHeaderOperator.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintViewHeaderOperator/ConstraintViewHeaderOperator.tsx
new file mode 100644
index 0000000000..5c254e12b5
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ConstraintViewHeaderOperator/ConstraintViewHeaderOperator.tsx
@@ -0,0 +1,56 @@
+import { IConstraint } from 'interfaces/strategy';
+import { ConditionallyRender } from '../../../../ConditionallyRender/ConditionallyRender';
+import { Tooltip, Box } from '@mui/material';
+import { stringOperators } from 'constants/operators';
+import { ReactComponent as NegatedIcon } from 'assets/icons/24_Negator.svg';
+import { ConstraintOperator } from '../../../ConstraintOperator/ConstraintOperator';
+import { useStyles } from '../../../ConstraintAccordion.styles';
+import { StyledIconWrapper } from '../StyledIconWrapper/StyledIconWrapper';
+import { ReactComponent as CaseSensitive } from 'assets/icons/24_Text format.svg';
+import { oneOf } from 'utils/oneOf';
+
+interface ConstraintViewHeaderOperatorProps {
+ constraint: IConstraint;
+}
+
+export const ConstraintViewHeaderOperator = ({
+ constraint,
+}: ConstraintViewHeaderOperatorProps) => {
+ const { classes: styles } = useStyles();
+
+ return (
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+ }
+ />
+
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ContraintAccordionViewHeaderMultipleValues/ConstraintAccordionViewHeaderMultipleValues.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ContraintAccordionViewHeaderMultipleValues/ConstraintAccordionViewHeaderMultipleValues.tsx
new file mode 100644
index 0000000000..d3aff894fb
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ContraintAccordionViewHeaderMultipleValues/ConstraintAccordionViewHeaderMultipleValues.tsx
@@ -0,0 +1,72 @@
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { styled } from '@mui/material';
+import React, { useEffect, useMemo, useState } from 'react';
+import classnames from 'classnames';
+import { IConstraint } from 'interfaces/strategy';
+import { useStyles } from '../../../ConstraintAccordion.styles';
+
+const StyledValuesSpan = styled('span')(({ theme }) => ({
+ display: '-webkit-box',
+ WebkitLineClamp: 2,
+ WebkitBoxOrient: 'vertical',
+ overflow: 'hidden',
+ wordBreak: 'break-word',
+ fontSize: theme.fontSizes.smallBody,
+ margin: 'auto 0',
+ [theme.breakpoints.down(710)]: {
+ margin: theme.spacing(1, 0),
+ textAlign: 'center',
+ },
+}));
+
+interface ConstraintSingleValueProps {
+ constraint: IConstraint;
+ expanded: boolean;
+ maxLength: number;
+ allowExpand: (shouldExpand: boolean) => void;
+}
+
+export const ConstraintAccordionViewHeaderMultipleValues = ({
+ constraint,
+ expanded,
+ allowExpand,
+ maxLength,
+}: ConstraintSingleValueProps) => {
+ const { classes: styles } = useStyles();
+
+ const [expandable, setExpandable] = useState(false);
+
+ const text = useMemo(() => {
+ return constraint?.values?.map(value => value).join(', ');
+ }, [constraint]);
+
+ useEffect(() => {
+ if (text) {
+ allowExpand((text?.length ?? 0) > maxLength);
+ setExpandable((text?.length ?? 0) > maxLength);
+ }
+ }, [text, maxLength, allowExpand, setExpandable]);
+
+ return (
+
+
+ {text}
+
+ {!expanded
+ ? `View all (${constraint?.values?.length})`
+ : 'View less'}
+
+ }
+ />
+
+
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ContraintAccordionViewHeaderSingleValue/ConstraintAccordionViewHeaderSingleValue.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ContraintAccordionViewHeaderSingleValue/ConstraintAccordionViewHeaderSingleValue.tsx
new file mode 100644
index 0000000000..64a3cab927
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/ContraintAccordionViewHeaderSingleValue/ConstraintAccordionViewHeaderSingleValue.tsx
@@ -0,0 +1,39 @@
+import React, { useEffect } from 'react';
+import { Chip, styled } from '@mui/material';
+import { formatConstraintValue } from 'utils/formatConstraintValue';
+import { useStyles } from '../../../ConstraintAccordion.styles';
+import { IConstraint } from 'interfaces/strategy';
+import { useLocationSettings } from 'hooks/useLocationSettings';
+
+const StyledSingleValueChip = styled(Chip)(({ theme }) => ({
+ margin: 'auto 0',
+ marginLeft: theme.spacing(1),
+ [theme.breakpoints.down(710)]: {
+ margin: theme.spacing(1, 0),
+ },
+}));
+
+interface ConstraintSingleValueProps {
+ constraint: IConstraint;
+ allowExpand: (shouldExpand: boolean) => void;
+}
+
+export const ConstraintAccordionViewHeaderSingleValue = ({
+ constraint,
+ allowExpand,
+}: ConstraintSingleValueProps) => {
+ const { locationSettings } = useLocationSettings();
+ const { classes: styles } = useStyles();
+
+ useEffect(() => {
+ allowExpand(false);
+ }, [allowExpand]);
+
+ return (
+
+
+
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/StyledIconWrapper/StyledIconWrapper.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/StyledIconWrapper/StyledIconWrapper.tsx
new file mode 100644
index 0000000000..f12acf3f2f
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionViewHeader/StyledIconWrapper/StyledIconWrapper.tsx
@@ -0,0 +1,34 @@
+import { forwardRef, ReactNode } from 'react';
+import { styled } from '@mui/material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+
+export const StyledIconWrapperBase = styled('div')<{
+ prefix?: boolean;
+}>(({ theme }) => ({
+ backgroundColor: theme.palette.grey[200],
+ width: 24,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ alignSelf: 'stretch',
+ color: theme.palette.primary.main,
+ marginLeft: theme.spacing(1),
+ borderRadius: theme.shape.borderRadius,
+}));
+
+const StyledPrefixIconWrapper = styled(StyledIconWrapperBase)(() => ({
+ marginLeft: 0,
+ borderTopRightRadius: 0,
+ borderBottomRightRadius: 0,
+}));
+
+export const StyledIconWrapper = forwardRef<
+ HTMLDivElement,
+ { isPrefix?: boolean; children?: ReactNode }
+>(({ isPrefix, ...props }, ref) => (
+ }
+ elseShow={() => }
+ />
+));
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintIcon.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintIcon.tsx
new file mode 100644
index 0000000000..b941342d4c
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintIcon.tsx
@@ -0,0 +1,29 @@
+import { VFC } from 'react';
+import { Box } from '@mui/material';
+import { TrackChanges } from '@mui/icons-material';
+
+interface IConstraintIconProps {
+ compact?: boolean;
+}
+
+export const ConstraintIcon: VFC = ({ compact }) => (
+
+
+
+);
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/ConstraintOperator.styles.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/ConstraintOperator.styles.ts
new file mode 100644
index 0000000000..4d74d7c893
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/ConstraintOperator.styles.ts
@@ -0,0 +1,31 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ padding: theme.spacing(0.5, 1.5),
+ borderRadius: theme.shape.borderRadius,
+ backgroundColor: theme.palette.constraintAccordion.operatorBackground,
+ lineHeight: 1.25,
+ },
+ name: {
+ fontSize: theme.fontSizes.smallBody,
+ lineHeight: 17 / 14,
+ },
+ text: {
+ fontSize: theme.fontSizes.smallerBody,
+ color: theme.palette.grey[700],
+ },
+ not: {
+ display: 'block',
+ margin: '-1rem 0 0.25rem 0',
+ height: '1rem',
+ '& > span': {
+ display: 'inline-block',
+ padding: '0 0.25rem',
+ borderRadius: theme.shape.borderRadius,
+ fontSize: theme.fontSizes.smallerBody,
+ backgroundColor: theme.palette.primary.light,
+ color: 'white',
+ },
+ },
+}));
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/ConstraintOperator.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/ConstraintOperator.tsx
new file mode 100644
index 0000000000..427f98afa0
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/ConstraintOperator.tsx
@@ -0,0 +1,33 @@
+import { IConstraint } from 'interfaces/strategy';
+import { formatOperatorDescription } from 'component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription';
+import { useStyles } from 'component/common/ConstraintAccordion/ConstraintOperator/ConstraintOperator.styles';
+import React from 'react';
+
+interface IConstraintOperatorProps {
+ constraint: IConstraint;
+ hasPrefix?: boolean;
+}
+
+export const ConstraintOperator = ({
+ constraint,
+ hasPrefix,
+}: IConstraintOperatorProps) => {
+ const { classes: styles } = useStyles();
+
+ const operatorName = constraint.operator;
+ const operatorText = formatOperatorDescription(constraint.operator);
+
+ return (
+
+
{operatorName}
+
{operatorText}
+
+ );
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription.ts
new file mode 100644
index 0000000000..ef4a76118e
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription.ts
@@ -0,0 +1,23 @@
+import { Operator } from 'constants/operators';
+
+export const formatOperatorDescription = (operator: Operator): string => {
+ return constraintOperatorDescriptions[operator];
+};
+
+const constraintOperatorDescriptions = {
+ IN: 'is one of',
+ NOT_IN: 'is not one of',
+ STR_CONTAINS: 'is a string that contains',
+ STR_STARTS_WITH: 'is a string that starts with',
+ STR_ENDS_WITH: 'is a string that ends with',
+ NUM_EQ: 'is a number equal to',
+ NUM_GT: 'is a number greater than',
+ NUM_GTE: 'is a number greater than or equal to',
+ NUM_LT: 'is a number less than',
+ NUM_LTE: 'is a number less than or equal to',
+ DATE_BEFORE: 'is a date before',
+ DATE_AFTER: 'is a date after',
+ SEMVER_EQ: 'is a SemVer equal to',
+ SEMVER_GT: 'is a SemVer greater than',
+ SEMVER_LT: 'is a SemVer less than',
+};
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintOperatorSelect/ConstraintOperatorSelect.styles.ts b/frontend/src/component/common/ConstraintAccordion/ConstraintOperatorSelect/ConstraintOperatorSelect.styles.ts
new file mode 100644
index 0000000000..3c2d139bc9
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintOperatorSelect/ConstraintOperatorSelect.styles.ts
@@ -0,0 +1,43 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ valueContainer: {
+ lineHeight: 1.1,
+ marginTop: -2,
+ marginBottom: -10,
+ },
+ optionContainer: {
+ lineHeight: 1.2,
+ },
+ label: {
+ fontSize: theme.fontSizes.smallBody,
+ },
+ description: {
+ fontSize: theme.fontSizes.smallerBody,
+ color: theme.palette.grey[700],
+ overflow: 'hidden',
+ whiteSpace: 'nowrap',
+ textOverflow: 'ellipsis',
+ },
+ separator: {
+ position: 'relative',
+ overflow: 'visible',
+ marginTop: '1rem',
+ '&:before': {
+ content: '""',
+ display: 'block',
+ position: 'absolute',
+ top: '-0.5rem',
+ left: 0,
+ right: 0,
+ borderTop: '1px solid',
+ borderTopColor: theme.palette.grey[300],
+ },
+ },
+ formInput: {
+ [theme.breakpoints.between(1101, 1365)]: {
+ width: '170px',
+ marginRight: '8px',
+ },
+ },
+}));
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintOperatorSelect/ConstraintOperatorSelect.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintOperatorSelect/ConstraintOperatorSelect.tsx
new file mode 100644
index 0000000000..82e3c23d29
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintOperatorSelect/ConstraintOperatorSelect.tsx
@@ -0,0 +1,107 @@
+import {
+ Select,
+ MenuItem,
+ FormControl,
+ InputLabel,
+ SelectChangeEvent,
+} from '@mui/material';
+import {
+ Operator,
+ stringOperators,
+ semVerOperators,
+ dateOperators,
+ numOperators,
+ inOperators,
+} from 'constants/operators';
+import React, { useState } from 'react';
+import { formatOperatorDescription } from 'component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription';
+import { useStyles } from 'component/common/ConstraintAccordion/ConstraintOperatorSelect/ConstraintOperatorSelect.styles';
+import classNames from 'classnames';
+
+interface IConstraintOperatorSelectProps {
+ options: Operator[];
+ value: Operator;
+ onChange: (value: Operator) => void;
+}
+
+export const ConstraintOperatorSelect = ({
+ options,
+ value,
+ onChange,
+}: IConstraintOperatorSelectProps) => {
+ const { classes: styles } = useStyles();
+ const [open, setOpen] = useState(false);
+
+ const onSelectChange = (event: SelectChangeEvent) => {
+ onChange(event.target.value as Operator);
+ };
+
+ const renderValue = () => {
+ return (
+
+
{value}
+
+ {formatOperatorDescription(value)}
+
+
+ );
+ };
+
+ return (
+
+ Operator
+
+
+ );
+};
+
+const needSeparatorAbove = (options: Operator[], option: Operator): boolean => {
+ if (option === options[0]) {
+ return false;
+ }
+
+ return operatorGroups.some(group => {
+ return group[0] === option;
+ });
+};
+
+const operatorGroups = [
+ inOperators,
+ stringOperators,
+ numOperators,
+ dateOperators,
+ semVerOperators,
+];
diff --git a/frontend/src/component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch.tsx b/frontend/src/component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch.tsx
new file mode 100644
index 0000000000..22f153cb6a
--- /dev/null
+++ b/frontend/src/component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch.tsx
@@ -0,0 +1,50 @@
+import { TextField, InputAdornment, Chip } from '@mui/material';
+import { Search } from '@mui/icons-material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+
+interface IConstraintValueSearchProps {
+ filter: string;
+ setFilter: React.Dispatch>;
+}
+
+export const ConstraintValueSearch = ({
+ filter,
+ setFilter,
+}: IConstraintValueSearchProps) => {
+ return (
+
+
+ setFilter(e.target.value)}
+ placeholder="Filter values"
+ style={{
+ width: '100%',
+ margin: '1rem 0',
+ }}
+ variant="outlined"
+ size="small"
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ }}
+ />
+
+
setFilter('')}
+ />
+ }
+ />
+
+ );
+};
diff --git a/frontend/src/component/common/CreateButton/CreateButton.tsx b/frontend/src/component/common/CreateButton/CreateButton.tsx
new file mode 100644
index 0000000000..05d9306af2
--- /dev/null
+++ b/frontend/src/component/common/CreateButton/CreateButton.tsx
@@ -0,0 +1,15 @@
+import PermissionButton, {
+ IPermissionButtonProps,
+} from '../PermissionButton/PermissionButton';
+
+interface ICreateButtonProps extends IPermissionButtonProps {
+ name: string;
+}
+
+export const CreateButton = ({ name, ...rest }: ICreateButtonProps) => {
+ return (
+
+ Create {name}
+
+ );
+};
diff --git a/frontend/src/component/common/Dialogue/Dialogue.styles.ts b/frontend/src/component/common/Dialogue/Dialogue.styles.ts
new file mode 100644
index 0000000000..d127fb3e82
--- /dev/null
+++ b/frontend/src/component/common/Dialogue/Dialogue.styles.ts
@@ -0,0 +1,14 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ dialogTitle: {
+ backgroundColor: theme.palette.dialogHeaderBackground,
+ color: theme.palette.dialogHeaderText,
+ height: '150px',
+ padding: '2rem 3rem',
+ clipPath: ' ellipse(130% 115px at 120% 20%)',
+ },
+ dialogContentPadding: {
+ padding: '2rem 3rem',
+ },
+}));
diff --git a/frontend/src/component/common/Dialogue/Dialogue.tsx b/frontend/src/component/common/Dialogue/Dialogue.tsx
new file mode 100644
index 0000000000..e4436a67f4
--- /dev/null
+++ b/frontend/src/component/common/Dialogue/Dialogue.tsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import {
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+} from '@mui/material';
+
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { useStyles } from './Dialogue.styles';
+import { DIALOGUE_CONFIRM_ID } from 'utils/testIds';
+
+interface IDialogue {
+ primaryButtonText?: string;
+ secondaryButtonText?: string;
+ open: boolean;
+ onClick?: (e: React.SyntheticEvent) => void;
+ onClose?: (e: React.SyntheticEvent, reason?: string) => void;
+ style?: object;
+ title: string;
+ fullWidth?: boolean;
+ maxWidth?: 'lg' | 'sm' | 'xs' | 'md' | 'xl';
+ disabledPrimaryButton?: boolean;
+ formId?: string;
+ permissionButton?: JSX.Element;
+}
+
+export const Dialogue: React.FC = ({
+ children,
+ open,
+ onClick,
+ onClose,
+ title,
+ primaryButtonText,
+ disabledPrimaryButton = false,
+ secondaryButtonText,
+ maxWidth = 'sm',
+ fullWidth = false,
+ formId,
+ permissionButton,
+}) => {
+ const { classes: styles } = useStyles();
+ const handleClick = formId
+ ? (e: React.SyntheticEvent) => {
+ e.preventDefault();
+ if (onClick) {
+ onClick(e);
+ }
+ }
+ : onClick;
+ return (
+
+ );
+};
diff --git a/frontend/src/component/common/DividerText/DividerText.styles.ts b/frontend/src/component/common/DividerText/DividerText.styles.ts
new file mode 100644
index 0000000000..58023603e0
--- /dev/null
+++ b/frontend/src/component/common/DividerText/DividerText.styles.ts
@@ -0,0 +1,21 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ margin: '1rem auto',
+ },
+ wing: {
+ width: '80px',
+ height: '3px',
+ backgroundColor: theme.palette.divider,
+ borderRadius: theme.shape.borderRadius,
+ },
+ text: {
+ textAlign: 'center',
+ display: 'block',
+ margin: '0 1rem',
+ },
+}));
diff --git a/frontend/src/component/common/DividerText/DividerText.tsx b/frontend/src/component/common/DividerText/DividerText.tsx
new file mode 100644
index 0000000000..13eda22945
--- /dev/null
+++ b/frontend/src/component/common/DividerText/DividerText.tsx
@@ -0,0 +1,22 @@
+import { Typography } from '@mui/material';
+import { useStyles } from 'component/common/DividerText/DividerText.styles';
+
+interface IDividerTextProps {
+ text: string;
+}
+
+const DividerText = ({ text, ...rest }: IDividerTextProps) => {
+ const { classes: styles } = useStyles();
+
+ return (
+
+
+
+ {text}
+
+
+
+ );
+};
+
+export default DividerText;
diff --git a/frontend/src/component/common/DropdownMenu/DropdownButton/DropdownButton.tsx b/frontend/src/component/common/DropdownMenu/DropdownButton/DropdownButton.tsx
new file mode 100644
index 0000000000..a492668899
--- /dev/null
+++ b/frontend/src/component/common/DropdownMenu/DropdownButton/DropdownButton.tsx
@@ -0,0 +1,23 @@
+import { ReactNode, VFC } from 'react';
+import { Button, ButtonProps, Icon } from '@mui/material';
+
+interface IDropdownButtonProps {
+ label: string;
+ id?: string;
+ title?: ButtonProps['title'];
+ className?: string;
+ icon?: ReactNode;
+ startIcon?: ButtonProps['startIcon'];
+ style?: ButtonProps['style'];
+ onClick: ButtonProps['onClick'];
+}
+
+export const DropdownButton: VFC = ({
+ label,
+ icon,
+ ...rest
+}) => (
+
+);
diff --git a/frontend/src/component/common/DropdownMenu/DropdownMenu.tsx b/frontend/src/component/common/DropdownMenu/DropdownMenu.tsx
new file mode 100644
index 0000000000..32f773ff4d
--- /dev/null
+++ b/frontend/src/component/common/DropdownMenu/DropdownMenu.tsx
@@ -0,0 +1,74 @@
+import {
+ CSSProperties,
+ MouseEventHandler,
+ ReactNode,
+ useState,
+ VFC,
+} from 'react';
+import { Menu } from '@mui/material';
+import { ArrowDropDown } from '@mui/icons-material';
+import { DropdownButton } from './DropdownButton/DropdownButton';
+
+export interface IDropdownMenuProps {
+ renderOptions: () => ReactNode;
+ id: string;
+ title?: string;
+ callback?: MouseEventHandler;
+ icon?: ReactNode;
+ label: string;
+ startIcon?: ReactNode;
+ style?: CSSProperties;
+}
+
+const DropdownMenu: VFC = ({
+ renderOptions,
+ id,
+ title,
+ callback,
+ icon = ,
+ label,
+ style,
+ startIcon,
+ ...rest
+}) => {
+ const [anchor, setAnchor] = useState(null);
+
+ const handleOpen: MouseEventHandler = e => {
+ setAnchor(e.currentTarget);
+ };
+
+ const handleClose: MouseEventHandler = e => {
+ if (callback && typeof callback === 'function') {
+ callback(e);
+ }
+
+ setAnchor(null);
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default DropdownMenu;
diff --git a/frontend/src/component/common/EnvironmentIcon/EnvironmentIcon.tsx b/frontend/src/component/common/EnvironmentIcon/EnvironmentIcon.tsx
new file mode 100644
index 0000000000..1d7e251b48
--- /dev/null
+++ b/frontend/src/component/common/EnvironmentIcon/EnvironmentIcon.tsx
@@ -0,0 +1,41 @@
+import { useTheme } from '@mui/material/styles';
+import { Cloud } from '@mui/icons-material';
+
+interface IEnvironmentIcon {
+ enabled: boolean;
+ className?: string;
+}
+
+const EnvironmentIcon = ({ enabled, className }: IEnvironmentIcon) => {
+ const theme = useTheme();
+
+ const title = enabled ? 'Environment enabled' : 'Environment disabled';
+
+ const container = {
+ backgroundColor: enabled
+ ? theme.palette.primary.light
+ : theme.palette.neutral.border,
+ borderRadius: '50%',
+ width: '28px',
+ height: '28px',
+ minWidth: '28px',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: theme.spacing(1),
+ };
+
+ const icon = {
+ fill: '#fff',
+ width: '16px',
+ height: '16px',
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default EnvironmentIcon;
diff --git a/frontend/src/component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog.styles.ts b/frontend/src/component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog.styles.ts
new file mode 100644
index 0000000000..7de67a83af
--- /dev/null
+++ b/frontend/src/component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog.styles.ts
@@ -0,0 +1,8 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ infoText: {
+ marginBottom: '10px',
+ fontSize: theme.fontSizes.bodySize,
+ },
+}));
diff --git a/frontend/src/component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog.tsx b/frontend/src/component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog.tsx
new file mode 100644
index 0000000000..19d1ee44a2
--- /dev/null
+++ b/frontend/src/component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog.tsx
@@ -0,0 +1,69 @@
+import { useNavigate } from 'react-router-dom';
+import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import PermissionButton from '../PermissionButton/PermissionButton';
+import { useStyles } from './EnvironmentStrategyDialog.styles';
+import { formatCreateStrategyPath } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
+
+interface IEnvironmentStrategyDialogProps {
+ open: boolean;
+ featureId: string;
+ projectId: string;
+ onClose: () => void;
+ environmentName: string;
+}
+const EnvironmentStrategyDialog = ({
+ open,
+ environmentName,
+ featureId,
+ projectId,
+ onClose,
+}: IEnvironmentStrategyDialogProps) => {
+ const { classes: styles } = useStyles();
+ const navigate = useNavigate();
+
+ const createStrategyPath = formatCreateStrategyPath(
+ projectId,
+ featureId,
+ environmentName,
+ 'default'
+ );
+
+ const onClick = () => {
+ onClose();
+ navigate(createStrategyPath);
+ };
+
+ return (
+ onClose()}
+ title="You need to add a strategy to your toggle"
+ primaryButtonText="Take me directly to add strategy"
+ permissionButton={
+
+ Take me directly to add strategy
+
+ }
+ secondaryButtonText="Cancel"
+ >
+
+ Before you can enable the toggle in the environment, you need to
+ add an activation strategy.
+
+
+ You can add the activation strategy by selecting the toggle,
+ open the environment accordion and add the activation strategy.
+
+
+ );
+};
+
+export default EnvironmentStrategyDialog;
diff --git a/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx b/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx
new file mode 100644
index 0000000000..25e3c5c972
--- /dev/null
+++ b/frontend/src/component/common/FeatureArchiveDialog/FeatureArchiveDialog.tsx
@@ -0,0 +1,53 @@
+import { VFC } from 'react';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
+import useToast from 'hooks/useToast';
+import { formatUnknownError } from 'utils/formatUnknownError';
+
+interface IFeatureArchiveDialogProps {
+ isOpen: boolean;
+ onConfirm: () => void;
+ onClose: () => void;
+ projectId: string;
+ featureId: string;
+}
+
+export const FeatureArchiveDialog: VFC = ({
+ isOpen,
+ onClose,
+ onConfirm,
+ projectId,
+ featureId,
+}) => {
+ const { archiveFeatureToggle } = useFeatureApi();
+ const { setToastData, setToastApiError } = useToast();
+
+ const archiveToggle = async () => {
+ try {
+ await archiveFeatureToggle(projectId, featureId);
+ setToastData({
+ text: 'Your feature toggle has been archived',
+ type: 'success',
+ title: 'Feature archived',
+ });
+ onConfirm();
+ onClose();
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ onClose();
+ }
+ };
+
+ return (
+ archiveToggle()}
+ open={isOpen}
+ onClose={onClose}
+ primaryButtonText="Archive toggle"
+ secondaryButtonText="Cancel"
+ title="Archive feature toggle"
+ >
+ Are you sure you want to archive this feature toggle?
+
+ );
+};
diff --git a/frontend/src/component/common/FeatureStaleDialog/FeatureStaleDialog.tsx b/frontend/src/component/common/FeatureStaleDialog/FeatureStaleDialog.tsx
new file mode 100644
index 0000000000..e73b71747e
--- /dev/null
+++ b/frontend/src/component/common/FeatureStaleDialog/FeatureStaleDialog.tsx
@@ -0,0 +1,81 @@
+import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
+import { Typography } from '@mui/material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import React from 'react';
+import useToast from 'hooks/useToast';
+import { formatUnknownError } from 'utils/formatUnknownError';
+
+interface IFeatureStaleDialogProps {
+ isStale: boolean;
+ isOpen: boolean;
+ projectId: string;
+ featureId: string;
+ onClose: () => void;
+}
+
+export const FeatureStaleDialog = ({
+ isStale,
+ isOpen,
+ projectId,
+ featureId,
+ onClose,
+}: IFeatureStaleDialogProps) => {
+ const { setToastData, setToastApiError } = useToast();
+ const { patchFeatureToggle } = useFeatureApi();
+
+ const toggleToStaleContent = (
+ Setting a toggle to stale marks it for cleanup
+ );
+
+ const toggleToActiveContent = (
+
+ Setting a toggle to active marks it as in active use
+
+ );
+
+ const toggleActionText = isStale ? 'active' : 'stale';
+
+ const onSubmit = async (event: React.SyntheticEvent) => {
+ event.stopPropagation();
+
+ try {
+ const patch = [{ op: 'replace', path: '/stale', value: !isStale }];
+ await patchFeatureToggle(projectId, featureId, patch);
+ onClose();
+ } catch (err: unknown) {
+ setToastApiError(formatUnknownError(err));
+ }
+
+ if (isStale) {
+ setToastData({
+ type: 'success',
+ title: "And we're back!",
+ text: 'The toggle is no longer marked as stale.',
+ });
+ } else {
+ setToastData({
+ type: 'success',
+ title: 'A job well done.',
+ text: 'The toggle has been marked as stale.',
+ });
+ }
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/frontend/src/component/common/FormTemplate/FormTemplate.styles.ts b/frontend/src/component/common/FormTemplate/FormTemplate.styles.ts
new file mode 100644
index 0000000000..24b34cc50d
--- /dev/null
+++ b/frontend/src/component/common/FormTemplate/FormTemplate.styles.ts
@@ -0,0 +1,109 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const formTemplateSidebarWidth = '27.5rem';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ minHeight: '80vh',
+ width: '100%',
+ display: 'flex',
+ margin: '0 auto',
+ borderRadius: '1rem',
+ overflow: 'hidden',
+ [theme.breakpoints.down(1100)]: {
+ flexDirection: 'column',
+ minHeight: 0,
+ },
+ },
+ modal: {
+ minHeight: '100vh',
+ borderRadius: 0,
+ },
+ sidebar: {
+ backgroundColor: theme.palette.formSidebar,
+ padding: '2rem',
+ flexGrow: 0,
+ flexShrink: 0,
+ width: formTemplateSidebarWidth,
+ [theme.breakpoints.down(1100)]: {
+ width: '100%',
+ color: 'red',
+ },
+ [theme.breakpoints.down(500)]: {
+ padding: '2rem 1rem',
+ },
+ },
+ sidebarDivider: {
+ opacity: 0.3,
+ marginBottom: '8px',
+ },
+ title: {
+ marginBottom: '1.5rem',
+ fontWeight: 'normal',
+ },
+ subtitle: {
+ color: theme.palette.formSidebarTextColor,
+ marginBottom: '1rem',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ fontWeight: theme.fontWeight.bold,
+ fontSize: theme.fontSizes.bodySize,
+ },
+ description: {
+ color: theme.palette.formSidebarTextColor,
+ zIndex: 1,
+ position: 'relative',
+ },
+ linkContainer: {
+ margin: '1.5rem 0',
+ display: 'flex',
+ alignItems: 'center',
+ },
+ linkIcon: {
+ marginRight: '0.5rem',
+ color: '#fff',
+ },
+ documentationLink: {
+ color: '#fff',
+ display: 'block',
+ '&:hover': {
+ textDecoration: 'none',
+ },
+ },
+ formContent: {
+ backgroundColor: theme.palette.formBackground,
+ display: 'flex',
+ flexDirection: 'column',
+ padding: '3rem',
+ flexGrow: 1,
+ [theme.breakpoints.down(1200)]: {
+ padding: '2rem',
+ },
+ [theme.breakpoints.down(1100)]: {
+ width: '100%',
+ },
+ [theme.breakpoints.down(500)]: {
+ padding: '2rem 1rem',
+ },
+ },
+ icon: { fill: '#fff' },
+ mobileGuidanceBgContainer: {
+ zIndex: 1,
+ position: 'absolute',
+ right: -3,
+ top: -3,
+ },
+ mobileGuidanceBackground: {
+ width: '75px',
+ height: '75px',
+ },
+ mobileGuidanceButton: {
+ position: 'absolute',
+ zIndex: 400,
+ right: 0,
+ },
+ infoIcon: {
+ fill: '#fff',
+ },
+}));
diff --git a/frontend/src/component/common/FormTemplate/FormTemplate.tsx b/frontend/src/component/common/FormTemplate/FormTemplate.tsx
new file mode 100644
index 0000000000..c20cf92be4
--- /dev/null
+++ b/frontend/src/component/common/FormTemplate/FormTemplate.tsx
@@ -0,0 +1,193 @@
+import { useStyles } from './FormTemplate.styles';
+import MenuBookIcon from '@mui/icons-material/MenuBook';
+import Codebox from '../Codebox/Codebox';
+import {
+ Collapse,
+ IconButton,
+ useMediaQuery,
+ Tooltip,
+ Divider,
+} from '@mui/material';
+import { FileCopy, Info } from '@mui/icons-material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import Loader from '../Loader/Loader';
+import copy from 'copy-to-clipboard';
+import useToast from 'hooks/useToast';
+import React, { useState } from 'react';
+import classNames from 'classnames';
+import { ReactComponent as MobileGuidanceBG } from 'assets/img/mobileGuidanceBg.svg';
+import { useThemeStyles } from 'themes/themeStyles';
+
+interface ICreateProps {
+ title: string;
+ description: string;
+ documentationLink: string;
+ documentationLinkLabel: string;
+ loading?: boolean;
+ modal?: boolean;
+ formatApiCode: () => string;
+}
+
+const FormTemplate: React.FC = ({
+ title,
+ description,
+ children,
+ documentationLink,
+ documentationLinkLabel,
+ loading,
+ modal,
+ formatApiCode,
+}) => {
+ const { setToastData } = useToast();
+ const { classes: styles } = useStyles();
+ const { classes: themeStyles } = useThemeStyles();
+ const smallScreen = useMediaQuery(`(max-width:${1099}px)`);
+
+ const copyCommand = () => {
+ if (copy(formatApiCode())) {
+ setToastData({
+ title: 'Successfully copied the command',
+ text: 'The command should now be automatically copied to your clipboard',
+ autoHideDuration: 6000,
+ type: 'success',
+ show: true,
+ });
+ } else {
+ setToastData({
+ title: 'Could not copy the command',
+ text: 'Sorry, but we could not copy the command.',
+ autoHideDuration: 6000,
+ type: 'error',
+ show: true,
+ });
+ }
+ };
+
+ return (
+
+
+
+
+ }
+ />
+
+ }
+ elseShow={
+ <>
+
{title}
+ {children}
+ >
+ }
+ />{' '}
+
+
+
+
+ API Command{' '}
+
+
+
+
+
+
+
+
+ }
+ />
+
+ );
+};
+
+interface IMobileGuidance {
+ description: string;
+ documentationLink: string;
+ documentationLinkLabel?: string;
+}
+
+const MobileGuidance = ({
+ description,
+ documentationLink,
+ documentationLinkLabel,
+}: IMobileGuidance) => {
+ const [open, setOpen] = useState(false);
+ const { classes: styles } = useStyles();
+
+ return (
+ <>
+
+
+
+
+ setOpen(prev => !prev)}
+ size="large"
+ >
+
+
+
+
+
+
+ >
+ );
+};
+
+interface IGuidanceProps {
+ description: string;
+ documentationLink: string;
+ documentationLinkLabel?: string;
+}
+
+const Guidance: React.FC = ({
+ description,
+ children,
+ documentationLink,
+ documentationLinkLabel = 'Learn more',
+}) => {
+ const { classes: styles } = useStyles();
+
+ return (
+
+ );
+};
+
+export default FormTemplate;
diff --git a/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx b/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx
new file mode 100644
index 0000000000..90dca9cc59
--- /dev/null
+++ b/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx
@@ -0,0 +1,88 @@
+import React from 'react';
+import {
+ FormControl,
+ InputLabel,
+ MenuItem,
+ Select,
+ SelectProps,
+ SelectChangeEvent,
+} from '@mui/material';
+import { SELECT_ITEM_ID } from 'utils/testIds';
+import { KeyboardArrowDownOutlined } from '@mui/icons-material';
+
+export interface ISelectOption {
+ key: string;
+ title?: string;
+ label?: string;
+ disabled?: boolean;
+}
+
+export interface IGeneralSelectProps extends Omit {
+ name?: string;
+ value?: string;
+ label?: string;
+ options: ISelectOption[];
+ onChange: (key: string) => void;
+ disabled?: boolean;
+ fullWidth?: boolean;
+ classes?: any;
+ defaultValue?: string;
+}
+
+const GeneralSelect: React.FC = ({
+ name,
+ value = '',
+ label = '',
+ options,
+ onChange,
+ id,
+ disabled = false,
+ className,
+ classes,
+ fullWidth,
+ ...rest
+}) => {
+ const renderSelectItems = () =>
+ options.map(option => (
+
+ ));
+
+ const onSelectChange = (event: SelectChangeEvent) => {
+ event.preventDefault();
+ onChange(String(event.target.value));
+ };
+
+ return (
+
+ {label}
+
+
+ );
+};
+
+export default GeneralSelect;
diff --git a/frontend/src/component/common/Gradient/Gradient.tsx b/frontend/src/component/common/Gradient/Gradient.tsx
new file mode 100644
index 0000000000..4de131d677
--- /dev/null
+++ b/frontend/src/component/common/Gradient/Gradient.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+
+interface IGradientProps {
+ from: string;
+ to: string;
+ style?: object;
+ className?: string;
+}
+
+const Gradient: React.FC = ({
+ children,
+ from,
+ to,
+ style,
+ ...rest
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default Gradient;
diff --git a/frontend/src/component/common/GridCol/GridCol.tsx b/frontend/src/component/common/GridCol/GridCol.tsx
new file mode 100644
index 0000000000..95b56c7be7
--- /dev/null
+++ b/frontend/src/component/common/GridCol/GridCol.tsx
@@ -0,0 +1,19 @@
+import { Grid } from '@mui/material';
+import { FC } from 'react';
+
+export const GridCol: FC<{ vertical?: boolean }> = ({
+ children,
+ vertical = false,
+}) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/frontend/src/component/common/GridRow/GridRow.tsx b/frontend/src/component/common/GridRow/GridRow.tsx
new file mode 100644
index 0000000000..54110f4cb3
--- /dev/null
+++ b/frontend/src/component/common/GridRow/GridRow.tsx
@@ -0,0 +1,21 @@
+import { Grid, styled, SxProps, Theme } from '@mui/material';
+import { FC } from 'react';
+
+const StyledGrid = styled(Grid)(({ theme }) => ({
+ flexWrap: 'nowrap',
+ gap: theme.spacing(1),
+}));
+
+export const GridRow: FC<{ sx?: SxProps }> = ({ sx, children }) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/frontend/src/component/common/GuidanceIndicator/GuidanceIndicator.tsx b/frontend/src/component/common/GuidanceIndicator/GuidanceIndicator.tsx
new file mode 100644
index 0000000000..167fdef936
--- /dev/null
+++ b/frontend/src/component/common/GuidanceIndicator/GuidanceIndicator.tsx
@@ -0,0 +1,40 @@
+import { styled, useTheme } from '@mui/material';
+import { FC } from 'react';
+
+const StyledIndicator = styled('div')(({ style, theme }) => ({
+ width: '25px',
+ height: '25px',
+ borderRadius: '50%',
+ color: theme.palette.text.tertiaryContrast,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ fontWeight: 'bold',
+ ...style,
+}));
+
+interface IGuidanceIndicatorProps {
+ style?: React.CSSProperties;
+ type?: guidanceIndicatorType;
+}
+
+type guidanceIndicatorType = 'primary' | 'secondary';
+
+export const GuidanceIndicator: FC = ({
+ style,
+ children,
+ type,
+}) => {
+ const theme = useTheme();
+
+ const defaults = { backgroundColor: theme.palette.primary.main };
+ if (type === 'secondary') {
+ defaults.backgroundColor = theme.palette.tertiary.dark;
+ }
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/frontend/src/component/common/HelpIcon/HelpIcon.styles.ts b/frontend/src/component/common/HelpIcon/HelpIcon.styles.ts
new file mode 100644
index 0000000000..a017afcda9
--- /dev/null
+++ b/frontend/src/component/common/HelpIcon/HelpIcon.styles.ts
@@ -0,0 +1,22 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ display: 'inline-grid',
+ alignItems: 'center',
+ outline: 0,
+
+ '&:is(:focus-visible, :active) > *, &:hover > *': {
+ outlineStyle: 'solid',
+ outlineWidth: 2,
+ outlineOffset: 0,
+ outlineColor: theme.palette.primary.main,
+ borderRadius: '100%',
+ color: theme.palette.primary.main,
+ },
+ },
+ icon: {
+ fontSize: '1rem',
+ color: theme.palette.inactiveIcon,
+ },
+}));
diff --git a/frontend/src/component/common/HelpIcon/HelpIcon.tsx b/frontend/src/component/common/HelpIcon/HelpIcon.tsx
new file mode 100644
index 0000000000..09b12df9e8
--- /dev/null
+++ b/frontend/src/component/common/HelpIcon/HelpIcon.tsx
@@ -0,0 +1,21 @@
+import { Tooltip, TooltipProps } from '@mui/material';
+import { Info } from '@mui/icons-material';
+import { useStyles } from 'component/common/HelpIcon/HelpIcon.styles';
+import React from 'react';
+
+interface IHelpIconProps {
+ tooltip: string;
+ placement?: TooltipProps['placement'];
+}
+
+export const HelpIcon = ({ tooltip, placement }: IHelpIconProps) => {
+ const { classes: styles } = useStyles();
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/common/Highlighter/Highlighter.styles.ts b/frontend/src/component/common/Highlighter/Highlighter.styles.ts
new file mode 100644
index 0000000000..970aa4835f
--- /dev/null
+++ b/frontend/src/component/common/Highlighter/Highlighter.styles.ts
@@ -0,0 +1,9 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ highlighter: {
+ '&>mark': {
+ backgroundColor: theme.palette.highlight,
+ },
+ },
+}));
diff --git a/frontend/src/component/common/Highlighter/Highlighter.tsx b/frontend/src/component/common/Highlighter/Highlighter.tsx
new file mode 100644
index 0000000000..fa5f47815f
--- /dev/null
+++ b/frontend/src/component/common/Highlighter/Highlighter.tsx
@@ -0,0 +1,36 @@
+import { VFC } from 'react';
+import { useStyles } from './Highlighter.styles';
+
+interface IHighlighterProps {
+ search?: string;
+ children?: string;
+ caseSensitive?: boolean;
+}
+
+const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+export const Highlighter: VFC = ({
+ search,
+ children,
+ caseSensitive,
+}) => {
+ const { classes } = useStyles();
+ if (!children) {
+ return null;
+ }
+
+ if (!search) {
+ return <>{children}>;
+ }
+
+ const regex = new RegExp(escapeRegex(search), caseSensitive ? 'g' : 'gi');
+
+ return (
+ $&') || '',
+ }}
+ />
+ );
+};
diff --git a/frontend/src/component/common/Input/Input.styles.ts b/frontend/src/component/common/Input/Input.styles.ts
new file mode 100644
index 0000000000..12295699d6
--- /dev/null
+++ b/frontend/src/component/common/Input/Input.styles.ts
@@ -0,0 +1,12 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ helperText: {
+ position: 'absolute',
+ top: '35px',
+ },
+ inputContainer: {
+ width: '100%',
+ position: 'relative',
+ },
+}));
diff --git a/frontend/src/component/common/Input/Input.tsx b/frontend/src/component/common/Input/Input.tsx
new file mode 100644
index 0000000000..59ccf9cfc2
--- /dev/null
+++ b/frontend/src/component/common/Input/Input.tsx
@@ -0,0 +1,58 @@
+import { INPUT_ERROR_TEXT } from 'utils/testIds';
+import { useStyles } from './Input.styles';
+import React from 'react';
+import { TextField, OutlinedTextFieldProps } from '@mui/material';
+
+interface IInputProps extends Omit {
+ label: string;
+ error?: boolean;
+ errorText?: string;
+ style?: Object;
+ className?: string;
+ value: string;
+ onChange: (e: any) => any;
+ onFocus?: (e: any) => any;
+ onBlur?: (e: any) => any;
+ multiline?: boolean;
+ rows?: number;
+}
+
+const Input = ({
+ label,
+ placeholder,
+ error,
+ errorText,
+ style,
+ className,
+ value,
+ onChange,
+ InputProps,
+ ...rest
+}: IInputProps) => {
+ const { classes: styles } = useStyles();
+ return (
+
+
+
+ );
+};
+
+export default Input;
diff --git a/frontend/src/component/common/InputCaption/InputCaption.tsx b/frontend/src/component/common/InputCaption/InputCaption.tsx
new file mode 100644
index 0000000000..ecf6fcb0c6
--- /dev/null
+++ b/frontend/src/component/common/InputCaption/InputCaption.tsx
@@ -0,0 +1,23 @@
+import { Box } from '@mui/material';
+
+export interface IInputCaptionProps {
+ text?: string;
+}
+
+export const InputCaption = ({ text }: IInputCaptionProps) => {
+ if (!text) {
+ return null;
+ }
+
+ return (
+ ({
+ color: theme.palette.text.secondary,
+ fontSize: theme.fontSizes.smallerBody,
+ marginTop: theme.spacing(1),
+ })}
+ >
+ {text}
+
+ );
+};
diff --git a/frontend/src/component/common/InputListField/InputListField.tsx b/frontend/src/component/common/InputListField/InputListField.tsx
new file mode 100644
index 0000000000..4d4a95dce2
--- /dev/null
+++ b/frontend/src/component/common/InputListField/InputListField.tsx
@@ -0,0 +1,53 @@
+import { VFC } from 'react';
+import { TextField, TextFieldProps } from '@mui/material';
+
+interface IInputListFieldProps {
+ label: string;
+ values?: any[];
+ error?: boolean;
+ placeholder?: string;
+ name: string;
+ updateValues: (values: string[]) => void;
+ onBlur?: TextFieldProps['onBlur'];
+ helperText?: TextFieldProps['helperText'];
+ FormHelperTextProps?: TextFieldProps['FormHelperTextProps'];
+}
+
+export const InputListField: VFC = ({
+ values = [],
+ updateValues,
+ placeholder = '',
+ error,
+ ...rest
+}) => {
+ const handleChange: TextFieldProps['onChange'] = event => {
+ const values = event.target.value.split(/,\s?/);
+ const trimmedValues = values.map(v => v.trim());
+ updateValues(trimmedValues);
+ };
+
+ const handleKeyDown: TextFieldProps['onKeyDown'] = event => {
+ if (event.key === 'Backspace') {
+ const currentValue = (event.target as HTMLInputElement).value;
+ if (currentValue.endsWith(', ')) {
+ event.preventDefault();
+ const value = currentValue.slice(0, -2);
+ updateValues(value.split(/,\s*/));
+ }
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/frontend/src/component/common/InstanceStatus/InstanceStatus.tsx b/frontend/src/component/common/InstanceStatus/InstanceStatus.tsx
new file mode 100644
index 0000000000..2af9b6edfd
--- /dev/null
+++ b/frontend/src/component/common/InstanceStatus/InstanceStatus.tsx
@@ -0,0 +1,131 @@
+import { useInstanceStatus } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
+import React, { FC, VFC, useEffect, useState, useContext } from 'react';
+import { InstanceStatusBar } from 'component/common/InstanceStatus/InstanceStatusBar';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import { Typography, useTheme } from '@mui/material';
+import { useNavigate } from 'react-router-dom';
+import { IInstanceStatus } from 'interfaces/instance';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import AccessContext from 'contexts/AccessContext';
+import useInstanceStatusApi from 'hooks/api/actions/useInstanceStatusApi/useInstanceStatusApi';
+import { trialHasExpired, canExtendTrial } from 'utils/instanceTrial';
+import useToast from 'hooks/useToast';
+import { formatUnknownError } from 'utils/formatUnknownError';
+
+interface ITrialDialogProps {
+ instanceStatus: IInstanceStatus;
+ onExtendTrial: () => Promise;
+}
+
+const TrialDialog: VFC = ({
+ instanceStatus,
+ onExtendTrial,
+}) => {
+ const { hasAccess } = useContext(AccessContext);
+ const navigate = useNavigate();
+ const expired = trialHasExpired(instanceStatus);
+ const [dialogOpen, setDialogOpen] = useState(expired);
+
+ const onClose = (event: React.SyntheticEvent, muiCloseReason?: string) => {
+ if (!muiCloseReason) {
+ setDialogOpen(false);
+ if (canExtendTrial(instanceStatus)) {
+ onExtendTrial().catch(console.error);
+ }
+ }
+ };
+
+ useEffect(() => {
+ setDialogOpen(expired);
+ const interval = setInterval(() => {
+ setDialogOpen(expired);
+ }, 60000);
+ return () => clearInterval(interval);
+ }, [expired]);
+
+ if (hasAccess(ADMIN)) {
+ return (
+ {
+ navigate('/admin/billing');
+ setDialogOpen(false);
+ }}
+ onClose={onClose}
+ title={`Your free ${instanceStatus.plan} trial has expired!`}
+ >
+
+ Upgrade trial otherwise your{' '}
+ account will be deleted.
+
+
+ );
+ }
+
+ return (
+ {
+ setDialogOpen(false);
+ }}
+ title={`Your free ${instanceStatus.plan} trial has expired!`}
+ >
+
+ Please inform your admin to Upgrade trial or
+ your account will be deleted.
+
+
+ );
+};
+
+export const InstanceStatus: FC = ({ children }) => {
+ const { instanceStatus, refetchInstanceStatus, isBilling } =
+ useInstanceStatus();
+ const { extendTrial } = useInstanceStatusApi();
+ const { setToastApiError } = useToast();
+ const theme = useTheme();
+
+ const onExtendTrial = async () => {
+ try {
+ await extendTrial();
+ await refetchInstanceStatus();
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ return (
+
+ (
+ <>
+
+
+ >
+ )}
+ />
+ {children}
+
+ );
+};
+
+const InstanceStatusBarMemo = React.memo(InstanceStatusBar);
diff --git a/frontend/src/component/common/InstanceStatus/InstanceStatusBar.test.tsx b/frontend/src/component/common/InstanceStatus/InstanceStatusBar.test.tsx
new file mode 100644
index 0000000000..8791740e68
--- /dev/null
+++ b/frontend/src/component/common/InstanceStatus/InstanceStatusBar.test.tsx
@@ -0,0 +1,103 @@
+import { InstanceStatusBar } from 'component/common/InstanceStatus/InstanceStatusBar';
+import { InstancePlan, InstanceState } from 'interfaces/instance';
+import { render } from 'utils/testRenderer';
+import { screen } from '@testing-library/react';
+import { addDays, subDays } from 'date-fns';
+import { INSTANCE_STATUS_BAR_ID } from 'utils/testIds';
+import { UNKNOWN_INSTANCE_STATUS } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
+
+test('InstanceStatusBar should be hidden by default', async () => {
+ render();
+
+ expect(
+ screen.queryByTestId(INSTANCE_STATUS_BAR_ID)
+ ).not.toBeInTheDocument();
+});
+
+test('InstanceStatusBar should be hidden when state is active', async () => {
+ render(
+
+ );
+
+ expect(
+ screen.queryByTestId(INSTANCE_STATUS_BAR_ID)
+ ).not.toBeInTheDocument();
+});
+
+test('InstanceStatusBar should warn when the trial is far from expired', async () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId(INSTANCE_STATUS_BAR_ID)).toBeInTheDocument();
+ expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot();
+});
+
+test('InstanceStatusBar should warn when the trial is about to expire', async () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId(INSTANCE_STATUS_BAR_ID)).toBeInTheDocument();
+ expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot();
+});
+
+test('InstanceStatusBar should warn when trialExpiry has passed', async () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId(INSTANCE_STATUS_BAR_ID)).toBeInTheDocument();
+ expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot();
+});
+
+test('InstanceStatusBar should warn when the trial has expired', async () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId(INSTANCE_STATUS_BAR_ID)).toBeInTheDocument();
+ expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot();
+});
+
+test('InstanceStatusBar should warn when the trial has churned', async () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId(INSTANCE_STATUS_BAR_ID)).toBeInTheDocument();
+ expect(await screen.findByTestId(INSTANCE_STATUS_BAR_ID)).toMatchSnapshot();
+});
diff --git a/frontend/src/component/common/InstanceStatus/InstanceStatusBar.tsx b/frontend/src/component/common/InstanceStatus/InstanceStatusBar.tsx
new file mode 100644
index 0000000000..089c225800
--- /dev/null
+++ b/frontend/src/component/common/InstanceStatus/InstanceStatusBar.tsx
@@ -0,0 +1,142 @@
+import { styled, Button, Typography } from '@mui/material';
+import { IInstanceStatus } from 'interfaces/instance';
+import { INSTANCE_STATUS_BAR_ID } from 'utils/testIds';
+import { InfoOutlined, WarningAmber } from '@mui/icons-material';
+import { useNavigate } from 'react-router-dom';
+import { useContext } from 'react';
+import AccessContext from 'contexts/AccessContext';
+import { ADMIN } from 'component/providers/AccessProvider/permissions';
+import {
+ trialHasExpired,
+ trialExpiresSoon,
+ isTrialInstance,
+} from 'utils/instanceTrial';
+import { formatDistanceToNowStrict, parseISO } from 'date-fns';
+
+const StyledWarningBar = styled('aside')(({ theme }) => ({
+ position: 'relative',
+ zIndex: 1,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: theme.spacing(1),
+ gap: theme.spacing(1),
+ borderBottom: '1px solid',
+ borderColor: theme.palette.warning.border,
+ background: theme.palette.warning.light,
+ color: theme.palette.warning.dark,
+}));
+
+const StyledInfoBar = styled('aside')(({ theme }) => ({
+ position: 'relative',
+ zIndex: 1,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: theme.spacing(1),
+ gap: theme.spacing(1),
+ borderBottom: '1px solid',
+ borderColor: theme.palette.info.border,
+ background: theme.palette.info.light,
+ color: theme.palette.info.dark,
+}));
+
+const StyledButton = styled(Button)(({ theme }) => ({
+ whiteSpace: 'nowrap',
+ minWidth: '8rem',
+ marginLeft: theme.spacing(2),
+}));
+
+const StyledWarningIcon = styled(WarningAmber)(({ theme }) => ({
+ color: theme.palette.warning.main,
+}));
+
+const StyledInfoIcon = styled(InfoOutlined)(({ theme }) => ({
+ color: theme.palette.info.main,
+}));
+
+interface IInstanceStatusBarProps {
+ instanceStatus: IInstanceStatus;
+}
+
+export const InstanceStatusBar = ({
+ instanceStatus,
+}: IInstanceStatusBarProps) => {
+ if (trialHasExpired(instanceStatus)) {
+ return ;
+ }
+
+ if (trialExpiresSoon(instanceStatus)) {
+ return ;
+ }
+
+ if (isTrialInstance(instanceStatus)) {
+ return ;
+ }
+
+ return null;
+};
+
+const StatusBarExpired = ({ instanceStatus }: IInstanceStatusBarProps) => {
+ return (
+
+
+ ({ fontSize: theme.fontSizes.smallBody })}>
+ Warning! Your free {instanceStatus.plan} trial
+ has expired. Upgrade trial otherwise your{' '}
+ account will be deleted.
+
+
+
+ );
+};
+
+const StatusBarExpiresSoon = ({ instanceStatus }: IInstanceStatusBarProps) => {
+ const timeRemaining = formatDistanceToNowStrict(
+ parseISO(instanceStatus.trialExpiry!),
+ { roundingMethod: 'floor' }
+ );
+
+ return (
+
+
+ ({ fontSize: theme.fontSizes.smallBody })}>
+ Heads up! You have{' '}
+ {timeRemaining} left of your free{' '}
+ {instanceStatus.plan} trial.
+
+
+
+ );
+};
+
+const StatusBarExpiresLater = ({ instanceStatus }: IInstanceStatusBarProps) => {
+ return (
+
+
+ ({ fontSize: theme.fontSizes.smallBody })}>
+ Heads up! You're currently on a free{' '}
+ {instanceStatus.plan} trial account.
+
+
+
+ );
+};
+
+const BillingLink = () => {
+ const { hasAccess } = useContext(AccessContext);
+ const navigate = useNavigate();
+
+ if (!hasAccess(ADMIN)) {
+ return null;
+ }
+
+ return (
+ navigate('/admin/billing')}
+ variant="outlined"
+ >
+ Upgrade trial
+
+ );
+};
diff --git a/frontend/src/component/common/InstanceStatus/__snapshots__/InstanceStatusBar.test.tsx.snap b/frontend/src/component/common/InstanceStatus/__snapshots__/InstanceStatusBar.test.tsx.snap
new file mode 100644
index 0000000000..24c0071941
--- /dev/null
+++ b/frontend/src/component/common/InstanceStatus/__snapshots__/InstanceStatusBar.test.tsx.snap
@@ -0,0 +1,186 @@
+// Vitest Snapshot v1
+
+exports[`InstanceStatusBar should warn when the trial has churned 1`] = `
+
+`;
+
+exports[`InstanceStatusBar should warn when the trial has expired 1`] = `
+
+`;
+
+exports[`InstanceStatusBar should warn when the trial is about to expire 1`] = `
+
+`;
+
+exports[`InstanceStatusBar should warn when the trial is far from expired 1`] = `
+
+`;
+
+exports[`InstanceStatusBar should warn when trialExpiry has passed 1`] = `
+
+`;
diff --git a/frontend/src/component/common/Loader/Loader.styles.ts b/frontend/src/component/common/Loader/Loader.styles.ts
new file mode 100644
index 0000000000..7284ea586f
--- /dev/null
+++ b/frontend/src/component/common/Loader/Loader.styles.ts
@@ -0,0 +1,15 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ loader: {
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ height: '100%',
+ backgroundColor: theme.palette.background.paper,
+ },
+ img: {
+ width: '100px',
+ height: '100px',
+ },
+}));
diff --git a/frontend/src/component/common/Loader/Loader.tsx b/frontend/src/component/common/Loader/Loader.tsx
new file mode 100644
index 0000000000..73a8bf2d17
--- /dev/null
+++ b/frontend/src/component/common/Loader/Loader.tsx
@@ -0,0 +1,15 @@
+import logo from 'assets/img/unleashLogoIconDarkAlpha.gif';
+import { formatAssetPath } from 'utils/formatPath';
+import { useStyles } from './Loader.styles';
+
+const Loader = () => {
+ const { classes: styles } = useStyles();
+
+ return (
+
+
})
+
+ );
+};
+
+export default Loader;
diff --git a/frontend/src/component/common/LoginRedirect/LoginRedirect.tsx b/frontend/src/component/common/LoginRedirect/LoginRedirect.tsx
new file mode 100644
index 0000000000..38a9bcbea1
--- /dev/null
+++ b/frontend/src/component/common/LoginRedirect/LoginRedirect.tsx
@@ -0,0 +1,10 @@
+import { useLocation, Navigate } from 'react-router-dom';
+
+export const LoginRedirect = () => {
+ const { pathname, search } = useLocation();
+
+ const redirect = encodeURIComponent(pathname + search);
+ const loginLink = `/login?redirect=${redirect}`;
+
+ return ;
+};
diff --git a/frontend/src/component/common/MainHeader/MainHeader.tsx b/frontend/src/component/common/MainHeader/MainHeader.tsx
new file mode 100644
index 0000000000..9c5464bfdb
--- /dev/null
+++ b/frontend/src/component/common/MainHeader/MainHeader.tsx
@@ -0,0 +1,64 @@
+import { Paper, styled } from '@mui/material';
+import { usePageTitle } from 'hooks/usePageTitle';
+import { ReactNode } from 'react';
+import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender';
+
+const StyledMainHeader = styled(Paper)(({ theme }) => ({
+ borderRadius: theme.shape.borderRadiusLarge,
+ padding: theme.spacing(2.5, 4),
+ boxShadow: 'none',
+ marginBottom: theme.spacing(2),
+ fontSize: theme.fontSizes.smallBody,
+}));
+
+const StyledTitleHeader = styled('div')(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'flex-start',
+ justifyContent: 'space-between',
+}));
+
+const StyledTitle = styled('h1')(({ theme }) => ({
+ fontSize: theme.fontSizes.mainHeader,
+}));
+
+const StyledActions = styled('div')(({ theme }) => ({
+ display: 'flex',
+}));
+
+const StyledDescription = styled('span')(({ theme }) => ({
+ color: theme.palette.text.secondary,
+ fontSize: theme.fontSizes.smallBody,
+ marginLeft: theme.spacing(1),
+}));
+
+interface IMainHeaderProps {
+ title?: string;
+ description?: string;
+ actions?: ReactNode;
+}
+
+export const MainHeader = ({
+ title,
+ description,
+ actions,
+}: IMainHeaderProps) => {
+ usePageTitle(title);
+
+ return (
+
+
+ {title}
+ {actions}
+
+
+ Description:
+ {description}
+ >
+ }
+ />
+
+ );
+};
diff --git a/frontend/src/component/common/NoItems/NoItems.styles.ts b/frontend/src/component/common/NoItems/NoItems.styles.ts
new file mode 100644
index 0000000000..bc041bf0cf
--- /dev/null
+++ b/frontend/src/component/common/NoItems/NoItems.styles.ts
@@ -0,0 +1,35 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ display: 'flex',
+ width: '80%',
+ margin: '0 auto',
+ [theme.breakpoints.down(700)]: {
+ flexDirection: 'column',
+ alignItems: 'center',
+ },
+ },
+ textContainer: {
+ width: '50%',
+ [theme.breakpoints.down(700)]: {
+ width: '100%',
+ },
+ },
+ iconContainer: {
+ width: '50%',
+ [theme.breakpoints.down(700)]: {
+ width: '100%',
+ },
+ },
+ icon: {
+ width: '300px',
+ height: '200px',
+ [theme.breakpoints.down(700)]: {
+ marginTop: '2rem',
+ },
+ [theme.breakpoints.down(500)]: {
+ display: 'none',
+ },
+ },
+}));
diff --git a/frontend/src/component/common/NoItems/NoItems.tsx b/frontend/src/component/common/NoItems/NoItems.tsx
new file mode 100644
index 0000000000..77d547e3b5
--- /dev/null
+++ b/frontend/src/component/common/NoItems/NoItems.tsx
@@ -0,0 +1,17 @@
+import { ReactComponent as NoItemsIcon } from 'assets/icons/addfiles.svg';
+import { useStyles } from './NoItems.styles';
+import React from 'react';
+
+const NoItems: React.FC = ({ children }) => {
+ const { classes: styles } = useStyles();
+ return (
+
+ );
+};
+
+export default NoItems;
diff --git a/frontend/src/component/common/NotFound/NotFound.styles.ts b/frontend/src/component/common/NotFound/NotFound.styles.ts
new file mode 100644
index 0000000000..2f6fa50efe
--- /dev/null
+++ b/frontend/src/component/common/NotFound/NotFound.styles.ts
@@ -0,0 +1,35 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()({
+ container: {
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ minHeight: '100vh',
+ padding: '2rem',
+ position: 'fixed',
+ inset: 0,
+ backgroundColor: '#fff',
+ width: '100%',
+ },
+ logo: {
+ height: '80px',
+ },
+ content: {
+ display: 'flex',
+ position: 'relative',
+ },
+ buttonContainer: {
+ marginTop: '2rem',
+ },
+ homeButton: {
+ marginLeft: '1rem',
+ },
+ icon: {
+ height: '150px',
+ width: '150px',
+ position: 'absolute',
+ right: 100,
+ top: 45,
+ },
+});
diff --git a/frontend/src/component/common/NotFound/NotFound.tsx b/frontend/src/component/common/NotFound/NotFound.tsx
new file mode 100644
index 0000000000..e20fd10beb
--- /dev/null
+++ b/frontend/src/component/common/NotFound/NotFound.tsx
@@ -0,0 +1,47 @@
+import { Button, Typography } from '@mui/material';
+import { useNavigate } from 'react-router';
+
+import { ReactComponent as LogoIcon } from 'assets/icons/logoBg.svg';
+
+import { useStyles } from './NotFound.styles';
+import { GO_BACK } from 'constants/navigate';
+
+const NotFound = () => {
+ const navigate = useNavigate();
+ const { classes: styles } = useStyles();
+
+ const onClickHome = () => {
+ navigate('/');
+ };
+
+ const onClickBack = () => {
+ navigate(GO_BACK);
+ };
+
+ return (
+
+
+
+
+
+ Ooops. That's a page we haven't toggled on yet.
+
+
+
+
+
+
+
+
+ );
+};
+
+export default NotFound;
diff --git a/frontend/src/component/common/OperatorUpgradeAlert/OperatorUpgradeAlert.tsx b/frontend/src/component/common/OperatorUpgradeAlert/OperatorUpgradeAlert.tsx
new file mode 100644
index 0000000000..2927f4a04a
--- /dev/null
+++ b/frontend/src/component/common/OperatorUpgradeAlert/OperatorUpgradeAlert.tsx
@@ -0,0 +1,23 @@
+import { Alert } from '@mui/material';
+
+export const OperatorUpgradeAlert = () => {
+ return (
+
+ Remember to update your Unleash client! New operators require new
+ SDK versions. .
+
+ );
+};
+
+const OperatorDocsLink = () => {
+ return (
+
+ Read more
+
+ );
+};
diff --git a/frontend/src/component/common/PageContent/PageContent.styles.ts b/frontend/src/component/common/PageContent/PageContent.styles.ts
new file mode 100644
index 0000000000..d9ead5943b
--- /dev/null
+++ b/frontend/src/component/common/PageContent/PageContent.styles.ts
@@ -0,0 +1,29 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ borderRadius: theme.shape.borderRadiusLarge,
+ boxShadow: 'none',
+ },
+ headerContainer: {
+ padding: theme.spacing(2, 4),
+ borderBottomStyle: 'solid',
+ borderBottomWidth: 1,
+ borderBottomColor: theme.palette.divider,
+ [theme.breakpoints.down('md')]: {
+ padding: '1.5rem 1rem',
+ },
+ },
+ bodyContainer: {
+ padding: theme.spacing(4),
+ [theme.breakpoints.down('md')]: {
+ padding: theme.spacing(2),
+ },
+ },
+ paddingDisabled: {
+ padding: '0',
+ },
+ borderDisabled: {
+ border: 'none',
+ },
+}));
diff --git a/frontend/src/component/common/PageContent/PageContent.tsx b/frontend/src/component/common/PageContent/PageContent.tsx
new file mode 100644
index 0000000000..e96973eea2
--- /dev/null
+++ b/frontend/src/component/common/PageContent/PageContent.tsx
@@ -0,0 +1,95 @@
+import React, { FC, ReactNode } from 'react';
+import classnames from 'classnames';
+import { PageHeader } from 'component/common/PageHeader/PageHeader';
+import { Paper, PaperProps } from '@mui/material';
+import { useStyles } from './PageContent.styles';
+import useLoading from 'hooks/useLoading';
+import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender';
+
+interface IPageContentProps extends PaperProps {
+ header?: ReactNode;
+ isLoading?: boolean;
+ /**
+ * @deprecated fix feature event log and remove
+ */
+ disablePadding?: boolean;
+ /**
+ * @deprecated fix feature event log and remove
+ */
+ disableBorder?: boolean;
+ disableLoading?: boolean;
+ bodyClass?: string;
+}
+
+const PageContentLoading: FC<{ isLoading: boolean }> = ({
+ children,
+ isLoading,
+}) => {
+ const ref = useLoading(isLoading);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const PageContent: FC = ({
+ children,
+ header,
+ disablePadding = false,
+ disableBorder = false,
+ bodyClass = '',
+ isLoading = false,
+ disableLoading = false,
+ className,
+ ...rest
+}) => {
+ const { classes: styles } = useStyles();
+
+ const headerClasses = classnames('header', styles.headerContainer, {
+ [styles.paddingDisabled]: disablePadding,
+ [styles.borderDisabled]: disableBorder,
+ });
+
+ const bodyClasses = classnames(
+ 'body',
+ bodyClass ? bodyClass : styles.bodyContainer,
+ {
+ [styles.paddingDisabled]: disablePadding,
+ [styles.borderDisabled]: disableBorder,
+ }
+ );
+
+ const paperProps = disableBorder ? { elevation: 0 } : {};
+
+ const content = (
+
+
+ }
+ elseShow={header}
+ />
+
+ }
+ />
+ {children}
+
+ );
+
+ if (disableLoading) {
+ return content;
+ }
+
+ return (
+ {content}
+ );
+};
diff --git a/frontend/src/component/common/PageHeader/PageHeader.styles.ts b/frontend/src/component/common/PageHeader/PageHeader.styles.ts
new file mode 100644
index 0000000000..e13fba7db2
--- /dev/null
+++ b/frontend/src/component/common/PageHeader/PageHeader.styles.ts
@@ -0,0 +1,31 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ headerContainer: {
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ topContainer: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ position: 'relative',
+ },
+ header: {
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ marginRight: theme.spacing(2),
+ },
+ headerTitle: {
+ fontSize: theme.fontSizes.mainHeader,
+ fontWeight: 'normal',
+ },
+ headerActions: {
+ display: 'flex',
+ flexGrow: 1,
+ justifyContent: 'flex-end',
+ alignItems: 'center',
+ gap: theme.spacing(1),
+ },
+}));
diff --git a/frontend/src/component/common/PageHeader/PageHeader.tsx b/frontend/src/component/common/PageHeader/PageHeader.tsx
new file mode 100644
index 0000000000..d60a5544c6
--- /dev/null
+++ b/frontend/src/component/common/PageHeader/PageHeader.tsx
@@ -0,0 +1,88 @@
+import { ReactNode, FC, VFC } from 'react';
+import classnames from 'classnames';
+
+import {
+ Divider,
+ styled,
+ SxProps,
+ Theme,
+ Typography,
+ TypographyProps,
+} from '@mui/material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+
+import { useStyles } from './PageHeader.styles';
+import { usePageTitle } from 'hooks/usePageTitle';
+
+const StyledDivider = styled(Divider)(({ theme }) => ({
+ height: '100%',
+ borderColor: theme.palette.dividerAlternative,
+ width: '1px',
+ display: 'inline-block',
+ marginLeft: theme.spacing(2),
+ marginRight: theme.spacing(2),
+ padding: '10px 0',
+ verticalAlign: 'middle',
+}));
+
+interface IPageHeaderProps {
+ title?: string;
+ titleElement?: ReactNode;
+ subtitle?: string;
+ variant?: TypographyProps['variant'];
+ loading?: boolean;
+ actions?: ReactNode;
+ className?: string;
+ secondary?: boolean;
+}
+
+const PageHeaderComponent: FC & {
+ Divider: typeof PageHeaderDivider;
+} = ({
+ title,
+ titleElement,
+ actions,
+ subtitle,
+ variant,
+ loading,
+ className = '',
+ secondary,
+ children,
+}) => {
+ const { classes: styles } = useStyles();
+ const headerClasses = classnames({ skeleton: loading });
+
+ usePageTitle(secondary ? '' : title);
+
+ return (
+
+
+
+
+ {titleElement || title}
+
+ {subtitle && {subtitle}}
+
+
{actions}}
+ />
+
+ {children}
+
+ );
+};
+
+const PageHeaderDivider: VFC<{ sx?: SxProps }> = ({ sx }) => {
+ return ;
+};
+
+PageHeaderComponent.Divider = PageHeaderDivider;
+
+export const PageHeader = PageHeaderComponent;
diff --git a/frontend/src/component/common/PaginateUI/PaginateUI.tsx b/frontend/src/component/common/PaginateUI/PaginateUI.tsx
new file mode 100644
index 0000000000..26718b0ca1
--- /dev/null
+++ b/frontend/src/component/common/PaginateUI/PaginateUI.tsx
@@ -0,0 +1,171 @@
+import React, { useEffect, useState } from 'react';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import classnames from 'classnames';
+import { useStyles } from './PaginationUI.styles';
+
+import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos';
+import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
+
+import DoubleArrowIcon from '@mui/icons-material/DoubleArrow';
+import { useMediaQuery, useTheme } from '@mui/material';
+
+interface IPaginateUIProps {
+ pages: any[];
+ pageIndex: number;
+ prevPage: () => void;
+ setPageIndex: (idx: number) => void;
+ nextPage: () => void;
+ style?: React.CSSProperties;
+}
+
+/**
+ * @deprecated
+ */
+const PaginateUI = ({
+ pages,
+ pageIndex,
+ prevPage,
+ setPageIndex,
+ nextPage,
+ ...rest
+}: IPaginateUIProps) => {
+ const STARTLIMIT = 6;
+ const theme = useTheme();
+ const { classes: styles } = useStyles();
+ const [limit, setLimit] = useState(STARTLIMIT);
+ const [start, setStart] = useState(0);
+ const matches = useMediaQuery(theme.breakpoints.down('md'));
+
+ useEffect(() => {
+ if (matches) {
+ setLimit(4);
+ }
+ }, [matches]);
+
+ useEffect(() => {
+ if (pageIndex === 0 && start !== 0) {
+ setStart(0);
+ setLimit(STARTLIMIT);
+ }
+ }, [pageIndex, start]);
+
+ return (
+ 1}
+ show={
+
+
+
0}
+ show={
+ <>
+
+
+ >
+ }
+ />
+
+ {pages
+ .map((page, idx) => {
+ const active = pageIndex === idx;
+ return (
+
+ );
+ })
+ .slice(start, limit)}
+
+
+
+ >
+ }
+ />
+
+
+ }
+ />
+ );
+};
+
+export default PaginateUI;
diff --git a/frontend/src/component/common/PaginateUI/PaginationUI.styles.ts b/frontend/src/component/common/PaginateUI/PaginationUI.styles.ts
new file mode 100644
index 0000000000..727320f814
--- /dev/null
+++ b/frontend/src/component/common/PaginateUI/PaginationUI.styles.ts
@@ -0,0 +1,58 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ pagination: {
+ margin: '1rem 0 0 0',
+ display: 'flex',
+ justifyContent: ' center',
+ position: 'absolute',
+ bottom: '25px',
+ right: '0',
+ left: '0',
+ },
+ paginationInnerContainer: {
+ position: 'relative',
+ },
+ paginationButton: {
+ border: 'none',
+ cursor: 'pointer',
+ backgroundColor: 'efefef',
+ margin: '0 0.2rem',
+ width: '31px',
+ borderRadius: theme.shape.borderRadius,
+ padding: '0.25rem 0.5rem',
+ },
+ paginationButtonActive: {
+ backgroundColor: '#635DC5',
+ color: '#fff',
+ transition: 'backgroundColor 0.3s ease',
+ },
+ idxBtn: {
+ border: 'none',
+ borderRadius: theme.shape.borderRadius,
+ background: 'transparent',
+ position: 'absolute',
+ height: '23px',
+ cursor: 'pointer',
+ },
+ doubleArrowBtnLeft: {
+ left: '-55px',
+ },
+ doubleArrowBtnRight: {
+ right: '-55px',
+ },
+ arrowIcon: { height: '15px', width: '15px' },
+ arrowIconLeft: {
+ transform: 'rotate(180deg)',
+ },
+ idxBtnIcon: {
+ height: '15px',
+ width: '15px',
+ },
+ idxBtnLeft: {
+ left: '-30px',
+ },
+ idxBtnRight: {
+ right: '-30px',
+ },
+}));
diff --git a/frontend/src/component/common/PasswordField/PasswordField.tsx b/frontend/src/component/common/PasswordField/PasswordField.tsx
new file mode 100644
index 0000000000..49b7df5a12
--- /dev/null
+++ b/frontend/src/component/common/PasswordField/PasswordField.tsx
@@ -0,0 +1,52 @@
+import {
+ IconButton,
+ InputAdornment,
+ TextField,
+ TextFieldProps,
+} from '@mui/material';
+import { Visibility, VisibilityOff } from '@mui/icons-material';
+import React, { useState, VFC } from 'react';
+
+const PasswordField: VFC = ({ ...rest }) => {
+ const [showPassword, setShowPassword] = useState(false);
+
+ const handleClickShowPassword = () => {
+ setShowPassword(!showPassword);
+ };
+
+ const handleMouseDownPassword = (
+ e: React.MouseEvent
+ ) => {
+ e.preventDefault();
+ };
+
+ const IconComponent = showPassword ? Visibility : VisibilityOff;
+ const iconTitle = 'Toggle password visibility';
+
+ return (
+
+
+
+
+
+ ),
+ }}
+ {...rest}
+ />
+ );
+};
+
+export default PasswordField;
diff --git a/frontend/src/component/common/PercentageCircle/PercentageCircle.tsx b/frontend/src/component/common/PercentageCircle/PercentageCircle.tsx
new file mode 100644
index 0000000000..60b7ea86bf
--- /dev/null
+++ b/frontend/src/component/common/PercentageCircle/PercentageCircle.tsx
@@ -0,0 +1,45 @@
+import { useTheme } from '@mui/material';
+import { CSSProperties } from 'react';
+
+interface IPercentageCircleProps {
+ percentage: number;
+ size?: `${number}rem`;
+}
+
+const PercentageCircle = ({
+ percentage,
+ size = '4rem',
+}: IPercentageCircleProps) => {
+ const theme = useTheme();
+
+ const style: CSSProperties = {
+ display: 'block',
+ borderRadius: '100%',
+ transform: 'rotate(-90deg)',
+ height: size,
+ width: size,
+ background: theme.palette.grey[200],
+ };
+
+ // The percentage circle used to be drawn by CSS with a conic-gradient,
+ // but the result was either jagged or blurry. SVG seems to look better.
+ // See https://stackoverflow.com/a/70659532.
+ const r = 100 / (2 * Math.PI);
+ const d = 2 * r;
+
+ return (
+
+ );
+};
+
+export default PercentageCircle;
diff --git a/frontend/src/component/common/PermissionButton/PermissionButton.tsx b/frontend/src/component/common/PermissionButton/PermissionButton.tsx
new file mode 100644
index 0000000000..0882d40a7b
--- /dev/null
+++ b/frontend/src/component/common/PermissionButton/PermissionButton.tsx
@@ -0,0 +1,93 @@
+import { Button, ButtonProps } from '@mui/material';
+import { Lock } from '@mui/icons-material';
+import AccessContext from 'contexts/AccessContext';
+import React, { useContext } from 'react';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import {
+ TooltipResolver,
+ ITooltipResolverProps,
+} from 'component/common/TooltipResolver/TooltipResolver';
+import { formatAccessText } from 'utils/formatAccessText';
+import { useId } from 'hooks/useId';
+
+export interface IPermissionButtonProps extends Omit {
+ permission: string | string[];
+ onClick?: (e: any) => void;
+ disabled?: boolean;
+ projectId?: string;
+ environmentId?: string;
+ tooltipProps?: Omit;
+}
+
+const PermissionButton: React.FC = ({
+ permission,
+ variant = 'contained',
+ color = 'primary',
+ onClick,
+ children,
+ disabled,
+ projectId,
+ environmentId,
+ tooltipProps,
+ ...rest
+}) => {
+ const { hasAccess } = useContext(AccessContext);
+ const id = useId();
+ let access;
+
+ const handleAccess = () => {
+ let access;
+ if (Array.isArray(permission)) {
+ access = permission.some(permission => {
+ if (projectId && environmentId) {
+ return hasAccess(permission, projectId, environmentId);
+ } else if (projectId) {
+ return hasAccess(permission, projectId);
+ } else {
+ return hasAccess(permission);
+ }
+ });
+ } else {
+ if (projectId && environmentId) {
+ access = hasAccess(permission, projectId, environmentId);
+ } else if (projectId) {
+ access = hasAccess(permission, projectId);
+ } else {
+ access = hasAccess(permission);
+ }
+ }
+
+ return access;
+ };
+
+ access = handleAccess();
+
+ return (
+
+
+ }
+ />
+ }
+ >
+ {children}
+
+
+
+ );
+};
+
+export default PermissionButton;
diff --git a/frontend/src/component/common/PermissionHOC/PermissionHOC.tsx b/frontend/src/component/common/PermissionHOC/PermissionHOC.tsx
new file mode 100644
index 0000000000..25a833b0cc
--- /dev/null
+++ b/frontend/src/component/common/PermissionHOC/PermissionHOC.tsx
@@ -0,0 +1,45 @@
+import { useContext, FC, ReactElement } from 'react';
+import AccessContext from 'contexts/AccessContext';
+import {
+ ITooltipResolverProps,
+ TooltipResolver,
+} from 'component/common/TooltipResolver/TooltipResolver';
+import { formatAccessText } from 'utils/formatAccessText';
+
+type IPermissionHOCProps = {
+ permission: string;
+ projectId?: string;
+ environmentId?: string;
+ tooltip?: string;
+ tooltipProps?: Omit;
+ children: ({ hasAccess }: { hasAccess?: boolean }) => ReactElement;
+};
+
+export const PermissionHOC: FC = ({
+ permission,
+ projectId,
+ children,
+ environmentId,
+ tooltip,
+ tooltipProps,
+}) => {
+ const { hasAccess } = useContext(AccessContext);
+ let access;
+
+ if (projectId && environmentId) {
+ access = hasAccess(permission, projectId, environmentId);
+ } else if (projectId) {
+ access = hasAccess(permission, projectId);
+ } else {
+ access = hasAccess(permission);
+ }
+
+ return (
+
+ {children({ hasAccess: access })}
+
+ );
+};
diff --git a/frontend/src/component/common/PermissionIconButton/PermissionIconButton.tsx b/frontend/src/component/common/PermissionIconButton/PermissionIconButton.tsx
new file mode 100644
index 0000000000..aa3c44c3c0
--- /dev/null
+++ b/frontend/src/component/common/PermissionIconButton/PermissionIconButton.tsx
@@ -0,0 +1,78 @@
+import { IconButton, IconButtonProps } from '@mui/material';
+import React, { useContext, ReactNode } from 'react';
+import AccessContext from 'contexts/AccessContext';
+import { Link } from 'react-router-dom';
+import {
+ TooltipResolver,
+ ITooltipResolverProps,
+} from 'component/common/TooltipResolver/TooltipResolver';
+import { formatAccessText } from 'utils/formatAccessText';
+import { useId } from 'hooks/useId';
+
+interface IPermissionIconButtonProps {
+ permission: string;
+ projectId?: string;
+ environmentId?: string;
+ className?: string;
+ children?: ReactNode;
+ disabled?: boolean;
+ hidden?: boolean;
+ type?: 'button';
+ edge?: IconButtonProps['edge'];
+ tooltipProps?: Omit;
+ sx?: IconButtonProps['sx'];
+ size?: string;
+}
+
+interface IButtonProps extends IPermissionIconButtonProps {
+ onClick: (event: React.SyntheticEvent) => void;
+}
+
+interface ILinkProps extends IPermissionIconButtonProps {
+ component: typeof Link;
+ to: string;
+}
+
+const PermissionIconButton = ({
+ permission,
+ projectId,
+ children,
+ environmentId,
+ tooltipProps,
+ disabled,
+ ...rest
+}: IButtonProps | ILinkProps) => {
+ const { hasAccess } = useContext(AccessContext);
+ const id = useId();
+ let access;
+
+ if (projectId && environmentId) {
+ access = hasAccess(permission, projectId, environmentId);
+ } else if (projectId) {
+ access = hasAccess(permission, projectId);
+ } else {
+ access = hasAccess(permission);
+ }
+
+ return (
+ e.preventDefault()}
+ >
+
+
+ {children}
+
+
+
+ );
+};
+
+export default PermissionIconButton;
diff --git a/frontend/src/component/common/PermissionSwitch/PermissionSwitch.tsx b/frontend/src/component/common/PermissionSwitch/PermissionSwitch.tsx
new file mode 100644
index 0000000000..3d99a7af94
--- /dev/null
+++ b/frontend/src/component/common/PermissionSwitch/PermissionSwitch.tsx
@@ -0,0 +1,58 @@
+import { Switch, SwitchProps } from '@mui/material';
+import AccessContext from 'contexts/AccessContext';
+import React, { useContext } from 'react';
+import { formatAccessText } from 'utils/formatAccessText';
+import { TooltipResolver } from 'component/common/TooltipResolver/TooltipResolver';
+
+interface IPermissionSwitchProps extends SwitchProps {
+ permission: string;
+ tooltip?: string;
+ onChange?: (e: React.ChangeEvent) => void;
+ disabled?: boolean;
+ projectId?: string;
+ environmentId?: string;
+ checked: boolean;
+}
+
+const PermissionSwitch = React.forwardRef<
+ HTMLButtonElement,
+ IPermissionSwitchProps
+>((props, ref) => {
+ const {
+ permission,
+ tooltip,
+ disabled,
+ projectId,
+ environmentId,
+ checked,
+ onChange,
+ ...rest
+ } = props;
+
+ const { hasAccess } = useContext(AccessContext);
+
+ let access;
+ if (projectId && environmentId) {
+ access = hasAccess(permission, projectId, environmentId);
+ } else if (projectId) {
+ access = hasAccess(permission, projectId);
+ } else {
+ access = hasAccess(permission);
+ }
+
+ return (
+
+
+
+
+
+ );
+});
+
+export default PermissionSwitch;
diff --git a/frontend/src/component/common/Proclamation/Proclamation.styles.ts b/frontend/src/component/common/Proclamation/Proclamation.styles.ts
new file mode 100644
index 0000000000..3997888d0c
--- /dev/null
+++ b/frontend/src/component/common/Proclamation/Proclamation.styles.ts
@@ -0,0 +1,15 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()({
+ proclamation: {
+ marginBottom: '1rem',
+ },
+ content: {
+ maxWidth: '800px',
+ },
+ link: {
+ display: 'block',
+ marginTop: '0.5rem',
+ width: '100px',
+ },
+});
diff --git a/frontend/src/component/common/Proclamation/Proclamation.tsx b/frontend/src/component/common/Proclamation/Proclamation.tsx
new file mode 100644
index 0000000000..df0fe67ee1
--- /dev/null
+++ b/frontend/src/component/common/Proclamation/Proclamation.tsx
@@ -0,0 +1,66 @@
+import { useState, useEffect } from 'react';
+import { Alert } from '@mui/material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { Typography } from '@mui/material';
+import { useStyles } from './Proclamation.styles';
+import { IProclamationToast } from 'interfaces/uiConfig';
+
+interface IProclamationProps {
+ toast?: IProclamationToast;
+}
+
+const renderProclamation = (id: string) => {
+ if (!id) return false;
+ if (localStorage) {
+ const value = localStorage.getItem(id);
+ if (value) {
+ return false;
+ }
+ }
+ return true;
+};
+
+const Proclamation = ({ toast }: IProclamationProps) => {
+ const [show, setShow] = useState(false);
+ const { classes: styles } = useStyles();
+
+ useEffect(() => {
+ setShow(renderProclamation(toast?.id || ''));
+ }, [toast?.id]);
+
+ const onClose = () => {
+ if (localStorage) {
+ localStorage.setItem(toast?.id || '', 'seen');
+ }
+ setShow(false);
+ };
+
+ if (!toast) return null;
+
+ return (
+
+
+ {toast.message}
+
+
+ View more
+
+
+ }
+ />
+ );
+};
+
+export default Proclamation;
diff --git a/frontend/src/component/common/ProjectSelect/ProjectSelect.tsx b/frontend/src/component/common/ProjectSelect/ProjectSelect.tsx
new file mode 100644
index 0000000000..9f8daeab7a
--- /dev/null
+++ b/frontend/src/component/common/ProjectSelect/ProjectSelect.tsx
@@ -0,0 +1,74 @@
+import React, { MouseEventHandler, useMemo, VFC } from 'react';
+import { MenuItem, Typography } from '@mui/material';
+import DropdownMenu, { IDropdownMenuProps } from '../DropdownMenu/DropdownMenu';
+import useProjects from 'hooks/api/getters/useProjects/useProjects';
+import { IProjectCard } from 'interfaces/project';
+
+const ALL_PROJECTS = { id: '*', name: '> All projects' };
+
+interface IProjectSelectProps {
+ currentProjectId: string;
+ updateCurrentProject: (id: string) => void;
+}
+
+const ProjectSelect: VFC> = ({
+ currentProjectId,
+ updateCurrentProject,
+ ...rest
+}) => {
+ const { projects } = useProjects();
+
+ const setProject = (value?: string | null) => {
+ const id = value && typeof value === 'string' ? value.trim() : '*';
+ updateCurrentProject(id);
+ };
+
+ const currentProject = useMemo(() => {
+ const project = projects.find(i => i.id === currentProjectId);
+ return project || ALL_PROJECTS;
+ }, [currentProjectId, projects]);
+
+ const handleChangeProject: MouseEventHandler = event => {
+ const target = (event.target as Element).getAttribute('data-target');
+ setProject(target);
+ };
+
+ const renderProjectItem = (selectedId: string, item: IProjectCard) => (
+
+ );
+
+ const renderProjectOptions = () => [
+ ,
+ ...projects.map(project =>
+ renderProjectItem(currentProjectId, project)
+ ),
+ ];
+
+ return (
+
+
+
+ );
+};
+
+export default ProjectSelect;
diff --git a/frontend/src/component/common/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/component/common/ProtectedRoute/ProtectedRoute.tsx
new file mode 100644
index 0000000000..ca1ff60804
--- /dev/null
+++ b/frontend/src/component/common/ProtectedRoute/ProtectedRoute.tsx
@@ -0,0 +1,18 @@
+import { IRoute } from 'interfaces/route';
+import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
+import { LoginRedirect } from 'component/common/LoginRedirect/LoginRedirect';
+
+interface IProtectedRouteProps {
+ route: IRoute;
+}
+
+export const ProtectedRoute = ({ route }: IProtectedRouteProps) => {
+ const { user } = useAuthUser();
+ const isLoggedIn = Boolean(user?.id);
+
+ if (!isLoggedIn && route.type === 'protected') {
+ return ;
+ }
+
+ return ;
+};
diff --git a/frontend/src/component/common/ResponsiveButton/ResponsiveButton.tsx b/frontend/src/component/common/ResponsiveButton/ResponsiveButton.tsx
new file mode 100644
index 0000000000..128c60ddaf
--- /dev/null
+++ b/frontend/src/component/common/ResponsiveButton/ResponsiveButton.tsx
@@ -0,0 +1,66 @@
+import { useMediaQuery } from '@mui/material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import PermissionButton from '../PermissionButton/PermissionButton';
+import PermissionIconButton from '../PermissionIconButton/PermissionIconButton';
+import React from 'react';
+
+interface IResponsiveButtonProps {
+ Icon: React.ElementType;
+ onClick: () => void;
+ disabled?: boolean;
+ permission: string;
+ projectId?: string;
+ environmentId?: string;
+ maxWidth: string;
+ className?: string;
+}
+
+const ResponsiveButton: React.FC = ({
+ Icon,
+ onClick,
+ maxWidth,
+ disabled = false,
+ children,
+ permission,
+ environmentId,
+ projectId,
+ ...rest
+}) => {
+ const smallScreen = useMediaQuery(`(max-width:${maxWidth})`);
+
+ return (
+
+
+
+ }
+ elseShow={
+
+ {children}
+
+ }
+ />
+ );
+};
+
+export default ResponsiveButton;
diff --git a/frontend/src/component/common/ScrollTop/ScrollTop.tsx b/frontend/src/component/common/ScrollTop/ScrollTop.tsx
new file mode 100644
index 0000000000..3e70b83e3b
--- /dev/null
+++ b/frontend/src/component/common/ScrollTop/ScrollTop.tsx
@@ -0,0 +1,21 @@
+import { useEffect } from 'react';
+import { useLocation } from 'react-router-dom';
+
+export const ScrollTop = (): null => {
+ const { pathname } = useLocation();
+
+ useEffect(() => {
+ if (!noScrollPaths.some(noScroll => pathname.includes(noScroll))) {
+ window.scrollTo(0, 0);
+ }
+ }, [pathname]);
+
+ return null;
+};
+
+const noScrollPaths = [
+ '/admin/api',
+ '/admin/users',
+ '/admin/auth',
+ '/admin/roles',
+];
diff --git a/frontend/src/component/common/Search/Search.styles.ts b/frontend/src/component/common/Search/Search.styles.ts
new file mode 100644
index 0000000000..09d8bc076f
--- /dev/null
+++ b/frontend/src/component/common/Search/Search.styles.ts
@@ -0,0 +1,47 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ display: 'flex',
+ flexGrow: 1,
+ alignItems: 'center',
+ position: 'relative',
+ backgroundColor: theme.palette.background.paper,
+ maxWidth: '400px',
+ [theme.breakpoints.down('md')]: {
+ marginTop: theme.spacing(1),
+ maxWidth: '100%',
+ },
+ },
+ search: {
+ display: 'flex',
+ alignItems: 'center',
+ backgroundColor: theme.palette.background.paper,
+ border: `1px solid ${theme.palette.grey[500]}`,
+ borderRadius: theme.shape.borderRadiusExtraLarge,
+ padding: '3px 5px 3px 12px',
+ width: '100%',
+ zIndex: 3,
+ '&.search-container:focus-within': {
+ borderColor: theme.palette.primary.light,
+ boxShadow: theme.boxShadows.main,
+ },
+ },
+ searchIcon: {
+ marginRight: 8,
+ color: theme.palette.inactiveIcon,
+ },
+ clearContainer: {
+ width: '30px',
+ '& > button': {
+ padding: '7px',
+ },
+ },
+ clearIcon: {
+ color: theme.palette.grey[700],
+ fontSize: '18px',
+ },
+ inputRoot: {
+ width: '100%',
+ },
+}));
diff --git a/frontend/src/component/common/Search/Search.tsx b/frontend/src/component/common/Search/Search.tsx
new file mode 100644
index 0000000000..6733984e7b
--- /dev/null
+++ b/frontend/src/component/common/Search/Search.tsx
@@ -0,0 +1,121 @@
+import React, { useRef, useState } from 'react';
+import { IconButton, InputBase, Tooltip } from '@mui/material';
+import { Search as SearchIcon, Close } from '@mui/icons-material';
+import classnames from 'classnames';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { useStyles } from './Search.styles';
+import { SearchSuggestions } from './SearchSuggestions/SearchSuggestions';
+import { IGetSearchContextOutput } from 'hooks/useSearch';
+import { useKeyboardShortcut } from 'hooks/useKeyboardShortcut';
+import { useAsyncDebounce } from 'react-table';
+
+interface ISearchProps {
+ initialValue?: string;
+ onChange: (value: string) => void;
+ className?: string;
+ placeholder?: string;
+ hasFilters?: boolean;
+ disabled?: boolean;
+ getSearchContext?: () => IGetSearchContextOutput;
+ containerStyles?: React.CSSProperties;
+ debounceTime?: number;
+}
+
+export const Search = ({
+ initialValue = '',
+ onChange,
+ className,
+ placeholder: customPlaceholder,
+ hasFilters,
+ disabled,
+ getSearchContext,
+ containerStyles,
+ debounceTime = 200,
+}: ISearchProps) => {
+ const ref = useRef();
+ const { classes: styles } = useStyles();
+ const [showSuggestions, setShowSuggestions] = useState(false);
+
+ const [value, setValue] = useState(initialValue);
+ const debouncedOnChange = useAsyncDebounce(onChange, debounceTime);
+
+ const onSearchChange = (value: string) => {
+ debouncedOnChange(value);
+ setValue(value);
+ };
+
+ const hotkey = useKeyboardShortcut(
+ { modifiers: ['ctrl'], key: 'k', preventDefault: true },
+ () => {
+ if (document.activeElement === ref.current) {
+ ref.current?.blur();
+ } else {
+ ref.current?.focus();
+ }
+ }
+ );
+ useKeyboardShortcut({ key: 'Escape' }, () => {
+ if (document.activeElement === ref.current) {
+ ref.current?.blur();
+ }
+ });
+ const placeholder = `${customPlaceholder ?? 'Search'} (${hotkey})`;
+
+ return (
+
+
+
+
onSearchChange(e.target.value)}
+ onFocus={() => setShowSuggestions(true)}
+ onBlur={() => setShowSuggestions(false)}
+ disabled={disabled}
+ />
+
+
+ {
+ onSearchChange('');
+ ref.current?.focus();
+ }}
+ >
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+ );
+};
diff --git a/frontend/src/component/common/Search/SearchSuggestions/SearchDescription/SearchDescription.tsx b/frontend/src/component/common/Search/SearchSuggestions/SearchDescription/SearchDescription.tsx
new file mode 100644
index 0000000000..66396db282
--- /dev/null
+++ b/frontend/src/component/common/Search/SearchSuggestions/SearchDescription/SearchDescription.tsx
@@ -0,0 +1,72 @@
+import { styled } from '@mui/material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import {
+ getSearchTextGenerator,
+ IGetSearchContextOutput,
+} from 'hooks/useSearch';
+import { VFC } from 'react';
+
+const StyledHeader = styled('span')(({ theme }) => ({
+ fontSize: theme.fontSizes.smallBody,
+ color: theme.palette.text.primary,
+}));
+
+const StyledCode = styled('span')(({ theme }) => ({
+ backgroundColor: theme.palette.secondaryContainer,
+ color: theme.palette.text.primary,
+ padding: theme.spacing(0, 0.5),
+ borderRadius: theme.spacing(0.5),
+}));
+
+interface ISearchDescriptionProps {
+ filters: any[];
+ getSearchContext: () => IGetSearchContextOutput;
+ searchableColumnsString: string;
+}
+
+export const SearchDescription: VFC = ({
+ filters,
+ getSearchContext,
+ searchableColumnsString,
+}) => {
+ const searchContext = getSearchContext();
+ const getSearchText = getSearchTextGenerator(searchContext.columns);
+ const searchText = getSearchText(searchContext.searchValue);
+ const searchFilters = filters.filter(filter => filter.values.length > 0);
+
+ return (
+ <>
+
+ Searching for:
+
+ {searchText}{' '}
+ {searchableColumnsString
+ ? ` in ${searchableColumnsString}`
+ : ''}
+
+ >
+ }
+ />
+ 0}
+ show={
+ <>
+ Filtering by:
+ {searchFilters.map(filter => (
+
+
+ {filter.values.join(',')}
+ {' '}
+ in {filter.header}. Options:{' '}
+ {filter.options.join(', ')}
+
+ ))}
+ >
+ }
+ />
+ >
+ );
+};
diff --git a/frontend/src/component/common/Search/SearchSuggestions/SearchInstructions/SearchInstructions.tsx b/frontend/src/component/common/Search/SearchSuggestions/SearchInstructions/SearchInstructions.tsx
new file mode 100644
index 0000000000..bfaf3a557e
--- /dev/null
+++ b/frontend/src/component/common/Search/SearchSuggestions/SearchInstructions/SearchInstructions.tsx
@@ -0,0 +1,62 @@
+import { styled } from '@mui/material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { IGetSearchContextOutput } from 'hooks/useSearch';
+import { VFC } from 'react';
+
+const StyledHeader = styled('span')(({ theme }) => ({
+ fontSize: theme.fontSizes.smallBody,
+ color: theme.palette.text.primary,
+}));
+
+const StyledCode = styled('span')(({ theme }) => ({
+ backgroundColor: theme.palette.secondaryContainer,
+ color: theme.palette.text.primary,
+ padding: theme.spacing(0, 0.5),
+ borderRadius: theme.spacing(0.5),
+}));
+
+interface ISearchInstructionsProps {
+ filters: any[];
+ getSearchContext: () => IGetSearchContextOutput;
+ searchableColumnsString: string;
+}
+
+export const SearchInstructions: VFC = ({
+ filters,
+ getSearchContext,
+ searchableColumnsString,
+}) => {
+ return (
+ <>
+
+ {filters.length > 0
+ ? 'Filter your search with operators like:'
+ : `Start typing to search${
+ searchableColumnsString
+ ? ` in ${searchableColumnsString}`
+ : '...'
+ }`}
+
+ {filters.map(filter => (
+
+ Filter by {filter.header}:{' '}
+
+ {filter.name}:{filter.options[0]}
+
+ 1}
+ show={
+ <>
+ {' or '}
+
+ {filter.name}:
+ {filter.options.slice(0, 2).join(',')}
+
+ >
+ }
+ />
+
+ ))}
+ >
+ );
+};
diff --git a/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.tsx b/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.tsx
new file mode 100644
index 0000000000..3ba355c9df
--- /dev/null
+++ b/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.tsx
@@ -0,0 +1,150 @@
+import { FilterList } from '@mui/icons-material';
+import { Box, Divider, Paper, styled } from '@mui/material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import {
+ getColumnValues,
+ getFilterableColumns,
+ getFilterValues,
+ IGetSearchContextOutput,
+} from 'hooks/useSearch';
+import { useMemo, VFC } from 'react';
+import { SearchDescription } from './SearchDescription/SearchDescription';
+import { SearchInstructions } from './SearchInstructions/SearchInstructions';
+
+const randomIndex = (arr: any[]) => Math.floor(Math.random() * arr.length);
+
+const StyledPaper = styled(Paper)(({ theme }) => ({
+ position: 'absolute',
+ width: '100%',
+ left: 0,
+ top: '20px',
+ zIndex: 2,
+ padding: theme.spacing(4, 1.5, 1.5),
+ borderBottomLeftRadius: theme.spacing(1),
+ borderBottomRightRadius: theme.spacing(1),
+ boxShadow: '0px 8px 20px rgba(33, 33, 33, 0.15)',
+ fontSize: theme.fontSizes.smallBody,
+ color: theme.palette.text.secondary,
+ wordBreak: 'break-word',
+}));
+
+const StyledBox = styled(Box)(({ theme }) => ({
+ display: 'flex',
+ gap: theme.spacing(2),
+}));
+
+const StyledFilterList = styled(FilterList)(({ theme }) => ({
+ color: theme.palette.text.secondary,
+}));
+
+const StyledDivider = styled(Divider)(({ theme }) => ({
+ border: `1px dashed ${theme.palette.dividerAlternative}`,
+ margin: theme.spacing(1.5, 0),
+}));
+
+const StyledCode = styled('span')(({ theme }) => ({
+ backgroundColor: theme.palette.secondaryContainer,
+ color: theme.palette.text.primary,
+ padding: theme.spacing(0, 0.5),
+ borderRadius: theme.spacing(0.5),
+}));
+
+interface SearchSuggestionsProps {
+ getSearchContext: () => IGetSearchContextOutput;
+}
+
+export const SearchSuggestions: VFC = ({
+ getSearchContext,
+}) => {
+ const searchContext = getSearchContext();
+
+ const randomRow = useMemo(
+ () => randomIndex(searchContext.data),
+ [searchContext.data]
+ );
+
+ const filters = getFilterableColumns(searchContext.columns)
+ .map(column => {
+ const filterOptions = searchContext.data.map(row =>
+ getColumnValues(column, row)
+ );
+
+ return {
+ name: column.filterName,
+ header: column.Header ?? column.filterName,
+ options: [...new Set(filterOptions)].sort((a, b) =>
+ a.localeCompare(b)
+ ),
+ suggestedOption:
+ filterOptions[randomRow] ?? `example-${column.filterName}`,
+ values: getFilterValues(
+ column.filterName,
+ searchContext.searchValue
+ ),
+ };
+ })
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+ const searchableColumns = searchContext.columns.filter(
+ column => column.searchable && column.accessor
+ );
+
+ const searchableColumnsString = searchableColumns
+ .map(column => column.Header ?? column.accessor)
+ .join(', ');
+
+ const suggestedTextSearch =
+ searchContext.data.length && searchableColumns.length
+ ? getColumnValues(
+ searchableColumns[0],
+ searchContext.data[randomRow]
+ )
+ : 'example-search-text';
+
+ return (
+
+
+
+
+
+ }
+ elseShow={
+
+ }
+ />
+
+
+
+ 0}
+ show="Combine filters and search."
+ />
+
+ Example:{' '}
+
+ {filters.map(filter => (
+
+ {filter.name}:{filter.suggestedOption}{' '}
+
+ ))}
+ {suggestedTextSearch}
+
+
+
+ );
+};
diff --git a/frontend/src/component/common/SearchField/SearchField.tsx b/frontend/src/component/common/SearchField/SearchField.tsx
new file mode 100644
index 0000000000..ac0798d6a7
--- /dev/null
+++ b/frontend/src/component/common/SearchField/SearchField.tsx
@@ -0,0 +1,78 @@
+import React, { useState, VFC } from 'react';
+import classnames from 'classnames';
+import { debounce } from 'debounce';
+import { InputBase, Chip } from '@mui/material';
+import SearchIcon from '@mui/icons-material/Search';
+import { useStyles } from 'component/common/SearchField/styles';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+
+interface ISearchFieldProps {
+ updateValue: (value: string) => void;
+ initialValue?: string;
+ className?: string;
+ showValueChip?: boolean;
+}
+
+/**
+ * @deprecated use `Search` instead.
+ */
+export const SearchField: VFC = ({
+ updateValue,
+ initialValue = '',
+ className = '',
+ showValueChip,
+}) => {
+ const { classes: styles } = useStyles();
+ const [localValue, setLocalValue] = useState(initialValue);
+ const debounceUpdateValue = debounce(updateValue, 500);
+
+ const handleChange = (event: React.ChangeEvent) => {
+ event.preventDefault();
+ const value = event.target.value || '';
+ setLocalValue(value);
+ debounceUpdateValue(value);
+ };
+
+ const handleKeyPress = (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter') {
+ updateValue(localValue);
+ }
+ };
+
+ const updateNow = () => {
+ updateValue(localValue);
+ };
+
+ const onDelete = () => {
+ setLocalValue('');
+ updateValue('');
+ };
+
+ return (
+
+ );
+};
diff --git a/frontend/src/component/common/SearchField/styles.ts b/frontend/src/component/common/SearchField/styles.ts
new file mode 100644
index 0000000000..3317521aac
--- /dev/null
+++ b/frontend/src/component/common/SearchField/styles.ts
@@ -0,0 +1,28 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ display: 'flex',
+ alignItems: 'center',
+ flexWrap: 'wrap',
+ gap: '1rem',
+ },
+ search: {
+ display: 'flex',
+ alignItems: 'center',
+ backgroundColor: theme.palette.background.default,
+ borderRadius: theme.shape.borderRadiusExtraLarge,
+ padding: '0.25rem 0.5rem',
+ maxWidth: '450px',
+ [theme.breakpoints.down('sm')]: {
+ width: '100%',
+ },
+ },
+ searchIcon: {
+ marginRight: 8,
+ color: theme.palette.inactiveIcon,
+ },
+ inputRoot: {
+ width: '100%',
+ },
+}));
diff --git a/frontend/src/component/common/SegmentItem/SegmentItem.styles.ts b/frontend/src/component/common/SegmentItem/SegmentItem.styles.ts
new file mode 100644
index 0000000000..729a715fd0
--- /dev/null
+++ b/frontend/src/component/common/SegmentItem/SegmentItem.styles.ts
@@ -0,0 +1,45 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ width: '100%',
+ padding: theme.spacing(2, 3),
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ fontSize: theme.fontSizes.smallBody,
+ border: `1px solid ${theme.palette.dividerAlternative}`,
+ position: 'relative',
+ borderRadius: '5px',
+ },
+ link: {
+ textDecoration: 'none',
+ marginLeft: theme.spacing(1),
+ '&:hover': {
+ textDecoration: 'underline',
+ },
+ },
+ accordion: {
+ border: `1px solid ${theme.palette.dividerAlternative}`,
+ borderRadius: theme.shape.borderRadiusMedium,
+ backgroundColor: '#fff',
+ boxShadow: 'none',
+ margin: 0,
+ },
+ accordionRoot: {
+ transition: 'all 0.1s ease',
+ },
+ accordionExpanded: {
+ backgroundColor: theme.palette.neutral.light,
+ },
+ previewButton: {
+ paddingTop: 0,
+ paddingBottom: 0,
+ marginLeft: 'auto',
+ fontSize: theme.fontSizes.smallBody,
+ },
+ summary: {
+ fontSize: theme.fontSizes.smallBody,
+ margin: theme.spacing(0.5, 0),
+ },
+}));
diff --git a/frontend/src/component/common/SegmentItem/SegmentItem.tsx b/frontend/src/component/common/SegmentItem/SegmentItem.tsx
new file mode 100644
index 0000000000..79a5ced0a7
--- /dev/null
+++ b/frontend/src/component/common/SegmentItem/SegmentItem.tsx
@@ -0,0 +1,95 @@
+import { useState, VFC } from 'react';
+import { Link } from 'react-router-dom';
+import { DonutLarge } from '@mui/icons-material';
+import { ISegment } from 'interfaces/segment';
+import {
+ Accordion,
+ AccordionDetails,
+ AccordionSummary,
+ Button,
+ Typography,
+} from '@mui/material';
+import { ConstraintAccordionList } from '../ConstraintAccordion/ConstraintAccordionList/ConstraintAccordionList';
+import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender';
+import { useStyles } from './SegmentItem.styles';
+
+interface ISegmentItemProps {
+ segment: Partial;
+ isExpanded?: boolean;
+ constraintList?: JSX.Element;
+ headerContent?: JSX.Element;
+}
+
+export const SegmentItem: VFC = ({
+ segment,
+ isExpanded,
+ headerContent,
+ constraintList,
+}) => {
+ const { classes } = useStyles();
+ const [isOpen, setIsOpen] = useState(isExpanded || false);
+
+ return (
+
+
+
+ Segment:
+
+ {segment.name}
+
+
+ setIsOpen(value => !value)}
+ className={classes.previewButton}
+ >
+ {isOpen ? 'Close preview' : 'Preview'}
+
+ }
+ />
+
+
+ 0}
+ show={
+
+ }
+ elseShow={
+
+ This segment has no constraints.
+
+ }
+ />
+ }
+ />
+
+
+ );
+};
diff --git a/frontend/src/component/common/SidebarModal/SidebarModal.styles.ts b/frontend/src/component/common/SidebarModal/SidebarModal.styles.ts
new file mode 100644
index 0000000000..c63fb8c13c
--- /dev/null
+++ b/frontend/src/component/common/SidebarModal/SidebarModal.styles.ts
@@ -0,0 +1,15 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(() => ({
+ modal: {
+ position: 'absolute',
+ top: 0,
+ right: 0,
+ bottom: 0,
+ height: '100vh',
+ maxWidth: '98vw',
+ width: 1300,
+ overflow: 'auto',
+ boxShadow: '0 0 1rem rgba(0, 0, 0, 0.25)',
+ },
+}));
diff --git a/frontend/src/component/common/SidebarModal/SidebarModal.tsx b/frontend/src/component/common/SidebarModal/SidebarModal.tsx
new file mode 100644
index 0000000000..8c27e629d4
--- /dev/null
+++ b/frontend/src/component/common/SidebarModal/SidebarModal.tsx
@@ -0,0 +1,39 @@
+import { ReactNode } from 'react';
+import { Modal, Backdrop } from '@mui/material';
+import Fade from '@mui/material/Fade';
+import { useStyles } from 'component/common/SidebarModal/SidebarModal.styles';
+import { SIDEBAR_MODAL_ID } from 'utils/testIds';
+
+interface ISidebarModalProps {
+ open: boolean;
+ onClose: () => void;
+ label: string;
+ children: ReactNode;
+}
+
+const TRANSITION_DURATION = 250;
+
+export const SidebarModal = ({
+ open,
+ onClose,
+ label,
+ children,
+}: ISidebarModalProps) => {
+ const { classes: styles } = useStyles();
+
+ return (
+
+
+ {children}
+
+
+ );
+};
diff --git a/frontend/src/component/common/SkipNav/SkipNavLink.styles.ts b/frontend/src/component/common/SkipNav/SkipNavLink.styles.ts
new file mode 100644
index 0000000000..a4fa96d4ce
--- /dev/null
+++ b/frontend/src/component/common/SkipNav/SkipNavLink.styles.ts
@@ -0,0 +1,33 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ link: {
+ position: 'fixed',
+ overflow: 'hidden',
+ zIndex: 1000,
+ top: '1.125rem',
+ left: '1.125rem',
+ padding: '0.5rem 1rem',
+ whiteSpace: 'nowrap',
+ textDecoration: 'none',
+ background: theme.palette.primary.dark,
+ color: theme.palette.primary.contrastText,
+ borderRadius: theme.shape.borderRadius,
+ fontSize: theme.fontSizes.smallBody,
+
+ [theme.breakpoints.down(960)]: {
+ top: '0.8rem',
+ left: '0.8rem',
+ },
+
+ '&:not(:focus):not(:active)': {
+ clip: 'rect(0 0 0 0)',
+ clipPath: 'inset(50%)',
+ zIndex: -1,
+ width: 1,
+ height: 1,
+ margin: -1,
+ padding: 0,
+ },
+ },
+}));
diff --git a/frontend/src/component/common/SkipNav/SkipNavLink.tsx b/frontend/src/component/common/SkipNav/SkipNavLink.tsx
new file mode 100644
index 0000000000..635dbbb352
--- /dev/null
+++ b/frontend/src/component/common/SkipNav/SkipNavLink.tsx
@@ -0,0 +1,12 @@
+import { SKIP_NAV_TARGET_ID } from 'component/common/SkipNav/SkipNavTarget';
+import { useStyles } from 'component/common/SkipNav/SkipNavLink.styles';
+
+export const SkipNavLink = () => {
+ const { classes: styles } = useStyles();
+
+ return (
+
+ Skip to content ↓
+
+ );
+};
diff --git a/frontend/src/component/common/SkipNav/SkipNavTarget.tsx b/frontend/src/component/common/SkipNav/SkipNavTarget.tsx
new file mode 100644
index 0000000000..feeff614fe
--- /dev/null
+++ b/frontend/src/component/common/SkipNav/SkipNavTarget.tsx
@@ -0,0 +1,5 @@
+export const SKIP_NAV_TARGET_ID = 'skip-nav-target-id';
+
+export const SkipNavTarget = () => {
+ return ;
+};
diff --git a/frontend/src/component/common/StatusChip/StatusChip.styles.ts b/frontend/src/component/common/StatusChip/StatusChip.styles.ts
new file mode 100644
index 0000000000..d939efd67f
--- /dev/null
+++ b/frontend/src/component/common/StatusChip/StatusChip.styles.ts
@@ -0,0 +1,9 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ chip: {
+ background: 'transparent',
+ border: `1px solid ${theme.palette.primary.main}`,
+ color: theme.palette.primary.main,
+ },
+}));
diff --git a/frontend/src/component/common/StatusChip/StatusChip.tsx b/frontend/src/component/common/StatusChip/StatusChip.tsx
new file mode 100644
index 0000000000..7e30c3f0ca
--- /dev/null
+++ b/frontend/src/component/common/StatusChip/StatusChip.tsx
@@ -0,0 +1,34 @@
+import { Chip } from '@mui/material';
+import { useStyles } from './StatusChip.styles';
+
+interface IStatusChip {
+ stale: boolean;
+ showActive?: boolean;
+}
+
+const StatusChip = ({ stale, showActive = true }: IStatusChip) => {
+ const { classes: styles } = useStyles();
+
+ if (!stale && !showActive) {
+ return null;
+ }
+
+ const title = stale
+ ? 'Feature toggle is deprecated.'
+ : 'Feature toggle is active.';
+ const value = stale ? 'Stale' : 'Active';
+
+ return (
+
+
+
+ );
+};
+
+export default StatusChip;
diff --git a/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.styles.ts b/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.styles.ts
new file mode 100644
index 0000000000..701ed5f7e4
--- /dev/null
+++ b/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.styles.ts
@@ -0,0 +1,39 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ container: {
+ borderRadius: theme.shape.borderRadiusMedium,
+ border: `1px solid ${theme.palette.divider}`,
+ '& + &': {
+ marginTop: theme.spacing(2),
+ },
+ background: theme.palette.background.paper,
+ },
+ header: {
+ padding: theme.spacing(0.5, 2),
+ display: 'flex',
+ gap: theme.spacing(1),
+ alignItems: 'center',
+ borderBottom: `1px solid ${theme.palette.divider}`,
+ fontWeight: theme.typography.fontWeightMedium,
+ },
+ headerDraggable: {
+ paddingLeft: theme.spacing(1),
+ },
+ icon: {
+ fill: theme.palette.inactiveIcon,
+ },
+ actions: {
+ marginLeft: 'auto',
+ display: 'flex',
+ minHeight: theme.spacing(6),
+ alignItems: 'center',
+ },
+ resultChip: {
+ marginLeft: 'auto',
+ },
+ body: {
+ padding: theme.spacing(2),
+ justifyItems: 'center',
+ },
+}));
diff --git a/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.tsx b/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.tsx
new file mode 100644
index 0000000000..fc3efe8e61
--- /dev/null
+++ b/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.tsx
@@ -0,0 +1,96 @@
+import { DragEventHandler, FC, ReactNode } from 'react';
+import { DragIndicator } from '@mui/icons-material';
+import { styled, IconButton, Box } from '@mui/material';
+import classNames from 'classnames';
+import { IFeatureStrategy } from 'interfaces/strategy';
+import {
+ getFeatureStrategyIcon,
+ formatStrategyName,
+} from 'utils/strategyNames';
+import StringTruncator from 'component/common/StringTruncator/StringTruncator';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { useStyles } from './StrategyItemContainer.styles';
+
+interface IStrategyItemContainerProps {
+ strategy: IFeatureStrategy;
+ onDragStart?: DragEventHandler;
+ onDragEnd?: DragEventHandler;
+ actions?: ReactNode;
+ orderNumber?: number;
+ className?: string;
+}
+
+const DragIcon = styled(IconButton)(({ theme }) => ({
+ padding: 0,
+ cursor: 'inherit',
+ transition: 'color 0.2s ease-in-out',
+}));
+
+const StyledIndexLabel = styled('div')(({ theme }) => ({
+ fontSize: theme.typography.fontSize,
+ color: theme.palette.text.secondary,
+ position: 'absolute',
+ display: 'none',
+ right: 'calc(100% + 6px)',
+ top: theme.spacing(2.5),
+ [theme.breakpoints.up('md')]: {
+ display: 'block',
+ },
+}));
+
+export const StrategyItemContainer: FC = ({
+ strategy,
+ onDragStart,
+ onDragEnd,
+ actions,
+ children,
+ orderNumber,
+ className,
+}) => {
+ const { classes: styles } = useStyles();
+ const Icon = getFeatureStrategyIcon(strategy.name);
+
+ return (
+
+ {orderNumber}}
+ />
+
+
+
(
+
+
+
+ )}
+ />
+
+
+ {actions}
+
+ {children}
+
+
+ );
+};
diff --git a/frontend/src/component/common/StrategySeparator/StrategySeparator.tsx b/frontend/src/component/common/StrategySeparator/StrategySeparator.tsx
new file mode 100644
index 0000000000..19e0896483
--- /dev/null
+++ b/frontend/src/component/common/StrategySeparator/StrategySeparator.tsx
@@ -0,0 +1,52 @@
+import { Box, styled, useTheme } from '@mui/material';
+import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender';
+
+interface IStrategySeparatorProps {
+ text: 'AND' | 'OR';
+}
+
+const StyledContent = styled('div')(({ theme }) => ({
+ padding: theme.spacing(0.75, 1),
+ color: theme.palette.text.primary,
+ fontSize: theme.fontSizes.smallerBody,
+ backgroundColor: theme.palette.secondaryContainer,
+ borderRadius: theme.shape.borderRadius,
+ position: 'absolute',
+ zIndex: theme.zIndex.fab,
+ top: '50%',
+ left: theme.spacing(2),
+ transform: 'translateY(-50%)',
+ lineHeight: 1,
+}));
+
+const StyledCenteredContent = styled(StyledContent)(({ theme }) => ({
+ top: '50%',
+ left: '50%',
+ transform: 'translate(-50%, -50%)',
+ backgroundColor: theme.palette.activityIndicators.primary,
+ border: `1px solid ${theme.palette.primary.border}`,
+ borderRadius: theme.shape.borderRadiusLarge,
+ padding: theme.spacing(0.75, 1.5),
+}));
+
+export const StrategySeparator = ({ text }: IStrategySeparatorProps) => {
+ const theme = useTheme();
+
+ return (
+
+ {text}}
+ elseShow={() => (
+ {text}
+ )}
+ />
+
+ );
+};
diff --git a/frontend/src/component/common/StringTruncator/StringTruncator.tsx b/frontend/src/component/common/StringTruncator/StringTruncator.tsx
new file mode 100644
index 0000000000..74205701f8
--- /dev/null
+++ b/frontend/src/component/common/StringTruncator/StringTruncator.tsx
@@ -0,0 +1,45 @@
+import { Tooltip } from '@mui/material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+
+interface IStringTruncatorProps {
+ text: string;
+ maxWidth: string;
+ className?: string;
+ maxLength: number;
+}
+
+const StringTruncator = ({
+ text,
+ maxWidth,
+ maxLength,
+ className,
+ ...rest
+}: IStringTruncatorProps) => {
+ return (
+ maxLength}
+ show={
+
+
+ {text}
+
+
+ }
+ elseShow={{text}}
+ />
+ );
+};
+
+export default StringTruncator;
diff --git a/frontend/src/component/common/TabNav/TabNav/TabNav.styles.ts b/frontend/src/component/common/TabNav/TabNav/TabNav.styles.ts
new file mode 100644
index 0000000000..856db96277
--- /dev/null
+++ b/frontend/src/component/common/TabNav/TabNav/TabNav.styles.ts
@@ -0,0 +1,15 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ tabNav: {
+ backgroundColor: theme.palette.background.paper,
+ borderBottom: '1px solid',
+ borderBottomColor: theme.palette.grey[300],
+ borderRadius: 0,
+ },
+ tab: {
+ [theme.breakpoints.up('lg')]: {
+ minWidth: 160,
+ },
+ },
+}));
diff --git a/frontend/src/component/common/TabNav/TabNav/TabNav.tsx b/frontend/src/component/common/TabNav/TabNav/TabNav.tsx
new file mode 100644
index 0000000000..b3c1122456
--- /dev/null
+++ b/frontend/src/component/common/TabNav/TabNav/TabNav.tsx
@@ -0,0 +1,67 @@
+import React, { useState, ReactNode } from 'react';
+import classnames from 'classnames';
+import { Tabs, Tab, Paper } from '@mui/material';
+import { useStyles } from 'component/common/TabNav/TabNav/TabNav.styles';
+import { TabPanel } from 'component/common/TabNav/TabPanel/TabPanel';
+
+interface ITabNavProps {
+ tabData: ITabData[];
+ className?: string;
+ navClass?: string;
+ startingTab?: number;
+}
+
+interface ITabData {
+ label: string;
+ component: ReactNode;
+}
+
+export const TabNav = ({
+ tabData,
+ className = '',
+ navClass = '',
+ startingTab = 0,
+}: ITabNavProps) => {
+ const { classes: styles } = useStyles();
+ const [activeTab, setActiveTab] = useState(startingTab);
+
+ const renderTabs = () =>
+ tabData.map((tab, index) => (
+
+ ));
+
+ const renderTabPanels = () =>
+ tabData.map((tab, index) => (
+
+ {tab.component}
+
+ ));
+
+ return (
+ <>
+
+ {
+ setActiveTab(tabId);
+ }}
+ indicatorColor="primary"
+ textColor="primary"
+ centered
+ >
+ {renderTabs()}
+
+
+ {renderTabPanels()}
+ >
+ );
+};
diff --git a/frontend/src/component/common/TabNav/TabPanel/TabPanel.tsx b/frontend/src/component/common/TabNav/TabPanel/TabPanel.tsx
new file mode 100644
index 0000000000..3c2fc65a39
--- /dev/null
+++ b/frontend/src/component/common/TabNav/TabPanel/TabPanel.tsx
@@ -0,0 +1,18 @@
+import React, { ReactNode } from 'react';
+
+interface ITabPanelProps {
+ value: number;
+ index: number;
+ children: ReactNode;
+}
+
+export const TabPanel = ({ children, value, index }: ITabPanelProps) => (
+
+ {value === index && children}
+
+);
diff --git a/frontend/src/component/common/Table/SearchHighlightContext/SearchHighlightContext.tsx b/frontend/src/component/common/Table/SearchHighlightContext/SearchHighlightContext.tsx
new file mode 100644
index 0000000000..2a2e34a58c
--- /dev/null
+++ b/frontend/src/component/common/Table/SearchHighlightContext/SearchHighlightContext.tsx
@@ -0,0 +1,9 @@
+import { createContext, useContext } from 'react';
+
+const SearchHighlightContext = createContext('');
+
+export const SearchHighlightProvider = SearchHighlightContext.Provider;
+
+export const useSearchHighlightContext = (): { searchQuery: string } => {
+ return { searchQuery: useContext(SearchHighlightContext) };
+};
diff --git a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.styles.ts b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.styles.ts
new file mode 100644
index 0000000000..3a7ffa4823
--- /dev/null
+++ b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.styles.ts
@@ -0,0 +1,99 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ header: {
+ position: 'relative',
+ fontWeight: theme.fontWeight.medium,
+ },
+ flex: {
+ justifyContent: 'stretch',
+ alignItems: 'center',
+ display: 'flex',
+ flexShrink: 0,
+ '& > *': {
+ flexGrow: 1,
+ },
+ },
+ flexGrow: {
+ flexGrow: 1,
+ },
+ sortable: {
+ padding: 0,
+ '&:hover, &:focus': {
+ backgroundColor: theme.palette.tableHeaderHover,
+ '& svg': {
+ color: 'inherit',
+ },
+ },
+ },
+ sortButton: {
+ all: 'unset',
+ whiteSpace: 'nowrap',
+ width: '100%',
+ position: 'relative',
+ zIndex: 1,
+ ':hover, :focus, &:focus-visible, &:active': {
+ outline: 'revert',
+ '.hover-only': {
+ display: 'inline-block',
+ },
+ },
+ display: 'flex',
+ boxSizing: 'inherit',
+ cursor: 'pointer',
+ },
+ sortedButton: {
+ fontWeight: theme.fontWeight.bold,
+ },
+ label: {
+ display: 'flex',
+ flexDirection: 'column',
+ flexShrink: 1,
+ minWidth: 0,
+ '::after': {
+ fontWeight: 'bold',
+ display: 'inline-block',
+ height: 0,
+ content: 'attr(data-text)',
+ visibility: 'hidden',
+ overflow: 'hidden',
+ },
+ },
+ alignLeft: {
+ justifyContent: 'flex-start',
+ textAlign: 'left',
+ },
+ alignRight: {
+ justifyContent: 'flex-end',
+ textAlign: 'right',
+ },
+ alignCenter: {
+ justifyContent: 'center',
+ textAlign: 'center',
+ },
+ hiddenMeasurementLayer: {
+ padding: theme.spacing(2),
+ visibility: 'hidden',
+ display: 'flex',
+ alignItems: 'center',
+ width: '100%',
+ },
+ visibleAbsoluteLayer: {
+ padding: theme.spacing(2),
+ position: 'absolute',
+ display: 'flex',
+ alignItems: 'center',
+ width: '100%',
+ height: '100%',
+ '.hover-only': {
+ display: 'none',
+ },
+ '& > span': {
+ minWidth: 0,
+ whiteSpace: 'nowrap',
+ textOverflow: 'ellipsis',
+ overflowX: 'hidden',
+ overflowY: 'visible',
+ },
+ },
+}));
diff --git a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.tsx b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.tsx
new file mode 100644
index 0000000000..bef033443d
--- /dev/null
+++ b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/CellSortable.tsx
@@ -0,0 +1,161 @@
+import {
+ FC,
+ MouseEventHandler,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { TableCell, Tooltip } from '@mui/material';
+import classnames from 'classnames';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { useStyles } from './CellSortable.styles';
+import { AnnouncerContext } from 'component/common/Announcer/AnnouncerContext/AnnouncerContext';
+import { SortArrow } from './SortArrow/SortArrow';
+
+interface ICellSortableProps {
+ isSortable?: boolean;
+ isSorted?: boolean;
+ isDescending?: boolean;
+ ariaTitle?: string;
+ width?: number | string;
+ minWidth?: number | string;
+ maxWidth?: number | string;
+ align?: 'left' | 'center' | 'right';
+ isFlex?: boolean;
+ isFlexGrow?: boolean;
+ onClick?: MouseEventHandler;
+}
+
+export const CellSortable: FC = ({
+ children,
+ isSortable = true,
+ isSorted = false,
+ isDescending,
+ width,
+ minWidth,
+ maxWidth,
+ align,
+ ariaTitle,
+ isFlex,
+ isFlexGrow,
+ onClick = () => {},
+}) => {
+ const { setAnnouncement } = useContext(AnnouncerContext);
+ const [title, setTitle] = useState('');
+ const ref = useRef(null);
+ const { classes: styles } = useStyles();
+
+ const ariaSort = isSorted
+ ? isDescending
+ ? 'descending'
+ : 'ascending'
+ : undefined;
+
+ const onSortClick: MouseEventHandler = event => {
+ onClick(event);
+ setAnnouncement(
+ `Sorted${ariaTitle ? ` by ${ariaTitle} ` : ''}, ${
+ isDescending ? 'ascending' : 'descending'
+ }`
+ );
+ };
+
+ const alignClass = useMemo(() => {
+ switch (align) {
+ case 'left':
+ return styles.alignLeft;
+ case 'center':
+ return styles.alignCenter;
+ case 'right':
+ return styles.alignRight;
+ default:
+ return undefined;
+ }
+ }, [align, styles]);
+
+ useEffect(() => {
+ const updateTitle = () => {
+ const newTitle =
+ ariaTitle &&
+ ref.current &&
+ ref?.current?.offsetWidth < ref?.current?.scrollWidth
+ ? `${children}`
+ : '';
+
+ if (newTitle !== title) {
+ setTitle(newTitle);
+ }
+ };
+
+ updateTitle();
+ }, [setTitle, ariaTitle]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return (
+
+
+
+
+ }
+ elseShow={{children}
}
+ />
+
+ );
+};
diff --git a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.styles.ts b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.styles.ts
new file mode 100644
index 0000000000..2cfd400e7b
--- /dev/null
+++ b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.styles.ts
@@ -0,0 +1,14 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ icon: {
+ marginLeft: theme.spacing(0.25),
+ marginRight: theme.spacing(-0.5),
+ color: theme.palette.grey[700],
+ fontSize: theme.fontSizes.mainHeader,
+ verticalAlign: 'middle',
+ },
+ sorted: {
+ color: theme.palette.grey[900],
+ },
+}));
diff --git a/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.tsx b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.tsx
new file mode 100644
index 0000000000..5ddf22dd74
--- /dev/null
+++ b/frontend/src/component/common/Table/SortableTableHeader/CellSortable/SortArrow/SortArrow.tsx
@@ -0,0 +1,60 @@
+import { VFC } from 'react';
+import {
+ KeyboardArrowDown,
+ KeyboardArrowUp,
+ UnfoldMoreOutlined,
+} from '@mui/icons-material';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { useStyles } from './SortArrow.styles';
+import classnames from 'classnames';
+
+interface ISortArrowProps {
+ isSorted?: boolean;
+ isDesc?: boolean;
+ className?: string;
+}
+
+export const SortArrow: VFC = ({
+ isSorted: sorted,
+ isDesc: desc = false,
+ className,
+}) => {
+ const { classes: styles } = useStyles();
+
+ return (
+
+ }
+ elseShow={
+
+ }
+ />
+ }
+ elseShow={
+
+ }
+ />
+ );
+};
diff --git a/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.styles.ts b/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.styles.ts
new file mode 100644
index 0000000000..e1904e1818
--- /dev/null
+++ b/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.styles.ts
@@ -0,0 +1,20 @@
+import { makeStyles } from 'tss-react/mui';
+
+export const useStyles = makeStyles()(theme => ({
+ tableHeader: {
+ '& > th': {
+ height: theme.shape.tableRowHeightCompact,
+ border: 0,
+ backgroundColor: theme.palette.tableHeaderBackground,
+ color: theme.palette.tableHeaderColor,
+ '&:first-of-type': {
+ borderTopLeftRadius: theme.shape.borderRadiusMedium,
+ borderBottomLeftRadius: theme.shape.borderRadiusMedium,
+ },
+ '&:last-of-type': {
+ borderTopRightRadius: theme.shape.borderRadiusMedium,
+ borderBottomRightRadius: theme.shape.borderRadiusMedium,
+ },
+ },
+ },
+}));
diff --git a/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.tsx b/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.tsx
new file mode 100644
index 0000000000..9eb020fda6
--- /dev/null
+++ b/frontend/src/component/common/Table/SortableTableHeader/SortableTableHeader.tsx
@@ -0,0 +1,61 @@
+import { VFC } from 'react';
+import { TableHead, TableRow } from '@mui/material';
+import { HeaderGroup } from 'react-table';
+import { useStyles } from './SortableTableHeader.styles';
+import { CellSortable } from './CellSortable/CellSortable';
+
+interface ISortableTableHeaderProps {
+ headerGroups: HeaderGroup