Compare commits

..

107 Commits

Author SHA1 Message Date
Szilárd Dóró
7e113bfb1f Merge pull request #1253 from nhost/changeset-release/main
chore: update versions
2022-11-30 14:11:45 +01:00
github-actions[bot]
f1d358d77c chore: update versions 2022-11-30 11:16:45 +00:00
Szilárd Dóró
d558ef9ecf Merge pull request #1249 from nhost/chore/dashboard-cleanup
chore(dashboard): cleanup unused files
2022-11-30 12:08:39 +01:00
Szilárd Dóró
75c7ba7f12 Merge pull request #1252 from nhost/changeset-release/main
chore: update versions
2022-11-30 08:29:25 +01:00
github-actions[bot]
d62dfe19a2 chore: update versions 2022-11-30 07:28:00 +00:00
Szilárd Dóró
0dbef188b1 Merge pull request #1247 from nhost/chore/sidebar-menu-item-height
chore(dashboard): update settings sidebar menu item density
2022-11-30 08:26:39 +01:00
Szilárd Dóró
8857314e22 Merge pull request #1237 from nhost/changeset-release/main
chore: update versions
2022-11-29 20:57:22 +01:00
github-actions[bot]
85f1c4a98e chore: update versions 2022-11-29 19:33:07 +00:00
Pilou
efa6b5755d Merge pull request #1248 from nhost/chore/remove-sort-lint-rule
chore: remove eslint plugin simple-import-sort
2022-11-29 20:31:24 +01:00
Szilárd Dóró
44f13f6240 chore(dashboard): cleanup unused files 2022-11-29 20:29:25 +01:00
Pierre-Louis Mercereau
2b19416787 chore: remove eslint plugin simple-import-sort 2022-11-29 20:28:13 +01:00
Szilárd Dóró
e01cb2ed49 chore(dashboard): add changeset 2022-11-29 17:18:48 +01:00
Szilárd Dóró
388eef041f chore(dashboard): change settings sidebar menu item density 2022-11-29 17:18:09 +01:00
Szilárd Dóró
4e5d43f300 Merge pull request #1245 from nhost/chore/settings-roles-and-permissions-refactor
chore(dashboard): refactor Roles and Permissions settings sections
2022-11-29 17:00:12 +01:00
Szilárd Dóró
db342f453e chore(dashboard): add changesets 2022-11-29 16:15:45 +01:00
Szilárd Dóró
54386a3b56 chore(dashboard): simplify env var dialog props 2022-11-29 16:14:20 +01:00
Pilou
ff40b99f84 Merge pull request #1242 from nhost/fix/set-access-token
fix: 🐛 Distribute the access token to all the sub-clients
2022-11-29 16:08:00 +01:00
Szilárd Dóró
33f8f1d78a Merge remote-tracking branch 'origin/main' into chore/settings-roles-and-permissions-refactor 2022-11-29 16:07:19 +01:00
Szilárd Dóró
c50fe47ab4 Merge pull request #1215 from nhost/feat/settings-environment-variables
feat(dashboard): add Environment Variables page
2022-11-29 16:04:09 +01:00
Pilou
0580f832c8 Merge pull request #1239 from nhost/chore/version-bumps
chore: 🤖 bump to axios 1.2.0
2022-11-29 15:59:38 +01:00
Szilárd Dóró
7d1eb099c0 fix(dashboard): correct system environment variables 2022-11-29 15:53:11 +01:00
Pierre-Louis Mercereau
e15322296b chore: lint 2022-11-29 15:52:25 +01:00
Pierre-Louis Mercereau
91a2bf905b refactor: simplify 2022-11-29 15:37:31 +01:00
Szilárd Dóró
0f9393fe27 fix(dashboard): change system env vars layout 2022-11-29 15:10:37 +01:00
Szilárd Dóró
aebb822549 feat(dashboard): extend system variables 2022-11-29 15:05:58 +01:00
Pierre-Louis Mercereau
1e2be6fadf Merge branch 'main' into chore/version-bumps 2022-11-29 13:41:00 +01:00
Pilou
aafbf5173d Merge pull request #1240 from nhost/test/nhost-js
test: 💍 `nhost.graphql.request` as an authenticated user
2022-11-29 13:00:46 +01:00
Pierre-Louis Mercereau
01e13e2f8c chore: 🤖 lint 2022-11-29 12:52:34 +01:00
Pierre-Louis Mercereau
4364647501 fix: 🐛 accept any encoding 2022-11-29 12:16:35 +01:00
Pierre-Louis Mercereau
ef117c284e fix: 🐛 Distribute the access token to all the sub-clients 2022-11-29 11:49:59 +01:00
Szilárd Dóró
3f919c0a80 chore(dashboard): refactor system variable layout 2022-11-29 11:30:31 +01:00
Pierre-Louis Mercereau
49e447e7b7 test: 💍 nhost.graphql.request as an authenticated user 2022-11-29 11:04:56 +01:00
Pierre-Louis Mercereau
66b4f3d0be chore: 🤖 bump to axios 1.2.0 2022-11-29 10:29:51 +01:00
Szilárd Dóró
aa7fdafe8b chore(dashboard): split Permission Variable dialog 2022-11-28 21:45:15 +01:00
Pilou
7d6de3b289 Merge pull request #1234 from nhost/chore/bump-hasura-auth
chore: 🤖 bump hasura-auth version
2022-11-28 19:51:10 +01:00
Pilou
57e41f77a9 Merge pull request #1238 from nhost/ci/turbo-team
ci: hardcode turbo team
2022-11-28 19:36:48 +01:00
Pierre-Louis Mercereau
f5c2a0ef4f ci: hardcode turbo team 2022-11-28 18:38:03 +01:00
Szilárd Dóró
d52bc8cca5 chore(dashboard): refactor role delete and default 2022-11-28 16:58:28 +01:00
Pilou
04a3e4c965 Merge pull request #1236 from nhost/refactor/state-snapshots
refactor: use state snapshots
2022-11-28 16:52:27 +01:00
Szilárd Dóró
853c0c5775 chore(dashboard): split RoleForm into multiple forms 2022-11-28 16:44:02 +01:00
Pierre-Louis Mercereau
2e6923dc73 refactor: use state snapshots 2022-11-28 16:41:53 +01:00
Pierre-Louis Mercereau
7d6d70d0c7 chore: 🤖 bump hasura-auth version 2022-11-28 15:59:04 +01:00
Szilárd Dóró
7a2100cc17 fix(dashboard): do not try to autofocus a disable input 2022-11-28 15:19:42 +01:00
Szilárd Dóró
5d55f3fa60 chore(dashboard): remove async form loading 2022-11-28 15:13:16 +01:00
Szilárd Dóró
8b0c44a93c chore(dashboard): rename env var forms
- chore(dashboard): do not convert env vars to uppercase by default
2022-11-28 14:59:27 +01:00
Szilárd Dóró
e0cc7cce0a chore(dashboard): update env var dialog subtitle 2022-11-28 13:40:25 +01:00
Szilárd Dóró
6e7d5e0dd4 fix(dashboard): change env var name placeholder 2022-11-28 13:19:17 +01:00
Szilárd Dóró
54c143ebf6 fix(dashboard): allow lowercase letters, convert variable to uppercase 2022-11-28 13:18:16 +01:00
Szilárd Dóró
8b9fa0b150 feat(dashboard): env var validation 2022-11-28 13:05:42 +01:00
Szilárd Dóró
c3bb79e1dd chore(dashboard): refactor env var forms 2022-11-28 11:15:38 +01:00
Szilárd Dóró
128d21e4ec Merge branch 'main' into feat/settings-environment-variables 2022-11-28 09:21:56 +01:00
Szilárd Dóró
40e503c356 Merge pull request #1227 from nhost/fix/vercel-deployment-token
fix(changesets): add Vercel deployment token to CI
2022-11-28 08:28:33 +01:00
Szilárd Dóró
d007e0ade8 chore(changesets): do not create dedicated env var for deploy token 2022-11-28 08:26:11 +01:00
Pilou
fa32513ba7 Merge pull request #1231 from nhost/docs/docker-compose-dashboard
docs: run hasura console from the cli to run the dashboard
2022-11-27 22:50:14 +01:00
Pierre-Louis Mercereau
8893d9e010 docs: run hasura console from the cli to run the dashboard 2022-11-27 21:37:37 +01:00
Szilárd Dóró
81d2fd865c fix(changesets): add Vercel deployment token to CI 2022-11-27 19:51:07 +01:00
Szilárd Dóró
fe3c462099 Merge pull request #1217 from nhost/fix/vercel-pipeline
fix(changesets): add missing `pnpm` command, pre-build project
2022-11-26 11:49:08 +01:00
Szilárd Dóró
f8b082cb02 chore(changesets): split Vercel CLI command 2022-11-25 16:59:56 +01:00
Szilárd Dóró
0c748e6ee6 feat(dashboard): add env var management 2022-11-25 16:57:22 +01:00
Pilou
e2c4ca85b3 Merge pull request #1219 from nhost/docs/docker-compose-dashboard
docs(docker-compose): add the dashboard to the docker-compose example
2022-11-25 16:38:54 +01:00
Szilárd Dóró
0165b998c2 chore(changesets): create separate step for Vercel 2022-11-25 16:33:09 +01:00
Pierre-Louis Mercereau
5d970cc229 feat(docs): add the dashboard to the docker-compose example 2022-11-25 16:14:46 +01:00
Szilárd Dóró
7167170663 feat(dashboard): add env var management dialog 2022-11-25 15:47:34 +01:00
Szilárd Dóró
0f77de2dd0 feat(dashboard): add menu to env vars 2022-11-25 15:13:54 +01:00
Szilárd Dóró
6ae91e48d1 feat(dashboard): list env vars 2022-11-25 14:52:47 +01:00
Szilárd Dóró
69db1594cc fix(changesets): add missing pnpm command, pre-build project 2022-11-25 14:36:54 +01:00
Szilárd Dóró
158cf0da49 Merge pull request #1216 from nhost/changeset-release/main
chore: update versions
2022-11-25 14:12:10 +01:00
github-actions[bot]
7992fc3baa chore: update versions 2022-11-25 13:09:54 +00:00
Szilárd Dóró
85d9596956 Merge branch 'main' into feat/settings-environment-variables 2022-11-25 14:08:46 +01:00
Szilárd Dóró
16d383516e Merge pull request #1206 from nhost/feat/settings-roles-and-permissions
feat(dashboard): Roles and Permissions
2022-11-25 14:08:13 +01:00
Szilárd Dóró
2ca193ccf3 chore(dashboard): improve inline secrets 2022-11-25 14:07:44 +01:00
Szilárd Dóró
ab8e12003d feat(dashboard): show JWT secret 2022-11-25 14:02:48 +01:00
Szilárd Dóró
29cdf6b125 Merge pull request #1214 from nhost/elitan-patch-3
Update serverless-functions.mdx
2022-11-25 13:51:24 +01:00
Szilárd Dóró
41cc3dc5d0 feat(dashboard): settings page for Environment Variables 2022-11-25 13:50:08 +01:00
Johan Eliasson
6b67c9996a Update serverless-functions.mdx 2022-11-25 12:50:39 +01:00
Szilárd Dóró
23274dee41 chore(docs): add changeset 2022-11-25 11:59:42 +01:00
Szilárd Dóró
a5b55c2667 chore(docs): update permission variables image 2022-11-25 11:58:46 +01:00
Szilárd Dóró
1263676eb3 fix(dashboard): permission variable validation 2022-11-25 11:49:46 +01:00
Szilárd Dóró
b1b647ad96 fix(dashboard): reset default role on role removal 2022-11-25 11:34:51 +01:00
Szilárd Dóró
21bbaf5e95 feat(dashboard): add docs link to roles section 2022-11-25 10:56:14 +01:00
Szilárd Dóró
eef9c91403 feat(dashboard): add support for default roles
- remove unnecessary helper labels from roles and permissions
2022-11-25 10:55:20 +01:00
Johan Eliasson
1742cb444d Merge pull request #1212 from nhost/szilarddoro-patch-1
chore(docs): add WorkOS to Authentication page
2022-11-25 10:49:45 +01:00
Szilárd Dóró
c4f374d7f3 Merge remote-tracking branch 'origin/main' into feat/settings-roles-and-permissions 2022-11-25 09:36:57 +01:00
Szilárd Dóró
369ec13070 chore(docs): add WorkOS to Authentication page 2022-11-25 09:36:21 +01:00
Szilárd Dóró
101129eef2 Update dashboard/src/components/settings/permissions/PermissionVariableSettings/PermissionVariableSettings.tsx
Co-authored-by: Nuno Pato <nunopato@gmail.com>
2022-11-25 09:33:33 +01:00
Szilárd Dóró
228fda0364 Merge pull request #1210 from nhost/fix/vercel-deployment 2022-11-25 09:05:57 +01:00
Szilárd Dóró
74085c67a2 fix(dashboard): correct production deployment 2022-11-24 22:31:11 +01:00
Szilárd Dóró
a273725419 Merge pull request #1209 from nhost/fix/precommit-hook
fix(docgen): prevent docgen from breaking the pre-commit hook
2022-11-24 21:07:40 +01:00
Pierre-Louis Mercereau
c5240f8d74 docs: ✏️ remove irrelevant workaround to fixed docgen git hook 2022-11-24 20:53:17 +01:00
Szilárd Dóró
4490068257 chore(docgen): copy binary to node_modules
execute `pnpm i` to copy the `docgen` binary to every package
2022-11-24 20:41:59 +01:00
Szilárd Dóró
3d151c448c chore(precommit): extend precommit hook with docgen build 2022-11-24 19:42:23 +01:00
Szilárd Dóró
fdd417ed25 feat(dashboard): add router cancellation to permission form
- chore(dashboard): update terminology
2022-11-24 18:03:04 +01:00
Szilárd Dóró
4416ceb9cf chore(dashboard): cleanup unused files 2022-11-24 17:00:35 +01:00
Szilárd Dóró
4762ebf61e fix(dashboard): correct original value for role editing 2022-11-24 16:54:01 +01:00
Szilárd Dóró
73e28b5831 feat(dashboard): simplify role management form 2022-11-24 16:52:26 +01:00
Szilárd Dóró
2a7dc5060f feat(dashboard): add support for permission variable management 2022-11-24 16:25:14 +01:00
Szilárd Dóró
9b8ede40a9 feat(dashboard): prevent invalid characters for variables 2022-11-24 15:50:14 +01:00
Szilárd Dóró
f005c20d99 feat(dashboard): add modals for permission variable management 2022-11-24 15:23:30 +01:00
Szilárd Dóró
4adfd613b6 feat(dashboard): support variable listing
- chore(dashboard): rename RolesSettings to RoleSettings
2022-11-24 15:01:51 +01:00
Szilárd Dóró
b6da82c8e3 fix(dashboard): wrong tooltip appearance 2022-11-24 14:25:49 +01:00
Szilárd Dóró
816456edc4 fix(dashboard): remove unnecessary manual focus 2022-11-24 14:24:07 +01:00
Szilárd Dóró
deaf0e86d4 fix(dashboard): changed role form's logic 2022-11-24 14:16:02 +01:00
Szilárd Dóró
23f8206f18 feat(dashboard): add support for role deletion 2022-11-24 12:46:41 +01:00
Szilárd Dóró
9dde4d7988 feat(dashboard): add support for role editing 2022-11-24 12:31:48 +01:00
Szilárd Dóró
26385b9cf9 fix(dashboard): fix ESLint working directories 2022-11-24 12:17:50 +01:00
Szilárd Dóró
6d318206ef feat(dashboard): finalize Create Role modal
- fix(dashboard): lint errors
- chore(dashboard): reduce max allowed linter warnings
2022-11-24 12:11:41 +01:00
Szilárd Dóró
4d727b78a1 feat(dashboard): updated Roles and Permissions page
- created modal for role creation
2022-11-24 11:46:56 +01:00
190 changed files with 3726 additions and 4382 deletions

View File

@@ -13,7 +13,7 @@ on:
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TEAM: nhost
DASHBOARD_PACKAGE: '@nhost/dashboard'
jobs:
@@ -31,8 +31,8 @@ jobs:
- name: Install Node and dependencies
uses: ./.github/actions/install-dependencies
with:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
TURBO_TEAM: ${{ env.TURBO_TEAM }}
- name: Create PR or Publish release
id: changesets
uses: changesets/action@v1
@@ -61,8 +61,8 @@ jobs:
uses: ./.github/workflows/dashboard.yaml
secrets: inherit
publish:
name: Publish
publish-docker:
name: Publish to Docker Hub
runs-on: ubuntu-latest
needs:
- test
@@ -112,8 +112,6 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
push: true
- name: Trigger a Vercel deployment
run: curl -X POST -d {} https://api.vercel.com/v1/integrations/deploy/${{ secrets.DASHBOARD_VERCEL_PROJECT_ID }}/${{ secrets.DASHBOARD_VERCEL_WEBHOOK_ID }}
- name: Create GitHub Release
uses: taiki-e/create-gh-release-action@v1
with:
@@ -124,3 +122,29 @@ jobs:
- name: Remove tag on failure
if: failure()
run: git push --delete origin ${{ env.DASHBOARD_PACKAGE }}@${{ needs.version.outputs.dashboardVersion }}
publish-vercel:
name: Publish to Vercel
runs-on: ubuntu-latest
needs:
- publish-docker
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install Node and dependencies
uses: ./.github/actions/install-dependencies
with:
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
TURBO_TEAM: ${{ env.TURBO_TEAM }}
- name: Setup Vercel CLI
run: pnpm add -g vercel
- name: Trigger a Vercel deployment
env:
VERCEL_ORG_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_VERCEL_PROJECT_ID }}
run: |
vercel pull --environment=production --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
vercel build --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
vercel deploy --prebuilt --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}

View File

@@ -11,7 +11,7 @@ on:
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TEAM: nhost
jobs:
build:
name: Build

View File

@@ -21,7 +21,7 @@ on:
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TEAM: nhost
jobs:
build:
name: Build @nhost packages

4
.gitignore vendored
View File

@@ -48,6 +48,10 @@ todo.md
.netlify
.monorepo-example
# Local Vercel folder
.vercel
# Next.js build output
.next
# TypeDoc output

View File

@@ -1,5 +1,6 @@
{
"editor.codeActionsOnSave": {
"source.organizeImports": true
}
}
},
"eslint.workingDirectories": ["./dashboard"]
}

View File

@@ -99,11 +99,6 @@ You can take a look at the changeset documentation: [How to add a changeset](htt
You'll notice that `git commit` takes a few seconds to run. We set a commit hook that scans the changes in the code, automatically generates documentation from the inline [TSDoc](https://tsdoc.org/) annotations, and adds these generated documentation files to the commit. They automatically update the [reference documentation](https://docs.nhost.io/reference).
The document generation script that is run in the pre-commit hook requires to be built first. You may need to run the following command before the commit:
```sh
pnpm run build
```
<!-- ## Good practices
- lint

View File

@@ -21,7 +21,7 @@ module.exports = {
'tests/**/*.ts',
'tests/**/*.d.ts'
],
plugins: ['@typescript-eslint', 'simple-import-sort', 'cypress'],
plugins: ['@typescript-eslint', 'cypress'],
extends: ['plugin:cypress/recommended'],
parserOptions: {
ecmaVersion: 2020,
@@ -30,31 +30,6 @@ module.exports = {
rules: {
'react/prop-types': 'off',
'no-use-before-define': 'off',
'simple-import-sort/exports': 'error',
'simple-import-sort/imports': [
'error',
{
groups: [
// Node.js builtins. You could also generate this regex if you use a `.js` config.
// For example: `^(${require("module").builtinModules.join("|")})(/|$)`
[
'^(assert|buffer|child_process|cluster|console|constants|crypto|dgram|dns|domain|events|fs|http|https|module|net|os|path|punycode|querystring|readline|repl|stream|string_decoder|sys|timers|tls|tty|url|util|vm|zlib|freelist|v8|process|async_hooks|http2|perf_hooks)(/.*|$)'
],
// Packages
['^\\w'],
// Internal packages.
['^(@|config/)(/*|$)'],
// Side effect imports.
['^\\u0000'],
// Parent imports. Put `..` last.
['^\\.\\.(?!/?$)', '^\\.\\./?$'],
// Other relative imports. Put same-folder imports and `.` last.
['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'],
// Style imports.
['^.+\\.s?css$']
]
}
],
'import/no-anonymous-default-export': [
'error',
{

View File

@@ -1,3 +1,4 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
extends: ['next', 'airbnb', 'airbnb-typescript', 'airbnb/hooks', 'prettier'],

View File

@@ -1,5 +1,42 @@
# @nhost/dashboard
## 0.7.2
### Patch Changes
- 44f13f62: chore(dashboard): cleanup unused files
## 0.7.1
### Patch Changes
- e01cb2ed: chore(dashboard): change settings sidebar menu item density
## 0.7.0
### Minor Changes
- db342f45: chore(dashboard): refactor Roles and Permissions settings sections
- 8b9fa0b1: feat(dashboard): add Environment Variables page
### Patch Changes
- Updated dependencies [66b4f3d0]
- Updated dependencies [2e6923dc]
- Updated dependencies [ef117c28]
- Updated dependencies [aebb8225]
- @nhost/core@0.9.4
- @nhost/nhost-js@1.6.2
- @nhost/nextjs@1.9.1
- @nhost/react@0.15.1
- @nhost/react-apollo@4.9.1
## 0.6.0
### Minor Changes
- eef9c914: feat(dashboard): add Roles and Permissions page
## 0.5.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/dashboard",
"version": "0.5.0",
"version": "0.7.2",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -8,7 +8,7 @@
"build": "next build --no-lint",
"analyze": "ANALYZE=true pnpm build --no-lint",
"start": "next start",
"lint": "next lint --max-warnings 6",
"lint": "next lint --max-warnings 3",
"test": "vitest",
"codegen": "graphql-codegen --config graphql.config.yaml --errors-only",
"nhost:dev": "nhost dev -d",
@@ -34,11 +34,11 @@
"@mui/material": "^5.10.14",
"@mui/system": "^5.10.14",
"@mui/x-date-pickers": "^5.0.8",
"@nhost/core": "^0.9.3",
"@nhost/nextjs": "^1.9.0",
"@nhost/nhost-js": "^1.6.1",
"@nhost/react": "^0.15.0",
"@nhost/react-apollo": "^4.9.0",
"@nhost/core": "^0.9.4",
"@nhost/nextjs": "^1.9.1",
"@nhost/nhost-js": "^1.6.2",
"@nhost/react": "^0.15.1",
"@nhost/react-apollo": "^4.9.1",
"@segment/snippet": "^4.15.3",
"@tailwindcss/forms": "^0.5.3",
"@tanstack/react-query": "^4.16.1",

View File

@@ -1,107 +0,0 @@
import { useWorkspaceContext } from '@/context/workspace-context';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import { discordAnnounce } from '@/utils/discordAnnounce';
import { inputErrorMessages } from '@/utils/getErrorMessage';
import { slugifyString } from '@/utils/helpers';
import { triggerToast } from '@/utils/toast';
import { updateOwnCache } from '@/utils/updateOwnCache';
import { useUpdateApplicationMutation } from '@/utils/__generated__/graphql';
import { useRouter } from 'next/router';
import React, { useState } from 'react';
export function ChangeApplicationName({ close }: any) {
const [updateAppName, { client }] = useUpdateApplicationMutation({});
const { workspaceContext } = useWorkspaceContext();
const [name, setName] = useState(workspaceContext.appName);
const [applicationError, setApplicationError] = useState<any>('');
const { currentWorkspace, currentApplication } =
useCurrentWorkspaceAndApplication();
const router = useRouter();
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const slug = slugifyString(name);
if (slug.length < 4 || slug.length > 32) {
setApplicationError('Slug should be within 4 and 32 characters.');
return;
}
if (!inputErrorMessages(name, setName, setApplicationError, 'project')) {
return;
}
try {
await updateAppName({
variables: {
appId: currentApplication.id,
app: {
name,
slug,
},
},
});
triggerToast('Project name changed');
} catch (error) {
await discordAnnounce(
`Error trying to delete project: ${currentApplication.name}`,
);
}
await updateOwnCache(client);
await router.push(`/${currentWorkspace.slug}/${slug}`);
}
return (
<div className="w-modal px-6 py-6 text-left">
<div className="flex flex-col">
<Text variant="h3" component="h2">
Change Project Name
</Text>
<form onSubmit={handleSubmit}>
<div className="mt-4 grid grid-flow-row gap-2">
<Input
label="New Project Name"
id="projectName"
value={name}
onChange={(e) => {
setName(e.target.value);
setApplicationError('');
}}
fullWidth
autoFocus
helperText={`https://app.nhost.io/${
currentWorkspace.slug
}/${slugifyString(name)}`}
/>
{applicationError && (
<Alert severity="error">{applicationError}</Alert>
)}
</div>
<div className="mt-4 grid grid-flow-row gap-2">
<Button type="submit" disabled={applicationError}>
Save
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
onClick={close}
>
Close
</Button>
</div>
</form>
</div>
</div>
);
}
export default ChangeApplicationName;

View File

@@ -1,166 +0,0 @@
import { useWorkspaceContext } from '@/context/workspace-context';
import { Modal } from '@/ui/Modal';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import { triggerToast } from '@/utils/toast';
import type { EnvironmentVariableFragment } from '@/utils/__generated__/graphql';
import {
refetchGetEnvironmentVariablesWhereQuery,
useDeleteEnvironmentVariableMutation,
useUpdateEnvironmentVariableMutation,
} from '@/utils/__generated__/graphql';
import React, { useState } from 'react';
type EnvModalProps = {
show: boolean;
close: VoidFunction;
envVar: EnvironmentVariableFragment;
};
export default function EditEnvVarModal({
show,
close,
envVar,
}: EnvModalProps) {
const { workspaceContext } = useWorkspaceContext();
const { appId } = workspaceContext;
const [updateEnvVar, { loading: updateLoading }] =
useUpdateEnvironmentVariableMutation({
refetchQueries: [
refetchGetEnvironmentVariablesWhereQuery({
where: {
appId: {
_eq: appId,
},
},
}),
],
});
const [deleteEnvVar, { loading: deleteLoading }] =
useDeleteEnvironmentVariableMutation({
refetchQueries: [
refetchGetEnvironmentVariablesWhereQuery({
where: {
appId: {
_eq: appId,
},
},
}),
],
});
const [prodValue, setProdValue] = useState(envVar.prodValue || '');
const [devValue, setDevValue] = useState(envVar.devValue || '');
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
try {
await updateEnvVar({
variables: {
id: envVar.id,
environmentVariable: {
prodValue,
devValue,
},
},
});
} catch (error) {
close();
triggerToast('Error updating environment variable');
return;
}
triggerToast(`Environment variable ${envVar.name} updated successfully`);
close();
};
const handleDelete = async () => {
try {
await deleteEnvVar({
variables: {
id: envVar.id,
},
});
} catch (error) {
close();
triggerToast('Error deleting environment variable');
return;
}
triggerToast(`Environment variable ${envVar.name} removed successfully`);
close();
};
return (
<Modal showModal={show} close={close}>
<form onSubmit={handleSubmit}>
<div className="w-modal px-6 py-6 text-left">
<div className="grid grid-flow-row gap-1">
<Text variant="h3" component="h2">
{envVar.name}
</Text>
<Text variant="subtitle2">
The default value will be available in all environments, unless
you override it. All values are encrypted.
</Text>
<div className="my-2 grid grid-flow-row gap-2">
<Input
id="name"
label="Name"
autoFocus
disabled
autoComplete="off"
defaultValue={envVar.name}
fullWidth
hideEmptyHelperText
/>
<Input
id="prodValue"
label="Production Value"
fullWidth
placeholder="Enter a value"
value={prodValue}
onChange={(event) => setProdValue(event.target.value)}
hideEmptyHelperText
/>
<Input
id="devValue"
label="Development Value"
fullWidth
placeholder="Enter a value"
value={devValue}
onChange={(event) => setDevValue(event.target.value)}
hideEmptyHelperText
/>
</div>
<div className="grid grid-flow-row gap-2">
<Button type="submit" loading={updateLoading}>
Save
</Button>
<Button
variant="outlined"
color="error"
loading={deleteLoading}
onClick={handleDelete}
>
Delete
</Button>
<Button onClick={close} variant="outlined" color="secondary">
Close
</Button>
</div>
</div>
</div>
</form>
</Modal>
);
}

View File

@@ -1,327 +0,0 @@
import ErrorBoundaryFallback from '@/components/common/ErrorBoundaryFallback';
import TwilioIcon from '@/components/icons/TwilioIcon';
import {
useGetSmsSettingsQuery,
useUpdateAppMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Button, Input } from '@/ui';
import { Alert } from '@/ui/Alert';
import DelayedLoading from '@/ui/DelayedLoading';
import { Text } from '@/ui/Text';
import { showLoadingToast, triggerToast } from '@/utils/toast';
import { useApolloClient } from '@apollo/client';
import { useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import {
Controller,
FormProvider,
useForm,
useFormContext,
} from 'react-hook-form';
import toast from 'react-hot-toast';
export function EditSMSSettingsForm({
close,
isAlreadyEnabled,
}: {
close: () => void;
isAlreadyEnabled: boolean;
}) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const {
handleSubmit,
watch,
formState: { isSubmitting, errors },
} = useFormContext<EditSMSSettingsFormData>();
const { control } = useFormContext<EditSMSSettingsFormData>();
const [updateApp] = useUpdateAppMutation();
let toastId: string;
const client = useApolloClient();
const isNotCompleted =
!watch('accountSID') ||
!watch('authToken') ||
!watch('messagingServiceSID');
const handleEditSMSSettings = async (data: EditSMSSettingsFormData) => {
try {
toastId = showLoadingToast('Updating SMS settings...');
await updateApp({
variables: {
id: currentApplication.id,
app: {
authSmsTwilioAccountSid: data.accountSID,
authSmsTwilioAuthToken: data.authToken,
authSmsTwilioMessagingServiceId: data.messagingServiceSID,
authSmsPasswordlessEnabled: true,
},
},
});
await client.refetchQueries({ include: ['getSMSSettings'] });
toast.remove(toastId);
triggerToast('SMS settings updated successfully.');
close();
} catch (error) {
if (toastId) {
toast.remove(toastId);
}
throw error;
}
};
return (
<form
onSubmit={handleSubmit(handleEditSMSSettings)}
className="flex w-full flex-col pb-1"
autoComplete="off"
>
{errors &&
Object.entries(errors).map(([type, error]) => (
<Alert key={type} className="mb-4" severity="error">
{error.message}
</Alert>
))}
<div>
<div className="flex flex-row place-content-between border-t border-b px-2 py-2.5">
<div className="flex w-full flex-row">
<Text
color="greyscaleDark"
className="self-center font-medium"
size="normal"
>
Account SID
</Text>
</div>
<div className="flex w-full">
<Controller
name="accountSID"
control={control}
rules={{
required: true,
pattern: {
value: /^[a-zA-Z0-9-_]+$/,
message:
'The Account SID must contain only letters, hyphens, and numbers.',
},
}}
render={({ field }) => (
<Input
{...field}
id="accountSID"
placeholder="Account SID"
required
value={field.value || ''}
onChange={(value: string) => {
if (value && !/^[a-zA-Z0-9-_]+$/gi.test(value)) {
// prevent the user from entering invalid characters
return;
}
field.onChange(value);
}}
/>
)}
/>
</div>
</div>
<div className="flex flex-row place-content-between border-b px-2 py-2.5">
<div className="flex w-full flex-row">
<Text
color="greyscaleDark"
className="self-center font-medium"
size="normal"
>
Auth Token
</Text>
</div>
<div className="flex w-full">
<Controller
name="authToken"
control={control}
rules={{
required: true,
pattern: {
value: /^[a-zA-Z0-9-_]+$/,
message:
'The Auth Token must contain only letters, hyphens, and numbers.',
},
}}
render={({ field }) => (
<Input
{...field}
id="authToken"
placeholder="Auth Token"
required
value={field.value || ''}
onChange={(value: string) => {
if (value && !/^[a-zA-Z0-9-_/.]+$/gi.test(value)) {
return;
}
field.onChange(value);
}}
/>
)}
/>
</div>
</div>
<div className="flex flex-row place-content-between border-b px-2 py-2.5">
<div className="flex w-full flex-row">
<Text
color="greyscaleDark"
className="self-center font-medium"
size="normal"
>
Messaging Service SID
</Text>
</div>
<div className="flex w-full">
<Controller
name="messagingServiceSID"
control={control}
rules={{
required: true,
pattern: {
value: /^[+a-zA-Z0-9-_/.]+$/,
message:
'The Messaging Service SID must either be a valid phone number or contain only letters, hyphens, and numbers.',
},
}}
render={({ field }) => (
<Input
{...field}
id="messagingServiceSID"
required
placeholder="Messaging Service SID"
value={field.value || ''}
onChange={(value: string) => {
if (value && !/^[+a-zA-Z0-9-_/.]+$/gi.test(value)) {
return;
}
field.onChange(value);
}}
/>
)}
/>
</div>
</div>
</div>
<div className="flex flex-col">
<Button
variant="primary"
type="submit"
className="text-grayscaleDark mt-2 border text-sm+ font-normal"
loading={isSubmitting}
disabled={isSubmitting || isNotCompleted}
>
{isAlreadyEnabled ? 'Update SMS Settings' : 'Enable SMS'}
</Button>
</div>
</form>
);
}
export function EditSMSSettingsModal({
close,
isAlreadyEnabled,
}: {
close: () => void;
isAlreadyEnabled: boolean;
}) {
return (
<div className="w-modal px-6 py-4 text-left">
<div className="flex flex-col">
<div className="mx-auto mt-2.5">
<TwilioIcon className=" text-greyscaleDark" />
</div>
<Text
variant="subHeading"
color="greyscaleDark"
size="large"
className="mt-3 text-center"
>
Set up Twilio SMS Service
</Text>
<Text
variant="body"
color="greyscaleDark"
size="small"
className="mt-0.5 mb-6 text-center font-normal"
>
SMS messages are sent through Twilio. Create an account and a
messaging service at https://console.twilio.com.
</Text>
<div>
<ErrorBoundary fallbackRender={ErrorBoundaryFallback}>
<EditSMSSettingsForm
close={close}
isAlreadyEnabled={isAlreadyEnabled}
/>
</ErrorBoundary>
</div>
</div>
</div>
);
}
export interface EditSMSSettingsProps {
close: () => void;
}
export interface EditSMSSettingsFormData {
accountSID: string;
authToken: string;
messagingServiceSID: string;
}
export function EditSMSSettings({ close }: EditSMSSettingsProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const form = useForm<EditSMSSettingsFormData>({
reValidateMode: 'onSubmit',
defaultValues: {
accountSID: '',
authToken: '',
messagingServiceSID: '',
},
});
const { data, loading, error } = useGetSmsSettingsQuery({
variables: {
id: currentApplication.id,
},
});
useEffect(() => {
if (!data) {
return;
}
form.setValue('accountSID', data.app.authSmsTwilioAccountSid);
form.setValue('authToken', data.app.authSmsTwilioAuthToken);
form.setValue(
'messagingServiceSID',
data.app.authSmsTwilioMessagingServiceId,
);
}, [data, form]);
if (loading) {
return <DelayedLoading delay={500} />;
}
if (error) {
throw error;
}
return (
<FormProvider {...form}>
<EditSMSSettingsModal
close={close}
isAlreadyEnabled={data.app.authSmsPasswordlessEnabled}
/>
</FormProvider>
);
}

View File

@@ -1,134 +0,0 @@
import {
useGetSmsSettingsQuery,
useUpdateAppMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Text, Toggle } from '@/ui';
import DelayedLoading from '@/ui/DelayedLoading';
import { showLoadingToast, triggerToast } from '@/utils/toast';
import { useApolloClient } from '@apollo/client';
import clsx from 'clsx';
import Image from 'next/image';
import { useEffect, useState } from 'react';
import toast from 'react-hot-toast';
export function EnableSMSSignIn({ openSMSSettingsModal }: any) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const { data, loading, error } = useGetSmsSettingsQuery({
variables: {
id: currentApplication.id,
},
});
const [enableSMSLoginMethod, setEnableSMSLoginMethod] = useState(false);
const client = useApolloClient();
let toastId: string;
useEffect(() => {
if (!data) {
return;
}
setEnableSMSLoginMethod(data.app.authSmsPasswordlessEnabled);
}, [data]);
if (loading) {
return <DelayedLoading delay={500} />;
}
if (error) {
throw error;
}
const handleDisable = async () => {
try {
toastId = showLoadingToast('Disabling SMS login...');
await updateApp({
variables: {
id: currentApplication.id,
app: {
authSmsPasswordlessEnabled: false,
},
},
});
setEnableSMSLoginMethod(false);
await client.refetchQueries({ include: ['getSMSSettings'] });
toast.remove(toastId);
triggerToast('Passwordless SMS disabled.');
} catch (updateError) {
if (toastId) {
toast.remove(toastId);
}
throw updateError;
}
};
return (
<div className="mt-20">
<div className="mx-auto font-display">
<div className="flex flex-col place-content-between">
<div className="">
<div className="flex flex-row place-content-between">
<div className="relative flex flex-row">
<Image
src="/assets/SMS.svg"
width={24}
height={24}
alt="Phone Number (SMS)"
/>
<Text
variant="body"
size="large"
className="ml-2 font-medium"
color="greyscaleDark"
>
Phone Number (SMS)
</Text>
<div>
<button
type="button"
className={clsx(
'ml-2 align-bottom text-sm- font-medium text-blue transition-opacity duration-300',
!enableSMSLoginMethod && 'invisible opacity-0',
enableSMSLoginMethod && 'opacity-100',
)}
onClick={() => openSMSSettingsModal()}
>
Edit SMS settings
</button>
</div>
</div>
<div className="flex flex-row">
<Toggle
checked={enableSMSLoginMethod}
onChange={async () => {
if (enableSMSLoginMethod) {
await handleDisable();
} else {
openSMSSettingsModal();
}
}}
/>
</div>
</div>
<div className="mt-3 flex flex-row self-center align-middle">
<Text
variant="body"
size="normal"
color="greyscaleDark"
className="self-center"
>
Sign in users with Phone Number (SMS).
</Text>
</div>
</div>
</div>
</div>
</div>
);
}
export default EnableSMSSignIn;

View File

@@ -1,224 +0,0 @@
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { useSubmitState } from '@/hooks/useSubmitState';
import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import { triggerToast } from '@/utils/toast';
import {
refetchGetAppInjectedVariablesQuery,
useUpdateApplicationMutation,
} from '@/utils/__generated__/graphql';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
export interface EditCustomUserJWTTokenData {
customUserJWTToken: string;
}
export type JWTSecretModalState = 'SHOW' | 'EDIT';
export interface JWTSecretModalProps {
close: () => void;
data?: any;
jwtSecret: string;
initialModalState?: JWTSecretModalState;
}
export function EditJWTSecretModal({ close }: any) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { submitState, setSubmitState } = useSubmitState();
const [updateApplication] = useUpdateApplicationMutation({
refetchQueries: [
refetchGetAppInjectedVariablesQuery({ id: currentApplication.id }),
],
});
const {
handleSubmit,
control,
formState: { isSubmitting },
} = useForm<EditCustomUserJWTTokenData>({
reValidateMode: 'onSubmit',
defaultValues: {
customUserJWTToken: '',
},
});
const handleEditJWTSecret = async (data: EditCustomUserJWTTokenData) => {
setSubmitState({
error: null,
loading: false,
fieldsWithError: [],
});
try {
JSON.parse(data.customUserJWTToken);
} catch (error) {
setSubmitState({
error: new Error('The custom JWT token should be valid json.'),
loading: false,
fieldsWithError: [],
});
return;
}
try {
await updateApplication({
variables: {
appId: currentApplication.id,
app: {
hasuraGraphqlJwtSecret: data.customUserJWTToken,
},
},
});
triggerToast(
`Successfully added custom JWT token to ${currentApplication.name}.`,
);
close();
} catch (error) {
triggerToast(
`Error adding custom JWT token to ${currentApplication.name}`,
);
setSubmitState({ error, loading: false, fieldsWithError: [] });
}
};
return (
<form
className="w-modal px-6 py-4"
onSubmit={handleSubmit(handleEditJWTSecret)}
>
<div className="grid grid-flow-row gap-2">
<div className="grid grid-flow-row text-left">
<Text variant="h3" component="h2">
Add Custom JWT Secret
</Text>
<Text variant="subtitle2">
You can add your custom JWT key here. Hasura will use this key to
validate the identity of your users.
</Text>
</div>
{submitState.error && (
<Alert severity="error">{submitState.error.message}</Alert>
)}
<Controller
name="customUserJWTToken"
control={control}
rules={{
required: true,
}}
render={({ field }) => (
<Input
{...field}
placeholder="Paste your custom JWT token here..."
componentsProps={{
inputRoot: {
className: 'font-mono bg-header',
},
}}
aria-label="Custom JWT token"
type="text"
value={field.value}
onBlur={() =>
setSubmitState({
error: null,
loading: false,
fieldsWithError: [],
})
}
multiline
rows={6}
fullWidth
hideEmptyHelperText
/>
)}
/>
<div className="grid grid-flow-row gap-2">
<Button type="submit" loading={isSubmitting}>
Save Changes
</Button>
<Button variant="outlined" color="secondary" onClick={close}>
Cancel
</Button>
</div>
</div>
</form>
);
}
export function ShowJWTTokenModal({ JWTKey, editJWTSecret }: any) {
return (
<div className="w-modal px-6 py-4">
<div className="grid grid-flow-row gap-2">
<div className="grid grid-flow-row text-left">
<Text variant="h3" component="h2">
Auth JWT Secret
</Text>
<Text variant="subtitle2">
This is the key used for generating JWTs. It&apos;s HMAC-SHA-based
and the same as configured in Hasura.
</Text>
</div>
<div>
<Input
defaultValue={JWTKey}
multiline
disabled
fullWidth
hideEmptyHelperText
rows={6}
componentsProps={{
inputRoot: { className: 'font-mono' },
}}
/>
</div>
<div className="mx-auto max-w-sm text-center">
<Text variant="subtitle2">
Already using a third party auth service? <br />
<button
type="button"
className="mt-0.5 ml-0.5 text-xs font-medium text-blue"
onClick={() => {
editJWTSecret();
}}
>
Add your custom JWT token
</button>
</Text>
</div>
</div>
</div>
);
}
export function JWTSecretModal({
close,
data,
jwtSecret,
initialModalState,
}: any) {
const [jwtSecretModalState, setJwtSecretModalState] =
useState<JWTSecretModalState>(initialModalState || 'SHOW');
const editJWTSecret = () => {
setJwtSecretModalState('EDIT');
};
if (jwtSecretModalState === 'EDIT') {
return <EditJWTSecretModal close={close} />;
}
return (
<ShowJWTTokenModal
JWTKey={jwtSecret || data}
editJWTSecret={editJWTSecret}
/>
);
}

View File

@@ -1,28 +0,0 @@
import { Avatar } from '@/ui/Avatar';
import { Text } from '@/ui/Text';
import { nhost } from '@/utils/nhost';
import Image from 'next/image';
export function SelectedWorkspaceOnNewApp({ current }: any) {
const user = nhost.auth.getUser();
return (
<div className="flex flex-row space-x-2 self-center">
{current.name === 'Default Workspace' ? (
<Avatar
className="h-5 w-5 self-center rounded-full"
name={user?.displayName}
avatarUrl={user?.avatarUrl}
/>
) : (
<div className="h-5 w-5 overflow-hidden rounded-md">
<Image src="/logos/new.svg" alt="Nhost Logo" width={20} height={20} />
</div>
)}
<Text size="small" color="greyscaleDark" className="font-normal">
{current.name}
</Text>
</div>
);
}
export default SelectedWorkspaceOnNewApp;

View File

@@ -1 +0,0 @@
export { default } from './AppDeployments';

View File

@@ -1,71 +0,0 @@
import type { SelectorOption } from '@/ui/Selector';
import Selector from '@/ui/Selector';
import { Text } from '@/ui/Text';
import { Toggle } from '@/ui/Toggle';
import clsx from 'clsx';
export interface PermissionSettingsProps {
text: string;
desc?: string;
toggle?: boolean;
onChange?: any;
checked?: boolean;
options?: any;
value?: SelectorOption;
} // @TODO: Fix alt attribute on images.
// @FIX: Double border
export function PermissionSetting({
text,
desc,
toggle,
checked = false,
onChange,
options,
value,
}: PermissionSettingsProps) {
return (
<div className="flex flex-row place-content-between py-2">
<div
className={clsx(
'flex flex-col space-y-1 self-center px-0.5',
!desc && 'py-3.5',
desc && 'py-2',
)}
>
<Text
variant="body"
size="normal"
className="font-medium"
color="greyscaleDark"
>
{text}
</Text>
{desc && (
<Text
variant="body"
size="tiny"
className="font-normal"
color="greyscaleDark"
>
{desc}
</Text>
)}
</div>
{toggle ? (
<div className="flex flex-row">
<Toggle checked={checked} onChange={onChange} />
</div>
) : (
<div className="flex flex-row self-center">
<Selector
width="w-28"
options={options}
onChange={onChange}
value={value}
/>
</div>
)}
</div>
);
}

View File

@@ -1,58 +0,0 @@
import type { Provider as ProviderType } from '@/types/providers';
import Status, { StatusEnum } from '@/ui/Status';
import { Text } from '@/ui/Text';
import { ChevronRightIcon } from '@heroicons/react/solid';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/router';
interface ProviderProps {
provider: ProviderType;
enabled: boolean;
}
export function Provider({ provider, enabled }: ProviderProps) {
const { name, logo } = provider;
const {
query: { workspaceSlug, appSlug },
} = useRouter();
const nameLowerCase = name.toLowerCase();
return (
<Link
href={`/${workspaceSlug}/${appSlug}/settings/sign-in-methods/${nameLowerCase}`}
passHref
>
<a
href={`${workspaceSlug}/${appSlug}/settings/sign-in-methods/${nameLowerCase}`}
className="flex cursor-pointer flex-row place-content-between border-t py-2.5"
>
<div className="grid grid-flow-col items-center gap-2">
<div className="h-6 w-6">
<Image
src={logo}
alt={`Logo of ${name}`}
width={24}
height={24}
layout="responsive"
/>
</div>
<Text className="font-medium" color="greyscaleDark" size="normal">
{name}
</Text>
</div>
<div className="flex flex-row">
{enabled ? (
<Status status={StatusEnum.Live}>Enabled</Status>
) : (
<Status status={StatusEnum.Closed}>Disabled</Status>
)}
<ChevronRightIcon className="ml-2 h-4 w-4 cursor-pointer self-center" />
</div>
</a>
</Link>
);
}
export default Provider;

View File

@@ -1,183 +0,0 @@
import { CreateUserRoleModal } from '@/components/applications/users/roles/CreateRoleModal';
import { EditUserRoleModal } from '@/components/applications/users/roles/EditUserRoleModal';
import Lock from '@/components/icons/Lock';
import type { GetRolesQuery } from '@/generated/graphql';
import { Modal } from '@/ui';
import { Text } from '@/ui/Text';
import { ChevronRightIcon } from '@heroicons/react/solid';
import clsx from 'clsx';
import type { Dispatch, MouseEvent, MouseEventHandler } from 'react';
import { useReducer } from 'react';
function RolesTableHead() {
return (
<thead>
<tr>
<th className="w-64 py-3 text-left font-medium text-base">
<Text className="text-xs font-bold text-greyscaleDark">Role</Text>
</th>
</tr>
</thead>
);
}
interface UserRoleProps {
role: string;
isSystemRole: boolean;
onClick?: MouseEventHandler<HTMLTableRowElement>;
}
function UserRole({ role, isSystemRole, onClick }: UserRoleProps) {
return (
<tr
className={clsx(isSystemRole ? 'cursor-not-allowed' : 'cursor-pointer')}
onClick={onClick}
>
<td className="py-2">
<Text
size="normal"
className={clsx(
isSystemRole ? 'text-greyscaleGrey' : 'text-greyscaleDark',
'pl-1 font-medium',
)}
>
{role}
</Text>
</td>
<td className="text-right">
{isSystemRole ? (
<div className="inline-flex pr-1">
<Text
size="tiny"
className=" font-mono text-xs font-medium uppercase tracking-wide text-greyscaleGrey"
>
System Role
</Text>
<Lock className="ml-1 h-5 w-5 text-greyscaleGrey" />
</div>
) : (
<div className="inline-flex self-center py-2 pr-1.5">
<ChevronRightIcon className="h-4.5 w-4.5 text-greyscaleDark" />
</div>
)}
</td>
</tr>
);
}
export type UserRoleDetails = {
name: string;
isSystemRole: boolean;
};
export const getUserRoles = (data): UserRoleDetails[] => {
const authUserDefaultAllowedRoles =
data.app.authUserDefaultAllowedRoles.split(',');
return authUserDefaultAllowedRoles.map((role: string) => ({
name: role,
isSystemRole: ['user', 'me'].includes(role),
}));
};
type ModalState = {
visible: boolean;
type: 'create' | 'edit';
payload: UserRoleDetails;
};
type ModalAction = {
type: 'OPEN_CREATE_MODAL' | 'OPEN_EDIT_MODAL' | 'CLOSE_MODAL';
payload?: UserRoleDetails;
};
function modalStateReducer(state: ModalState, action: ModalAction): ModalState {
switch (action.type) {
case 'OPEN_CREATE_MODAL':
return { ...state, visible: true, type: 'create', payload: null };
case 'OPEN_EDIT_MODAL':
return { ...state, visible: true, type: 'edit', payload: action.payload };
case 'CLOSE_MODAL':
return { ...state, visible: false };
default:
throw new Error(`Action type ${action.type} is not supported.`);
}
}
function AddNewUserRole({ dispatch }: { dispatch: Dispatch<ModalAction> }) {
return (
<tr className="cursor-pointer border-y-1 border-solid border-gray-300">
<td className="p-2">
<button
type="button"
onClick={() => dispatch({ type: 'OPEN_CREATE_MODAL' })}
>
<Text className="text-sm+ font-medium text-blue">
Create New Role
</Text>
</button>
</td>
<td />
</tr>
);
}
function RolesTableBody({ data }: { data: GetRolesQuery }) {
const userRoles = getUserRoles(data);
const [
{ visible: modalVisible, type: modalType, payload: modalPayload },
dispatch,
] = useReducer(modalStateReducer, {
visible: false,
type: null,
payload: null,
});
function handleRoleEdit(event: MouseEvent<HTMLTableRowElement>, role: any) {
dispatch({ type: 'OPEN_EDIT_MODAL', payload: role });
}
return (
<>
<Modal
showModal={modalVisible}
close={() => dispatch({ type: 'CLOSE_MODAL' })}
>
{modalType === 'create' ? (
<CreateUserRoleModal
onClose={() => dispatch({ type: 'CLOSE_MODAL' })}
/>
) : (
<EditUserRoleModal
onClose={() => dispatch({ type: 'CLOSE_MODAL' })}
payload={modalPayload}
/>
)}
</Modal>
<tbody className="divide-y-1 border-t-1 border-b-1 border-solid border-gray-300 ">
{userRoles.map((role) => (
<UserRole
key={role.name}
role={role.name}
isSystemRole={role.isSystemRole}
onClick={
role.isSystemRole
? undefined
: (event) => handleRoleEdit(event, role)
}
/>
))}
<AddNewUserRole dispatch={dispatch} />
</tbody>
</>
);
}
export function RolesTable({ data }: { data: GetRolesQuery }) {
return (
<table className="w-full table-fixed overflow-x-auto">
<RolesTableHead />
<RolesTableBody data={data} />
</table>
);
}

View File

@@ -1,73 +0,0 @@
import type { TextProps } from '@/ui/Text';
import { Text } from '@/ui/Text';
import type { PropsWithChildren, ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
export interface SettingsSectionProps {
/**
* Title of this section.
*/
title: ReactNode;
/**
* Props to be passed to the title component.
*/
titleProps?: TextProps;
/**
* Props to be passed to the wrapper component.
*/
wrapperProps?: TextProps;
/**
* Description of this section.
*/
desc?: ReactNode;
/**
* Props to be passed to the description component.
*/
descriptionProps?: TextProps;
}
export function SettingsSection({
children,
title,
titleProps,
descriptionProps,
desc,
wrapperProps,
}: PropsWithChildren<SettingsSectionProps>) {
const { className: titleClassName, ...restTitleProps } = titleProps || {};
const { className: wrapperClassName } = wrapperProps || {};
const { className: descriptionClassName, ...restDescriptionProps } =
descriptionProps || {};
return (
<div className={twMerge('mt-10', wrapperClassName)}>
<div className="mx-auto font-display">
<div className="flex flex-col place-content-between">
<div>
<Text
size="large"
variant="heading"
className={twMerge('mb-1.5 font-medium', titleClassName)}
color="greyscaleDark"
{...restTitleProps}
>
{title}
</Text>
{desc && (
<Text
variant="body"
size="normal"
color="greyscaleDark"
className={twMerge('mb-3 font-normal', descriptionClassName)}
{...restDescriptionProps}
>
{desc}
</Text>
)}
</div>
</div>
{children}
</div>
</div>
);
}

View File

@@ -1,110 +0,0 @@
import { useWorkspaceContext } from '@/context/workspace-context';
import useCustomClaims from '@/hooks/useCustomClaims';
import type { CustomClaim } from '@/types/application';
import { Alert } from '@/ui/Alert';
import { triggerToast } from '@/utils/toast';
import {
refetchGetAppCustomClaimsQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import type {
CreatePermissionVariableBaseFormData,
CreatePermissionVariableModalBaseProps,
} from './CreatePermissionVariableModalBase';
import CreatePermissionVariableModalBase from './CreatePermissionVariableModalBase';
export type CreatePermissionVariableFormData =
CreatePermissionVariableBaseFormData;
export type CreatePermissionVariableModalProps = Pick<
CreatePermissionVariableModalBaseProps,
'onClose'
>;
export default function CreatePermissionVariableModal({
onClose,
}: CreatePermissionVariableModalProps) {
const [error, setError] = useState<Error>();
const form = useForm<CreatePermissionVariableFormData>({
reValidateMode: 'onSubmit',
});
const {
workspaceContext: { appId },
} = useWorkspaceContext();
const { data: customClaims } = useCustomClaims({ appId });
const [updateApp] = useUpdateAppMutation({
refetchQueries: [refetchGetAppCustomClaimsQuery({ id: appId })],
});
async function handleSubmit(permissionVariable: CustomClaim) {
setError(undefined);
try {
if (
customClaims.some(
(claim) =>
claim.key.toLowerCase() === permissionVariable.key.toLowerCase(),
)
) {
throw new Error(
'Permission variable with this field name already exists.',
);
}
await updateApp({
variables: {
id: appId,
app: {
authJwtCustomClaims: [...customClaims, permissionVariable]
.filter((claim) => !claim.system)
.reduce(
(authJwtCustomClaims, claim) => ({
...authJwtCustomClaims,
[claim.key]: claim.value,
}),
{},
),
},
},
});
triggerToast('Permission variable created');
if (!onClose) {
return;
}
onClose();
} catch (updateError) {
if (updateError instanceof Error) {
setError(updateError);
} else {
setError(new Error(updateError));
}
}
}
return (
<FormProvider {...form}>
<CreatePermissionVariableModalBase
title="Create Permission Variable"
type="create"
onSubmit={handleSubmit}
onClose={onClose}
errorComponent={
error && (
<Alert className="mt-4" severity="error">
{error.message}
</Alert>
)
}
/>
</FormProvider>
);
}

View File

@@ -1,187 +0,0 @@
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import type { ChangeEvent, MouseEventHandler, ReactNode } from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
export interface CreatePermissionVariableBaseFormData {
key: string;
value: string;
}
export interface CreateModalBaseProps<T> {
/**
* Title of this modal.
*/
title: string;
/**
* Type of this modal.
*/
type?: 'create' | 'edit';
/**
* Callback to be called when the modal is closed.
*/
onClose?: VoidFunction;
/**
* Callback to be called when remove button is clicked.
*/
onRemove?: MouseEventHandler<HTMLButtonElement>;
/**
* Callback to be called when the form is submitted.
*/
onSubmit: SubmitHandler<T>;
/**
* Error to be displayed.
*/
errorComponent?: ReactNode;
}
export type CreatePermissionVariableModalBaseProps =
CreateModalBaseProps<CreatePermissionVariableBaseFormData>;
export default function CreatePermissionVariableModalBase({
title,
type,
onClose,
onRemove,
onSubmit,
errorComponent,
}: CreatePermissionVariableModalBaseProps) {
const {
handleSubmit,
watch,
register,
formState: { isSubmitting, errors },
} = useFormContext<CreatePermissionVariableBaseFormData>();
const keyHandlers = register('key', {
required: true,
pattern: {
value: /^[a-zA-Z-]+$/i,
message: 'Must contain only letters and hyphens',
},
});
const valueHandlers = register('value', {
required: true,
pattern: {
value: /^[a-zA-Z0-9._[\]]+$/i,
message: 'Must contain only letters, dots, brackets, and underscores',
},
});
const isComplete = !!watch('key') && !!watch('value');
return (
<div className="w-modal p-6 text-left">
<div className="grid w-full grid-flow-col items-center justify-between">
<Text variant="h3" component="h2">
{title}
</Text>
{type === 'edit' && onRemove && (
<Button variant="borderless" color="error" onClick={onRemove}>
Remove
</Button>
)}
</div>
<Text className="mt-2 text-sm+ text-greyscaleDark">
Enter the field name and the path you want to use in this permission
variable.
</Text>
{errorComponent}
<form onSubmit={handleSubmit(onSubmit)} autoComplete="off">
<div className="my-4 grid grid-flow-row divide-y-1 divide-solid divide-gray-200 border-y border-gray-200">
<Input
{...keyHandlers}
value={watch('key')}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
if (
event.target.value &&
!/^[a-zA-Z-]+$/gi.test(event.target.value)
) {
// prevent the user from entering invalid characters
return;
}
keyHandlers.onChange(event);
}}
id="key"
variant="inline"
inlineInputProportion="66%"
label="Field name"
fullWidth
startAdornment={
<Text className="min-w-[73px] text-sm+ text-greyscaleGrey">
X-Hasura-
</Text>
}
componentsProps={{
inputWrapper: { className: 'my-1' },
input: {
className: 'border-transparent focus-within:border-solid pl-2',
},
inputRoot: { className: '!pl-[1px]' },
}}
autoFocus
error={!!errors?.key?.message}
helperText={errors?.key?.message}
hideEmptyHelperText
/>
<Input
{...valueHandlers}
value={watch('value')}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
if (
event.target.value &&
!/^[a-zA-Z-]+$/gi.test(event.target.value)
) {
// prevent the user from entering invalid characters
return;
}
valueHandlers.onChange(event);
}}
id="value"
variant="inline"
inlineInputProportion="66%"
label="Path"
fullWidth
startAdornment={
<Text className="text-sm+ text-greyscaleGrey">user.</Text>
}
componentsProps={{
inputWrapper: { className: 'my-1' },
input: {
className: 'border-transparent focus-within:border-solid pl-2',
},
inputRoot: { className: '!pl-[1px]' },
}}
error={!!errors?.value?.message}
helperText={errors?.value?.message}
hideEmptyHelperText
/>
</div>
<div className="grid gap-2">
<Button
type="submit"
loading={isSubmitting}
disabled={isSubmitting || !isComplete}
>
{type === 'create' ? 'Create Permission Variable' : 'Save Changes'}
</Button>
<Button variant="outlined" color="secondary" onClick={onClose}>
Close
</Button>
</div>
</form>
</div>
);
}

View File

@@ -1,209 +0,0 @@
import { useWorkspaceContext } from '@/context/workspace-context';
import useCustomClaims from '@/hooks/useCustomClaims';
import type { CustomClaim } from '@/types/application';
import { Alert } from '@/ui/Alert';
import { Modal } from '@/ui/Modal';
import Button from '@/ui/v2/Button';
import Text from '@/ui/v2/Text';
import { triggerToast } from '@/utils/toast';
import {
refetchGetAppCustomClaimsQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import type {
CreatePermissionVariableBaseFormData,
CreatePermissionVariableModalBaseProps,
} from './CreatePermissionVariableModalBase';
import CreatePermissionVariableModalBase from './CreatePermissionVariableModalBase';
export type EditPermissionVariableFormData =
CreatePermissionVariableBaseFormData;
export type EditPermissionVariableModalProps = Pick<
CreatePermissionVariableModalBaseProps,
'onClose'
> & {
/**
* The permission variable to edit.
*/
payload: CustomClaim;
};
export default function EditPermissionVariableModal({
payload: originalCustomClaim,
...props
}: EditPermissionVariableModalProps) {
const [error, setError] = useState<Error>();
const [showRemoveModal, setShowRemoveModal] = useState(false);
const form = useForm<EditPermissionVariableFormData>({
reValidateMode: 'onSubmit',
defaultValues: {
key: originalCustomClaim.key || '',
value: originalCustomClaim.value || '',
},
});
const {
workspaceContext: { appId },
} = useWorkspaceContext();
const { data: customClaims } = useCustomClaims({ appId });
const [updateApp] = useUpdateAppMutation({
refetchQueries: [refetchGetAppCustomClaimsQuery({ id: appId })],
});
async function handleSubmit(permissionVariable: CustomClaim) {
setError(undefined);
try {
if (
originalCustomClaim.key.toLowerCase() !==
permissionVariable.key.toLowerCase() &&
customClaims.some(
(claim) =>
claim.key.toLowerCase() === permissionVariable.key.toLowerCase(),
)
) {
throw new Error(
'Permission variable with this field name already exists.',
);
}
// we need to preserve the original position of the permission variable
const currentIndex = customClaims.findIndex(
(claim) =>
claim.key.toLowerCase() === originalCustomClaim.key.toLowerCase(),
);
await updateApp({
variables: {
id: appId,
app: {
authJwtCustomClaims: customClaims
.slice(0, currentIndex)
.concat(permissionVariable)
.concat(customClaims.slice(currentIndex + 1))
.filter((claim) => !claim.system)
.reduce(
(authJwtCustomClaims, claim) => ({
...authJwtCustomClaims,
[claim.key]: claim.value,
}),
{},
),
},
},
});
triggerToast(`Permission variable updated`);
if (props.onClose) {
props.onClose();
}
} catch (updateError) {
if (updateError instanceof Error) {
setError(updateError);
} else {
setError(new Error(updateError));
}
}
}
async function handleRemove() {
setError(undefined);
try {
await updateApp({
variables: {
id: appId,
app: {
authJwtCustomClaims: customClaims
.filter(
(claim) =>
claim.key !== originalCustomClaim.key && !claim.system,
)
.reduce(
(authJwtCustomClaims, claim) => ({
...authJwtCustomClaims,
[claim.key]: claim.value,
}),
{},
),
},
},
});
setShowRemoveModal(false);
triggerToast('Permission variable removed');
if (props.onClose) {
props.onClose();
}
} catch (updateError) {
if (updateError instanceof Error) {
setError(updateError);
} else {
setError(new Error(updateError));
}
}
}
return (
<>
<Modal
showModal={showRemoveModal}
close={() => setShowRemoveModal(false)}
>
<div className="grid w-96 grid-flow-row gap-2 p-6 text-left text-greyscaleDark">
<Text variant="h3" component="h2">
Remove {originalCustomClaim.key}?
</Text>
<Text>You will not be able to use it in permissions anymore.</Text>
<Text>
If you have permission checks currently using this property, they
will never resolve to true.
</Text>
<div className="mt-2 grid grid-flow-row gap-2">
<Button color="error" onClick={handleRemove} className="w-full">
Remove Permission Variable
</Button>
<Button
variant="outlined"
color="secondary"
onClick={() => setShowRemoveModal(false)}
className="w-full"
>
Cancel
</Button>
</div>
</div>
</Modal>
<FormProvider {...form}>
<CreatePermissionVariableModalBase
title="Edit Permission Variable"
type="edit"
onSubmit={handleSubmit}
onRemove={() => setShowRemoveModal(true)}
errorComponent={
error && (
<Alert className="mt-4" severity="error">
{error.message}
</Alert>
)
}
{...props}
/>
</FormProvider>
</>
);
}

View File

@@ -1,101 +0,0 @@
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Alert } from '@/ui/Alert';
import Loading from '@/ui/Loading';
import {
refetchGetRolesQuery,
useGetRolesQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import type {
CreateUserRoleBaseFormData,
CreateUserRoleModalBaseProps,
} from './CreateUserRoleModalBase';
import { CreateUserRoleModalBase } from './CreateUserRoleModalBase';
export type CreateUserRoleFormData = CreateUserRoleBaseFormData;
export type CreateUserRoleModalProps = Pick<
CreateUserRoleModalBaseProps,
'onClose'
>;
export function CreateUserRoleModal({ onClose }: CreateUserRoleModalProps) {
const [error, setError] = useState<Error>();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const form = useForm<CreateUserRoleBaseFormData>({
reValidateMode: 'onSubmit',
});
const [updateApp] = useUpdateAppMutation({
refetchQueries: [refetchGetRolesQuery({ id: currentApplication.id })],
});
const {
data: currentRolesData,
loading,
error: getRolesError,
} = useGetRolesQuery({
variables: {
id: currentApplication.id,
},
});
if (loading) {
return <Loading />;
}
if (getRolesError) {
return (
<div className="mx-auto max-w-2.5xl">
<Alert severity="error">{error.message}</Alert>
</div>
);
}
async function handleSubmit(data) {
setError(undefined);
const newAuthUserDefaultAllowedRoles = `${currentRolesData.app.authUserDefaultAllowedRoles},${data.roleName}`;
try {
await updateApp({
variables: {
id: currentApplication.id,
app: {
authUserDefaultAllowedRoles: newAuthUserDefaultAllowedRoles,
},
},
});
if (!onClose) {
return;
}
onClose();
} catch (updateError) {
setError(updateError);
}
}
return (
<FormProvider {...form}>
<CreateUserRoleModalBase
title="Create New Role"
type="create"
onSubmit={handleSubmit}
onClose={onClose}
errorComponent={
error && (
<Alert className="mt-4" severity="error">
{error.message}
</Alert>
)
}
/>
</FormProvider>
);
}

View File

@@ -1,102 +0,0 @@
import type { CreateModalBaseProps } from '@/components/applications/users/permissions/modal/CreatePermissionVariableModalBase';
import { Input } from '@/ui';
import { Button } from '@/ui/Button';
import { Text } from '@/ui/Text';
import { Controller, useFormContext } from 'react-hook-form';
export interface CreateUserRoleBaseFormData {
roleName: string;
}
export type CreateUserRoleModalBaseProps =
CreateModalBaseProps<CreateUserRoleBaseFormData>;
export type CreateUserRoleModal = Pick<CreateUserRoleModalBaseProps, 'onClose'>;
export function CreateUserRoleModalBase({
title,
type,
onRemove,
onSubmit,
errorComponent,
}: CreateUserRoleModalBaseProps) {
const {
control,
handleSubmit,
formState: { isSubmitting },
} = useFormContext<CreateUserRoleBaseFormData>();
return (
<div className="w-modal- p-6 text-left">
<div className="mx-auto items-center justify-between">
<Text
variant="heading"
className="text-center text-lg font-medium text-greyscaleDark"
>
{title}
</Text>
</div>
{errorComponent}
<form onSubmit={handleSubmit(onSubmit)} autoComplete="off">
<div className="mt-3 mb-3 divide-y border-t border-b py-1">
<div className="flex flex-row place-content-between py-2">
<div className="flex w-full flex-row">
<Text
color="greyscaleDark"
className="self-center font-medium"
size="normal"
>
New Role Name
</Text>
</div>
<div className="flex w-full">
<Controller
name="roleName"
control={control}
rules={{
required: true,
pattern: {
value: /^[a-zA-Z0-9-_]+$/,
message: 'Must contain only letters, hyphens, and numbers.',
},
}}
render={({ field }) => (
<Input
{...field}
id="roleName"
required
value={field.value || ''}
onChange={(value: string) => {
if (value && !/^[a-zA-Z0-9-_]+$/gi.test(value)) {
// prevent the user from entering invalid characters
return;
}
field.onChange(value);
}}
/>
)}
/>
</div>
</div>
</div>
<div className="grid gap-2">
<Button
variant="primary"
type="submit"
loading={isSubmitting}
disabled={isSubmitting}
>
{type === 'create' ? 'Create New User Role' : 'Save Changes'}
</Button>
{type === 'edit' && onRemove && (
<Button variant="menu" border onClick={onRemove}>
<Text className="text-sm+ font-medium text-red">Remove Role</Text>
</Button>
)}
</div>
</form>
</div>
);
}

View File

@@ -1,190 +0,0 @@
import type { GetRolesQuery } from '@/generated/graphql';
import {
refetchGetRolesQuery,
useGetRolesQuery,
useUpdateAppMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Alert } from '@/ui/Alert';
import { Button } from '@/ui/Button';
import Loading from '@/ui/Loading';
import { Modal } from '@/ui/Modal';
import { Text } from '@/ui/Text';
import { triggerToast } from '@/utils/toast';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import type {
CreateUserRoleBaseFormData,
CreateUserRoleModalBaseProps,
} from './CreateUserRoleModalBase';
import { CreateUserRoleModalBase } from './CreateUserRoleModalBase';
export type EditUserRoleFormData = CreateUserRoleBaseFormData;
export type EditUserRoleModalProps = Pick<
CreateUserRoleModalBaseProps,
'onClose'
> & {
/**
* The permission variable to edit.
*/
payload: any;
};
export function EditUserRoleModal({
payload: originalRole,
...props
}: EditUserRoleModalProps) {
const [error, setError] = useState<Error>();
const [showRemoveModal, setShowRemoveModal] = useState(false);
const { currentApplication } = useCurrentWorkspaceAndApplication();
const form = useForm<EditUserRoleFormData>({
reValidateMode: 'onSubmit',
defaultValues: {
roleName: originalRole.name || '',
},
});
const [updateApp, { loading: loadingUpdateAppMutation }] =
useUpdateAppMutation({
refetchQueries: [refetchGetRolesQuery({ id: currentApplication.id })],
});
const {
data: currentRolesData,
loading,
error: getRolesError,
} = useGetRolesQuery({
variables: {
id: currentApplication.id,
},
});
if (loading) {
return <Loading />;
}
if (getRolesError) {
return (
<div className="mx-auto max-w-2.5xl">
<Alert severity="error">{error.message}</Alert>
</div>
);
}
async function handleSubmit(data: EditUserRoleFormData) {
setError(undefined);
const currentUserRoles =
currentRolesData.app.authUserDefaultAllowedRoles.split(',');
const roleBeingEdited = currentUserRoles.find(
(role) => role === originalRole.name,
);
const indexofRoleBeingEdited = currentUserRoles.indexOf(roleBeingEdited);
const newRoleName = data.roleName;
const newAuthUserDefaultAllowedRoles = currentUserRoles.slice();
if (data.roleName !== originalRole.name) {
newAuthUserDefaultAllowedRoles[indexofRoleBeingEdited] = newRoleName;
}
try {
await updateApp({
variables: {
id: currentApplication.id,
app: {
authUserDefaultAllowedRoles:
newAuthUserDefaultAllowedRoles.join(','),
},
},
});
triggerToast(`Role "${data.roleName}" updated successfully`);
props.onClose();
} catch (updateError) {
setError(updateError);
}
}
async function handleRemove(data: GetRolesQuery) {
setError(undefined);
// Get the current roles of this application.
const currentUserRoles = data.app.authUserDefaultAllowedRoles.split(',');
// Remove the role from the current roles.
const filteredCurrentUserRoles = currentUserRoles.filter(
(role) => role !== originalRole.name,
);
const newAuthUserDefaultAllowedRoles = filteredCurrentUserRoles.join(',');
try {
await updateApp({
variables: {
id: currentApplication.id,
app: {
authUserDefaultAllowedRoles: newAuthUserDefaultAllowedRoles,
},
},
});
props.onClose();
triggerToast(`Role "${originalRole.name}" removed successfully`);
} catch (updateError) {
setError(updateError);
}
}
return (
<>
<Modal
showModal={showRemoveModal}
close={() => setShowRemoveModal(false)}
>
<div className="px-6 pt-5 text-center text-greyscaleDark">
<Text variant="heading" className="mb-2 text-lg font-medium">
Remove Role &quot;{originalRole.name}&quot;?
</Text>
<div className="my-4">
<Button
variant="danger"
onClick={() => handleRemove(currentRolesData)}
className="w-full"
loading={loadingUpdateAppMutation}
>
Remove Role
</Button>
<Button
onClick={() => setShowRemoveModal(false)}
className="w-full"
>
Cancel
</Button>
</div>
</div>
</Modal>
<FormProvider {...form}>
<CreateUserRoleModalBase
title="Edit Role"
type="edit"
onSubmit={handleSubmit}
onRemove={() => setShowRemoveModal(true)}
errorComponent={
error && (
<Alert className="mt-4" severity="error">
{error.message}
</Alert>
)
}
{...props}
/>
</FormProvider>
</>
);
}

View File

@@ -1,201 +0,0 @@
import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import { useState } from 'react';
type EnvModalProps = {
onSubmit: (props: {
name: string;
prodValue: string;
devValue: string;
}) => Promise<void>;
name?: string;
prodValue?: string;
devValue?: string;
close: VoidFunction;
};
interface AddEnvVarModalVariablesError {
hasError: boolean;
message: string;
}
const DISABLED_START_ENV_VARIABLES = [
'NHOST_',
'HASURA_',
'AUTH_',
'STORAGE_',
'POSTGRES_',
];
const DISABLED_ENV_VARIABLES = [
'PATH',
'NODE_PATH',
'PYTHONPATH',
'GEM_PATH',
'HOSTNAME',
'TERM',
'NODE_VERSION',
'YARN_VERSION',
'NODE_ENV',
'HOME',
];
export default function AddEnvVarModal({
name: externalName,
prodValue: externalProdValue,
devValue: externalDevValue,
close,
onSubmit,
}: EnvModalProps) {
const [name, setName] = useState(externalName || '');
const [prodValue, setProdValue] = useState(externalProdValue || '');
const [devValue, setDevValue] = useState(externalDevValue || '');
const [error, setError] = useState<AddEnvVarModalVariablesError>({
hasError: false,
message: '',
});
const noError: AddEnvVarModalVariablesError = {
hasError: false,
message: '',
};
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
setError({ hasError: false, message: '' });
e.preventDefault();
if (
DISABLED_START_ENV_VARIABLES.some((envVar) =>
name.toUpperCase().startsWith(envVar),
)
) {
setError({
hasError: true,
message:
'The environment variable name cannot start with a value that is reserved for an internal environment variable.',
});
return;
}
if (
DISABLED_ENV_VARIABLES.some((envVar) => envVar === name.toUpperCase())
) {
setError({
hasError: true,
message:
'The environment variable name cannot be a value that is reserved for internal use.',
});
return;
}
// only allow alphabet characters and underscores
const onlyLettersWithNumbersStartsWithLetter = /^[a-zA-Z_]+[a-zA-Z0-9_]*$/;
if (!onlyLettersWithNumbersStartsWithLetter.test(name)) {
setError({
hasError: true,
message:
'The name contains invalid characters. Only letters, digits, and underscores are allowed. Furthermore, the name should start with a letter.',
});
return;
}
if (!name) {
setError({ hasError: true, message: 'Variable name is required.' });
return;
}
if (!prodValue) {
setError({ hasError: true, message: 'Production value is required.' });
return;
}
if (!devValue) {
setError({ hasError: true, message: 'Development value is required.' });
return;
}
await onSubmit({
name,
prodValue,
devValue,
});
close();
};
return (
<form onSubmit={handleSubmit}>
<div className="w-modal px-6 py-6 text-left">
<div className="grid grid-flow-row gap-1">
<Text variant="h3" component="h2">
{name || 'EXAMPLE_NAME'}
</Text>
<Text variant="subtitle2">
The default value will be available in all environments, unless you
override it. All values are encrypted.
</Text>
<div className="my-2 grid grid-flow-row gap-2">
<Input
id="name"
label="Name"
autoFocus
autoComplete="off"
fullWidth
placeholder="EXAMPLE_NAME"
value={name}
onChange={(event) => {
setError(noError);
setName(event.target.value);
}}
hideEmptyHelperText
/>
<Input
id="prodValue"
label="Production Value"
fullWidth
placeholder="Enter a value"
value={prodValue}
onChange={(event) => {
setError(noError);
setProdValue(event.target.value);
}}
hideEmptyHelperText
/>
<Input
id="devValue"
label="Development Value"
fullWidth
placeholder="Enter a value"
value={devValue}
onChange={(event) => {
setError(noError);
setDevValue(event.target.value);
}}
hideEmptyHelperText
/>
</div>
{error.hasError && (
<Alert severity="warning" className="mb-2">
<Text className="font-medium">Warning</Text>
<Text>{error.message}</Text>
</Alert>
)}
<div className="grid grid-flow-row gap-2">
<Button type="submit">Add</Button>
<Button onClick={close} variant="outlined" color="secondary">
Close
</Button>
</div>
</div>
</div>
</form>
);
}

View File

@@ -12,7 +12,13 @@ export type DialogType =
| 'CREATE_TABLE'
| 'EDIT_TABLE'
| 'CREATE_FOREIGN_KEY'
| 'EDIT_FOREIGN_KEY';
| 'EDIT_FOREIGN_KEY'
| 'CREATE_ROLE'
| 'EDIT_ROLE'
| 'CREATE_PERMISSION_VARIABLE'
| 'EDIT_PERMISSION_VARIABLE'
| 'CREATE_ENVIRONMENT_VARIABLE'
| 'EDIT_ENVIRONMENT_VARIABLE';
export interface DialogConfig<TPayload = unknown> {
/**

View File

@@ -1,13 +1,25 @@
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
import { CreateForeignKeyForm } from '@/components/data-browser/CreateForeignKeyForm';
import { EditForeignKeyForm } from '@/components/data-browser/EditForeignKeyForm';
import CreateForeignKeyForm from '@/components/data-browser/CreateForeignKeyForm';
import EditForeignKeyForm from '@/components/data-browser/EditForeignKeyForm';
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
import CreatePermissionVariableForm from '@/components/settings/permissions/CreatePermissionVariableForm';
import EditPermissionVariableForm from '@/components/settings/permissions/EditPermissionVariableForm';
import CreateRoleForm from '@/components/settings/roles/CreateRoleForm';
import EditRoleForm from '@/components/settings/roles/EditRoleForm';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import AlertDialog from '@/ui/v2/AlertDialog';
import { BaseDialog } from '@/ui/v2/Dialog';
import Drawer from '@/ui/v2/Drawer';
import dynamic from 'next/dynamic';
import type { BaseSyntheticEvent, PropsWithChildren } from 'react';
import type {
BaseSyntheticEvent,
DetailedHTMLProps,
HTMLProps,
PropsWithChildren,
} from 'react';
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import type { DialogConfig, DialogType } from './DialogContext';
import DialogContext from './DialogContext';
import {
@@ -16,13 +28,21 @@ import {
drawerReducer,
} from './dialogReducers';
function LoadingComponent() {
function LoadingComponent({
className,
...props
}: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> = {}) {
return (
<div className="grid items-center justify-center px-6 py-4">
<div
{...props}
className={twMerge(
'grid items-center justify-center px-6 py-4',
className,
)}
>
<ActivityIndicator
circularProgressProps={{ className: 'w-5 h-5' }}
label="Loading form..."
delay={500}
/>
</div>
);
@@ -30,27 +50,27 @@ function LoadingComponent() {
const CreateRecordForm = dynamic(
() => import('@/components/data-browser/CreateRecordForm'),
{ ssr: false, loading: LoadingComponent },
{ ssr: false, loading: () => LoadingComponent() },
);
const CreateColumnForm = dynamic(
() => import('@/components/data-browser/CreateColumnForm'),
{ ssr: false, loading: LoadingComponent },
{ ssr: false, loading: () => LoadingComponent() },
);
const EditColumnForm = dynamic(
() => import('@/components/data-browser/EditColumnForm'),
{ ssr: false, loading: LoadingComponent },
{ ssr: false, loading: () => LoadingComponent() },
);
const CreateTableForm = dynamic(
() => import('@/components/data-browser/CreateTableForm'),
{ ssr: false, loading: LoadingComponent },
{ ssr: false, loading: () => LoadingComponent() },
);
const EditTableForm = dynamic(
() => import('@/components/data-browser/EditTableForm'),
{ ssr: false, loading: LoadingComponent },
{ ssr: false, loading: () => LoadingComponent() },
);
function DialogProvider({ children }: PropsWithChildren<unknown>) {
@@ -209,6 +229,25 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
[closeDialog, closeDrawer, onDirtyStateChange, openDialog, openDrawer],
);
const sharedDialogProps = {
...dialogPayload,
onSubmit: async (values: any) => {
await dialogPayload?.onSubmit?.(values);
closeDialog();
},
onCancel: closeDialogWithDirtyGuard,
};
const sharedDrawerProps = {
onSubmit: async () => {
await drawerPayload?.onSubmit();
closeDrawer();
},
onCancel: closeDrawerWithDirtyGuard,
};
return (
<DialogContext.Provider value={contextValue}>
<AlertDialog
@@ -249,33 +288,47 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
open={dialogOpen}
onClose={closeDialogWithDirtyGuard}
TransitionProps={{ onExited: clearDialogContent, unmountOnExit: false }}
PaperProps={{ className: 'max-w-md w-full' }}
PaperProps={{
...dialogProps?.PaperProps,
className: twMerge(
'max-w-md w-full',
dialogProps?.PaperProps?.className,
),
}}
>
<RetryableErrorBoundary
errorMessageProps={{ className: 'pt-0 pb-5 px-6' }}
>
{activeDialogType === 'CREATE_FOREIGN_KEY' && (
<CreateForeignKeyForm
{...dialogPayload}
onSubmit={async (values) => {
await dialogPayload?.onSubmit(values);
closeDialog();
}}
onCancel={closeDialogWithDirtyGuard}
/>
<CreateForeignKeyForm {...sharedDialogProps} />
)}
{activeDialogType === 'EDIT_FOREIGN_KEY' && (
<EditForeignKeyForm
{...dialogPayload}
onSubmit={async (values) => {
await dialogPayload?.onSubmit(values);
<EditForeignKeyForm {...sharedDialogProps} />
)}
closeDialog();
}}
onCancel={closeDialogWithDirtyGuard}
/>
{activeDialogType === 'CREATE_ROLE' && (
<CreateRoleForm {...sharedDialogProps} />
)}
{activeDialogType === 'EDIT_ROLE' && (
<EditRoleForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_PERMISSION_VARIABLE' && (
<CreatePermissionVariableForm {...sharedDialogProps} />
)}
{activeDialogType === 'EDIT_PERMISSION_VARIABLE' && (
<EditPermissionVariableForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_ENVIRONMENT_VARIABLE' && (
<CreateEnvironmentVariableForm {...sharedDialogProps} />
)}
{activeDialogType === 'EDIT_ENVIRONMENT_VARIABLE' && (
<EditEnvironmentVariableForm {...sharedDialogProps} />
)}
</RetryableErrorBoundary>
</BaseDialog>
@@ -292,61 +345,34 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
<RetryableErrorBoundary>
{activeDrawerType === 'CREATE_RECORD' && (
<CreateRecordForm
{...sharedDrawerProps}
columns={drawerPayload?.columns}
onSubmit={async () => {
await drawerPayload?.onSubmit();
closeDrawer();
}}
onCancel={closeDrawerWithDirtyGuard}
/>
)}
{activeDrawerType === 'CREATE_COLUMN' && (
<CreateColumnForm
onSubmit={async () => {
await drawerPayload?.onSubmit();
closeDrawer();
}}
onCancel={closeDrawerWithDirtyGuard}
/>
<CreateColumnForm {...sharedDrawerProps} />
)}
{activeDrawerType === 'EDIT_COLUMN' && (
<EditColumnForm
{...sharedDrawerProps}
column={drawerPayload?.column}
onSubmit={async () => {
await drawerPayload?.onSubmit();
closeDrawer();
}}
onCancel={closeDrawerWithDirtyGuard}
/>
)}
{activeDrawerType === 'CREATE_TABLE' && (
<CreateTableForm
{...sharedDrawerProps}
schema={drawerPayload?.schema}
onSubmit={async () => {
await drawerPayload?.onSubmit();
closeDrawer();
}}
onCancel={closeDrawerWithDirtyGuard}
/>
)}
{activeDrawerType === 'EDIT_TABLE' && (
<EditTableForm
{...sharedDrawerProps}
table={drawerPayload?.table}
schema={drawerPayload?.schema}
onSubmit={async () => {
await drawerPayload?.onSubmit();
closeDrawer();
}}
onCancel={closeDrawerWithDirtyGuard}
/>
)}
</RetryableErrorBoundary>

View File

@@ -24,7 +24,7 @@ export interface CreateForeignKeyFormProps
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void>;
}
export function CreateForeignKeyForm({
export default function CreateForeignKeyForm({
onSubmit,
selectedColumn,
...props

View File

@@ -1,2 +1,2 @@
export * from './CreateForeignKeyForm';
export { CreateForeignKeyForm as default } from './CreateForeignKeyForm';
export { default } from './CreateForeignKeyForm';

View File

@@ -29,7 +29,7 @@ export interface EditForeignKeyFormProps
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void>;
}
export function EditForeignKeyForm({
export default function EditForeignKeyForm({
foreignKeyRelation,
selectedColumn,
onSubmit,

View File

@@ -1,2 +1,2 @@
export * from './EditForeignKeyForm';
export { EditForeignKeyForm as default } from './EditForeignKeyForm';
export { default } from './EditForeignKeyForm';

View File

@@ -1,17 +0,0 @@
import { Text } from '@/ui/Text';
interface ErrorComponentProps {
message: string;
}
function ErrorComponent({ message }: ErrorComponentProps) {
return (
<div className="my-4 rounded-md bg-warning px-4 py-2 text-dark">
<Text className="font-medium text-textOrange">Error</Text>
<Text className="pt-2 font-medium text-dimBlack" size="normal">
{message}
</Text>
</div>
);
}
export default ErrorComponent;

View File

@@ -1,22 +0,0 @@
function Copy({ stroke = '#21324B', ...props }: any) {
return (
<svg fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M10.5 10.5h3v-8h-8v3"
stroke={stroke}
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10.5 5.5h-8v8h8v-8z"
stroke={stroke}
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default Copy;

View File

@@ -1,29 +0,0 @@
import * as React from 'react';
function Eye(props: JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
);
}
export default Eye;

View File

@@ -1,25 +0,0 @@
import * as React from 'react';
function EyeOff(
props: JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>,
) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
</svg>
);
}
export default EyeOff;

View File

@@ -1,14 +0,0 @@
export default function Lock(
props: JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>,
) {
return (
<svg fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 1.75a1.5 1.5 0 0 0-1.5 1.5v1.5h3v-1.5A1.5 1.5 0 0 0 8 1.75Zm-3 1.5v1.5H3c-.69 0-1.25.56-1.25 1.25v7c0 .69.56 1.25 1.25 1.25h10c.69 0 1.25-.56 1.25-1.25V6c0-.69-.56-1.25-1.25-1.25h-2v-1.5a3 3 0 0 0-6 0Zm-1.75 3h9.5v6.5h-9.5v-6.5ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"
fill="#21324B"
/>
</svg>
);
}

View File

@@ -1,21 +0,0 @@
import * as React from 'react';
function Plus(props: any) {
return (
<svg
viewBox="0 0 32 32"
stroke="currentColor"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16 4.75C9.787 4.75 4.75 9.787 4.75 16S9.787 27.25 16 27.25 27.25 22.213 27.25 16 22.213 4.75 16 4.75zM3.25 16C3.25 8.958 8.958 3.25 16 3.25S28.75 8.958 28.75 16 23.042 28.75 16 28.75 3.25 23.042 3.25 16zm12 .75H10v-1.5h5.25V10h1.5v5.25H22v1.5h-5.25V22h-1.5v-5.25z"
/>
</svg>
);
}
export default Plus;

View File

@@ -1,20 +0,0 @@
import * as React from 'react';
function TwilioIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
width={108}
height={32}
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M16.864 0c8.844 0 15.984 7.147 15.984 16s-7.14 16-15.984 16C8.019 32 .88 24.853.88 16S8.02 0 16.864 0Zm0 4.267c-6.5 0-11.722 5.226-11.722 11.733a11.694 11.694 0 0 0 11.722 11.733c6.5 0 11.721-5.226 11.721-11.733A11.694 11.694 0 0 0 16.864 4.267Zm27.173 2.026c.213-.106.426.107.426.214v4.586h8.525c.106 0 .32.214.32.32l.639 2.667.639 2.667.107.32.106-.32 1.599-5.334c0-.213.213-.32.32-.32h4.262c.106 0 .32.214.32.32l1.704 5.654.107-.32 1.385-5.334c0-.213.213-.32.32-.32h10.869c.106 0 .213.214.213.32V25.6c0 .213-.213.32-.32.32h-5.434c-.213 0-.32-.213-.32-.32V12.16l-4.05 13.44c0 .187-.162.292-.275.315l-.044.005H60.98c-.107 0-.32-.213-.32-.32l-.853-2.88-.959-3.093L56.93 25.6c0 .213-.213.32-.32.32h-4.475c-.106 0-.32-.213-.32-.32l-4.049-13.44v3.413c0 .214-.213.32-.32.32h-3.09v3.627c0 1.067.533 1.493 1.492 1.493.426 0 .96-.106 1.492-.32.106-.106.32 0 .32.214v4.266c-.96.534-2.345.854-3.836.854-3.517 0-5.435-1.6-5.435-5.12v-5.014h-1.385c-.213 0-.32-.213-.32-.32V11.52c0-.213.213-.32.32-.32h1.385V8.32c0-.213.106-.32.32-.32l5.328-1.707Zm54.984 4.48c4.689 0 8.099 3.52 8.099 7.574 0 4.053-3.41 7.573-8.205 7.573-4.689 0-8.099-3.52-8.099-7.573 0-4.054 3.41-7.574 8.205-7.574Zm-16.197-4.48c.213 0 .32.107.32.214v18.986c0 .214-.213.32-.32.32H77.39c-.213 0-.32-.213-.32-.32V6.613c0-.213.213-.32.32-.32h5.434Zm7.14 4.8c.213 0 .32.214.32.32v13.974c0 .213-.214.32-.32.32h-5.435c-.213 0-.32-.214-.32-.32V11.413c0-.213.214-.32.32-.32h5.435ZM12.92 16.64a3.322 3.322 0 0 1 3.303 3.307 3.322 3.322 0 0 1-3.303 3.306 3.322 3.322 0 0 1-3.303-3.306 3.322 3.322 0 0 1 3.303-3.307Zm7.886 0a3.322 3.322 0 0 1 3.303 3.307 3.322 3.322 0 0 1-3.303 3.306 3.322 3.322 0 0 1-3.304-3.306 3.322 3.322 0 0 1 3.304-3.307Zm78.214-.747c-1.385 0-2.344 1.174-2.344 2.56 0 1.387 1.066 2.56 2.344 2.56 1.386 0 2.345-1.173 2.345-2.56 0-1.493-1.066-2.666-2.345-2.56ZM20.807 8.747a3.322 3.322 0 0 1 3.303 3.306 3.322 3.322 0 0 1-3.303 3.307 3.322 3.322 0 0 1-3.304-3.307 3.322 3.322 0 0 1 3.304-3.306Zm-7.886 0a3.322 3.322 0 0 1 3.303 3.306 3.322 3.322 0 0 1-3.303 3.307 3.322 3.322 0 0 1-3.303-3.307 3.322 3.322 0 0 1 3.303-3.306Zm62.87-2.454c.107 0 .213.107.32.214V9.92c0 .213-.213.32-.32.32h-5.647c-.213 0-.32-.213-.32-.32V6.613c0-.213.213-.32.32-.32h5.647Zm14.28 0c.213 0 .319.107.319.214V9.92c0 .213-.213.32-.32.32h-5.647c-.213 0-.32-.213-.32-.32V6.613c0-.213.213-.32.32-.32h5.647Z"
fill="#F22F46"
/>
</svg>
);
}
export default TwilioIcon;

View File

@@ -4,19 +4,19 @@ import { twMerge } from 'tailwind-merge';
export interface ContainerProps
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> {
/**
* Class name passed to the wrapper element.
* Class name passed to the root element.
*/
wrapperClassName?: string;
rootClassName?: string;
}
export default function Container({
children,
className,
wrapperClassName,
rootClassName,
...props
}: ContainerProps) {
return (
<div className={twMerge('mx-auto w-full bg-white', wrapperClassName)}>
<div className={twMerge('mx-auto w-full bg-white', rootClassName)}>
<div
className={twMerge(
'mx-auto max-w-7xl bg-white px-5 pt-6 pb-20',

View File

@@ -11,7 +11,7 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment';
import { copy } from '@/utils/copy';
import { discordAnnounce } from '@/utils/discordAnnounce';
import generateRandomDatabasePassword from '@/utils/settings/generateRandomDatabasePassword/generateRandomDatabasePassword';
import generateRandomDatabasePassword from '@/utils/settings/generateRandomDatabasePassword';
import { resetDatabasePasswordValidationSchema } from '@/utils/settings/resetDatabasePasswordValidationSchema';
import { triggerToast } from '@/utils/toast';
import { yupResolver } from '@hookform/resolvers/yup';

View File

@@ -8,7 +8,6 @@ import Switch from '@/ui/v2/Switch';
import Text from '@/ui/v2/Text';
import Image from 'next/image';
import type { DetailedHTMLProps, HTMLProps, ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
export interface SettingsContainerProps
@@ -31,7 +30,7 @@ export interface SettingsContainerProps
/**
* The description for the section.
*/
description: string | ReactNode;
description?: string | ReactNode;
/**
* Link to the documentation.
*
@@ -40,6 +39,8 @@ export interface SettingsContainerProps
docsLink?: string;
/**
* Props for the primary action.
*
* @deprecated Use `slotProps.submitButton` instead.
*/
primaryActionButtonProps?: ButtonProps;
/**
@@ -75,9 +76,26 @@ export interface SettingsContainerProps
*/
className?: string;
/**
* Props to be passed to the Switch component.
* Props to be passed to different slots inside the component.
*/
switchProps?: SwitchProps;
slotProps?: {
/**
* Props to be passed to the root element.
*/
root?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
/**
* Props to be passed to the `<Switch />` component.
*/
switch?: SwitchProps;
/**
* Props to be passed to the footer element.
*/
submitButton?: ButtonProps;
/**
* Props to be passed to the footer element.
*/
footer?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
};
}
export default function SettingsContainer({
@@ -94,14 +112,15 @@ export default function SettingsContainer({
switchId,
showSwitch = false,
rootClassName,
switchProps,
docsTitle,
slotProps: { root, switch: switchSlot, submitButton, footer } = {},
}: SettingsContainerProps) {
return (
<div
{...root}
className={twMerge(
'grid grid-flow-row gap-4 rounded-lg border-1 border-gray-200 bg-white py-4',
rootClassName,
root?.className || rootClassName,
)}
>
<div className="grid grid-flow-col place-content-between gap-3 px-4">
@@ -131,14 +150,14 @@ export default function SettingsContainer({
checked={enabled}
onChange={(e) => onEnabledChange(e.target.checked)}
className="self-center"
{...switchProps}
{...switchSlot}
/>
)}
{switchId && showSwitch && (
<ControlledSwitch
className="self-center"
name={switchId}
{...switchProps}
{...switchSlot}
/>
)}
</div>
@@ -148,9 +167,11 @@ export default function SettingsContainer({
</div>
<div
{...footer}
className={twMerge(
'grid grid-flow-col items-center gap-x-2 border-t border-gray-200 px-4 pt-3.5',
docsLink ? 'place-content-between' : 'justify-end',
footer?.className,
)}
>
{docsLink && (
@@ -173,11 +194,17 @@ export default function SettingsContainer({
<Button
variant={
primaryActionButtonProps?.disabled ? 'outlined' : 'contained'
(submitButton || primaryActionButtonProps)?.disabled
? 'outlined'
: 'contained'
}
color={
(submitButton || primaryActionButtonProps)?.disabled
? 'secondary'
: 'primary'
}
color={primaryActionButtonProps?.disabled ? 'secondary' : 'primary'}
type="submit"
{...primaryActionButtonProps}
{...(submitButton || primaryActionButtonProps)}
>
{submitButtonText}
</Button>

View File

@@ -1,3 +1,4 @@
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
import type { ProjectLayoutProps } from '@/components/layout/ProjectLayout';
import ProjectLayout from '@/components/layout/ProjectLayout';
import type { SettingsSidebarProps } from '@/components/settings/SettingsSidebar';
@@ -33,8 +34,8 @@ export default function SettingsLayout({
{...sidebarProps}
/>
<div className="flex w-full flex-auto flex-col overflow-x-hidden">
{children}
<div className="flex w-full flex-auto flex-col overflow-x-hidden bg-[#fafafa]">
<RetryableErrorBoundary>{children}</RetryableErrorBoundary>
</div>
</ProjectLayout>
);

View File

@@ -48,6 +48,7 @@ function SettingsNavLink({
return (
<ListItem.Root>
<ListItem.Button
dense
href={finalUrl}
component={NavLink}
selected={active}

View File

@@ -0,0 +1,185 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import * as Yup from 'yup';
export interface BaseEnvironmentVariableFormValues {
/**
* Identifier of the environment variable.
*/
id: string;
/**
* The name of the role.
*/
name: string;
/**
* Development environment variable value.
*/
devValue: string;
/**
* Production environment variable value.
*/
prodValue: string;
}
export interface BaseEnvironmentVariableFormProps {
/**
* Determines the mode of the form.
*
* @default 'edit'
*/
mode?: 'edit' | 'create';
/**
* Function to be called when the form is submitted.
*/
onSubmit: (values: BaseEnvironmentVariableFormValues) => void;
/**
* Function to be called when the operation is cancelled.
*/
onCancel?: VoidFunction;
/**
* Submit button text.
*
* @default 'Save'
*/
submitButtonText?: string;
}
export const baseEnvironmentVariableFormValidationSchema = Yup.object({
name: Yup.string()
.required('This field is required.')
.test(
'isEnvVarPermitted',
'This is a reserved name.',
(value) =>
![
'PATH',
'NODE_PATH',
'PYTHONPATH',
'GEM_PATH',
'HOSTNAME',
'TERM',
'NODE_VERSION',
'YARN_VERSION',
'NODE_ENV',
'HOME',
].includes(value),
)
.test(
'isEnvVarPrefixPermitted',
`The name can't start with NHOST_, HASURA_, AUTH_, STORAGE_ or POSTGRES_.`,
(value) =>
['NHOST_', 'HASURA_', 'AUTH_', 'STORAGE_', 'POSTGRES_'].every(
(prefix) => !value.startsWith(prefix),
),
)
.test('isEnvVarValid', `The name must start with a letter.`, (value) =>
/^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/i.test(value),
),
devValue: Yup.string().required('This field is required.'),
prodValue: Yup.string().required('This field is required.'),
});
export default function BaseEnvironmentVariableForm({
mode = 'edit',
onSubmit,
onCancel,
submitButtonText = 'Save',
}: BaseEnvironmentVariableFormProps) {
const { onDirtyStateChange } = useDialog();
const form = useFormContext<BaseEnvironmentVariableFormValues>();
const {
register,
formState: { errors, dirtyFields, isSubmitting },
} = form;
// react-hook-form's isDirty gets true even if an input field is focused, then
// immediately unfocused - we can't rely on that information
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'dialog');
}, [isDirty, onDirtyStateChange]);
return (
<div className="grid grid-flow-row gap-6 px-6 pb-6">
<Text variant="subtitle1" component="span">
Environment Variables are made available to all your services. All
values are encrypted.
</Text>
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
<Input
{...register('name', {
onChange: (event) => {
if (
event.target.value &&
!/^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/g.test(event.target.value)
) {
// we need to prevent invalid characters from being entered
// eslint-disable-next-line no-param-reassign
event.target.value = event.target.value.replace(
/[^a-zA-Z0-9_]/g,
'',
);
}
},
})}
inputProps={{ maxLength: 100 }}
id="name"
label="Name"
placeholder="EXAMPLE_NAME"
hideEmptyHelperText
error={!!errors.name}
helperText={errors?.name?.message}
fullWidth
autoComplete="off"
autoFocus={mode === 'create'}
disabled={mode === 'edit'}
/>
<Input
{...register('prodValue')}
inputProps={{ maxLength: 100 }}
id="prodValue"
label="Production Value"
placeholder="Enter value"
hideEmptyHelperText
error={!!errors.prodValue}
helperText={errors?.prodValue?.message}
fullWidth
autoComplete="off"
autoFocus={mode === 'edit'}
/>
<Input
{...register('devValue')}
inputProps={{ maxLength: 100 }}
id="devValue"
label="Development Value"
placeholder="Enter value"
hideEmptyHelperText
error={!!errors.devValue}
helperText={errors?.devValue?.message}
fullWidth
autoComplete="off"
/>
<div className="grid grid-flow-row gap-2">
<Button type="submit" loading={isSubmitting}>
{submitButtonText}
</Button>
<Button variant="outlined" color="secondary" onClick={onCancel}>
Cancel
</Button>
</div>
</Form>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export * from './BaseEnvironmentVariableForm';
export { default } from './BaseEnvironmentVariableForm';

View File

@@ -0,0 +1,117 @@
import type {
BaseEnvironmentVariableFormProps,
BaseEnvironmentVariableFormValues,
} from '@/components/settings/environmentVariables/BaseEnvironmentVariableForm';
import BaseEnvironmentVariableForm, {
baseEnvironmentVariableFormValidationSchema,
} from '@/components/settings/environmentVariables/BaseEnvironmentVariableForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetEnvironmentVariablesQuery,
useInsertEnvironmentVariablesMutation,
} from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
export interface CreateEnvironmentVariableFormProps
extends Pick<BaseEnvironmentVariableFormProps, 'onCancel'> {
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
}
export default function CreateEnvironmentVariableForm({
onSubmit,
...props
}: CreateEnvironmentVariableFormProps) {
const form = useForm<BaseEnvironmentVariableFormValues>({
defaultValues: {
name: '',
devValue: '',
prodValue: '',
},
reValidateMode: 'onSubmit',
resolver: yupResolver(baseEnvironmentVariableFormValidationSchema),
});
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetEnvironmentVariablesQuery({
variables: {
id: currentApplication?.id,
},
fetchPolicy: 'cache-only',
});
const [insertEnvironmentVariables] = useInsertEnvironmentVariablesMutation({
refetchQueries: ['getEnvironmentVariables'],
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading environment variables..."
/>
);
}
if (error) {
throw error;
}
const { setError } = form;
async function handleSubmit({
name,
prodValue,
devValue,
}: BaseEnvironmentVariableFormValues) {
if (
data?.environmentVariables?.some(
(environmentVariable) => environmentVariable.name === name,
)
) {
setError('name', {
message: 'This environment variable already exists.',
});
return;
}
const insertEnvironmentVariablePromise = insertEnvironmentVariables({
variables: {
environmentVariables: [
{ appId: currentApplication.id, name, prodValue, devValue },
],
},
});
await toast.promise(
insertEnvironmentVariablePromise,
{
loading: 'Creating environment variable...',
success: 'Environment variable has been created successfully.',
error: 'An error occurred while creating the environment variable.',
},
toastStyleProps,
);
onSubmit?.();
}
return (
<FormProvider {...form}>
<BaseEnvironmentVariableForm
mode="create"
submitButtonText="Create"
onSubmit={handleSubmit}
{...props}
/>
</FormProvider>
);
}

View File

@@ -0,0 +1,2 @@
export * from './CreateEnvironmentVariableForm';
export { default } from './CreateEnvironmentVariableForm';

View File

@@ -0,0 +1,124 @@
import type {
BaseEnvironmentVariableFormProps,
BaseEnvironmentVariableFormValues,
} from '@/components/settings/environmentVariables/BaseEnvironmentVariableForm';
import BaseEnvironmentVariableForm, {
baseEnvironmentVariableFormValidationSchema,
} from '@/components/settings/environmentVariables/BaseEnvironmentVariableForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { EnvironmentVariable } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetEnvironmentVariablesQuery,
useUpdateEnvironmentVariableMutation,
} from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
export interface EditEnvironmentVariableFormProps
extends Pick<BaseEnvironmentVariableFormProps, 'onCancel'> {
/**
* The environment variable to edit.
*/
originalEnvironmentVariable: EnvironmentVariable;
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
}
export default function EditEnvironmentVariableForm({
originalEnvironmentVariable,
onSubmit,
...props
}: EditEnvironmentVariableFormProps) {
const form = useForm<BaseEnvironmentVariableFormValues>({
defaultValues: {
id: originalEnvironmentVariable.id || '',
name: originalEnvironmentVariable.name || '',
devValue: originalEnvironmentVariable.devValue || '',
prodValue: originalEnvironmentVariable.prodValue || '',
},
reValidateMode: 'onSubmit',
resolver: yupResolver(baseEnvironmentVariableFormValidationSchema),
});
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetEnvironmentVariablesQuery({
variables: {
id: currentApplication?.id,
},
fetchPolicy: 'cache-only',
});
const [updateEnvironmentVariable] = useUpdateEnvironmentVariableMutation({
refetchQueries: ['getEnvironmentVariables'],
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading environment variables..."
/>
);
}
if (error) {
throw error;
}
const { setError } = form;
async function handleSubmit({
id,
name,
prodValue,
devValue,
}: BaseEnvironmentVariableFormValues) {
if (
data?.environmentVariables?.some(
(environmentVariable) =>
environmentVariable.name === name &&
environmentVariable.name !== originalEnvironmentVariable.name,
)
) {
setError('name', {
message: 'This environment variable already exists.',
});
return;
}
const updateEnvironmentVariablePromise = updateEnvironmentVariable({
variables: {
id,
environmentVariable: {
prodValue,
devValue,
},
},
});
await toast.promise(
updateEnvironmentVariablePromise,
{
loading: 'Updating environment variable...',
success: 'Environment variable has been updated successfully.',
error: 'An error occurred while updating the environment variable.',
},
toastStyleProps,
);
onSubmit?.();
}
return (
<FormProvider {...form}>
<BaseEnvironmentVariableForm onSubmit={handleSubmit} {...props} />
</FormProvider>
);
}

View File

@@ -0,0 +1,2 @@
export * from './EditEnvironmentVariableForm';
export { default } from './EditEnvironmentVariableForm';

View File

@@ -0,0 +1,234 @@
import { useDialog } from '@/components/common/DialogProvider';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { EnvironmentVariable } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Button from '@/ui/v2/Button';
import Divider from '@/ui/v2/Divider';
import { Dropdown } from '@/ui/v2/Dropdown';
import IconButton from '@/ui/v2/IconButton';
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useDeleteEnvironmentVariableMutation,
useGetEnvironmentVariablesQuery,
} from '@/utils/__generated__/graphql';
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
import { Fragment } from 'react';
import toast from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
export interface PermissionVariableSettingsFormValues {
/**
* Permission variables.
*/
environmentVariables: EnvironmentVariable[];
}
export default function EnvironmentVariableSettings() {
const { openDialog, openAlertDialog } = useDialog();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetEnvironmentVariablesQuery({
variables: {
id: currentApplication?.id,
},
});
const [deleteEnvironmentVariable] = useDeleteEnvironmentVariableMutation({
refetchQueries: ['getEnvironmentVariables'],
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading environment variables..."
/>
);
}
if (error) {
throw error;
}
async function handleDeleteVariable({ id }: EnvironmentVariable) {
const deleteEnvironmentVariablePromise = deleteEnvironmentVariable({
variables: {
id,
},
});
await toast.promise(
deleteEnvironmentVariablePromise,
{
loading: 'Deleting environment variable...',
success: 'Environment variable has been deleted successfully.',
error: 'An error occurred while deleting the environment variable.',
},
toastStyleProps,
);
}
function handleOpenCreator() {
openDialog('CREATE_ENVIRONMENT_VARIABLE', {
title: 'Create Environment Variable',
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'gap-2 max-w-sm' },
},
});
}
function handleOpenEditor(originalVariable: EnvironmentVariable) {
openDialog('EDIT_ENVIRONMENT_VARIABLE', {
title: 'Edit Environment Variables',
payload: { originalEnvironmentVariable: originalVariable },
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'gap-2 max-w-sm' },
},
});
}
function handleConfirmDelete(originalVariable: EnvironmentVariable) {
openAlertDialog({
title: 'Delete Environment Variable',
payload: (
<Text>
Are you sure you want to delete the &quot;
<strong>{originalVariable.name}</strong>&quot; environment variable?
This cannot be undone.
</Text>
),
props: {
primaryButtonColor: 'error',
primaryButtonText: 'Delete',
onPrimaryAction: () => handleDeleteVariable(originalVariable),
},
});
}
const availableEnvironmentVariables =
[...data.environmentVariables].sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
) || [];
return (
<SettingsContainer
title="Project Environment Variables"
description="Environment Variables are key-value pairs configured outside your source code. They are used to store environment-specific values such as API keys."
docsLink="https://docs.nhost.io/platform/environment-variables"
docsTitle="Environment Variables"
rootClassName="gap-0"
className="px-0 my-2"
slotProps={{ submitButton: { className: 'hidden' } }}
>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-2 border-b-1 border-gray-200 px-4 py-3">
<Text className="font-medium">Variable Name</Text>
<Text className="font-medium lg:col-span-2">Updated</Text>
</div>
<div className="grid grid-flow-row gap-2">
<List>
{availableEnvironmentVariables.map((environmentVariable, index) => {
const timestamp = formatDistanceToNowStrict(
parseISO(environmentVariable.updatedAt),
{ addSuffix: true, roundingMethod: 'floor' },
);
return (
<Fragment key={environmentVariable.id}>
<ListItem.Root
className="px-4 grid grid-cols-2 lg:grid-cols-3 gap-2"
secondaryAction={
<Dropdown.Root>
<Dropdown.Trigger
asChild
hideChevron
className="absolute right-4 top-1/2 -translate-y-1/2"
>
<IconButton variant="borderless" color="secondary">
<DotsVerticalIcon />
</IconButton>
</Dropdown.Trigger>
<Dropdown.Content
menu
PaperProps={{ className: 'w-32' }}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Dropdown.Item
onClick={() => handleOpenEditor(environmentVariable)}
>
<Text className="font-medium">Edit</Text>
</Dropdown.Item>
<Divider component="li" />
<Dropdown.Item
onClick={() =>
handleConfirmDelete(environmentVariable)
}
>
<Text
className="font-medium"
sx={{
color: (theme) => theme.palette.error.main,
}}
>
Delete
</Text>
</Dropdown.Item>
</Dropdown.Content>
</Dropdown.Root>
}
>
<ListItem.Text className="truncate">
{environmentVariable.name}
</ListItem.Text>
<Text variant="subtitle1" className="lg:col-span-2 truncate">
{timestamp === '0 seconds ago' ||
timestamp === 'in 0 seconds'
? 'Now'
: timestamp}
</Text>
</ListItem.Root>
<Divider
component="li"
className={twMerge(
index === availableEnvironmentVariables.length - 1
? '!mt-4'
: '!my-4',
)}
/>
</Fragment>
);
})}
</List>
<Button
className="justify-self-start mx-4"
variant="borderless"
startIcon={<PlusIcon />}
onClick={handleOpenCreator}
>
Create Environment Variable
</Button>
</div>
</SettingsContainer>
);
}

View File

@@ -0,0 +1,2 @@
export * from './EnvironmentVariableSettings';
export { default } from './EnvironmentVariableSettings';

View File

@@ -0,0 +1,209 @@
import { useDialog } from '@/components/common/DialogProvider';
import InlineCode from '@/components/common/InlineCode';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useAppClient } from '@/hooks/useAppClient';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Button from '@/ui/v2/Button';
import Divider from '@/ui/v2/Divider';
import IconButton from '@/ui/v2/IconButton';
import EyeIcon from '@/ui/v2/icons/EyeIcon';
import EyeOffIcon from '@/ui/v2/icons/EyeOffIcon';
import Input from '@/ui/v2/Input';
import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
import { LOCAL_HASURA_URL } from '@/utils/env';
import { generateRemoteAppUrl } from '@/utils/helpers';
import { useGetAppInjectedVariablesQuery } from '@/utils/__generated__/graphql';
import { Fragment, useState } from 'react';
export default function SystemEnvironmentVariableSettings() {
const [showAdminSecret, setShowAdminSecret] = useState(false);
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
const { openAlertDialog } = useDialog();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetAppInjectedVariablesQuery({
variables: { id: currentApplication?.id },
});
const appClient = useAppClient({ start: false });
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading system environment variables..."
/>
);
}
if (error) {
throw error;
}
function showJwtSecret() {
openAlertDialog({
title: 'Auth JWT Secret',
payload: (
<div className="grid grid-flow-row gap-2">
<Text variant="subtitle2">
This is the key used for generating JWTs. It&apos;s HMAC-SHA-based
and the same as configured in Hasura.
</Text>
<Input
defaultValue={data?.app?.hasuraGraphqlJwtSecret}
disabled
fullWidth
multiline
minRows={5}
hideEmptyHelperText
inputProps={{ className: 'font-mono' }}
/>
</div>
),
props: {
hidePrimaryAction: true,
secondaryButtonText: 'Close',
},
});
}
const hasuraUrl =
process.env.NEXT_PUBLIC_ENV === 'dev'
? LOCAL_HASURA_URL
: generateRemoteAppUrl(currentApplication.subdomain);
const systemEnvironmentVariables = [
{
key: 'NHOST_BACKEND_URL',
value: generateRemoteAppUrl(currentApplication.subdomain),
},
{ key: 'NHOST_SUBDOMAIN', value: currentApplication.subdomain },
{ key: 'NHOST_REGION', value: currentApplication.region.awsName },
{ key: 'NHOST_HASURA_URL', value: `${hasuraUrl}/console` },
{ key: 'NHOST_AUTH_URL', value: appClient.auth.url },
{ key: 'NHOST_GRAPHQL_URL', value: appClient.graphql.url },
{ key: 'NHOST_STORAGE_URL', value: appClient.storage.url },
{ key: 'NHOST_FUNCTIONS_URL', value: appClient.functions.url },
];
return (
<SettingsContainer
title="System Environment Variables"
description="Environment Variables are key-value pairs configured outside your source code. They are used to store environment-specific values such as API keys."
docsLink="https://docs.nhost.io/platform/environment-variables#system-environment-variables"
rootClassName="gap-0"
className="px-0 mt-2 mb-2.5"
slotProps={{ submitButton: { className: 'invisible' } }}
>
<div className="grid grid-cols-3 border-b-1 gap-2 border-gray-200 px-4 py-3">
<Text className="font-medium">Variable Name</Text>
<Text className="font-medium lg:col-span-2">Value</Text>
</div>
<List>
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 gap-2">
<ListItem.Text>NHOST_ADMIN_SECRET</ListItem.Text>
<div className="grid grid-flow-col lg:col-span-2 gap-2 items-center justify-start">
<Text className="text-greyscaleGreyDark truncate">
{showAdminSecret ? (
<InlineCode className="!text-sm font-medium max-h-[initial] h-[initial]">
{currentApplication?.hasuraGraphqlAdminSecret}
</InlineCode>
) : (
'●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●'
)}
</Text>
<IconButton
variant="borderless"
color="secondary"
aria-label={
showAdminSecret ? 'Hide Admin Secret' : 'Show Admin Secret'
}
onClick={() => setShowAdminSecret((show) => !show)}
>
{showAdminSecret ? (
<EyeOffIcon className="w-5 h-5" />
) : (
<EyeIcon className="w-5 h-5" />
)}
</IconButton>
</div>
</ListItem.Root>
<Divider component="li" className="!my-4" />
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 gap-2">
<ListItem.Text>NHOST_WEBHOOK_SECRET</ListItem.Text>
<div className="grid grid-flow-col gap-2 lg:col-span-2 items-center justify-start">
<Text className="text-greyscaleGreyDark truncate">
{showWebhookSecret ? (
<InlineCode className="!text-sm font-medium max-h-[initial] h-[initial]">
{data?.app?.webhookSecret}
</InlineCode>
) : (
'●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●'
)}
</Text>
<IconButton
variant="borderless"
color="secondary"
aria-label={
showWebhookSecret
? 'Hide Webhook Secret'
: 'Show Webhook Secret'
}
onClick={() => setShowWebhookSecret((show) => !show)}
>
{showWebhookSecret ? (
<EyeOffIcon className="w-5 h-5" />
) : (
<EyeIcon className="w-5 h-5" />
)}
</IconButton>
</div>
</ListItem.Root>
<Divider component="li" className="!my-4" />
{systemEnvironmentVariables.map((environmentVariable, index) => (
<Fragment key={environmentVariable.key}>
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 gap-2">
<ListItem.Text>{environmentVariable.key}</ListItem.Text>
<Text className="truncate lg:col-span-2">
{environmentVariable.value}
</Text>
</ListItem.Root>
{index !== systemEnvironmentVariables.length - 1 && (
<Divider className="!my-4" />
)}
</Fragment>
))}
<Divider component="li" className="!mt-4 !mb-2.5" />
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 justify-start">
<ListItem.Text>NHOST_JWT_SECRET</ListItem.Text>
<Button
variant="borderless"
onClick={showJwtSecret}
size="small"
className="justify-self-start"
>
Show JWT Secret
</Button>
</ListItem.Root>
</List>
</SettingsContainer>
);
}

View File

@@ -0,0 +1 @@
export { default } from './SystemEnvironmentVariableSettings';

View File

@@ -0,0 +1,141 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import * as Yup from 'yup';
export interface BasePermissionVariableFormValues {
/**
* Permission variable key.
*/
key: string;
/**
* Permission variable value.
*/
value: string;
}
export interface BasePermissionVariableFormProps {
/**
* Function to be called when the form is submitted.
*/
onSubmit: (values: BasePermissionVariableFormValues) => void;
/**
* Function to be called when the operation is cancelled.
*/
onCancel?: VoidFunction;
/**
* Submit button text.
*
* @default 'Save'
*/
submitButtonText?: string;
}
export const basePermissionVariableValidationSchema = Yup.object({
key: Yup.string().required('This field is required.'),
value: Yup.string().required('This field is required.'),
});
export default function BasePermissionVariableForm({
onSubmit,
onCancel,
submitButtonText = 'Save',
}: BasePermissionVariableFormProps) {
const { onDirtyStateChange } = useDialog();
const form = useFormContext<BasePermissionVariableFormValues>();
const {
register,
formState: { dirtyFields, errors, isSubmitting },
} = form;
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'dialog');
}, [isDirty, onDirtyStateChange]);
return (
<div className="grid grid-flow-row gap-2 px-6 pb-6">
<Text variant="subtitle1" component="span">
Enter the field name and the path you want to use in this permission
variable.
</Text>
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
<Input
{...register('key', {
onChange: (event) => {
if (
event.target.value &&
!/^[a-zA-Z-]+$/gi.test(event.target.value)
) {
// we need to prevent invalid characters from being entered
// eslint-disable-next-line no-param-reassign
event.target.value = event.target.value.replace(
/[^a-zA-Z-]/gi,
'',
);
}
},
})}
id="key"
label="Field Name"
hideEmptyHelperText
error={!!errors.key}
helperText={errors?.key?.message}
fullWidth
autoComplete="off"
autoFocus
slotProps={{ input: { className: '!pl-px' } }}
startAdornment={
<Text className="shrink-0 pl-2 text-greyscaleGrey">X-Hasura-</Text>
}
/>
<Input
{...register('value', {
onChange: (event) => {
if (
event.target.value &&
!/^[a-zA-Z-_.[\]]+$/gi.test(event.target.value)
) {
// we need to prevent invalid characters from being entered
// eslint-disable-next-line no-param-reassign
event.target.value = event.target.value.replace(
/[^a-zA-Z-.[\]]/gi,
'',
);
}
},
})}
id="value"
label="Path"
hideEmptyHelperText
error={!!errors.value}
helperText={errors?.value?.message}
fullWidth
autoComplete="off"
slotProps={{ input: { className: '!pl-px' } }}
startAdornment={
<Text className="shrink-0 pl-2 text-greyscaleGrey">user.</Text>
}
/>
<div className="grid grid-flow-row gap-2">
<Button type="submit" loading={isSubmitting}>
{submitButtonText}
</Button>
<Button variant="outlined" color="secondary" onClick={onCancel}>
Cancel
</Button>
</div>
</Form>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export * from './BasePermissionVariableForm';
export { default } from './BasePermissionVariableForm';

View File

@@ -0,0 +1,119 @@
import type {
BasePermissionVariableFormProps,
BasePermissionVariableFormValues,
} from '@/components/settings/permissions/BasePermissionVariableForm';
import BasePermissionVariableForm, {
basePermissionVariableValidationSchema,
} from '@/components/settings/permissions/BasePermissionVariableForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
import getPermissionVariablesObject from '@/utils/settings/getPermissionVariablesObject';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetAppCustomClaimsQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
export interface CreatePermissionVariableFormProps
extends Pick<BasePermissionVariableFormProps, 'onCancel'> {
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
}
export default function CreatePermissionVariableForm({
onSubmit,
...props
}: CreatePermissionVariableFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, error, loading } = useGetAppCustomClaimsQuery({
variables: { id: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const form = useForm<BasePermissionVariableFormValues>({
defaultValues: {
key: '',
value: '',
},
reValidateMode: 'onSubmit',
resolver: yupResolver(basePermissionVariableValidationSchema),
});
const [updateApp] = useUpdateAppMutation({
refetchQueries: ['getAppCustomClaims'],
});
if (loading) {
return (
<ActivityIndicator delay={1000} label="Loading permission variables..." />
);
}
if (error) {
throw error;
}
const { setError } = form;
const availablePermissionVariables = getPermissionVariablesArray(
data?.app?.authJwtCustomClaims,
);
async function handleSubmit({
key,
value,
}: BasePermissionVariableFormValues) {
if (
availablePermissionVariables.some(
(permissionVariable) => permissionVariable.key === key,
)
) {
setError('key', { message: 'This key is already in use.' });
return;
}
const permissionVariablesObject = getPermissionVariablesObject(
availablePermissionVariables.filter(
(permissionVariable) => !permissionVariable.isSystemClaim,
),
);
const updateAppPromise = updateApp({
variables: {
id: currentApplication?.id,
app: {
authJwtCustomClaims: {
...permissionVariablesObject,
[key]: value,
},
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Creating permission variable...',
success: 'Permission variable has been created successfully.',
error:
'An error occurred while trying to create the permission variable.',
},
toastStyleProps,
);
await onSubmit?.();
}
return (
<FormProvider {...form}>
<BasePermissionVariableForm onSubmit={handleSubmit} {...props} />
</FormProvider>
);
}

View File

@@ -0,0 +1,2 @@
export * from './CreatePermissionVariableForm';
export { default } from './CreatePermissionVariableForm';

View File

@@ -0,0 +1,142 @@
import type {
BasePermissionVariableFormProps,
BasePermissionVariableFormValues,
} from '@/components/settings/permissions/BasePermissionVariableForm';
import BasePermissionVariableForm, {
basePermissionVariableValidationSchema,
} from '@/components/settings/permissions/BasePermissionVariableForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { CustomClaim } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getPermissionVariables from '@/utils/settings/getPermissionVariablesArray';
import getPermissionVariablesObject from '@/utils/settings/getPermissionVariablesObject';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetAppCustomClaimsQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
export interface EditPermissionVariableFormProps
extends Pick<BasePermissionVariableFormProps, 'onCancel'> {
/**
* The permission variable to be edited.
*/
originalVariable: CustomClaim;
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
}
export default function EditPermissionVariableForm({
originalVariable,
onSubmit,
...props
}: EditPermissionVariableFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, error, loading } = useGetAppCustomClaimsQuery({
variables: { id: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const form = useForm<BasePermissionVariableFormValues>({
defaultValues: {
key: originalVariable.key || '',
value: originalVariable.value || '',
},
reValidateMode: 'onSubmit',
resolver: yupResolver(basePermissionVariableValidationSchema),
});
const [updateApp] = useUpdateAppMutation({
refetchQueries: ['getAppCustomClaims'],
});
if (loading) {
return (
<ActivityIndicator delay={1000} label="Loading permission variables..." />
);
}
if (error) {
throw error;
}
const { setError } = form;
const availablePermissionVariables = getPermissionVariables(
data?.app?.authJwtCustomClaims,
);
async function handleSubmit({
key,
value,
}: BasePermissionVariableFormValues) {
if (
availablePermissionVariables.some(
(permissionVariable) =>
permissionVariable.key === key &&
permissionVariable.key !== originalVariable.key,
)
) {
setError('key', { message: 'This key is already in use.' });
return;
}
const originalPermissionVariableIndex =
availablePermissionVariables.findIndex(
(permissionVariable) => permissionVariable.key === originalVariable.key,
);
const updatedPermissionVariables = availablePermissionVariables.map(
(permissionVariable, index) => {
if (index === originalPermissionVariableIndex) {
return { key, value };
}
return permissionVariable;
},
);
const permissionVariablesObject = getPermissionVariablesObject(
updatedPermissionVariables.filter(
(permissionVariable) => !permissionVariable.isSystemClaim,
),
);
const updateAppPromise = updateApp({
variables: {
id: currentApplication?.id,
app: {
authJwtCustomClaims: {
...permissionVariablesObject,
[key]: value,
},
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Updating permission variable...',
success: 'Permission variable has been updated successfully.',
error:
'An error occurred while trying to update the permission variable.',
},
toastStyleProps,
);
await onSubmit?.();
}
return (
<FormProvider {...form}>
<BasePermissionVariableForm onSubmit={handleSubmit} {...props} />
</FormProvider>
);
}

View File

@@ -0,0 +1,2 @@
export * from './EditPermissionVariableForm';
export { default } from './EditPermissionVariableForm';

View File

@@ -0,0 +1,249 @@
import { useDialog } from '@/components/common/DialogProvider';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { CustomClaim } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Button from '@/ui/v2/Button';
import Divider from '@/ui/v2/Divider';
import { Dropdown } from '@/ui/v2/Dropdown';
import IconButton from '@/ui/v2/IconButton';
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
import LockIcon from '@/ui/v2/icons/LockIcon';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
import Tooltip from '@/ui/v2/Tooltip';
import getPermissionVariables from '@/utils/settings/getPermissionVariablesArray';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetAppCustomClaimsQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { Fragment } from 'react';
import toast from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
export interface PermissionVariableSettingsFormValues {
/**
* Permission variables.
*/
authJwtCustomClaims: CustomClaim[];
}
export default function PermissionVariableSettings() {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { openDialog, openAlertDialog } = useDialog();
const { data, loading, error } = useGetAppCustomClaimsQuery({
variables: {
id: currentApplication?.id,
},
});
const [updateApp] = useUpdateAppMutation({
refetchQueries: ['getAppCustomClaims'],
});
if (loading) {
return (
<ActivityIndicator delay={1000} label="Loading permission variables..." />
);
}
if (error) {
throw error;
}
async function handleDeleteVariable({ key }: CustomClaim) {
const filteredCustomClaims = Object.keys(
data?.app?.authJwtCustomClaims,
).filter((customClaimKey) => customClaimKey !== key);
const updateAppPromise = updateApp({
variables: {
id: currentApplication?.id,
app: {
authJwtCustomClaims: filteredCustomClaims.reduce(
(customClaims, currentKey) => ({
...customClaims,
[currentKey]: data?.app?.authJwtCustomClaims[currentKey],
}),
{},
),
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Deleting permission variable...',
success: 'Permission variable has been deleted successfully.',
error: 'An error occurred while trying to delete permission variable.',
},
toastStyleProps,
);
}
function handleOpenCreator() {
openDialog('CREATE_PERMISSION_VARIABLE', {
title: 'Create Permission Variable',
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'max-w-sm' },
},
});
}
function handleOpenEditor(originalVariable: CustomClaim) {
openDialog('EDIT_PERMISSION_VARIABLE', {
title: 'Edit Permission Variable',
payload: { originalVariable },
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'max-w-sm' },
},
});
}
function handleConfirmDelete(originalVariable: CustomClaim) {
openAlertDialog({
title: 'Delete Permission Variable',
payload: (
<Text>
Are you sure you want to delete the &quot;
<strong>X-Hasura-{originalVariable.key}</strong>&quot; permission
variable? This cannot be undone.
</Text>
),
props: {
onPrimaryAction: () => handleDeleteVariable(originalVariable),
primaryButtonColor: 'error',
primaryButtonText: 'Delete',
},
});
}
const availablePermissionVariables = getPermissionVariables(
data?.app?.authJwtCustomClaims,
);
return (
<SettingsContainer
title="Permission Variables"
description="Permission variables are used to define permission rules in the GraphQL API."
docsLink="https://docs.nhost.io/graphql/permissions"
rootClassName="gap-0"
className="px-0 my-2"
slotProps={{ submitButton: { className: 'invisible' } }}
>
<div className="grid grid-cols-2 border-b-1 border-gray-200 px-4 py-3">
<Text className="font-medium">Field name</Text>
<Text className="font-medium">Path</Text>
</div>
<div className="grid grid-flow-row gap-2">
<List>
{availablePermissionVariables.map((customClaim, index) => (
<Fragment key={customClaim.key}>
<ListItem.Root
className="px-4 grid grid-cols-2"
secondaryAction={
<Dropdown.Root>
<Tooltip
title={
customClaim.isSystemClaim
? "You can't edit system permission variables"
: ''
}
placement="right"
disableHoverListener={!customClaim.isSystemClaim}
hasDisabledChildren={customClaim.isSystemClaim}
className="absolute right-4 top-1/2 -translate-y-1/2"
>
<Dropdown.Trigger asChild hideChevron>
<IconButton
variant="borderless"
color="secondary"
disabled={customClaim.isSystemClaim}
>
<DotsVerticalIcon />
</IconButton>
</Dropdown.Trigger>
</Tooltip>
<Dropdown.Content
menu
PaperProps={{ className: 'w-32' }}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Dropdown.Item
onClick={() => handleOpenEditor(customClaim)}
>
<Text className="font-medium">Edit</Text>
</Dropdown.Item>
<Divider component="li" />
<Dropdown.Item
onClick={() => handleConfirmDelete(customClaim)}
>
<Text
className="font-medium"
sx={{
color: (theme) => theme.palette.error.main,
}}
>
Delete
</Text>
</Dropdown.Item>
</Dropdown.Content>
</Dropdown.Root>
}
>
<ListItem.Text
primary={
<>
X-Hasura-{customClaim.key}{' '}
{customClaim.isSystemClaim && (
<LockIcon className="w-4 h-4" />
)}
</>
}
/>
<Text className="font-medium">user.{customClaim.value}</Text>
</ListItem.Root>
<Divider
component="li"
className={twMerge(
index === availablePermissionVariables.length - 1
? '!mt-4'
: '!my-4',
)}
/>
</Fragment>
))}
</List>
<Button
className="justify-self-start mx-4"
variant="borderless"
startIcon={<PlusIcon />}
onClick={handleOpenCreator}
>
Create Permission Variable
</Button>
</div>
</SettingsContainer>
);
}

View File

@@ -0,0 +1,2 @@
export * from './PermissionVariableSettings';
export { default } from './PermissionVariableSettings';

View File

@@ -0,0 +1,90 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import * as Yup from 'yup';
export interface BaseRoleFormValues {
/**
* The name of the role.
*/
name: string;
}
export interface BaseRoleFormProps {
/**
* Function to be called when the form is submitted.
*/
onSubmit: (values: BaseRoleFormValues) => void;
/**
* Function to be called when the operation is cancelled.
*/
onCancel?: VoidFunction;
/**
* Submit button text.
*
* @default 'Save'
*/
submitButtonText?: string;
}
export const baseRoleFormValidationSchema = Yup.object({
name: Yup.string().required('This field is required.'),
});
export default function BaseRoleForm({
onSubmit,
onCancel,
submitButtonText = 'Save',
}: BaseRoleFormProps) {
const { onDirtyStateChange } = useDialog();
const form = useFormContext<BaseRoleFormValues>();
const {
register,
formState: { errors, dirtyFields, isSubmitting },
} = form;
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'dialog');
}, [isDirty, onDirtyStateChange]);
return (
<div className="grid grid-flow-row gap-2 px-6 pb-6">
<Text variant="subtitle1" component="span">
Enter the name for the role below.
</Text>
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
<Input
{...register('name')}
inputProps={{ maxLength: 100 }}
id="name"
label="Name"
placeholder="Enter value"
hideEmptyHelperText
error={!!errors.name}
helperText={errors?.name?.message}
fullWidth
autoComplete="off"
autoFocus
/>
<div className="grid grid-flow-row gap-2">
<Button type="submit" loading={isSubmitting}>
{submitButtonText}
</Button>
<Button variant="outlined" color="secondary" onClick={onCancel}>
Cancel
</Button>
</div>
</Form>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export * from './BaseRoleForm';
export { default } from './BaseRoleForm';

View File

@@ -0,0 +1,95 @@
import type {
BaseRoleFormProps,
BaseRoleFormValues,
} from '@/components/settings/roles/BaseRoleForm';
import BaseRoleForm, {
baseRoleFormValidationSchema,
} from '@/components/settings/roles/BaseRoleForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getUserRoles from '@/utils/settings/getUserRoles';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetRolesQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
export interface CreateRoleFormProps
extends Pick<BaseRoleFormProps, 'onCancel'> {
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
}
export default function CreateRoleForm({
onSubmit,
...props
}: CreateRoleFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetRolesQuery({
variables: { id: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const form = useForm<BaseRoleFormValues>({
defaultValues: {},
reValidateMode: 'onSubmit',
resolver: yupResolver(baseRoleFormValidationSchema),
});
const [updateApp] = useUpdateAppMutation({ refetchQueries: ['getRoles'] });
if (loading) {
return <ActivityIndicator delay={1000} label="Loading roles..." />;
}
if (error) {
throw error;
}
const { setError } = form;
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles);
async function handleSubmit({ name }: BaseRoleFormValues) {
if (availableRoles.some((role) => role.name === name)) {
setError('name', { message: 'This role already exists.' });
return;
}
const updateAppPromise = updateApp({
variables: {
id: currentApplication?.id,
app: {
authUserDefaultAllowedRoles: `${data?.app?.authUserDefaultAllowedRoles},${name}`,
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Creating role...',
success: 'Role has been created successfully.',
error: 'An error occurred while trying to create the role.',
},
toastStyleProps,
);
await onSubmit?.();
}
return (
<FormProvider {...form}>
<BaseRoleForm
submitButtonText="Create"
onSubmit={handleSubmit}
{...props}
/>
</FormProvider>
);
}

View File

@@ -0,0 +1,2 @@
export * from './CreateRoleForm';
export { default } from './CreateRoleForm';

View File

@@ -0,0 +1,125 @@
import type {
BaseRoleFormProps,
BaseRoleFormValues,
} from '@/components/settings/roles/BaseRoleForm';
import BaseRoleForm, {
baseRoleFormValidationSchema,
} from '@/components/settings/roles/BaseRoleForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { Role } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getUserRoles from '@/utils/settings/getUserRoles';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetRolesQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
export interface EditRoleFormProps extends Pick<BaseRoleFormProps, 'onCancel'> {
/**
* The role to be edited.
*/
originalRole: Role;
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
}
export default function EditRoleForm({
originalRole,
onSubmit,
...props
}: EditRoleFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetRolesQuery({
variables: { id: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const form = useForm<BaseRoleFormValues>({
defaultValues: {
name: originalRole.name || '',
},
reValidateMode: 'onSubmit',
resolver: yupResolver(baseRoleFormValidationSchema),
});
const [updateApp] = useUpdateAppMutation({
refetchQueries: ['getRoles'],
});
if (loading) {
return <ActivityIndicator delay={1000} label="Loading roles..." />;
}
if (error) {
throw error;
}
const { setError } = form;
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles);
async function handleSubmit({ name }: BaseRoleFormValues) {
if (
availableRoles.some(
(role) => role.name === name && role.name !== originalRole.name,
)
) {
setError('name', { message: 'This role already exists.' });
return;
}
const defaultAllowedRolesList =
data?.app?.authUserDefaultAllowedRoles.split(',') || [];
const originalRoleIndex = defaultAllowedRolesList.findIndex(
(role) => role.trim() === originalRole.name,
);
const updatedDefaultAllowedRoles = defaultAllowedRolesList
.map((role, index) => {
if (index === originalRoleIndex) {
return name;
}
return role;
})
.join(',');
const updateAppPromise = updateApp({
variables: {
id: currentApplication?.id,
app: {
authUserDefaultRole:
data?.app?.authUserDefaultRole === originalRole.name
? name
: data?.app?.authUserDefaultRole,
authUserDefaultAllowedRoles: updatedDefaultAllowedRoles,
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Updating role...',
success: 'Role has been updated successfully.',
error: 'An error occurred while trying to update the role.',
},
toastStyleProps,
);
await onSubmit?.();
}
return (
<FormProvider {...form}>
<BaseRoleForm onSubmit={handleSubmit} {...props} />
</FormProvider>
);
}

View File

@@ -0,0 +1,2 @@
export * from './EditRoleForm';
export { default } from './EditRoleForm';

View File

@@ -0,0 +1,270 @@
import { useDialog } from '@/components/common/DialogProvider';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { Role } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Button from '@/ui/v2/Button';
import Chip from '@/ui/v2/Chip';
import Divider from '@/ui/v2/Divider';
import { Dropdown } from '@/ui/v2/Dropdown';
import IconButton from '@/ui/v2/IconButton';
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
import LockIcon from '@/ui/v2/icons/LockIcon';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
import getUserRoles from '@/utils/settings/getUserRoles';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetRolesQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { Fragment } from 'react';
import toast from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
export interface RoleSettingsFormValues {
/**
* Default role.
*/
authUserDefaultRole: string;
/**
* Allowed roles for the project.
*/
authUserDefaultAllowedRoles: Role[];
}
export default function RoleSettings() {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { openDialog, openAlertDialog } = useDialog();
const { data, loading, error } = useGetRolesQuery({
variables: { id: currentApplication?.id },
});
const [updateApp] = useUpdateAppMutation({
refetchQueries: ['getRoles'],
});
if (loading) {
return <ActivityIndicator delay={1000} label="Loading user roles..." />;
}
if (error) {
throw error;
}
async function handleSetAsDefault({ name }: Role) {
const updateAppPromise = updateApp({
variables: {
id: currentApplication?.id,
app: {
authUserDefaultRole: name,
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Updating default role...',
success: 'Default role has been updated successfully.',
error: 'An error occurred while trying to update the default role.',
},
toastStyleProps,
);
}
async function handleDeleteRole({ name }: Role) {
const filteredRoles = data?.app?.authUserDefaultAllowedRoles
.split(',')
.filter((role) => role !== name)
.join(',');
const updateAppPromise = updateApp({
variables: {
id: currentApplication?.id,
app: {
authUserDefaultAllowedRoles: filteredRoles,
authUserDefaultRole:
name === data?.app?.authUserDefaultRole
? 'user'
: data?.app?.authUserDefaultRole,
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Deleting role...',
success: 'Role has been deleted successfully.',
error: 'An error occurred while trying to delete the role.',
},
toastStyleProps,
);
}
function handleOpenCreator() {
openDialog('CREATE_ROLE', {
title: 'Create Role',
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'max-w-sm' },
},
});
}
function handleOpenEditor(originalRole: Role) {
openDialog('EDIT_ROLE', {
title: 'Edit Role',
payload: { originalRole },
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'max-w-sm' },
},
});
}
function handleConfirmDelete(originalRole: Role) {
openAlertDialog({
title: 'Delete Role',
payload: (
<Text>
Are you sure you want to delete the &quot;
<strong>{originalRole.name}</strong>&quot; role? This cannot be
undone.
</Text>
),
props: {
onPrimaryAction: () => handleDeleteRole(originalRole),
primaryButtonColor: 'error',
primaryButtonText: 'Delete',
},
});
}
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles);
return (
<SettingsContainer
title="Roles"
description="Roles are used to control access to your application."
docsLink="https://docs.nhost.io/authentication/users#roles"
rootClassName="gap-0"
className="px-0 my-2"
slotProps={{ submitButton: { className: 'invisible' } }}
>
<div className="border-b-1 border-gray-200 px-4 py-3">
<Text className="font-medium">Name</Text>
</div>
<div className="grid grid-flow-row gap-2">
<List>
{availableRoles.map((role, index) => (
<Fragment key={role.name}>
<ListItem.Root
className="px-4"
secondaryAction={
<Dropdown.Root>
<Dropdown.Trigger
asChild
hideChevron
className="absolute right-4 top-1/2 -translate-y-1/2"
>
<IconButton variant="borderless" color="secondary">
<DotsVerticalIcon />
</IconButton>
</Dropdown.Trigger>
<Dropdown.Content
menu
PaperProps={{ className: 'w-32' }}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Dropdown.Item onClick={() => handleSetAsDefault(role)}>
<Text className="font-medium">Set as Default</Text>
</Dropdown.Item>
<Divider component="li" />
<Dropdown.Item
disabled={role.isSystemRole}
onClick={() => handleOpenEditor(role)}
>
<Text className="font-medium">Edit</Text>
</Dropdown.Item>
<Divider component="li" />
<Dropdown.Item
disabled={role.isSystemRole}
onClick={() => handleConfirmDelete(role)}
>
<Text
className="font-medium"
sx={{
color: (theme) => theme.palette.error.main,
}}
>
Delete
</Text>
</Dropdown.Item>
</Dropdown.Content>
</Dropdown.Root>
}
>
<ListItem.Text
primaryTypographyProps={{
className:
'inline-grid grid-flow-col gap-1 items-center h-6 font-medium',
}}
primary={
<>
{role.name}
{role.isSystemRole && <LockIcon className="w-4 h-4" />}
{data?.app?.authUserDefaultRole === role.name && (
<Chip
component="span"
color="info"
size="small"
label="Default"
/>
)}
</>
}
/>
</ListItem.Root>
<Divider
component="li"
className={twMerge(
index === availableRoles.length - 1 ? '!mt-4' : '!my-4',
)}
/>
</Fragment>
))}
</List>
<Button
className="justify-self-start mx-4"
variant="borderless"
startIcon={<PlusIcon />}
onClick={handleOpenCreator}
>
Create Role
</Button>
</div>
</SettingsContainer>
);
}

View File

@@ -0,0 +1,2 @@
export * from './RoleSettings';
export { default } from './RoleSettings';

View File

@@ -99,10 +99,14 @@ export default function EmailAndPasswordSettings() {
className="grid grid-flow-row"
showSwitch
enabled
switchProps={{ disabled: true }}
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
switch: {
disabled: true,
},
submitButton: {
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
},
}}
>
<ControlledCheckbox

View File

@@ -1,30 +0,0 @@
import clsx from 'clsx';
import type { HTMLAttributes } from 'react';
export interface DividerProps extends HTMLAttributes<HTMLDivElement> {
/**
* Determines the vertical margin of the divider.
*
* @default 'high'
*/
spacing?: 'low' | 'medium' | 'high';
/**
* Arbitrary classnames to be added to the divider.
*
*/
className?: string;
}
export function Divider({ spacing = 'high', className }: DividerProps) {
return (
<div
className={clsx(
'order-3 mx-auto h-[0.25px] w-full self-stretch bg-verydark opacity-20',
spacing === 'low' && 'my-12',
spacing === 'medium' && 'my-16',
spacing === 'high' && 'my-20',
className,
)}
/>
);
}

View File

@@ -1,121 +0,0 @@
import clsx from 'clsx';
import type {
DetailedHTMLProps,
ForwardedRef,
HTMLProps,
ReactNode,
} from 'react';
import { forwardRef } from 'react';
export interface InputFieldProps
extends DetailedHTMLProps<HTMLProps<HTMLInputElement>, HTMLInputElement> {
/**
* Props to be passed to the input wrapper.
*/
wrapperProps?: Omit<
DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>,
'children'
>;
/**
* Props to be passed to the label element.
*/
labelProps?: Omit<
DetailedHTMLProps<HTMLProps<HTMLLabelElement>, HTMLLabelElement>,
'htmlFor' | 'children'
>;
/**
* Input label.
*/
label?: string;
/**
* Start input adornment for this component.
*/
startAdornment?: ReactNode;
/**
* Error to be displayed.
*/
error?: ReactNode;
}
const InlineInput = forwardRef(
(
{
label,
labelProps,
startAdornment,
wrapperProps,
className,
error,
...props
}: InputFieldProps,
ref: ForwardedRef<HTMLInputElement>,
) => {
const { className: labelClassName, ...restLabelProps } = labelProps || {};
const { className: wrapperClassName, ...restWrapperProps } =
wrapperProps || {};
return (
<div className="grid grid-flow-row gap-1">
<div
className={clsx(
'grid grid-cols-5 items-center gap-y-1 py-1.5',
wrapperClassName,
)}
{...restWrapperProps}
>
{label && (
<label
htmlFor={props.id}
className={clsx(
'col-span-2 text-sm+ font-medium text-greyscaleDark',
labelClassName,
)}
{...restLabelProps}
>
{label}
</label>
)}
<div
className={clsx(
'flex flex-row place-content-start items-center rounded-sm px-2 py-1',
error
? 'outline outline-2 outline-red'
: 'focus-within:outline focus-within:outline-2 focus-within:outline-blue',
label ? 'col-span-3' : 'col-span-5',
)}
>
{startAdornment && (
<label className="flex-shrink-0" htmlFor={props.id}>
{startAdornment}
</label>
)}
<input
className={clsx(
'h-full w-full rounded-sm+ px-0.5 font-display text-sm+ text-greyscaleDark focus:outline-none',
className,
)}
aria-invalid={Boolean(error)}
ref={ref}
{...props}
/>
</div>
{error && (
<div
className="col-span-5 text-right text-xs text-red"
role="alert"
>
{error}
</div>
)}
</div>
</div>
);
},
);
InlineInput.displayName = 'NhostInlineInput';
export default InlineInput;

View File

@@ -1,2 +0,0 @@
export * from './InlineInput';
export { default } from './InlineInput';

View File

@@ -1,77 +0,0 @@
import type { PrefetchNewAppPlansFragment } from '@/generated/graphql';
import { Text } from '@/ui/Text';
import Checkbox from '@/ui/v2/Checkbox';
import { planDescriptions } from '@/utils/planDescriptions';
import { RadioGroup } from '@headlessui/react';
import clsx from 'clsx';
import React from 'react';
interface PlanSelectorProps {
options: PrefetchNewAppPlansFragment[];
value: PrefetchNewAppPlansFragment;
onChange:
| React.Dispatch<React.SetStateAction<PrefetchNewAppPlansFragment>>
| any;
}
export function PlanSelector({ options, value, onChange }: PlanSelectorProps) {
return (
<RadioGroup value={value} onChange={onChange}>
<RadioGroup.Label className="sr-only">Pricing plans</RadioGroup.Label>
<div className="relative divide-y-1 border-t-1 border-b-1 bg-white">
{options.map((plan) => (
<RadioGroup.Option key={plan.name} value={plan}>
{({ checked }) => (
<div className="cu flex cursor-pointer flex-row place-content-between items-center py-4 font-display">
<RadioGroup.Label
as="div"
className="flex flex-row font-medium"
>
<Checkbox
aria-describedby="plan"
checked={plan.name === value.name}
/>
<div className="flex w-80">
<div className=" self-center pl-2 text-xs font-medium text-greyscaleDark">
<span className="font-bold">{plan.name}:</span>{' '}
<span className="leading-4">
{planDescriptions[plan.name]}
</span>
</div>
</div>
</RadioGroup.Label>
<div className="flex">
<span
className={clsx(
'self-center font-medium',
checked ? 'text-indigo-900' : 'text-black',
)}
>
<div className="mr-3 self-center text-lg text-greyscaleDark">
{plan.isFree ? (
'Free'
) : (
<div className="flex flex-row">
$ {plan.price}{' '}
<Text
size="tiny"
className="ml-1 self-center tracking-wide"
>
/ mo
</Text>
</div>
)}
</div>
</span>
</div>
</div>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
);
}
export default PlanSelector;

View File

@@ -1,49 +0,0 @@
import type { ForwardedRef, PropsWithChildren } from 'react';
import { forwardRef } from 'react';
export interface TooltipProps extends PropsWithChildren<unknown> {
/**
* Title of the tooltip.
*/
title: string;
/**
* Determine if the tooltip should be shown.
*/
disabled?: boolean;
}
export const Tooltip = forwardRef(
(
{ title, children, disabled }: TooltipProps,
ref: ForwardedRef<HTMLDivElement>,
) => (
<div className="group relative" ref={ref}>
{children}
{!disabled && (
<div className="absolute left-2 bottom-1 z-50 hidden group-hover:block">
<svg
className="absolute -top-2 left-3 z-50 mr-3 h-3 w-3 -rotate-180 transform text-greyscaleDark"
x="0px"
y="0px"
viewBox="0 0 255 255"
xmlSpace="preserve"
>
<polygon
className="border border-greyscaleDark fill-current text-greyscaleDark"
points="0,0 127.5,127.5 255,0"
/>
</svg>
<div className="text-sharp absolute left-0 z-50 mt-1 w-40 origin-top-left rounded-md bg-greyscaleDark p-2 font-display text-sm- font-medium text-white shadow-2xl">
{title}
</div>
</div>
)}
</div>
),
);
Tooltip.displayName = 'NhostTooltip';
export default Tooltip;

View File

@@ -1,2 +0,0 @@
export * from './Tooltip';
export { default } from './Tooltip';

View File

@@ -60,7 +60,7 @@ const BaseButton = forwardRef(
ref={ref}
sx={[
props.size === 'small' && {
padding: (theme) => theme.spacing(0.5, 0.75),
padding: (theme) => theme.spacing(0.5, 0.5),
},
props.size === 'medium' && {
padding: (theme) => theme.spacing(0.875, 1),

View File

@@ -1,18 +1,26 @@
import { styled } from '@mui/material';
import type { ChipProps as MaterialChipProps } from '@mui/material/Chip';
import MaterialChip from '@mui/material/Chip';
import MaterialChip, { chipClasses } from '@mui/material/Chip';
import type { ElementType } from 'react';
export interface ChipProps extends MaterialChipProps {}
export interface ChipProps extends MaterialChipProps {
/**
* Custom component for the root node.
*/
component?: string | ElementType;
}
const Chip = styled(MaterialChip)(({ theme }) => ({
const Chip = styled(MaterialChip)<ChipProps>(({ theme }) => ({
fontFamily: theme.typography.fontFamily,
fontSize: '0.75rem',
fontSize: theme.typography.pxToRem(12),
lineHeight: theme.typography.pxToRem(16),
fontWeight: 500,
lineHeight: '16px',
padding: theme.spacing(1.5, 0.25),
color: theme.palette.text.primary,
borderRadius: '9999px',
backgroundColor: '#EAEDF0',
padding: theme.spacing(0, 0.25),
[`&.${chipClasses.colorInfo}`]: {
backgroundColor: theme.palette.primary.light,
color: theme.palette.primary.main,
},
}));
Chip.displayName = 'NhostChip';

View File

@@ -32,6 +32,9 @@ const StyledMenu = styled(MaterialMenu)({
[`& .${materialMenuClasses.list}`]: {
padding: 0,
},
[`& .${materialMenuClasses.paper}`]: {
boxShadow: '0px 4px 10px rgba(33, 50, 75, 0.25)',
},
});
function DropdownContent({
@@ -68,8 +71,7 @@ function DropdownContent({
sx: [
{
borderRadius: '0.5rem',
boxShadow:
'0px 1px 4px rgba(14, 24, 39, 0.1), 0px 8px 24px rgba(14, 24, 39, 0.1)',
boxShadow: '0px 4px 10px rgba(33, 50, 75, 0.25)',
fontFamily: (theme) => theme.typography.fontFamily,
},
],

View File

@@ -34,10 +34,10 @@ const StyledListItemButton = styled(MaterialListItemButton)(({ theme }) => ({
alignItems: 'center',
gap: theme.spacing(),
[`&.${getListItemButtonUtilityClass('dense')}`]: {
padding: theme.spacing(0.75, 1.25),
padding: theme.spacing(1, 1.25),
},
[`&.${listItemButtonClasses.selected}`]: {
backgroundColor: `#ebf3ff`,
backgroundColor: theme.palette.primary.light,
color: theme.palette.primary.main,
},
[`&.${listItemButtonClasses.selected} > .${listItemTextClasses.root}`]: {
@@ -50,7 +50,7 @@ const StyledListItemButton = styled(MaterialListItemButton)(({ theme }) => ({
color: theme.palette.primary.main,
},
[`&.${listItemButtonClasses.selected}:hover`]: {
backgroundColor: `#ebf3ff`,
backgroundColor: theme.palette.primary.light,
},
}));

View File

@@ -8,11 +8,15 @@ export interface ListItemTextProps extends MaterialListItemTextProps {}
const StyledListItemText = styled(MaterialListItemText)(({ theme }) => ({
color: theme.palette.text.primary,
display: 'grid',
justifyContent: 'start',
gridAutoFlow: 'row',
gap: theme.spacing(0.5),
fontSize: theme.typography.pxToRem(15),
[`&.${listItemTextClasses.root}`]: {
margin: 0,
},
[`& > .${listItemTextClasses.primary}`]: {
fontSize: '0.9375rem',
fontWeight: 500,
textOverflow: 'ellipsis',
overflow: 'hidden',

View File

@@ -0,0 +1,24 @@
import type { IconProps } from '@/ui/v2/icons';
import SvgIcon from '@mui/material/SvgIcon';
function DotsVerticalIcon(props: IconProps) {
return (
<SvgIcon
width="16"
height="16"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
aria-label="Three vertical dots"
{...props}
>
<path
d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM8 4.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM8 14.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
fill="currentColor"
/>
</SvgIcon>
);
}
DotsVerticalIcon.displayName = 'NhostDotsVerticalIcon';
export default DotsVerticalIcon;

View File

@@ -0,0 +1 @@
export { default } from './DotsVerticalIcon';

View File

@@ -0,0 +1,36 @@
import type { IconProps } from '@/ui/v2/icons';
import SvgIcon from '@mui/material/SvgIcon';
function EyeIcon(props: IconProps) {
return (
<SvgIcon
width="16"
height="16"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
aria-label="Eye"
{...props}
>
<path
d="M8 3.5C3 3.5 1 8 1 8s2 4.5 7 4.5S15 8 15 8s-2-4.5-7-4.5Z"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 10.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</SvgIcon>
);
}
EyeIcon.displayName = 'NhostEyeIcon';
export default EyeIcon;

View File

@@ -0,0 +1 @@
export { default } from './EyeIcon';

View File

@@ -0,0 +1,44 @@
import type { IconProps } from '@/ui/v2/icons';
import SvgIcon from '@mui/material/SvgIcon';
function EyeOffIcon(props: IconProps) {
return (
<SvgIcon
width="16"
height="16"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
aria-label="Eye crossed out"
{...props}
>
<path
d="m3 2.5 10 11M9.682 9.85a2.5 2.5 0 0 1-3.364-3.7"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4.625 4.287C2.077 5.577 1 8 1 8s2 4.5 7 4.5a7.376 7.376 0 0 0 3.375-.788M13.038 10.569C14.401 9.349 15 8 15 8s-2-4.5-7-4.5c-.433-.001-.865.034-1.292.105"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8.47 5.544a2.502 2.502 0 0 1 2.02 2.22"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</SvgIcon>
);
}
EyeOffIcon.displayName = 'NhostEyeOffIcon';
export default EyeOffIcon;

View File

@@ -0,0 +1 @@
export { default } from './EyeOffIcon';

View File

@@ -1,29 +0,0 @@
import { Avatar } from '@/ui/Avatar';
import { Text } from '@/ui/Text';
import { nhost } from '@/utils/nhost';
import Image from 'next/image';
export function WorkspaceSelectorNewApp({ option }: any) {
const user = nhost.auth.getUser();
return (
<div className="flex flex-row items-center py-0.5">
{option.name === 'Default Workspace' ? (
<Avatar
className="h-6 w-6 rounded-full"
name={user?.displayName}
avatarUrl={user?.avatarUrl}
/>
) : (
<div className="h-6 w-6 overflow-hidden rounded-md">
<Image src="/logos/new.svg" alt="Nhost Logo" width={24} height={24} />
</div>
)}
<Text className="ml-2 font-medium" color="greyscaleDark">
{option.name}
</Text>
</div>
);
}
export default WorkspaceSelectorNewApp;

View File

@@ -0,0 +1,9 @@
query getEnvironmentVariables($id: uuid!) {
environmentVariables(where: { appId: { _eq: $id } }) {
id
name
updatedAt
prodValue
devValue
}
}

View File

@@ -1,13 +0,0 @@
fragment EnvironmentVariable on environmentVariables {
id
name
updatedAt
prodValue
devValue
}
query getEnvironmentVariablesWhere($where: environmentVariables_bool_exp!) {
environmentVariables(where: $where) {
...EnvironmentVariable
}
}

View File

@@ -0,0 +1,2 @@
export * from './useLeaveConfirm';
export { default } from './useLeaveConfirm';

View File

@@ -0,0 +1,41 @@
import { useDialog } from '@/components/common/DialogProvider';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
export interface UseLeaveConfirmProps {
isDirty?: boolean;
}
export default function useLeaveConfirm({ isDirty }: UseLeaveConfirmProps) {
const router = useRouter();
const { openAlertDialog } = useDialog();
const [isConfirmed, setConfirmed] = useState(false);
useEffect(() => {
function onRouteChangeStart(route: string) {
if (!isDirty || isConfirmed) {
return;
}
openAlertDialog({
title: 'Unsaved changes',
payload:
'You have unsaved local changes. Are you sure you want to discard them?',
props: {
primaryButtonColor: 'error',
primaryButtonText: 'Discard',
onPrimaryAction: () => {
setConfirmed(true);
router.push(route);
},
},
});
throw new Error('Route change aborted');
}
router.events.on('routeChangeStart', onRouteChangeStart);
return () => router.events.off('routeChangeStart', onRouteChangeStart);
}, [isConfirmed, isDirty, openAlertDialog, router, router.events]);
}

View File

@@ -1,15 +0,0 @@
import { useRouter } from 'next/router';
export const useGetAppURL = (): {
workspaceSlug: string;
appSlug: string;
} => {
const router = useRouter();
const workspaceSlug = router.query.workspaceSlug as string;
const appSlug = router.query.appSlug as string;
return { workspaceSlug, appSlug };
};
export default useGetAppURL;

View File

@@ -1,42 +0,0 @@
import type { CustomClaim } from '@/types/application';
import { useGetAppCustomClaimsQuery } from '@/utils/__generated__/graphql';
export type UseCustomClaimsProps = {
/**
* Application identifier.
*/
appId: string;
};
export default function useCustomClaims({ appId }: UseCustomClaimsProps) {
const { data, loading, error } = useGetAppCustomClaimsQuery({
variables: { id: appId },
});
const systemClaims: CustomClaim[] = [
{ key: 'User-Id', value: 'id', system: true },
];
if (data?.app) {
const storedClaims: CustomClaim[] = Object.keys(
data.app.authJwtCustomClaims,
)
.sort()
.map((key) => ({
key,
value: data.app.authJwtCustomClaims[key],
}));
return {
data: systemClaims.concat(storedClaims),
loading,
error,
};
}
return {
data: systemClaims,
loading,
error,
};
}

View File

@@ -1,5 +1,5 @@
import Container from '@/components/layout/Container';
import AllowedEmailDomainsSettings from '@/components/settings/authentication/AllowedEmailSettings/AllowedEmailSettings';
import AllowedEmailDomainsSettings from '@/components/settings/authentication/AllowedEmailSettings';
import AllowedRedirectURLsSettings from '@/components/settings/authentication/AllowedRedirectURLsSettings';
import BlockedEmailSettings from '@/components/settings/authentication/BlockedEmailSettings';
import ClientURLSettings from '@/components/settings/authentication/ClientURLSettings';
@@ -37,8 +37,8 @@ export default function SettingsAuthenticationPage() {
return (
<Container
className="grid max-w-5xl grid-flow-row gap-y-6 bg-fafafa"
wrapperClassName="bg-fafafa"
className="grid max-w-5xl grid-flow-row gap-y-6 bg-transparent"
rootClassName="bg-transparent"
>
<ClientURLSettings />
<AllowedRedirectURLsSettings />
@@ -52,13 +52,5 @@ export default function SettingsAuthenticationPage() {
}
SettingsAuthenticationPage.getLayout = function getLayout(page: ReactElement) {
return (
<SettingsLayout
mainContainerProps={{
className: 'bg-fafafa',
}}
>
{page}
</SettingsLayout>
);
return <SettingsLayout>{page}</SettingsLayout>;
};

View File

@@ -81,8 +81,8 @@ export default function DatabaseSettingsPage() {
return (
<Container
className="grid max-w-5xl grid-flow-row gap-y-6 bg-fafafa"
wrapperClassName="bg-fafafa"
className="grid max-w-5xl grid-flow-row gap-y-6 bg-transparent"
rootClassName="bg-transparent"
>
<SettingsContainer
title="Connection Info"
@@ -136,13 +136,5 @@ export default function DatabaseSettingsPage() {
}
DatabaseSettingsPage.getLayout = function getLayout(page: ReactElement) {
return (
<SettingsLayout
mainContainerProps={{
className: 'bg-fafafa',
}}
>
{page}
</SettingsLayout>
);
return <SettingsLayout>{page}</SettingsLayout>;
};

View File

@@ -1,378 +1,17 @@
import EditEnvVarModal from '@/components/applications/EditEnvVarModal';
import { JWTSecretModal } from '@/components/applications/JWTSecretModal';
import AddEnvVarModal from '@/components/applications/variables/AddEnvVarModal';
import { LoadingScreen } from '@/components/common/LoadingScreen';
import Eye from '@/components/icons/Eye';
import EyeOff from '@/components/icons/EyeOff';
import Container from '@/components/layout/Container';
import EnvironmentVariableSettings from '@/components/settings/environmentVariables/EnvironmentVariableSettings';
import SystemEnvironmentVariableSettings from '@/components/settings/environmentVariables/SystemEnvironmentVariableSettings';
import SettingsLayout from '@/components/settings/SettingsLayout';
import { useWorkspaceContext } from '@/context/workspace-context';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Modal } from '@/ui/Modal';
import Button from '@/ui/v2/Button';
import IconButton from '@/ui/v2/IconButton';
import Link from '@/ui/v2/Link';
import Text from '@/ui/v2/Text';
import { generateRemoteAppUrl } from '@/utils/helpers';
import { triggerToast } from '@/utils/toast';
import type { EnvironmentVariableFragment } from '@/utils/__generated__/graphql';
import {
refetchGetEnvironmentVariablesWhereQuery,
useGetAppInjectedVariablesQuery,
useGetEnvironmentVariablesWhereQuery,
useInsertEnvironmentVariablesMutation,
} from '@/utils/__generated__/graphql';
import { format } from 'date-fns';
import type { ReactElement } from 'react';
import React, { useState } from 'react';
export type SystemVariableModalState = 'SHOW' | 'EDIT' | 'CLOSED';
function EnvHeader() {
return (
<div className="grid grid-flow-row gap-1">
<Text variant="h2" component="h1">
Environment Variables
</Text>
<Link
href="https://docs.nhost.io/platform/environment-variables"
target="_blank"
rel="noreferrer"
underline="hover"
className="justify-self-start font-medium"
>
Documentation
</Link>
</div>
);
}
function AppVariablesHeader() {
return (
<div>
<div className="flex flex-row place-content-between px-2 py-2">
<Text
variant="subtitle2"
className="w-drop font-bold !text-greyscaleDark"
>
Variable name
</Text>
<Text
variant="subtitle2"
className="w-drop font-bold !text-greyscaleDark"
>
Updated
</Text>
<Text
variant="subtitle2"
className="w-drop font-bold !text-greyscaleDark"
>
Overrides
</Text>
</div>
</div>
);
}
function AddNewAppVariable() {
const { workspaceContext } = useWorkspaceContext();
const { appId } = workspaceContext;
const [showModal, setShowModal] = useState(false);
const [insertEnvVar, { loading }] = useInsertEnvironmentVariablesMutation({
refetchQueries: [
refetchGetEnvironmentVariablesWhereQuery({
where: {
appId: {
_eq: appId,
},
},
}),
],
});
return (
<div className="flex flex-row py-1.5">
<Modal showModal={showModal} close={() => setShowModal(!showModal)}>
<AddEnvVarModal
onSubmit={async ({ name, prodValue, devValue }) => {
try {
await insertEnvVar({
variables: {
environmentVariables: [
{
appId,
name,
prodValue,
devValue,
},
],
},
});
triggerToast(
`New environment variable ${name} added successfully.`,
);
} catch (error) {
if (error.message.includes('Uniqueness violation.')) {
triggerToast('Environment variable already exists.');
return;
}
triggerToast('Error adding environment variable.');
return;
}
setShowModal(false);
}}
close={() => setShowModal(false)}
/>
</Modal>
<Button
variant="borderless"
onClick={() => setShowModal(true)}
loading={loading}
size="small"
>
New Variable
</Button>
</div>
);
}
type AppVariableProps = {
envVar: EnvironmentVariableFragment;
};
function AppVariable({ envVar }: AppVariableProps) {
const [showEditModal, setShowEditModal] = useState(false);
return (
<>
{showEditModal && (
<EditEnvVarModal
show={showEditModal}
close={() => {
setShowEditModal(false);
}}
envVar={envVar}
/>
)}
<div
className="flex cursor-pointer flex-row place-content-between px-2 py-2"
role="button"
onClick={() => setShowEditModal(true)}
tabIndex={0}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
setShowEditModal(true);
}}
>
<Text className="w-drop">{envVar.name}</Text>
<Text className="w-drop">
{format(new Date(envVar.updatedAt), 'dd MMM yyyy')}
</Text>
<Text className="w-drop">-</Text>
</div>
</>
);
}
function SectionContainer({ title, children }: any) {
return (
<div className="mt-8 w-full space-y-6">
<Text variant="h3">{title}</Text>
<div className="divide divide-y-1 border-t-1 border-b-1">{children}</div>
</div>
);
}
function AppEnvironmentVariables() {
const { workspaceContext } = useWorkspaceContext();
const { appId } = workspaceContext;
const { data, error } = useGetEnvironmentVariablesWhereQuery({
variables: {
where: {
appId: {
_eq: appId,
},
},
},
fetchPolicy: 'cache-first',
skip: !appId,
});
if (error) {
throw error;
}
if (!data?.environmentVariables) {
return (
<SectionContainer title="Project Environment Variables">
<AppVariablesHeader />
<AddNewAppVariable />
</SectionContainer>
);
}
const { environmentVariables } = data;
return (
<SectionContainer title="Project Environment Variables">
<AppVariablesHeader />
{environmentVariables.map((envVar) => (
<AppVariable key={envVar.id} envVar={envVar} />
))}
<AddNewAppVariable />
</SectionContainer>
);
}
function SensitiveValue({ value }: { value: string | any }) {
const [eye, setEye] = React.useState(false);
if (!value) {
return null;
}
return (
<div className="grid w-full grid-flow-col items-center gap-2">
<button
type="button"
onClick={() => setEye(!eye)}
tabIndex={-1}
aria-label={eye ? 'Hide sensitive value' : 'Reveal sensitive value'}
>
<Text>{eye ? value : Array(value.length).fill('•').join('')}</Text>
</button>
<IconButton
className="p-1"
onClick={() => setEye(!eye)}
aria-label={eye ? 'Hide sensitive value' : 'Reveal sensitive value'}
variant="borderless"
color="secondary"
>
{eye ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</IconButton>
</div>
);
}
interface SystemVariableProps {
envVar: string;
value: string;
sensitive?: boolean;
modal?: boolean;
systemVariableModal?: React.ElementType;
}
function SystemVariable({
envVar,
value,
modal = false,
sensitive = false,
systemVariableModal: SystemVariableModal,
}: SystemVariableProps) {
const [modalState, setModalState] =
useState<SystemVariableModalState>('CLOSED');
return (
<>
{modal && (
<Modal
showModal={modalState === 'SHOW' || modalState === 'EDIT'}
close={() => setModalState('CLOSED')}
>
<SystemVariableModal
close={() => setModalState('CLOSED')}
initialModalState={modalState}
data={value}
/>
</Modal>
)}
<div className="grid grid-flow-col place-content-start items-center gap-3 px-2 py-1.5">
<Text className="w-64 font-medium">{envVar}</Text>
{modal && (
<div className="-my-[4px] grid grid-flow-col gap-1">
<Button
variant="borderless"
onClick={() => setModalState('SHOW')}
size="small"
className="min-w-0"
>
Reveal
</Button>
<span className="self-center align-text-bottom text-sm text-gray-600">
or
</span>
<Button
variant="borderless"
onClick={() => setModalState('EDIT')}
size="small"
className="min-w-0"
>
Edit
</Button>
</div>
)}
{!modal && !sensitive && <Text className="break-all">{value}</Text>}
{!modal && sensitive && <SensitiveValue value={value} />}
</div>
</>
);
}
export default function EnvironmentVariablesPage() {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data } = useGetAppInjectedVariablesQuery({
variables: { id: currentApplication?.id },
skip: !currentApplication,
});
if (
!currentApplication?.subdomain ||
!currentApplication?.hasuraGraphqlAdminSecret
) {
return <LoadingScreen />;
}
const baseUrl = generateRemoteAppUrl(currentApplication?.subdomain);
return (
<Container>
<EnvHeader />
<AppEnvironmentVariables />
<SectionContainer title="System Variables">
<SystemVariable
envVar="NHOST_ADMIN_SECRET"
value={currentApplication.hasuraGraphqlAdminSecret}
sensitive
/>
<SystemVariable
envVar="NHOST_WEBHOOK_SECRET"
value={data?.app?.webhookSecret}
sensitive
/>
<SystemVariable
envVar="NHOST_JWT_SECRET"
value={JSON.stringify(
data?.app?.hasuraGraphqlJwtSecret || '',
).replace(/\\/g, '')}
modal
systemVariableModal={JWTSecretModal}
/>
<SystemVariable envVar="NHOST_BACKEND_URL" value={baseUrl} />
</SectionContainer>
<Container
className="grid grid-flow-row gap-6 max-w-5xl bg-transparent"
rootClassName="bg-transparent"
>
<EnvironmentVariableSettings />
<SystemEnvironmentVariableSettings />
</Container>
);
}

Some files were not shown because too many files have changed in this diff Show More