Compare commits

...

114 Commits

Author SHA1 Message Date
Szilárd Dóró
9c22a616a7 Merge pull request #1687 from nhost/changeset-release/main
chore: update versions
2023-03-01 14:56:43 +01:00
github-actions[bot]
6bc67e95a5 chore: update versions 2023-03-01 13:56:24 +00:00
Szilárd Dóró
0f6074c16f Merge pull request #1686 from nhost/fix/docker-build
fix(dashboard): fix docker build
2023-03-01 14:55:05 +01:00
Szilárd Dóró
c96d7ccdf2 fix(dashboard): fix docker build 2023-03-01 14:40:39 +01:00
Szilárd Dóró
fde7ac7c1c Merge pull request #1684 from nhost/changeset-release/main
chore: update versions
2023-03-01 13:40:28 +01:00
github-actions[bot]
24ef6071cc chore: update versions 2023-03-01 12:31:54 +00:00
Szilárd Dóró
bb993b6b03 Merge pull request #1595 from nhost/feat/settings-from-mimir
feat(dashboard): Settings from Mimir
2023-03-01 13:30:21 +01:00
Szilárd Dóró
89ca34be9a fix(dashboard): add tests, improve readability 2023-03-01 13:18:25 +01:00
Szilárd Dóró
b66d095c95 fix(dashboard): fix review comments 2023-03-01 13:07:41 +01:00
Szilárd Dóró
0bad9ff4fa feat(dashboard): add option to bypass maintenance 2023-03-01 11:15:24 +01:00
Szilárd Dóró
9a761f4fec feat(dashboard): add maintenance alert 2023-03-01 11:03:26 +01:00
Szilárd Dóró
afb3fe490e Merge pull request #1646 from nhost/renovate/major-graphqlcodegenerator-monorepo
chore(deps): update graphqlcodegenerator monorepo to v3 (major)
2023-02-28 17:02:07 +01:00
Szilárd Dóró
eaebd2b028 fix(dashboard): fix build errors 2023-02-28 16:31:19 +01:00
Szilárd Dóró
f03ecd91a9 fix(dashboard): disable settings through env vars 2023-02-28 16:20:54 +01:00
Szilárd Dóró
96f17c39b1 fix(dashboard): improve error handling 2023-02-28 15:25:08 +01:00
Szilárd Dóró
f65e4de955 chore(dashboard): add changeset 2023-02-28 11:54:23 +01:00
Szilárd Dóró
decb0b057c Merge pull request #1677 from nhost/changeset-release/main
chore: update versions
2023-02-28 11:39:26 +01:00
Szilárd Dóró
700cbd9e47 fix(dashboard): fix secrets' dialog management 2023-02-28 11:14:38 +01:00
Szilárd Dóró
3238543b08 Merge remote-tracking branch 'origin/main' into feat/settings-from-mimir 2023-02-28 11:11:09 +01:00
github-actions[bot]
fc79b890df chore: update versions 2023-02-28 10:06:25 +00:00
Szilárd Dóró
211eb42af5 Merge pull request #1622 from nhost/chore/improved-dialogs
chore(dashboard): improve Dialog and Drawer API
2023-02-28 11:05:03 +01:00
Szilárd Dóró
a7398451e3 fix(dashboard): add dirty state checking to user form 2023-02-28 10:47:42 +01:00
Szilárd Dóró
4b4f0d0150 chore(dashboard): add changeset 2023-02-28 10:31:47 +01:00
Szilárd Dóró
f37e2a23e2 Merge remote-tracking branch 'origin/main' into chore/improved-dialogs 2023-02-28 10:31:10 +01:00
Szilárd Dóró
3f8d68ffab fix(dashboard): fix build 2023-02-28 09:57:46 +01:00
Szilárd Dóró
f7e706724c chore(dashboard): update generated code 2023-02-28 09:52:51 +01:00
Szilárd Dóró
2832d7299f fix(dashboard): add adminSecret to local app 2023-02-27 14:03:39 +01:00
Szilárd Dóró
44c5b386c3 Merge branch 'main' into feat/settings-from-mimir 2023-02-27 13:52:26 +01:00
Johan Eliasson
1a4a061284 Merge pull request #1674 from nhost/changeset-release/main
chore: update versions
2023-02-27 11:55:28 +01:00
Szilárd Dóró
ddd41aae99 chore(dashboard): migrate DB settings to Mimir 2023-02-27 10:32:05 +01:00
github-actions[bot]
78555c7e85 chore: update versions 2023-02-27 08:22:19 +00:00
Johan Eliasson
01ded8ffff Merge pull request #1670 from nhost/functions-tests
Functions fix + tests
2023-02-27 09:21:05 +01:00
Johan Eliasson
3c7cf92edf Create .changeset/eighty-mugs-flash.md 2023-02-27 09:20:49 +01:00
Johan Eliasson
bb4301fd34 more tests 2023-02-26 17:49:19 +01:00
Szilárd Dóró
a09dad060c fix(dashboard): migrate to new admin secret location 2023-02-24 17:48:46 +01:00
Szilárd Dóró
76b63debf0 Merge branch 'main' into feat/settings-from-mimir 2023-02-24 17:13:56 +01:00
Szilárd Dóró
c8c8948755 Merge pull request #1667 from nhost/changeset-release/main
chore: update versions
2023-02-24 12:45:06 +01:00
github-actions[bot]
17e9e5899e chore: update versions 2023-02-24 11:44:31 +00:00
Szilárd Dóró
bd22c48131 Merge pull request #1666 from nhost/fix/workspace-invitation
fix(nhost-js): use correct URL for functions requests
2023-02-24 12:43:02 +01:00
Szilárd Dóró
095d6e918c Merge branch 'main' into feat/settings-from-mimir 2023-02-24 12:41:56 +01:00
Szilárd Dóró
89a239ff3a fix(nhost-js): improve code readability 2023-02-24 12:22:54 +01:00
Szilárd Dóró
0131886011 fix(nhost-js): use correct URL for functions requests 2023-02-24 12:06:30 +01:00
Szilárd Dóró
340c014fe8 Merge pull request #1664 from nhost/update-codeowners
chore: update codeowners
2023-02-24 11:06:07 +01:00
Szilárd Dóró
bc9c8b9456 chore: update codeowners 2023-02-24 11:05:35 +01:00
Nuno Pato
c22b2621ba Merge pull request #1661 from nhost/changeset-release/main
chore: update versions
2023-02-23 20:33:34 -01:00
github-actions[bot]
726746c4d3 chore: update versions 2023-02-23 19:19:03 +00:00
Nuno Pato
c431570783 Merge pull request #1662 from nhost/fix/file-upload
fix(hasura-storage-js): fix forbidden error
2023-02-23 18:17:47 -01:00
Szilárd Dóró
445d8ef449 fix(hasura-storage-js): fix forbidden error 2023-02-23 18:16:22 +01:00
Szilárd Dóró
0f4ea18e42 Merge pull request #1655 from nhost/feat/auth-storage-permissions
fix(dashboard): allow permission editing for auth and storage schemas
2023-02-23 15:22:05 +01:00
Szilárd Dóró
dae7c5d517 Merge pull request #1660 from nhost/fix/user-creation-content-type
fix(dashboard): set correct Content-Type for user creation
2023-02-23 14:38:16 +01:00
Szilárd Dóró
f673adea00 fix(dashboard): set correct Content-Type for user creation 2023-02-23 12:50:00 +01:00
Szilárd Dóró
1c6f1e3b33 Merge pull request #1656 from nhost/changeset-release/main
chore: update versions
2023-02-23 11:56:07 +01:00
renovate[bot]
6593e8d3eb chore(deps): update graphqlcodegenerator monorepo to v3 2023-02-23 10:28:10 +00:00
github-actions[bot]
d1365ea516 chore: update versions 2023-02-23 10:20:53 +00:00
Szilárd Dóró
72dbba7881 Merge pull request #1659 from nhost/chore/revert-graphql-client
chore(graphql-js): revert GraphQL Client
2023-02-23 11:19:26 +01:00
Szilárd Dóró
a3f3991d5a Merge pull request #1658 from nhost/fix/user-creation
fix(dashboard): false positive message on user creation
2023-02-23 11:01:44 +01:00
Szilárd Dóró
c71fe2cf72 Revert "chore(graphql-js): revert GraphQL client for now"
This reverts commit 9a0ab5b887.
2023-02-23 10:15:02 +01:00
Szilárd Dóró
24c5ed3ea4 chore(graphql-js): cleanup 2023-02-23 10:14:43 +01:00
Szilárd Dóró
2d9145f918 chore(graphql-js): revert GraphQL client for now 2023-02-23 10:10:14 +01:00
Szilárd Dóró
9a0ab5b887 chore(graphql-js): revert GraphQL client for now 2023-02-23 10:06:58 +01:00
Szilárd Dóró
1ddf704c5b fix(dashboard): false positive message on user creation 2023-02-23 09:39:32 +01:00
Szilárd Dóró
6f4ee845c6 Merge pull request #1643 from nhost/fix/auth-last-seen
fix(dashboard): use correct date for last seen
2023-02-22 20:01:18 +01:00
Szilárd Dóró
0368663dea fix(dashboard): allow permission editing for auth and storage schemas
fixes #1555
2023-02-22 19:59:20 +01:00
Szilárd Dóró
9219838127 Merge remote-tracking branch 'origin/main' into feat/settings-from-mimir 2023-02-22 15:13:21 +01:00
Szilárd Dóró
43b68a79eb fix(dashboard): improve error handling 2023-02-22 14:43:21 +01:00
Szilárd Dóró
ac845c6d92 Merge remote-tracking branch 'origin/main' into feat/settings-from-mimir 2023-02-22 11:06:08 +01:00
Szilárd Dóró
f4af81020b Merge branch 'main' into feat/settings-from-mimir 2023-02-21 18:51:11 +01:00
Szilárd Dóró
d167121093 chore(dashboard): add changeset
hide the "Secrets" menu item on the Settings page
2023-02-21 17:02:08 +01:00
Szilárd Dóró
822e251b11 cleanup part 2 2023-02-21 15:37:12 +01:00
Szilárd Dóró
328c6bb486 chore(packages): cleanup 2023-02-21 15:36:04 +01:00
Szilárd Dóró
bef8198cbf fix(dashboard): provider validation and scope 2023-02-21 14:59:06 +01:00
Szilárd Dóró
179313d8a2 fix(dashboard): run codegen, fix validation 2023-02-21 13:56:26 +01:00
Szilárd Dóró
c3ce004f46 Merge remote-tracking branch 'origin/main' into feat/settings-from-mimir 2023-02-21 10:48:29 +01:00
Szilárd Dóró
b755e9086c fix(dashboard): use correct date for last seen 2023-02-20 14:19:56 +01:00
Szilárd Dóró
962563d6a0 chore(dashboard): cleanup 2023-02-16 16:51:40 +01:00
Szilárd Dóró
8bf58ba26b chore(dashboard): migrate remaining dialogs 2023-02-16 16:37:44 +01:00
Szilárd Dóró
0c175e7a11 chore(dashboard): migrate additional dialogs to the new API 2023-02-16 16:13:47 +01:00
Szilárd Dóró
70f2fbcfc2 chore(dashboard): partially migrate dialogs to new API 2023-02-16 15:59:35 +01:00
Szilárd Dóró
d2c4ad3260 chore(dashboard): cleanup dialog provider 2023-02-16 13:32:21 +01:00
Szilárd Dóró
a9ca2c2946 chore(dashboard): migrate drawers to use new API 2023-02-16 13:25:23 +01:00
Szilárd Dóró
d854dd74b1 chore(dashboard): improve dialog management 2023-02-16 12:54:21 +01:00
Szilárd Dóró
b45aa420d9 fix(dashboard): use scope defined by the schema 2023-02-13 13:53:49 +01:00
Szilárd Dóró
1d76de3f60 Merge branch 'main' into feat/settings-from-mimir 2023-02-13 11:59:40 +01:00
Szilárd Dóró
9e37ca4cbc fix(dashboard): duplicate input IDs 2023-02-10 10:37:04 +01:00
Szilárd Dóró
af57ccce0f fix lint error and a UI warning 2023-02-10 10:26:06 +01:00
Szilárd Dóró
5f44aefcc6 Merge branch 'main' into feat/settings-from-mimir 2023-02-10 09:58:56 +01:00
Szilárd Dóró
96f9278c8f chore(dashboard): ID workaround for config 2023-01-31 18:52:39 +01:00
Szilárd Dóró
9fe2ecd317 Merge branch 'main' into feat/settings-from-mimir 2023-01-31 16:31:45 +01:00
Szilárd Dóró
ada5309b49 fix(dashboard): catch errors thrown by mutations 2023-01-31 15:59:49 +01:00
Szilárd Dóró
08698f8246 feat(dashboard): migrate system variables to mimir 2023-01-31 15:46:36 +01:00
Szilárd Dóró
0b56e31408 Merge branch 'feat/dark-mode' into feat/settings-from-mimir 2023-01-31 13:32:37 +01:00
Szilárd Dóró
c4e3e3f91f Merge branch 'feat/dark-mode' into feat/settings-from-mimir 2023-01-31 09:21:18 +01:00
Szilárd Dóró
483fd6c7f4 feat(dashboard): environment variables to use mimir 2023-01-30 17:19:52 +01:00
Szilárd Dóró
ac37d7bcae chore(dashboard): improve config caching in Apollo 2023-01-30 16:54:48 +01:00
Szilárd Dóró
9adf91ba87 fix(dashboard): infinite query loop 2023-01-30 16:43:26 +01:00
Szilárd Dóró
d11f6eebb0 feat(dashboard): migrate permission variables to mimir 2023-01-30 16:18:32 +01:00
Szilárd Dóró
8a678fbc87 Merge branch 'feat/dark-mode' into feat/settings-from-mimir 2023-01-30 15:42:59 +01:00
Szilárd Dóró
6411ec3ec3 chore(dashboard): all sign-in methods to use mimir 2023-01-30 15:38:23 +01:00
Szilárd Dóró
5187fe76aa feat(dashboard): allowed roles to use mimir 2023-01-30 15:06:28 +01:00
Szilárd Dóró
859f457e4a Merge remote-tracking branch 'origin/main' into feat/settings-from-mimir 2023-01-30 14:50:02 +01:00
Szilárd Dóró
dc2b5b4429 chore(dashboard): migrate rest of the auth forms to mimir 2023-01-30 12:04:10 +01:00
Szilárd Dóró
b7645e7892 chore(dashboard): migrate auth forms to mimir 2023-01-30 11:37:54 +01:00
Szilárd Dóró
b1338246aa Merge branch 'feat/dark-mode' into feat/settings-from-mimir 2023-01-30 11:07:54 +01:00
Szilárd Dóró
d04ccd600e feat(dashboard): add Mimir support for all providers 2023-01-28 12:18:27 +01:00
Szilárd Dóró
d483ad5602 feat(dashboard): Apple, Discord and Facebook to use Mimir 2023-01-28 11:37:23 +01:00
Szilárd Dóró
bcf3e6bc2c feat(dashboard): SMTP page to update Mimir 2023-01-27 17:00:13 +01:00
Szilárd Dóró
575ff4e9b5 Merge branch 'feat/dark-mode' into feat/settings-from-mimir 2023-01-27 16:43:10 +01:00
Szilárd Dóró
2010638540 feat(dashboard): migrate settings / authentication to Mimir 2023-01-27 16:10:43 +01:00
Szilárd Dóró
0346495a79 feat(dashboard): migrate SMTP settings to Mimir 2023-01-27 14:31:54 +01:00
Szilárd Dóró
2babb0b6f3 feat(dashboard): migrate the rest of the providers 2023-01-27 14:24:25 +01:00
Szilárd Dóró
1f293d0f0c feat(dashboard): migrate Sign In Methods to Mimir 2023-01-27 14:01:57 +01:00
Szilárd Dóró
af4c886437 Merge branch 'feat/dark-mode' into feat/settings-from-mimir 2023-01-27 11:21:25 +01:00
Szilárd Dóró
c182b3ca4b feat(dashboard): finalize secrets functionality 2023-01-27 10:57:40 +01:00
Szilárd Dóró
d5344ed31f feat(dashboard): initial secrets page code 2023-01-26 12:15:07 +01:00
348 changed files with 8129 additions and 22113 deletions

18
.github/CODEOWNERS vendored
View File

@@ -1,14 +1,14 @@
# Documentation
# https://help.github.com/en/articles/about-code-owners
/packages @plmercereau @szilarddoro
/packages @szilarddoro
/packages/docgen @szilarddoro
/integrations/stripe-graphql-js @elitan
/.github @plmercereau
/dashboard/ @szilarddoro @guicurcio
/docs/ @guicurcio @elitan
/config/ @plmercereau @szilarddoro
/examples/ @plmercereau
/examples/codegen-react-apollo @elitan @plmercereau
/examples/codegen-react-query @elitan @plmercereau
/examples/react-apollo-crm @elitan @plmercereau
/.github @szilarddoro
/dashboard/ @szilarddoro
/docs/ @elitan
/config/ @szilarddoro
/examples/ @szilarddoro
/examples/codegen-react-apollo @elitan @szilarddoro
/examples/codegen-react-query @elitan @szilarddoro
/examples/react-apollo-crm @elitan @szilarddoro

View File

@@ -30,6 +30,7 @@ module.exports = {
'error',
{ ignoreTypeReferences: true },
],
'no-console': ['warn', { allow: ['error'] }],
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'error',
'no-unused-vars': 'off',

View File

@@ -1,5 +1,66 @@
# @nhost/dashboard
## 0.12.1
### Patch Changes
- c96d7ccd: fix(dashboard): fix docker builds
## 0.12.0
### Minor Changes
- d1671210: feat(dashboard): use mimir to manage project configuration
### Patch Changes
- f65e4de9: chore(deps): bump @graphql-codegen monorepo to v3
## 0.11.20
### Patch Changes
- 4b4f0d01: chore(dashboard): improve dialog management
## 0.11.19
### Patch Changes
- @nhost/react-apollo@5.0.6
- @nhost/nextjs@1.13.11
## 0.11.18
### Patch Changes
- 01318860: fix(nhost-js): use correct URL for functions requests
- Updated dependencies [01318860]
- @nhost/react-apollo@5.0.5
- @nhost/nextjs@1.13.10
## 0.11.17
### Patch Changes
- f673adea: fix(dashboard): set correct Content-Type for user creation
- 445d8ef4: chore(deps): bump `@nhost/react-apollo` to 5.0.4
- 445d8ef4: chore(deps): bump `@nhost/nextjs` to 1.13.9
- 0368663d: fix(dashboard): allow permission editing for auth and storage schemas
- Updated dependencies [445d8ef4]
- Updated dependencies [445d8ef4]
- @nhost/react-apollo@5.0.4
- @nhost/nextjs@1.13.9
## 0.11.16
### Patch Changes
- b755e908: fix(dashboard): use correct date for last seen
- 2d9145f9: chore(deps): revert GraphQL client
- 1ddf704c: fix(dashboard): don't show false positive message for failed user creation
- @nhost/react-apollo@5.0.3
- @nhost/nextjs@1.13.8
## 0.11.15
### Patch Changes

View File

@@ -11,7 +11,7 @@ FROM node:16-alpine AS builder
ARG TURBO_TOKEN
ARG TURBO_TEAM
RUN apk add --no-cache libc6-compat
RUN apk add --no-cache libc6-compat python3 make g++
RUN apk update
WORKDIR /app

View File

@@ -59,6 +59,9 @@ pnpm storybook
| `NEXT_PUBLIC_GITHUB_APP_INSTALL_URL` | URL of the GitHub application. Not necessary for local development. |
| `NEXT_PUBLIC_ANALYTICS_WRITE_KEY` | Analytics key. Not necessary for local development. |
| `NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET` | URL of the Bragi websocket. Not necessary for local development. |
| `NEXT_PUBLIC_MAINTENANCE_ACTIVE` | Determines whether or not maintenance mode is active. |
| `NEXT_PUBLIC_MAINTENANCE_END_DATE` | Date when maintenance mode will end. |
| `NEXT_PUBLIC_MAINTENANCE_UNLOCK_SECRET` | Secret that can be used to bypass maintenance mode. |
## ESLint Rules

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/dashboard",
"version": "0.11.15",
"version": "0.12.1",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -64,7 +64,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-error-boundary": "^3.1.4",
"react-hook-form": "^7.39.5",
"react-hook-form": "^7.42.1",
"react-hot-toast": "^2.4.0",
"react-is": "18.2.0",
"react-loading-skeleton": "^2.2.0",
@@ -82,10 +82,10 @@
},
"devDependencies": {
"@babel/core": "^7.20.2",
"@graphql-codegen/cli": "^2.8.0",
"@graphql-codegen/typescript": "^2.7.1",
"@graphql-codegen/cli": "^3.0.0",
"@graphql-codegen/typescript": "^3.0.0",
"@graphql-codegen/typescript-graphql-request": "^4.5.1",
"@graphql-codegen/typescript-operations": "^2.5.1",
"@graphql-codegen/typescript-operations": "^3.0.0",
"@graphql-codegen/typescript-react-apollo": "^3.3.1",
"@next/bundle-analyzer": "^12.3.1",
"@storybook/addon-actions": "^6.5.14",

View File

@@ -1,3 +1,4 @@
import MaintenanceAlert from '@/components/common/MaintenanceAlert';
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
import Container from '@/components/layout/Container';
import { features } from '@/components/overview/features';
@@ -52,6 +53,7 @@ export default function ApplicationLive() {
return (
<Container>
<MaintenanceAlert />
<OverviewTopBar />
<div className="grid grid-cols-1 gap-12 pt-3 lg:grid-cols-3">

View File

@@ -22,11 +22,9 @@ interface HasuraDataProps {
export function HasuraData({ close }: HasuraDataProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const isPlatform = useIsPlatform();
const projectAdminSecret = currentApplication?.config?.hasura.adminSecret;
if (
!currentApplication?.subdomain ||
!currentApplication?.hasuraGraphqlAdminSecret
) {
if (!currentApplication?.subdomain || !projectAdminSecret) {
return <LoadingScreen />;
}
@@ -71,18 +69,11 @@ export function HasuraData({ close }: HasuraDataProps) {
<div className="col-span-1 grid grid-flow-col items-center justify-center gap-2 sm:col-span-2 sm:justify-end">
<Text className="font-medium" variant="subtitle2">
{Array(currentApplication.hasuraGraphqlAdminSecret.length)
.fill('•')
.join('')}
{Array(projectAdminSecret.length).fill('•').join('')}
</Text>
<IconButton
onClick={() =>
copy(
currentApplication.hasuraGraphqlAdminSecret,
'Hasura admin secret',
)
}
onClick={() => copy(projectAdminSecret, 'Hasura admin secret')}
variant="borderless"
color="secondary"
className="min-w-0 p-1"

View File

@@ -1,6 +1,6 @@
import { FindOldApps } from '@/components/home';
import type { UserData } from '@/hooks/useGetAllUserWorkspacesAndApplications';
import type { Application, ApplicationState } from '@/types/application';
import type { ApplicationState } from '@/types/application';
import { ApplicationStatus } from '@/types/application';
import { Avatar } from '@/ui/Avatar';
import StateBadge from '@/ui/StateBadge';
@@ -56,7 +56,7 @@ function CurrentDeployment({ deployment }: any) {
avatarUrl={deployment.commitUserAvatarUrl}
className="mr-1 h-4 w-4 self-center"
/>
<Text className="self-center text-sm">
<Text component="span" className="self-center text-sm">
{deployment.commitUserName} updated just now
</Text>
</span>
@@ -103,7 +103,7 @@ export function RenderWorkspacesWithApps({
}
const workspaceProjects = workspace.applications
.filter((app: Application) =>
.filter((app) =>
app.name.toLowerCase().includes(query.toLowerCase()),
)
.sort((appA, appB) => {
@@ -142,7 +142,9 @@ export function RenderWorkspacesWithApps({
<List className="grid grid-flow-row border-y">
{workspaceProjects.map((app, index) => {
const isDeployingToProduction = app.deployments[0]
? app.deployments[0].deploymentStatus === 'DEPLOYING'
? ['SCHEDULED', 'PENDING', 'DEPLOYING'].includes(
app.deployments[0].deploymentStatus,
)
: false;
return (

View File

@@ -52,7 +52,9 @@ function ControlledAutocomplete(
return (
<Autocomplete
inputValue={typeof field.value === 'string' ? field.value : undefined}
inputValue={
typeof field.value !== 'object' ? field.value.toString() : undefined
}
{...props}
{...field}
ref={mergeRefs([field.ref, ref])}

View File

@@ -205,7 +205,7 @@ export default function DataGridPreviewCell<TData extends object>({
}
const { presignedUrl } = await appClient.storage
.setAdminSecret(currentApplication.hasuraGraphqlAdminSecret)
.setAdminSecret(currentApplication.config?.hasura.adminSecret)
.getPresignedUrl({ fileId: id });
if (!presignedUrl) {

View File

@@ -1,31 +1,8 @@
import type { DialogFormProps } from '@/types/common';
import type { CommonDialogProps } from '@/ui/v2/Dialog';
import type { ReactNode } from 'react';
import type { ReactElement, ReactNode } from 'react';
import { createContext } from 'react';
/**
* Available dialog types.
*/
export type DialogType =
| 'EDIT_WORKSPACE_NAME'
| 'CREATE_RECORD'
| 'CREATE_COLUMN'
| 'EDIT_COLUMN'
| 'CREATE_TABLE'
| 'EDIT_TABLE'
| 'EDIT_PERMISSIONS'
| 'CREATE_FOREIGN_KEY'
| 'EDIT_FOREIGN_KEY'
| 'CREATE_ROLE'
| 'EDIT_ROLE'
| 'CREATE_USER'
| 'CREATE_PERMISSION_VARIABLE'
| 'EDIT_PERMISSION_VARIABLE'
| 'CREATE_ENVIRONMENT_VARIABLE'
| 'EDIT_ENVIRONMENT_VARIABLE'
| 'EDIT_USER'
| 'EDIT_USER_PASSWORD'
| 'EDIT_JWT_SECRET';
export interface DialogConfig<TPayload = unknown> {
/**
* Title of the dialog.
@@ -41,21 +18,36 @@ export interface DialogConfig<TPayload = unknown> {
payload?: TPayload;
}
export interface OpenDialogOptions {
/**
* Title of the dialog.
*/
title: ReactNode;
/**
* Component to render inside the dialog skeleton.
*/
component: ReactElement<{
location?: 'drawer' | 'dialog';
onCancel?: () => void;
onSubmit?: (args?: any) => Promise<any> | void;
}>;
/**
* Props to pass to the root dialog component.
*/
props?: Partial<CommonDialogProps>;
}
export interface DialogContextProps {
/**
* Call this function to open a dialog.
* Call this function to open a dialog. It will automatically apply the
* necessary functionality to the dialog.
*/
openDialog: <TPayload = unknown>(
type: DialogType,
config?: DialogConfig<TPayload>,
) => void;
openDialog: (options: OpenDialogOptions) => void;
/**
* Call this function to open a drawer.
* Call this function to open a drawer. It will automatically apply the
* necessary functionality to the drawer.
*/
openDrawer: <TPayload = unknown>(
type: DialogType,
config?: DialogConfig<TPayload>,
) => void;
openDrawer: (options: OpenDialogOptions) => void;
/**
* Call this function to open an alert dialog.
*/
@@ -87,7 +79,7 @@ export interface DialogContextProps {
*/
onDirtyStateChange: (
isDirty: boolean,
location?: 'drawer' | 'dialog',
location?: DialogFormProps['location'],
) => void;
/**
* Call this function to open a dirty confirmation dialog.

View File

@@ -1,30 +1,12 @@
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm';
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
import EditWorkspaceNameForm from '@/components/home/EditWorkspaceNameForm';
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
import EditJwtSecretForm from '@/components/settings/environmentVariables/EditJwtSecretForm';
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 CreateUserForm from '@/components/users/CreateUserForm';
import EditUserForm from '@/components/users/EditUserForm';
import EditUserPasswordForm from '@/components/users/EditUserPasswordForm';
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 { useRouter } from 'next/router';
import type {
BaseSyntheticEvent,
DetailedHTMLProps,
HTMLProps,
PropsWithChildren,
} from 'react';
import type { BaseSyntheticEvent, PropsWithChildren } from 'react';
import {
cloneElement,
isValidElement,
useCallback,
useEffect,
useMemo,
@@ -33,7 +15,7 @@ import {
useState,
} from 'react';
import { twMerge } from 'tailwind-merge';
import type { DialogConfig, DialogType } from './DialogContext';
import type { DialogConfig, OpenDialogOptions } from './DialogContext';
import DialogContext from './DialogContext';
import {
alertDialogReducer,
@@ -41,67 +23,11 @@ import {
drawerReducer,
} from './dialogReducers';
function LoadingComponent({
className,
...props
}: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> = {}) {
return (
<div
{...props}
className={twMerge(
'grid items-center justify-center px-6 py-4',
className,
)}
>
<ActivityIndicator
circularProgressProps={{ className: 'w-5 h-5' }}
label="Loading form..."
/>
</div>
);
}
const CreateRecordForm = dynamic(
() => import('@/components/dataBrowser/CreateRecordForm'),
{ ssr: false, loading: () => LoadingComponent() },
);
const CreateColumnForm = dynamic(
() => import('@/components/dataBrowser/CreateColumnForm'),
{ ssr: false, loading: () => LoadingComponent() },
);
const EditColumnForm = dynamic(
() => import('@/components/dataBrowser/EditColumnForm'),
{ ssr: false, loading: () => LoadingComponent() },
);
const CreateTableForm = dynamic(
() => import('@/components/dataBrowser/CreateTableForm'),
{ ssr: false, loading: () => LoadingComponent() },
);
const EditTableForm = dynamic(
() => import('@/components/dataBrowser/EditTableForm'),
{ ssr: false, loading: () => LoadingComponent() },
);
const EditPermissionsForm = dynamic(
() => import('@/components/dataBrowser/EditPermissionsForm'),
{ ssr: false, loading: () => LoadingComponent() },
);
function DialogProvider({ children }: PropsWithChildren<unknown>) {
const router = useRouter();
const [
{
open: dialogOpen,
activeDialogType,
dialogProps,
title: dialogTitle,
payload: dialogPayload,
},
{ open: dialogOpen, title: dialogTitle, activeDialog, dialogProps },
dialogDispatch,
] = useReducer(dialogReducer, {
open: false,
@@ -110,10 +36,9 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
const [
{
open: drawerOpen,
activeDialogType: activeDrawerType,
dialogProps: drawerProps,
title: drawerTitle,
payload: drawerPayload,
activeDialog: activeDrawer,
dialogProps: drawerProps,
},
drawerDispatch,
] = useReducer(drawerReducer, {
@@ -136,12 +61,9 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
const isDialogDirty = useRef(false);
const [showDirtyConfirmation, setShowDirtyConfirmation] = useState(false);
const openDialog = useCallback(
<TConfig,>(type: DialogType, config?: DialogConfig<TConfig>) => {
dialogDispatch({ type: 'OPEN_DIALOG', payload: { type, config } });
},
[],
);
const openDialog = useCallback((options: OpenDialogOptions) => {
dialogDispatch({ type: 'OPEN_DIALOG', payload: options });
}, []);
const closeDialog = useCallback(() => {
dialogDispatch({ type: 'HIDE_DIALOG' });
@@ -152,12 +74,9 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
dialogDispatch({ type: 'CLEAR_DIALOG_CONTENT' });
}, []);
const openDrawer = useCallback(
<TConfig,>(type: DialogType, config?: DialogConfig<TConfig>) => {
drawerDispatch({ type: 'OPEN_DRAWER', payload: { type, config } });
},
[],
);
const openDrawer = useCallback((options: OpenDialogOptions) => {
drawerDispatch({ type: 'OPEN_DRAWER', payload: options });
}, []);
const closeDrawer = useCallback(() => {
drawerDispatch({ type: 'HIDE_DRAWER' });
@@ -228,9 +147,6 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
[closeDialog, openDirtyConfirmation],
);
// We are coupling this logic with the location of the dialog content which is
// not ideal. We shoule figure out a better logic for tracking the dirty
// state in the future.
const onDirtyStateChange = useCallback(
(dirty: boolean, location: 'drawer' | 'dialog' = 'drawer') => {
if (location === 'dialog') {
@@ -271,25 +187,6 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
],
);
const sharedDialogProps = {
...dialogPayload,
onSubmit: async (values: any) => {
await dialogPayload?.onSubmit?.(values);
closeDialog();
},
onCancel: closeDialogWithDirtyGuard,
};
const sharedDrawerProps = {
onSubmit: async () => {
await drawerPayload?.onSubmit();
closeDrawer();
},
onCancel: closeDrawerWithDirtyGuard,
};
useEffect(() => {
function handleCloseDrawerAndDialog() {
if (isDrawerDirty.current || isDialogDirty.current) {
@@ -367,56 +264,20 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
<RetryableErrorBoundary
errorMessageProps={{ className: 'pt-0 pb-5 px-6' }}
>
{activeDialogType === 'EDIT_WORKSPACE_NAME' && (
<EditWorkspaceNameForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_FOREIGN_KEY' && (
<CreateForeignKeyForm {...sharedDialogProps} />
)}
{activeDialogType === 'EDIT_FOREIGN_KEY' && (
<EditForeignKeyForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_ROLE' && (
<CreateRoleForm {...sharedDialogProps} />
)}
{activeDialogType === 'EDIT_ROLE' && (
<EditRoleForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_USER' && (
<CreateUserForm {...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} />
)}
{activeDialogType === 'EDIT_USER_PASSWORD' && (
<EditUserPasswordForm
{...sharedDialogProps}
user={sharedDialogProps?.user}
/>
)}
{activeDialogType === 'EDIT_JWT_SECRET' && (
<EditJwtSecretForm {...sharedDialogProps} />
)}
{isValidElement(activeDialog)
? cloneElement(activeDialog, {
...activeDialog.props,
location: 'dialog',
onSubmit: async (values?: any) => {
await activeDialog?.props?.onSubmit?.(values);
closeDialog();
},
onCancel: () => {
activeDialog?.props?.onCancel?.();
closeDialogWithDirtyGuard();
},
})
: null}
</RetryableErrorBoundary>
</BaseDialog>
@@ -436,51 +297,20 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
}}
>
<RetryableErrorBoundary>
{activeDrawerType === 'CREATE_RECORD' && (
<CreateRecordForm
{...sharedDrawerProps}
columns={drawerPayload?.columns}
/>
)}
{activeDrawerType === 'CREATE_COLUMN' && (
<CreateColumnForm {...sharedDrawerProps} />
)}
{activeDrawerType === 'EDIT_COLUMN' && (
<EditColumnForm
{...sharedDrawerProps}
column={drawerPayload?.column}
/>
)}
{activeDrawerType === 'CREATE_TABLE' && (
<CreateTableForm
{...sharedDrawerProps}
schema={drawerPayload?.schema}
/>
)}
{activeDrawerType === 'EDIT_TABLE' && (
<EditTableForm
{...sharedDrawerProps}
table={drawerPayload?.table}
schema={drawerPayload?.schema}
/>
)}
{activeDrawerType === 'EDIT_PERMISSIONS' && (
<EditPermissionsForm
{...sharedDrawerProps}
disabled={drawerPayload?.disabled}
schema={drawerPayload?.schema}
table={drawerPayload?.table}
/>
)}
{activeDrawerType === 'EDIT_USER' && (
<EditUserForm {...sharedDrawerProps} {...drawerPayload} />
)}
{isValidElement(activeDrawer)
? cloneElement(activeDrawer, {
...activeDrawer.props,
location: 'drawer',
onSubmit: async (values?: any) => {
await activeDrawer?.props?.onSubmit?.(values);
closeDrawer();
},
onCancel: () => {
activeDrawer?.props?.onCancel?.();
closeDrawerWithDirtyGuard();
},
})
: null}
</RetryableErrorBoundary>
</Drawer>

View File

@@ -1,6 +1,6 @@
import type { CommonDialogProps } from '@/ui/v2/Dialog';
import type { ReactNode } from 'react';
import type { DialogConfig, DialogType } from './DialogContext';
import type { ReactElement, ReactNode } from 'react';
import type { DialogConfig, OpenDialogOptions } from './DialogContext';
export interface DialogState {
/**
@@ -12,9 +12,13 @@ export interface DialogState {
*/
open?: boolean;
/**
* Type of the currently active dialog.
* Component to render inside the dialog skeleton.
*/
activeDialogType?: DialogType;
activeDialog?: ReactElement<{
location?: 'drawer' | 'dialog';
onCancel?: () => void;
onSubmit?: (args?: any) => Promise<any> | void;
}>;
/**
* Props passed to the currently active dialog.
*/
@@ -27,10 +31,7 @@ export interface DialogState {
}
export type DialogAction =
| {
type: 'OPEN_DIALOG';
payload: { type: DialogType; config?: DialogConfig };
}
| { type: 'OPEN_DIALOG'; payload: OpenDialogOptions }
| { type: 'HIDE_DIALOG' }
| { type: 'CLEAR_DIALOG_CONTENT' };
@@ -50,10 +51,9 @@ export function dialogReducer(
return {
...state,
open: true,
activeDialogType: action.payload?.type,
dialogProps: action.payload.config?.props,
title: action.payload.config?.title,
payload: action.payload.config?.payload,
title: action.payload.title,
activeDialog: action.payload.component,
dialogProps: action.payload.props,
};
case 'HIDE_DIALOG':
return {
@@ -64,8 +64,7 @@ export function dialogReducer(
return {
...state,
title: undefined,
payload: undefined,
activeDialogType: undefined,
activeDialog: undefined,
dialogProps: undefined,
};
default:
@@ -74,10 +73,7 @@ export function dialogReducer(
}
export type DrawerAction =
| {
type: 'OPEN_DRAWER';
payload: { type: DialogType; config?: DialogConfig };
}
| { type: 'OPEN_DRAWER'; payload: OpenDialogOptions }
| { type: 'HIDE_DRAWER' }
| { type: 'CLEAR_DRAWER_CONTENT' };
@@ -97,10 +93,9 @@ export function drawerReducer(
return {
...state,
open: true,
activeDialogType: action.payload?.type,
dialogProps: action.payload.config?.props,
title: action.payload.config?.title,
payload: action.payload.config?.payload,
title: action.payload.title,
activeDialog: action.payload.component,
dialogProps: action.payload.props,
};
case 'HIDE_DRAWER':
return {
@@ -111,8 +106,7 @@ export function drawerReducer(
return {
...state,
title: undefined,
payload: undefined,
activeDialogType: undefined,
activeDialog: undefined,
dialogProps: undefined,
};
default:

View File

@@ -1,6 +1,7 @@
import type { BoxProps } from '@/ui/v2/Box';
import Box from '@/ui/v2/Box';
import type { KeyboardEvent } from 'react';
import { useRef } from 'react';
import { useFormContext } from 'react-hook-form';
export interface FormProps extends BoxProps {
@@ -11,6 +12,7 @@ export interface FormProps extends BoxProps {
}
export default function Form({ onSubmit, onKeyDown, ...props }: FormProps) {
const formRef = useRef<HTMLDivElement>();
const {
handleSubmit,
formState: { isSubmitting },
@@ -25,6 +27,15 @@ export default function Form({ onSubmit, onKeyDown, ...props }: FormProps) {
return;
}
const submitButton = Array.from(
formRef.current.getElementsByTagName('button'),
).find((item) => item.type === 'submit');
// Disabling submit if the submit button is disabled
if (submitButton?.disabled) {
return;
}
event.preventDefault();
handleSubmit(onSubmit)(event);
@@ -35,6 +46,7 @@ export default function Form({ onSubmit, onKeyDown, ...props }: FormProps) {
// so keyboard events must be handled on the form element itself.
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<Box
ref={formRef}
component="form"
{...props}
onKeyDown={(event) => {

View File

@@ -0,0 +1,26 @@
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import type { BoxProps } from '@/ui/v2/Box';
import Box from '@/ui/v2/Box';
import { twMerge } from 'tailwind-merge';
export interface FormActivityIndicatorProps extends BoxProps {}
export default function FormActivityIndicator({
className,
...props
}: FormActivityIndicatorProps) {
return (
<Box
{...props}
className={twMerge(
'grid items-center justify-center px-6 py-4',
className,
)}
>
<ActivityIndicator
circularProgressProps={{ className: 'w-5 h-5' }}
label="Loading form..."
/>
</Box>
);
}

View File

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

View File

@@ -23,6 +23,7 @@ export default function Header({ className, ...props }: HeaderProps) {
return (
<Box
component="header"
className={twMerge(
'z-40 grid w-full transform-gpu grid-flow-col items-center justify-between gap-2 border-b-1 px-4 py-3',
className,

View File

@@ -38,6 +38,12 @@ function IconLink(
: [icon.props?.sx]),
{
color: (theme) => {
if (props.disabled) {
return theme.palette.mode === 'dark'
? 'text.secondary'
: 'text.primary';
}
if (active) {
return 'primary.main';
}

View File

@@ -0,0 +1,46 @@
import { useUI } from '@/context/UIContext';
import { Alert } from '@/ui/Alert';
export default function MaintenanceAlert() {
const { maintenanceActive, maintenanceEndDate } = useUI();
if (!maintenanceActive) {
return null;
}
const dateTimeFormat = Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZoneName: 'short',
});
const parts = dateTimeFormat.formatToParts(maintenanceEndDate);
const year = parts.find((part) => part.type === 'year')?.value;
const month = parts.find((part) => part.type === 'month')?.value;
const day = parts.find((part) => part.type === 'day')?.value;
const hour = parts.find((part) => part.type === 'hour')?.value;
const minute = parts.find((part) => part.type === 'minute')?.value;
const timeZone = parts.find((part) => part.type === 'timeZoneName')?.value;
return (
<Alert severity="warning" className="mt-4">
<p>
We&apos;re currently doing maintenance on our infrastructure. Project
creation and project settings are temporarily disabled during the
maintenance period.
</p>
{maintenanceEndDate && (
<p>
Maintenance is expected to be completed at {year}-{month}-{day} {hour}
:{minute} {timeZone}.
</p>
)}
</Alert>
);
}

View File

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

View File

@@ -3,6 +3,7 @@ import ControlledCheckbox from '@/components/common/ControlledCheckbox';
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import InlineCode from '@/components/common/InlineCode';
import type { DialogFormProps } from '@/types/common';
import type { ColumnType, DatabaseColumn } from '@/types/dataBrowser';
import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button';
@@ -22,7 +23,7 @@ import ForeignKeyEditor from './ForeignKeyEditor';
export type BaseColumnFormValues = DatabaseColumn;
export interface BaseColumnFormProps {
export interface BaseColumnFormProps extends DialogFormProps {
/**
* Function to be called when the form is submitted.
*/
@@ -60,6 +61,7 @@ export default function BaseColumnForm({
onSubmit: handleExternalSubmit,
onCancel,
submitButtonText = 'Save',
location,
}: BaseColumnFormProps) {
const { onDirtyStateChange } = useDialog();
@@ -91,8 +93,8 @@ export default function BaseColumnForm({
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'drawer');
}, [isDirty, onDirtyStateChange]);
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
return (
<Form

View File

@@ -1,5 +1,6 @@
import { useDialog } from '@/components/common/DialogProvider';
import type { BaseForeignKeyFormValues } from '@/components/dataBrowser/BaseForeignKeyForm';
import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm';
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
import type { DatabaseColumn } from '@/types/dataBrowser';
import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button';
@@ -29,7 +30,7 @@ const ForeignKeyEditorInput = forwardRef(
) => {
const { openDialog } = useDialog();
const { setValue } = useFormContext();
const column = useWatch<Partial<DatabaseColumn>>();
const column = useWatch() as DatabaseColumn;
const { foreignKeyRelation } = column;
if (!column.foreignKeyRelation) {
@@ -39,8 +40,8 @@ const ForeignKeyEditorInput = forwardRef(
className="py-1"
disabled={!column.name || !column.type}
ref={ref}
onClick={() =>
openDialog('CREATE_FOREIGN_KEY', {
onClick={() => {
openDialog({
title: (
<span className="grid grid-flow-row">
<span>Add a Foreign Key Relation</span>
@@ -51,16 +52,18 @@ const ForeignKeyEditorInput = forwardRef(
</Text>
</span>
),
payload: {
selectedColumn: column.name,
availableColumns: [column],
onSubmit: (values: BaseForeignKeyFormValues) => {
setValue('foreignKeyRelation', values);
onCreateSubmit();
},
},
})
}
component: (
<CreateForeignKeyForm
selectedColumn={column.name}
availableColumns={[column]}
onSubmit={(values) => {
setValue('foreignKeyRelation', values);
onCreateSubmit();
}}
/>
),
});
}}
>
Add Foreign Key
</Button>
@@ -86,20 +89,22 @@ const ForeignKeyEditorInput = forwardRef(
<div className="grid grid-flow-col">
<Button
ref={ref}
onClick={() =>
openDialog('EDIT_FOREIGN_KEY', {
onClick={() => {
openDialog({
title: 'Edit Foreign Key Relation',
payload: {
foreignKeyRelation,
availableColumns: [column],
selectedColumn: column.name,
onSubmit: (values: BaseForeignKeyFormValues) => {
setValue('foreignKeyRelation', values);
onEditSubmit();
},
},
})
}
component: (
<EditForeignKeyForm
foreignKeyRelation={foreignKeyRelation}
selectedColumn={column.name}
availableColumns={[column]}
onSubmit={(values) => {
setValue('foreignKeyRelation', values);
onEditSubmit();
}}
/>
),
});
}}
variant="borderless"
className="min-w-[initial] py-1 px-2"
>

View File

@@ -2,6 +2,7 @@ import ControlledSelect from '@/components/common/ControlledSelect';
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import useDatabaseQuery from '@/hooks/dataBrowser/useDatabaseQuery';
import type { DialogFormProps } from '@/types/common';
import type { DatabaseColumn, ForeignKeyRelation } from '@/types/dataBrowser';
import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button';
@@ -23,7 +24,7 @@ export interface BaseForeignKeyFormValues extends ForeignKeyRelation {
disableOriginColumn?: boolean;
}
export interface BaseForeignKeyFormProps {
export interface BaseForeignKeyFormProps extends DialogFormProps {
/**
* Available columns in the table.
*/
@@ -64,6 +65,7 @@ export function BaseForeignKeyForm({
onSubmit: handleExternalSubmit,
onCancel,
submitButtonText = 'Save',
location,
}: BaseForeignKeyFormProps) {
const { onDirtyStateChange } = useDialog();
@@ -86,8 +88,8 @@ export function BaseForeignKeyForm({
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'dialog');
}, [isDirty, onDirtyStateChange]);
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
return (
<Form

View File

@@ -1,6 +1,7 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import DatabaseRecordInputGroup from '@/components/dataBrowser/DatabaseRecordInputGroup';
import type { DialogFormProps } from '@/types/common';
import type {
ColumnInsertOptions,
DataBrowserGridColumn,
@@ -10,7 +11,7 @@ import Button from '@/ui/v2/Button';
import { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
export interface BaseRecordFormProps {
export interface BaseRecordFormProps extends DialogFormProps {
/**
* The columns of the table.
*/
@@ -36,6 +37,7 @@ export default function BaseRecordForm({
onSubmit: handleExternalSubmit,
onCancel,
submitButtonText = 'Save',
location,
}: BaseRecordFormProps) {
const { onDirtyStateChange } = useDialog();
const { requiredColumns, optionalColumns } = columns.reduce(
@@ -70,8 +72,8 @@ export default function BaseRecordForm({
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'drawer');
}, [isDirty, onDirtyStateChange]);
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
// Stores columns in a map to have constant time lookup. This is necessary
// for tables with many columns.

View File

@@ -1,6 +1,7 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import { baseColumnValidationSchema } from '@/components/dataBrowser/BaseColumnForm';
import type { DialogFormProps } from '@/types/common';
import type { DatabaseTable, ForeignKeyRelation } from '@/types/dataBrowser';
import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button';
@@ -30,7 +31,7 @@ export interface BaseTableFormValues
foreignKeyRelations?: ForeignKeyRelation[];
}
export interface BaseTableFormProps {
export interface BaseTableFormProps extends DialogFormProps {
/**
* Function to be called when the form is submitted.
*/
@@ -99,7 +100,9 @@ function NameInput() {
function FormFooter({
onCancel,
submitButtonText,
}: Pick<BaseTableFormProps, 'onCancel' | 'submitButtonText'>) {
location,
}: Pick<BaseTableFormProps, 'onCancel' | 'submitButtonText'> &
Pick<DialogFormProps, 'location'>) {
const { onDirtyStateChange } = useDialog();
const { isSubmitting, dirtyFields } = useFormState();
@@ -108,8 +111,8 @@ function FormFooter({
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'drawer');
}, [isDirty, onDirtyStateChange]);
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
return (
<Box className="grid flex-shrink-0 grid-flow-col justify-between gap-3 border-t-1 p-2">
@@ -135,6 +138,7 @@ function FormFooter({
}
export default function BaseTableForm({
location,
onSubmit: handleExternalSubmit,
onCancel,
submitButtonText = 'Save',
@@ -168,7 +172,11 @@ export default function BaseTableForm({
<ForeignKeyEditorSection />
</div>
<FormFooter onCancel={onCancel} submitButtonText={submitButtonText} />
<FormFooter
onCancel={onCancel}
submitButtonText={submitButtonText}
location={location}
/>
</Form>
);
}

View File

@@ -1,5 +1,7 @@
import { useDialog } from '@/components/common/DialogProvider';
import type { BaseForeignKeyFormValues } from '@/components/dataBrowser/BaseForeignKeyForm';
import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm';
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
import type { DatabaseColumn, ForeignKeyRelation } from '@/types/dataBrowser';
import Button from '@/ui/v2/Button';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
@@ -68,18 +70,19 @@ export default function ForeignKeyEditorSection() {
onEdit={() => {
const primaryKeyIndex = getValues('primaryKeyIndex');
openDialog('EDIT_FOREIGN_KEY', {
openDialog({
title: 'Edit Foreign Key Relation',
payload: {
foreignKeyRelation: fields[index],
availableColumns: columns.map((column, columnIndex) =>
columnIndex === primaryKeyIndex
? { ...column, isPrimary: true }
: column,
),
onSubmit: (values: BaseForeignKeyFormValues) =>
handleEdit(values, index),
},
component: (
<EditForeignKeyForm
foreignKeyRelation={fields[index] as ForeignKeyRelation}
availableColumns={columns.map((column, columnIndex) =>
columnIndex === primaryKeyIndex
? { ...column, isPrimary: true }
: column,
)}
onSubmit={(values) => handleEdit(values, index)}
/>
),
});
}}
onDelete={() => remove(index)}
@@ -105,7 +108,7 @@ export default function ForeignKeyEditorSection() {
onClick={() => {
const primaryKeyIndex = getValues('primaryKeyIndex');
openDialog('CREATE_FOREIGN_KEY', {
openDialog({
title: (
<span className="grid grid-flow-row">
<span>Add a Foreign Key Relation</span>
@@ -116,14 +119,16 @@ export default function ForeignKeyEditorSection() {
</Text>
</span>
),
payload: {
availableColumns: columns.map((column, index) =>
index === primaryKeyIndex
? { ...column, isPrimary: true }
: column,
),
onSubmit: handleCreate,
},
component: (
<CreateForeignKeyForm
availableColumns={columns.map((column, index) =>
index === primaryKeyIndex
? { ...column, isPrimary: true }
: column,
)}
onSubmit={handleCreate}
/>
),
});
}}
>

View File

@@ -1,4 +1,4 @@
import customClaimsQuery from '@/utils/msw/mocks/graphql/customClaimsQuery';
import permissionVariablesQuery from '@/utils/msw/mocks/graphql/permissionVariablesQuery';
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
import { render, screen } from '@/utils/testUtils';
@@ -6,7 +6,11 @@ import { setupServer } from 'msw/node';
import { test, vi } from 'vitest';
import ColumnAutocomplete from './ColumnAutocomplete';
const server = setupServer(tableQuery, hasuraMetadataQuery, customClaimsQuery);
const server = setupServer(
tableQuery,
hasuraMetadataQuery,
permissionVariablesQuery,
);
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers());

View File

@@ -15,11 +15,11 @@ import { useRouter } from 'next/router';
import { FormProvider, useForm } from 'react-hook-form';
export interface CreateColumnFormProps
extends Pick<BaseColumnFormProps, 'onCancel'> {
extends Pick<BaseColumnFormProps, 'onCancel' | 'location'> {
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
onSubmit?: (args?: any) => Promise<any>;
}
export default function CreateColumnForm({

View File

@@ -13,7 +13,10 @@ import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
export interface CreateForeignKeyFormProps
extends Pick<BaseForeignKeyFormProps, 'onCancel' | 'availableColumns'> {
extends Pick<
BaseForeignKeyFormProps,
'onCancel' | 'availableColumns' | 'location'
> {
/**
* Column selected by default.
*/
@@ -21,7 +24,7 @@ export interface CreateForeignKeyFormProps
/**
* Function to be called when the form is submitted.
*/
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void>;
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void> | void;
}
export default function CreateForeignKeyForm({
@@ -51,9 +54,7 @@ export default function CreateForeignKeyForm({
setError(undefined);
try {
if (onSubmit) {
await onSubmit(values);
}
await onSubmit?.(values);
} catch (submitError) {
if (submitError && submitError instanceof Error) {
setError(submitError);

View File

@@ -10,11 +10,11 @@ import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
export interface CreateRecordFormProps
extends Pick<BaseRecordFormProps, 'columns' | 'onCancel'> {
extends Pick<BaseRecordFormProps, 'columns' | 'onCancel' | 'location'> {
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
onSubmit?: (args?: any) => Promise<any>;
}
export default function CreateRecordForm({

View File

@@ -17,7 +17,7 @@ import { useRouter } from 'next/router';
import { FormProvider, useForm } from 'react-hook-form';
export interface CreateTableFormProps
extends Pick<BaseTableFormProps, 'onCancel'> {
extends Pick<BaseTableFormProps, 'onCancel' | 'location'> {
/**
* Schema where the table should be created.
*/
@@ -25,7 +25,7 @@ export interface CreateTableFormProps
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
onSubmit?: (args?: any) => Promise<any>;
}
export default function CreateTableForm({

View File

@@ -5,6 +5,7 @@ import DataGridDateCell from '@/components/common/DataGridDateCell';
import DataGridNumericCell from '@/components/common/DataGridNumericCell';
import DataGridTextCell from '@/components/common/DataGridTextCell';
import { useDialog } from '@/components/common/DialogProvider';
import FormActivityIndicator from '@/components/common/FormActivityIndicator';
import InlineCode from '@/components/common/InlineCode';
import DataBrowserEmptyState from '@/components/dataBrowser/DataBrowserEmptyState';
import DataBrowserGridControls from '@/components/dataBrowser/DataBrowserGridControls';
@@ -28,9 +29,25 @@ import {
} from '@/utils/dataBrowser/postgresqlConstants';
import { isSchemaLocked } from '@/utils/dataBrowser/schemaHelpers';
import { useQueryClient } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/router';
import { useEffect, useMemo, useRef, useState } from 'react';
const CreateColumnForm = dynamic(
() => import('@/components/dataBrowser/CreateColumnForm'),
{ ssr: false, loading: () => <FormActivityIndicator /> },
);
const EditColumnForm = dynamic(
() => import('@/components/dataBrowser/EditColumnForm'),
{ ssr: false, loading: () => <FormActivityIndicator /> },
);
const CreateRecordForm = dynamic(
() => import('@/components/dataBrowser/CreateRecordForm'),
{ ssr: false, loading: () => <FormActivityIndicator /> },
);
export interface DataBrowserGridProps extends Partial<DataGridProps<any>> {}
export function createDataGridColumn(
@@ -273,33 +290,36 @@ export default function DataBrowserGrid({
const memoizedData = useMemo(() => rows, [rows]);
async function handleInsertRowClick() {
openDrawer('CREATE_RECORD', {
openDrawer({
title: 'Insert a New Row',
payload: {
columns: memoizedColumns,
onSubmit: refetch,
},
component: (
<CreateRecordForm
// TODO: Create proper typings for data browser columns
columns={memoizedColumns as unknown as DataBrowserGridColumn[]}
onSubmit={refetch}
/>
),
});
}
async function handleInsertColumnClick() {
openDrawer('CREATE_COLUMN', {
openDrawer({
title: 'Insert a New Column',
payload: {
onSubmit: refetch,
},
component: <CreateColumnForm onSubmit={refetch} />,
});
}
async function handleEditColumnClick(
column: DataBrowserGridColumn<NormalizedQueryDataRow>,
) {
openDrawer('EDIT_COLUMN', {
openDrawer({
title: 'Edit Column',
payload: {
column,
onSubmit: () => queryClient.refetchQueries([currentTablePath]),
},
component: (
<EditColumnForm
column={column}
onSubmit={() => queryClient.refetchQueries([currentTablePath])}
/>
),
});
}

View File

@@ -1,4 +1,5 @@
import { useDialog } from '@/components/common/DialogProvider';
import FormActivityIndicator from '@/components/common/FormActivityIndicator';
import InlineCode from '@/components/common/InlineCode';
import NavLink from '@/components/common/NavLink';
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
@@ -31,11 +32,36 @@ import Select from '@/ui/v2/Select';
import Text from '@/ui/v2/Text';
import { isSchemaLocked } from '@/utils/dataBrowser/schemaHelpers';
import { useQueryClient } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge';
const CreateTableForm = dynamic(
() => import('@/components/dataBrowser/CreateTableForm'),
{
ssr: false,
loading: () => <FormActivityIndicator />,
},
);
const EditTableForm = dynamic(
() => import('@/components/dataBrowser/EditTableForm'),
{
ssr: false,
loading: () => <FormActivityIndicator />,
},
);
const EditPermissionsForm = dynamic(
() => import('@/components/dataBrowser/EditPermissionsForm'),
{
ssr: false,
loading: () => <FormActivityIndicator />,
},
);
export interface DataBrowserSidebarProps extends Omit<BoxProps, 'children'> {
/**
* Function to be called when a sidebar item is clicked.
@@ -200,7 +226,7 @@ function DataBrowserSidebarContent({
table: string,
disabled?: boolean,
) {
openDrawer('EDIT_PERMISSIONS', {
openDrawer({
title: (
<span className="inline-grid grid-flow-col items-center gap-2">
Permissions
@@ -208,22 +234,18 @@ function DataBrowserSidebarContent({
<Chip label="Preview" size="small" color="info" component="span" />
</span>
),
component: (
<EditPermissionsForm
disabled={disabled}
schema={schema}
table={table}
/>
),
props: {
PaperProps: {
className: 'lg:w-[65%] lg:max-w-7xl',
},
},
payload: {
onSubmit: async () => {
await queryClient.refetchQueries([
`${dataSourceSlug}.${schema}.${table}`,
]);
await refetch();
},
disabled,
schema,
table,
},
});
}
@@ -296,9 +318,11 @@ function DataBrowserSidebarContent({
endIcon={<PlusIcon />}
className="mt-1 w-full justify-between px-2"
onClick={() => {
openDrawer('CREATE_TABLE', {
openDrawer({
title: 'Create a New Table',
payload: { onSubmit: refetch, schema: selectedSchema },
component: (
<CreateTableForm onSubmit={refetch} schema={selectedSchema} />
),
});
onSidebarItemClick();
@@ -328,69 +352,68 @@ function DataBrowserSidebarContent({
className="group"
key={tablePath}
secondaryAction={
!isSelectedSchemaLocked && (
<Dropdown.Root
id="table-management-menu"
onOpen={() => setSidebarMenuTable(tablePath)}
onClose={() => setSidebarMenuTable(undefined)}
<Dropdown.Root
id="table-management-menu"
onOpen={() => setSidebarMenuTable(tablePath)}
onClose={() => setSidebarMenuTable(undefined)}
>
<Dropdown.Trigger
asChild
hideChevron
disabled={tablePath === removableTable}
>
<Dropdown.Trigger
asChild
hideChevron
disabled={tablePath === removableTable}
<IconButton
variant="borderless"
color={isSelected ? 'primary' : 'secondary'}
className={twMerge(
!isSelected &&
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-active:opacity-100',
)}
>
<IconButton
variant="borderless"
color={isSelected ? 'primary' : 'secondary'}
className={twMerge(
!isSelected &&
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-active:opacity-100',
)}
<DotsHorizontalIcon />
</IconButton>
</Dropdown.Trigger>
<Dropdown.Content menu PaperProps={{ className: 'w-52' }}>
{isGitHubConnected ? (
<Dropdown.Item
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
onClick={() =>
handleEditPermissionClick(
table.table_schema,
table.table_name,
true,
)
}
>
<DotsHorizontalIcon />
</IconButton>
</Dropdown.Trigger>
<UsersIcon
className="h-4 w-4"
sx={{ color: 'text.secondary' }}
/>
<Dropdown.Content
menu
PaperProps={{ className: 'w-52' }}
>
{isGitHubConnected ? (
<Dropdown.Item
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
onClick={() =>
handleEditPermissionClick(
table.table_schema,
table.table_name,
true,
)
}
>
<UsersIcon
className="h-4 w-4"
sx={{ color: 'text.secondary' }}
/>
<span>View Permissions</span>
</Dropdown.Item>
) : (
[
<span>View Permissions</span>
</Dropdown.Item>
) : (
[
!isSelectedSchemaLocked && (
<Dropdown.Item
key="edit-table"
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
onClick={() =>
openDrawer('EDIT_TABLE', {
openDrawer({
title: 'Edit Table',
payload: {
onSubmit: async () => {
await queryClient.refetchQueries([
`${dataSourceSlug}.${table.table_schema}.${table.table_name}`,
]);
await refetch();
},
schema: table.table_schema,
table,
},
component: (
<EditTableForm
onSubmit={async () => {
await queryClient.refetchQueries([
`${dataSourceSlug}.${table.table_schema}.${table.table_name}`,
]);
await refetch();
}}
schema={table.table_schema}
table={table}
/>
),
})
}
>
@@ -400,32 +423,38 @@ function DataBrowserSidebarContent({
/>
<span>Edit Table</span>
</Dropdown.Item>,
</Dropdown.Item>
),
!isSelectedSchemaLocked && (
<Divider
key="edit-table-separator"
component="li"
/>,
<Dropdown.Item
key="edit-permissions"
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
onClick={() =>
handleEditPermissionClick(
table.table_schema,
table.table_name,
)
}
>
<UsersIcon
className="h-4 w-4"
sx={{ color: 'text.secondary' }}
/>
/>
),
<Dropdown.Item
key="edit-permissions"
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
onClick={() =>
handleEditPermissionClick(
table.table_schema,
table.table_name,
)
}
>
<UsersIcon
className="h-4 w-4"
sx={{ color: 'text.secondary' }}
/>
<span>Edit Permissions</span>
</Dropdown.Item>,
<span>Edit Permissions</span>
</Dropdown.Item>,
!isSelectedSchemaLocked && (
<Divider
key="edit-permissions-separator"
component="li"
/>,
/>
),
!isSelectedSchemaLocked && (
<Dropdown.Item
key="delete-table"
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
@@ -443,12 +472,12 @@ function DataBrowserSidebarContent({
/>
<span>Delete Table</span>
</Dropdown.Item>,
]
)}
</Dropdown.Content>
</Dropdown.Root>
)
</Dropdown.Item>
),
]
)}
</Dropdown.Content>
</Dropdown.Root>
}
>
<ListItem.Button
@@ -518,7 +547,7 @@ export default function DataBrowserSidebar({
document.removeEventListener('keydown', closeSidebarWhenEscapeIsPressed);
}, []);
if (isPlatform && !currentApplication?.hasuraGraphqlAdminSecret) {
if (isPlatform && !currentApplication?.config?.hasura.adminSecret) {
return null;
}

View File

@@ -18,7 +18,7 @@ import { useRouter } from 'next/router';
import { FormProvider, useForm } from 'react-hook-form';
export interface EditColumnFormProps
extends Pick<BaseColumnFormProps, 'onCancel'> {
extends Pick<BaseColumnFormProps, 'onCancel' | 'location'> {
/**
* Column to be edited.
*/

View File

@@ -14,7 +14,10 @@ import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
export interface EditForeignKeyFormProps
extends Pick<BaseForeignKeyFormProps, 'onCancel' | 'availableColumns'> {
extends Pick<
BaseForeignKeyFormProps,
'onCancel' | 'availableColumns' | 'location'
> {
/**
* Foreign key relation to be edited.
*/
@@ -26,7 +29,7 @@ export interface EditForeignKeyFormProps
/**
* Function to be called when the form is submitted.
*/
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void>;
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void> | void;
}
export default function EditForeignKeyForm({
@@ -57,9 +60,7 @@ export default function EditForeignKeyForm({
setError(undefined);
try {
if (onSubmit) {
await onSubmit(values);
}
await onSubmit?.(values);
} catch (submitError) {
if (submitError && submitError instanceof Error) {
setError(submitError);

View File

@@ -3,6 +3,7 @@ import useMetadataQuery from '@/hooks/dataBrowser/useMetadataQuery';
import useTableQuery from '@/hooks/dataBrowser/useTableQuery';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
import type { DialogFormProps } from '@/types/common';
import type {
DatabaseAccessLevel,
DatabaseAction,
@@ -30,7 +31,7 @@ import { twMerge } from 'tailwind-merge';
import RolePermissionEditorForm from './RolePermissionEditorForm';
import RolePermissionsRow from './RolePermissionsRow';
export interface EditPermissionsFormProps {
export interface EditPermissionsFormProps extends DialogFormProps {
/**
* Determines whether the form is disabled or not.
*/
@@ -54,6 +55,7 @@ export default function EditPermissionsForm({
schema,
table,
onCancel,
location,
}: EditPermissionsFormProps) {
const [role, setRole] = useState<string>();
const [action, setAction] = useState<DatabaseAction>();
@@ -181,6 +183,7 @@ export default function EditPermissionsForm({
return (
<RolePermissionEditorForm
location={location}
resourceVersion={metadata?.resourceVersion}
disabled={disabled}
schema={schema}

View File

@@ -2,6 +2,7 @@ import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import HighlightedText from '@/components/common/HighlightedText';
import useManagePermissionMutation from '@/hooks/dataBrowser/useManagePermissionMutation';
import type { DialogFormProps } from '@/types/common';
import type {
DatabaseAction,
HasuraMetadataPermission,
@@ -13,6 +14,7 @@ import Button from '@/ui/v2/Button';
import Text from '@/ui/v2/Text';
import convertToHasuraPermissions from '@/utils/dataBrowser/convertToHasuraPermissions';
import convertToRuleGroup from '@/utils/dataBrowser/convertToRuleGroup';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { useQueryClient } from '@tanstack/react-query';
@@ -72,7 +74,7 @@ export interface RolePermissionEditorFormValues {
computedFields?: string[];
}
export interface RolePermissionEditorFormProps {
export interface RolePermissionEditorFormProps extends DialogFormProps {
/**
* Determines whether or not the form is disabled.
*/
@@ -169,6 +171,7 @@ export default function RolePermissionEditorForm({
onCancel,
permission,
disabled,
location,
}: RolePermissionEditorFormProps) {
const queryClient = useQueryClient();
const {
@@ -214,8 +217,8 @@ export default function RolePermissionEditorForm({
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'drawer');
}, [isDirty, onDirtyStateChange]);
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
async function handleSubmit(values: RolePermissionEditorFormValues) {
const managePermissionPromise = managePermission({
@@ -245,7 +248,7 @@ export default function RolePermissionEditorForm({
: permission?.check,
backend_only: values.backendOnly,
computed_fields:
permission?.computed_fields.length > 0
permission?.computed_fields?.length > 0
? permission?.computed_fields
: null,
},
@@ -256,12 +259,12 @@ export default function RolePermissionEditorForm({
{
loading: 'Saving permission...',
success: 'Permission has been saved successfully.',
error: 'An error occurred while saving the permission.',
error: getServerError('An error occurred while saving the permission.'),
},
getToastStyleProps(),
);
onDirtyStateChange(false, 'drawer');
onDirtyStateChange(false, location);
onSubmit?.();
}
@@ -270,7 +273,7 @@ export default function RolePermissionEditorForm({
openDirtyConfirmation({
props: {
onPrimaryAction: () => {
onDirtyStateChange(false, 'drawer');
onDirtyStateChange(false, location);
onCancel?.();
},
},
@@ -295,12 +298,14 @@ export default function RolePermissionEditorForm({
{
loading: 'Deleting permission...',
success: 'Permission has been deleted successfully.',
error: 'An error occurred while deleting the permission.',
error: getServerError(
'An error occurred while deleting the permission.',
),
},
getToastStyleProps(),
);
onDirtyStateChange(false, 'drawer');
onDirtyStateChange(false, location);
onSubmit?.();
}

View File

@@ -11,8 +11,8 @@ import XIcon from '@/ui/v2/icons/XIcon';
import InputLabel from '@/ui/v2/InputLabel';
import Option from '@/ui/v2/Option';
import Text from '@/ui/v2/Text';
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
import { useGetAppCustomClaimsQuery } from '@/utils/__generated__/graphql';
import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
import { useGetRolesPermissionsQuery } from '@/utils/__generated__/graphql';
import { useTheme } from '@mui/material';
import clsx from 'clsx';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
@@ -51,8 +51,8 @@ export default function ColumnPresetsSection({
} = useTableQuery([`default.${schema}.${table}`], { schema, table });
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data: customClaimsData } = useGetAppCustomClaimsQuery({
variables: { id: currentApplication?.id },
const { data: permissionVariablesData } = useGetRolesPermissionsQuery({
variables: { appId: currentApplication?.id },
skip: !currentApplication?.id,
});
const {
@@ -74,8 +74,8 @@ export default function ColumnPresetsSection({
throw tableError;
}
const permissionVariableOptions = getPermissionVariablesArray(
customClaimsData?.app?.authJwtCustomClaims,
const permissionVariableOptions = getAllPermissionVariables(
permissionVariablesData?.config?.auth?.session?.accessToken?.customClaims,
).map(({ key }) => ({
label: `X-Hasura-${key}`,
value: `X-Hasura-${key}`,
@@ -136,7 +136,7 @@ export default function ColumnPresetsSection({
disableClearable={false}
clearIcon={
<XIcon
className="w-4 h-4 mt-px"
className="mt-px h-4 w-4"
sx={{ color: theme.palette.text.primary }}
/>
}
@@ -187,7 +187,7 @@ export default function ColumnPresetsSection({
disabled={disabled}
variant="outlined"
color="secondary"
className="shrink-0 grow-0 flex-[40px]"
className="flex-[40px] shrink-0 grow-0"
onClick={() => {
if (fields.length === 1) {
remove(index);
@@ -199,7 +199,7 @@ export default function ColumnPresetsSection({
remove(index);
}}
>
<XIcon className="w-4 h-4" />
<XIcon className="h-4 w-4" />
</IconButton>
</div>
))}

View File

@@ -6,6 +6,7 @@ import Input from '@/ui/v2/Input';
import Radio from '@/ui/v2/Radio';
import RadioGroup from '@/ui/v2/RadioGroup';
import Text from '@/ui/v2/Text';
import type { FocusEvent } from 'react';
import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import PermissionSettingsSection from './PermissionSettingsSection';
@@ -130,7 +131,13 @@ export default function RowPermissionsSection({
{action === 'select' && (
<Input
{...register('limit')}
{...register('limit', {
onBlur: (event: FocusEvent<HTMLInputElement>) => {
if (!event.target.value) {
setValue('limit', null);
}
},
})}
disabled={disabled}
id="limit"
type="number"

View File

@@ -43,7 +43,10 @@ const baseValidationSchema = Yup.object().shape({
});
const selectValidationSchema = baseValidationSchema.shape({
limit: Yup.number().min(0, 'Limit must not be negative.').nullable(true),
limit: Yup.number()
.label('Limit')
.min(0, 'Limit must not be negative.')
.nullable(true),
allowAggregations: Yup.boolean().nullable(true),
queryRootFields: Yup.array().of(Yup.string()).nullable(true),
subscriptionRootFields: Yup.array().of(Yup.string()).nullable(true),

View File

@@ -23,7 +23,7 @@ import { useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
export interface EditTableFormProps
extends Pick<BaseTableFormProps, 'onCancel'> {
extends Pick<BaseTableFormProps, 'onCancel' | 'location'> {
/**
* Schema where the table is located.
*/

View File

@@ -2,7 +2,7 @@ import Form from '@/components/common/Form';
import type { RuleGroup } from '@/types/dataBrowser';
import Button from '@/ui/v2/Button';
import Text from '@/ui/v2/Text';
import customClaimsQuery from '@/utils/msw/mocks/graphql/customClaimsQuery';
import permissionVariablesQuery from '@/utils/msw/mocks/graphql/permissionVariablesQuery';
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
import type { ComponentMeta, ComponentStory } from '@storybook/react';
@@ -36,7 +36,7 @@ const defaultParameters = {
},
},
msw: {
handlers: [tableQuery, hasuraMetadataQuery, customClaimsQuery],
handlers: [tableQuery, hasuraMetadataQuery, permissionVariablesQuery],
},
};

View File

@@ -6,11 +6,12 @@ import ColumnAutocomplete from '@/components/dataBrowser/ColumnAutocomplete';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { HasuraOperator } from '@/types/dataBrowser';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import type { AutocompleteOption } from '@/ui/v2/Autocomplete';
import type { InputProps } from '@/ui/v2/Input';
import { inputClasses } from '@/ui/v2/Input';
import Option from '@/ui/v2/Option';
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
import { useGetAppCustomClaimsQuery } from '@/utils/__generated__/graphql';
import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
import { useGetRolesPermissionsQuery } from '@/utils/__generated__/graphql';
import { useController, useFormContext, useWatch } from 'react-hook-form';
import useRuleGroupEditor from './useRuleGroupEditor';
@@ -116,8 +117,8 @@ export default function RuleValueInput({
data,
loading,
error: customClaimsError,
} = useGetAppCustomClaimsQuery({
variables: { id: currentApplication?.id },
} = useGetRolesPermissionsQuery({
variables: { appId: currentApplication?.id },
skip: !isHasuraInput || !currentApplication?.id,
});
@@ -199,8 +200,8 @@ export default function RuleValueInput({
);
}
const availableHasuraPermissionVariables = getPermissionVariablesArray(
data?.app?.authJwtCustomClaims,
const availableHasuraPermissionVariables = getAllPermissionVariables(
data?.config?.auth?.session?.accessToken?.customClaims,
).map(({ key }) => ({
value: `X-Hasura-${key}`,
label: `X-Hasura-${key}`,
@@ -211,11 +212,13 @@ export default function RuleValueInput({
<ControlledAutocomplete
disabled={disabled}
freeSolo={!isHasuraInput}
autoSelect={!isHasuraInput}
autoHighlight={isHasuraInput}
isOptionEqualToValue={(option, value) => {
if (typeof value === 'string') {
return option.value.toLowerCase() === (value as string).toLowerCase();
isOptionEqualToValue={(
option,
value: string | number | AutocompleteOption<string>,
) => {
if (typeof value !== 'object') {
return option.value.toLowerCase() === value?.toString().toLowerCase();
}
return option.value.toLowerCase() === value.value.toLowerCase();

View File

@@ -10,6 +10,7 @@ import ArrowCounterclockwiseIcon from '@/ui/v2/icons/ArrowCounterclockwiseIcon';
import ChevronRightIcon from '@/ui/v2/icons/ChevronRightIcon';
import { ListItem } from '@/ui/v2/ListItem';
import Tooltip from '@/ui/v2/Tooltip';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import type { DeploymentRowFragment } from '@/utils/__generated__/graphql';
import { useInsertDeploymentMutation } from '@/utils/__generated__/graphql';
@@ -122,7 +123,9 @@ export default function DeploymentListItem({
{
loading: 'Scheduling deployment...',
success: 'Deployment has been scheduled successfully.',
error: 'An error occurred when scheduling deployment.',
error: getServerError(
'An error occurred when scheduling deployment.',
),
},
getToastStyleProps(),
);

View File

@@ -262,7 +262,7 @@ export default function FilesDataGrid(props: FilesDataGridProps) {
.setAdminSecret(
process.env.NEXT_PUBLIC_ENV === 'dev'
? 'nhost-admin-secret'
: currentApplication.hasuraGraphqlAdminSecret,
: currentApplication.config?.hasura.adminSecret,
)
.upload({
file,

View File

@@ -72,7 +72,7 @@ export default function FilesDataGridControls({
const storageWithAdminSecret = appClient.storage.setAdminSecret(
process.env.NEXT_PUBLIC_ENV === 'dev'
? 'nhost-admin-secret'
: currentApplication.hasuraGraphqlAdminSecret,
: currentApplication.config?.hasura.adminSecret,
);
// note: this is not an optimal solution, but we don't have a better way
@@ -120,7 +120,7 @@ export default function FilesDataGridControls({
{...props}
>
{numberOfSelectedFiles > 0 ? (
<div className="mx-auto h-[40px] grid grid-flow-col justify-start items-center gap-2">
<div className="mx-auto grid h-[40px] grid-flow-col items-center justify-start gap-2">
<Chip
color="info"
size="small"

View File

@@ -1,7 +1,10 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import type { DialogFormProps } from '@/types/common';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import { slugifyString } from '@/utils/helpers';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
refetchGetOneUserQuery,
@@ -11,11 +14,12 @@ import {
import { yupResolver } from '@hookform/resolvers/yup';
import { useUserData } from '@nhost/nextjs';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface EditWorkspaceNameFormProps {
export interface EditWorkspaceNameFormProps extends DialogFormProps {
/**
* The current workspace name if this is an edit operation.
*/
@@ -44,14 +48,7 @@ export interface EditWorkspaceNameFormProps {
onCancel?: VoidFunction;
}
export interface EditWorkspaceNameFormValues {
/**
* New workspace name.
*/
newWorkspaceName: string;
}
const validationSchema = Yup.object().shape({
const validationSchema = Yup.object({
newWorkspaceName: Yup.string()
.required('Workspace name is required.')
.min(4, 'The new Workspace name must be at least 4 characters.')
@@ -71,14 +68,20 @@ const validationSchema = Yup.object().shape({
),
});
export default function EditWorkspaceName({
export type EditWorkspaceNameFormValues = Yup.InferType<
typeof validationSchema
>;
export default function EditWorkspaceNameForm({
disabled,
onSubmit,
onCancel,
currentWorkspaceName,
currentWorkspaceId,
submitButtonText = 'Create',
location,
}: EditWorkspaceNameFormProps) {
const { onDirtyStateChange } = useDialog();
const currentUser = useUserData();
const [insertWorkspace, { client }] = useInsertWorkspaceMutation();
const [updateWorkspaceName] = useUpdateWorkspaceMutation({
@@ -105,6 +108,10 @@ export default function EditWorkspaceName({
} = form;
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
async function handleSubmit({
newWorkspaceName,
}: EditWorkspaceNameFormValues) {
@@ -112,6 +119,8 @@ export default function EditWorkspaceName({
try {
if (currentWorkspaceId) {
onDirtyStateChange(false, location);
// In this bit of code we spread the props of the current path (e.g. /workspace/...) and add one key-value pair: `mutating: true`.
// We want to indicate that the currently we're in the process of running a mutation state that will affect the routing behaviour of the website
// i.e. redirecting to 404 if there's no workspace/project with that slug.
@@ -133,7 +142,9 @@ export default function EditWorkspaceName({
{
loading: 'Updating workspace name...',
success: 'Workspace name has been updated successfully.',
error: 'An error occurred while updating the workspace name.',
error: getServerError(
'An error occurred while updating the workspace name.',
),
},
getToastStyleProps(),
);
@@ -160,7 +171,9 @@ export default function EditWorkspaceName({
{
loading: 'Creating new workspace...',
success: 'The new workspace has been created successfully.',
error: 'An error occurred while creating the new workspace.',
error: getServerError(
'An error occurred while creating the new workspace.',
),
},
getToastStyleProps(),
);
@@ -186,6 +199,9 @@ export default function EditWorkspaceName({
include: ['getOneUser'],
});
// The form has been submitted, it's not dirty anymore
onDirtyStateChange(false, location);
await router.push(slug);
onSubmit?.();
}
@@ -194,9 +210,9 @@ export default function EditWorkspaceName({
<FormProvider {...form}>
<Form
onSubmit={handleSubmit}
className="flex flex-col content-between flex-auto pt-2 pb-6 overflow-hidden"
className="flex flex-auto flex-col content-between overflow-hidden pt-2 pb-6"
>
<div className="flex-auto px-6 overflow-y-auto">
<div className="flex-auto overflow-y-auto px-6">
<Input
{...register('newWorkspaceName')}
error={Boolean(errors.newWorkspaceName?.message)}

View File

@@ -1,3 +1,4 @@
import { useUI } from '@/context/UIContext';
import Button from '@/ui/v2/Button';
import PlusCircleIcon from '@/ui/v2/icons/PlusCircleIcon';
import SearchIcon from '@/ui/v2/icons/SearchIcon';
@@ -11,6 +12,8 @@ interface IndexHeaderAppsProps {
}
export function IndexHeaderApps({ query, setQuery }: IndexHeaderAppsProps) {
const { maintenanceActive } = useUI();
return (
<div className="mx-auto mb-6 grid w-full grid-flow-col place-content-between items-center py-2">
<Text variant="h2" component="h1" className="hidden md:block">
@@ -36,6 +39,7 @@ export function IndexHeaderApps({ query, setQuery }: IndexHeaderAppsProps) {
variant="outlined"
color="secondary"
startIcon={<PlusCircleIcon />}
disabled={maintenanceActive}
>
New Project
</Button>

View File

@@ -58,16 +58,19 @@ export function InviteAnnounce() {
error: null,
loading: true,
});
const res = await nhost.functions.call('/accept-workspace-invite', {
workspaceMemberInviteId: invite.id,
isAccepted: true,
});
const { res, error: acceptError } = await nhost.functions.call(
'/accept-workspace-invite',
{
workspaceMemberInviteId: invite.id,
isAccepted: true,
},
);
if (res?.res?.status !== 200) {
if (res?.status !== 200) {
triggerToast('An error occurred when trying to accept the invitation.');
return setSubmitState({
error: new Error(res.error.message),
error: new Error(acceptError.message),
loading: false,
});
}
@@ -90,7 +93,7 @@ export function InviteAnnounce() {
error: null,
});
const res = await nhost.functions.call(
const { error: ignoreError } = await nhost.functions.call(
'/accept-workspace-invite',
{
workspaceMemberInviteId: inviteId,
@@ -99,12 +102,12 @@ export function InviteAnnounce() {
{ useAxios: false },
);
if (res?.error) {
if (ignoreError) {
triggerToast('An error occurred when trying to ignore the invitation.');
setIgnoreState({
loading: false,
error: new Error(res.error.message),
error: new Error(ignoreError.message),
});
return;

View File

@@ -1,5 +1,5 @@
import { UserDataProvider } from '@/context/workspace1-context';
import type { Application } from '@/types/application';
import type { Project } from '@/types/application';
import { ApplicationStatus } from '@/types/application';
import type { Workspace } from '@/types/workspace';
import { render, screen, waitForElementToBeRemoved } from '@/utils/testUtils';
@@ -36,12 +36,11 @@ vi.mock('next/router', () => ({
}),
}));
const mockApplication: Application = {
const mockApplication: Project = {
id: '1',
name: 'Test Application',
slug: 'test-application',
appStates: [],
hasuraGraphqlAdminSecret: 'nhost-admin-secret',
subdomain: '',
isProvisioned: true,
region: {
@@ -56,6 +55,14 @@ const mockApplication: Application = {
featureFlags: [],
providersUpdated: true,
githubRepository: { fullName: 'test/git-project' },
repositoryProductionBranch: null,
nhostBaseFolder: null,
plan: null,
config: {
hasura: {
adminSecret: 'nhost-admin-secret',
},
},
};
const mockWorkspace: Workspace = {

View File

@@ -1,6 +1,7 @@
import useGitHubModal from '@/components/applications/github/useGitHubModal';
import DeploymentListItem from '@/components/deployments/DeploymentListItem';
import GithubIcon from '@/components/icons/GithubIcon';
import { useUI } from '@/context/UIContext';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Box from '@/ui/v2/Box';
@@ -146,6 +147,7 @@ function OverviewDeploymentList() {
export default function OverviewDeployments() {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { openGitHubModal } = useGitHubModal();
const { maintenanceActive } = useUI();
const { githubRepository } = currentApplication || {};
@@ -183,6 +185,7 @@ export default function OverviewDeployments() {
color="primary"
className="w-full"
onClick={openGitHubModal}
disabled={maintenanceActive}
>
<GithubIcon className="mr-1.5 h-4 w-4 self-center" />
Connect to GitHub

View File

@@ -1,4 +1,5 @@
import GithubIcon from '@/components/icons/GithubIcon';
import { useUI } from '@/context/UIContext';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button';
@@ -8,6 +9,7 @@ import NavLink from 'next/link';
export default function OverviewRepository() {
const { currentWorkspace, currentApplication } =
useCurrentWorkspaceAndApplication();
const { maintenanceActive } = useUI();
return (
<div>
@@ -28,6 +30,7 @@ export default function OverviewRepository() {
color="secondary"
className="w-full border-1 hover:border-1"
startIcon={<GithubIcon />}
disabled={maintenanceActive}
>
Connect to GitHub
</Button>
@@ -39,7 +42,7 @@ export default function OverviewRepository() {
sx={{ backgroundColor: 'grey.200' }}
>
<Box
className="grid grid-flow-col gap-1.5 ml-2"
className="ml-2 grid grid-flow-col gap-1.5"
sx={{ backgroundColor: 'transparent' }}
>
<GithubIcon className="h-4 w-4 self-center" />
@@ -52,7 +55,11 @@ export default function OverviewRepository() {
href={`/${currentWorkspace.slug}/${currentApplication.slug}/settings/git`}
passHref
>
<Button variant="borderless" size="small">
<Button
variant="borderless"
size="small"
disabled={maintenanceActive}
>
Edit
</Button>
</NavLink>

View File

@@ -1,6 +1,7 @@
import { ChangePlanModal } from '@/components/applications/ChangePlanModal';
import { useDialog } from '@/components/common/DialogProvider';
import { useUI } from '@/context/UIContext';
import useIsPlatform from '@/hooks/common/useIsPlatform';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import Button from '@/ui/v2/Button';
@@ -16,6 +17,7 @@ export default function OverviewTopBar() {
useCurrentWorkspaceAndApplication();
const isPro = !currentApplication?.plan?.isFree;
const { openAlertDialog } = useDialog();
const { maintenanceActive } = useUI();
if (!isPlatform) {
return (
@@ -104,6 +106,7 @@ export default function OverviewTopBar() {
endIcon={<CogIcon className="h-4 w-4" />}
variant="outlined"
color="secondary"
disabled={maintenanceActive}
>
Settings
</Button>

View File

@@ -1,5 +1,6 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import {
useResetPostgresPasswordMutation,
useUpdateApplicationMutation,
@@ -28,6 +29,7 @@ export interface ResetDatabasePasswordFormValues {
export default function ResetDatabasePasswordSettings() {
const [updateApplication] = useUpdateApplicationMutation();
const { maintenanceActive } = useUI();
const form = useForm<ResetDatabasePasswordFormValues>({
reValidateMode: 'onSubmit',
@@ -44,11 +46,10 @@ export default function ResetDatabasePasswordSettings() {
setValue,
getValues,
register,
formState: { errors },
formState: { errors, isDirty, isSubmitting },
} = form;
const [resetPostgresPasswordMutation, { loading }] =
useResetPostgresPasswordMutation();
const [resetPostgresPasswordMutation] = useResetPostgresPasswordMutation();
const user = useUserData();
const { currentApplication } = useCurrentWorkspaceAndApplication();
@@ -99,12 +100,16 @@ export default function ResetDatabasePasswordSettings() {
title="Reset Password"
description="This password is used for accessing your database."
submitButtonText="Reset"
rootClassName="border-[#F87171]"
primaryActionButtonProps={{
variant: 'contained',
color: 'error',
disabled: Boolean(errors?.databasePassword),
loading,
slotProps={{
root: {
sx: { borderColor: (theme) => theme.palette.error.main },
},
submitButton: {
variant: 'contained',
color: 'error',
disabled: !isDirty || maintenanceActive,
loading: isSubmitting,
},
}}
className="grid grid-flow-row pb-4"
>

View File

@@ -179,6 +179,15 @@ export default function SettingsSidebar({
>
Environment Variables
</SettingsNavLink>
<SettingsNavLink
href="/secrets"
exact={false}
onClick={handleSelect}
className="hidden"
>
Secrets
</SettingsNavLink>
</List>
</nav>
</Box>

View File

@@ -1,68 +1,61 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useGetAppQuery, useUpdateAppMutation } from '@/generated/graphql';
import { useUI } from '@/context/UIContext';
import {
GetAuthenticationSettingsDocument,
useGetAuthenticationSettingsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Input from '@/ui/v2/Input';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { useEffect } from 'react';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup';
export interface AllowedEmailSettingsFormValues {
/**
* Determines whether or not the allowed email settings are enabled.
*/
enabled: boolean;
/**
* Set of email that are allowed to be used for project's users authentication.
*/
authAccessControlAllowedEmails: string;
/**
* Set of email domains that are allowed to be used for project's users authentication.
* @example 'nhost.io'
*/
authAccessControlAllowedEmailDomains: string;
}
const validationSchema = Yup.object({
enabled: Yup.boolean().label('Enabled'),
allowedEmails: Yup.string().label('Allowed Emails'),
allowedEmailDomains: Yup.string().label('Allowed Email Domains'),
});
export type AllowedEmailSettingsFormValues = Yup.InferType<
typeof validationSchema
>;
export default function AllowedEmailDomainsSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const { data, loading, error } = useGetAppQuery({
variables: {
id: currentApplication?.id,
},
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetAuthenticationSettingsDocument],
});
const { data, loading, error } = useGetAuthenticationSettingsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { email, emailDomains } = data?.config?.auth?.user || {};
const form = useForm<AllowedEmailSettingsFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
enabled:
Boolean(data?.app?.authAccessControlAllowedEmails) ||
Boolean(data?.app?.authAccessControlAllowedEmailDomains),
authAccessControlAllowedEmails: data?.app?.authAccessControlAllowedEmails,
authAccessControlAllowedEmailDomains:
data?.app?.authAccessControlAllowedEmailDomains,
enabled: email?.allowed?.length > 0 || emailDomains?.allowed?.length > 0,
allowedEmails: email?.allowed?.join(', ') || '',
allowedEmailDomains: emailDomains?.allowed?.join(', ') || '',
},
resolver: yupResolver(validationSchema),
});
const { register, formState, setValue, watch } = form;
const { register, formState, watch } = form;
const enabled = watch('enabled');
const isDirty = Object.keys(formState.dirtyFields).length > 0;
useEffect(() => {
if (
!data.app?.authAccessControlAllowedEmails &&
!data.app?.authAccessControlAllowedEmailDomains
) {
return;
}
setValue('enabled', true, { shouldDirty: false });
}, [data.app, setValue]);
if (loading) {
return (
<ActivityIndicator
@@ -80,29 +73,51 @@ export default function AllowedEmailDomainsSettings() {
const handleAllowedEmailDomainsChange = async (
values: AllowedEmailSettingsFormValues,
) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: {
authAccessControlAllowedEmails: values.enabled
? values.authAccessControlAllowedEmails
: '',
authAccessControlAllowedEmailDomains: values.enabled
? values.authAccessControlAllowedEmailDomains
: '',
appId: currentApplication.id,
config: {
auth: {
user: {
email: {
blocked: email.blocked,
allowed:
values.enabled && values.allowedEmails
? values.allowedEmails
.split(',')
.map((allowedEmail) => allowedEmail.trim())
: [],
},
emailDomains: {
blocked: emailDomains.blocked,
allowed:
values.enabled && values.allowedEmailDomains
? values.allowedEmailDomains
.split(',')
.map((allowedEmailDomain) => allowedEmailDomain.trim())
: [],
},
},
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `Allowed email settings are being updated...`,
success: `Allowed email settings have been updated successfully.`,
error: `An error occurred while trying to update the project's allowed email settings.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `Allowed email settings are being updated...`,
success: `Allowed email settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update the project's allowed email settings.`,
),
},
getToastStyleProps(),
);
} catch {
// Note: The toast will handle the error
}
form.reset(values);
};
@@ -115,15 +130,12 @@ export default function AllowedEmailDomainsSettings() {
description="Allow specific email addresses and domains to sign up."
slotProps={{
submitButton: {
disabled: !formState.isValid || !isDirty,
disabled: !isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/authentication#allowed-emails-and-domains"
enabled={enabled}
onEnabledChange={(switchEnabled) =>
setValue('enabled', switchEnabled, { shouldDirty: true })
}
switchId="enabled"
showSwitch
className={twMerge(
'row-span-2 grid grid-flow-row gap-4 px-4 lg:grid-cols-3',
@@ -131,9 +143,9 @@ export default function AllowedEmailDomainsSettings() {
)}
>
<Input
{...register('authAccessControlAllowedEmails')}
name="authAccessControlAllowedEmails"
id="authAccessControlAllowedEmails"
{...register('allowedEmails')}
name="allowedEmails"
id="allowedEmails"
placeholder="These emails (separated by comma, e.g, david@ikea.com, lisa@mycompany.com)"
className="col-span-2"
label="Allowed Emails (comma separated)"
@@ -141,9 +153,9 @@ export default function AllowedEmailDomainsSettings() {
hideEmptyHelperText
/>
<Input
{...register('authAccessControlAllowedEmailDomains')}
name="authAccessControlAllowedEmailDomains"
id="authAccessControlAllowedEmailDomains"
{...register('allowedEmailDomains')}
name="allowedEmailDomains"
id="allowedEmailDomains"
label="Allowed Email Domains (comma sepated list)"
placeholder="These email domains (separated by comma, e.g, ikea.com, mycompany.com)"
className="col-span-2"

View File

@@ -1,36 +1,49 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useGetAppQuery, useUpdateAppMutation } from '@/generated/graphql';
import { useUI } from '@/context/UIContext';
import {
GetAuthenticationSettingsDocument,
useGetAuthenticationSettingsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Input from '@/ui/v2/Input';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface AllowedRedirectURLFormValues {
/**
* Set of URLs that are allowed to be redirected to after project's users authentication.
*/
authAccessControlAllowedRedirectUrls: string;
}
const validationSchema = Yup.object({
allowedUrls: Yup.string().label('Allowed Redirect URLs'),
});
export type AllowedRedirectURLFormValues = Yup.InferType<
typeof validationSchema
>;
export default function AllowedRedirectURLsSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const { data, loading, error } = useGetAppQuery({
variables: {
id: currentApplication?.id,
},
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetAuthenticationSettingsDocument],
});
const { data, loading, error } = useGetAuthenticationSettingsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { allowedUrls } = data?.config?.auth?.redirections || {};
const form = useForm<AllowedRedirectURLFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
authAccessControlAllowedRedirectUrls:
data?.app?.authAccessControlAllowedRedirectUrls,
allowedUrls: allowedUrls?.join(', ') || '',
},
resolver: yupResolver(validationSchema),
});
if (loading) {
@@ -52,26 +65,38 @@ export default function AllowedRedirectURLsSettings() {
const handleAllowedRedirectURLsChange = async (
values: AllowedRedirectURLFormValues,
) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: {
...values,
appId: currentApplication.id,
config: {
auth: {
redirections: {
allowedUrls: values.allowedUrls
? values.allowedUrls.split(',').map((url) => url.trim())
: [],
},
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `Allowed redirect URL settings are being updated...`,
success: `Allowed redirect URL settings have been updated successfully.`,
error: `An error occurred while trying to update the project's allowed redirect URL settings.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `Allowed redirect URL settings are being updated...`,
success: `Allowed redirect URL settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update the project's allowed redirect URL settings.`,
),
},
getToastStyleProps(),
);
form.reset(values);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
@@ -80,17 +105,19 @@ export default function AllowedRedirectURLsSettings() {
<SettingsContainer
title="Allowed Redirect URLs"
description="Allowed URLs where users can be redirected to after authentication. Separate multiple redirect URLs with comma."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
submitButton: {
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/authentication#allowed-redirect-urls"
className="grid grid-flow-row px-4 lg:grid-cols-5"
>
<Input
{...register('authAccessControlAllowedRedirectUrls')}
name="authAccessControlAllowedRedirectUrls"
id="authAccessControlAllowedRedirectUrls"
{...register('allowedUrls')}
name="allowedUrls"
id="allowedUrls"
placeholder="http://localhost:3000, http://localhost:4000"
className="col-span-2"
fullWidth

View File

@@ -1,67 +1,58 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useGetAppQuery, useUpdateAppMutation } from '@/generated/graphql';
import { useUI } from '@/context/UIContext';
import {
GetAuthenticationSettingsDocument,
useGetAuthenticationSettingsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Input from '@/ui/v2/Input';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { useEffect } from 'react';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup';
export interface BlockedEmailFormValues {
/**
* Determines whether or not the blocked email settings are enabled.
*/
enabled: boolean;
/**
* Set of emails that are blocked from registering to the user's project.
*/
authAccessControlBlockedEmails: string;
/**
* Set of email domains that are blocked from registering to the user's project.
*/
authAccessControlBlockedEmailDomains: string;
}
const validationSchema = Yup.object({
enabled: Yup.boolean().label('Enabled'),
blockedEmails: Yup.string().label('Blocked Emails'),
blockedEmailDomains: Yup.string().label('Blocked Email Domains'),
});
export type BlockedEmailFormValues = Yup.InferType<typeof validationSchema>;
export default function BlockedEmailSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const { data, loading, error } = useGetAppQuery({
variables: {
id: currentApplication?.id,
},
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetAuthenticationSettingsDocument],
});
const { data, loading, error } = useGetAuthenticationSettingsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { email, emailDomains } = data?.config?.auth?.user || {};
const form = useForm<BlockedEmailFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
enabled:
Boolean(data?.app?.authAccessControlBlockedEmails) ||
Boolean(data?.app?.authAccessControlBlockedEmailDomains),
authAccessControlBlockedEmails: data?.app?.authAccessControlBlockedEmails,
authAccessControlBlockedEmailDomains:
data?.app?.authAccessControlBlockedEmailDomains,
enabled: email?.blocked?.length > 0 || emailDomains?.blocked?.length > 0,
blockedEmails: email?.blocked?.join(', ') || '',
blockedEmailDomains: emailDomains?.blocked?.join(', ') || '',
},
resolver: yupResolver(validationSchema),
});
const { register, formState, setValue, watch } = form;
const { register, formState, watch } = form;
const enabled = watch('enabled');
const isDirty = Object.keys(formState.dirtyFields).length > 0;
useEffect(() => {
if (
!data.app?.authAccessControlBlockedEmails &&
!data.app?.authAccessControlBlockedEmailDomains
) {
return;
}
setValue('enabled', true, { shouldDirty: false });
}, [data.app, setValue]);
if (loading) {
return (
<ActivityIndicator
@@ -79,31 +70,63 @@ export default function BlockedEmailSettings() {
const handleAllowedEmailDomainsChange = async (
values: BlockedEmailFormValues,
) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: {
authAccessControlBlockedEmails: values.enabled
? values.authAccessControlBlockedEmails
: '',
authAccessControlBlockedEmailDomains: values.enabled
? values.authAccessControlBlockedEmailDomains
: '',
appId: currentApplication.id,
config: {
auth: {
user: {
email: {
allowed: email.allowed,
blocked:
values.enabled && values.blockedEmails
? [
...new Set(
values.blockedEmails
.split(',')
.map((blockedEmail) => blockedEmail.trim()),
),
]
: [],
},
emailDomains: {
allowed: emailDomains.allowed,
blocked:
values.enabled && values.blockedEmailDomains
? [
...new Set(
values.blockedEmailDomains
.split(',')
.map((blockedEmailDomain) =>
blockedEmailDomain.trim(),
),
),
]
: [],
},
},
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `Blocked email and domain settings are being updated...`,
success: `Blocked email and domain settings have been updated successfully.`,
error: `An error occurred while trying to update the project's blocked email and domain settings.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `Blocked email and domain settings are being updated...`,
success: `Blocked email and domain settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update the project's blocked email and domain settings.`,
),
},
getToastStyleProps(),
);
form.reset(values);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
@@ -114,15 +137,12 @@ export default function BlockedEmailSettings() {
description="Block specific email addresses and domains to sign up."
slotProps={{
submitButton: {
disabled: !formState.isValid || !isDirty,
disabled: !isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/authentication#blocked-emails-and-domains"
enabled={enabled}
onEnabledChange={(switchEnabled) =>
setValue('enabled', switchEnabled, { shouldDirty: true })
}
switchId="enabled"
showSwitch
className={twMerge(
'row-span-2 grid grid-flow-row gap-4 px-4 lg:grid-cols-3',
@@ -130,9 +150,9 @@ export default function BlockedEmailSettings() {
)}
>
<Input
{...register('authAccessControlBlockedEmails')}
name="authAccessControlBlockedEmails"
id="authAccessControlBlockedEmails"
{...register('blockedEmails')}
name="blockedEmails"
id="blockedEmails"
placeholder="These emails (separated by comma, e.g, david@ikea.com, lisa@mycompany.com)"
className="col-span-2"
label="Blocked Emails (comma separated)"
@@ -140,9 +160,9 @@ export default function BlockedEmailSettings() {
hideEmptyHelperText
/>
<Input
{...register('authAccessControlBlockedEmailDomains')}
name="authAccessControlBlockedEmailDomains"
id="authAccessControlBlockedEmailDomains"
{...register('blockedEmailDomains')}
name="blockedEmailDomains"
id="blockedEmailDomains"
label="Blocked Email Domains (comma sepated list)"
placeholder="These email domains (separated by comma, e.g, ikea.com, mycompany.com)"
className="col-span-2"

View File

@@ -1,36 +1,47 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useGetAppQuery, useUpdateAppMutation } from '@/generated/graphql';
import { useUI } from '@/context/UIContext';
import {
GetAuthenticationSettingsDocument,
useGetAuthenticationSettingsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Input from '@/ui/v2/Input';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface ClientURLFormValues {
/**
* The URL of the frontend app of where users are redirected after authenticating.
*/
authClientUrl: string;
}
const validationSchema = Yup.object({
clientUrl: Yup.string().label('Client URL'),
});
export type ClientURLFormValues = Yup.InferType<typeof validationSchema>;
export default function ClientURLSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation({ refetchQueries: ['GetApp'] });
const { data, loading, error } = useGetAppQuery({
variables: {
id: currentApplication?.id,
},
fetchPolicy: 'cache-first',
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetAuthenticationSettingsDocument],
});
const { data, loading, error } = useGetAuthenticationSettingsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { clientUrl, allowedUrls } = data?.config?.auth?.redirections || {};
const form = useForm<ClientURLFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
authClientUrl: data?.app?.authClientUrl,
clientUrl,
},
resolver: yupResolver(validationSchema),
});
if (loading) {
@@ -50,26 +61,37 @@ export default function ClientURLSettings() {
const { register, formState } = form;
const handleClientURLChange = async (values: ClientURLFormValues) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: {
...values,
appId: currentApplication.id,
config: {
auth: {
redirections: {
...values,
allowedUrls,
},
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `Client URL is being updated...`,
success: `Client URL has been updated successfully.`,
error: `An error occurred while trying to update the project's Client URL.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `Client URL is being updated...`,
success: `Client URL has been updated successfully.`,
error: getServerError(
`An error occurred while trying to update the project's Client URL.`,
),
},
getToastStyleProps(),
);
form.reset(values);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
@@ -78,22 +100,26 @@ export default function ClientURLSettings() {
<SettingsContainer
title="Client URL"
description="This should be the URL of your frontend app where users are redirected after authenticating."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
submitButton: {
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/authentication#client-url"
className="grid grid-flow-row lg:grid-cols-5"
>
<Input
{...register('authClientUrl')}
name="authClientUrl"
id="authClientUrl"
{...register('clientUrl')}
name="clientUrl"
id="clientUrl"
placeholder="http://localhost:3000"
className="col-span-2"
fullWidth
hideEmptyHelperText
aria-label="Client URL"
error={!!formState.errors?.clientUrl}
helperText={formState.errors?.clientUrl?.message}
/>
</SettingsContainer>
</Form>

View File

@@ -1,46 +1,44 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetAuthSettingsQuery,
useUpdateAppMutation,
GetAuthenticationSettingsDocument,
useGetAuthenticationSettingsQuery,
useUpdateConfigMutation,
} from '@/utils/__generated__/graphql';
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface DisableNewUsersFormValues {
/**
* Disable new users from signing up to this project
*/
authDisableNewUsers: boolean;
}
const validationSchema = Yup.object({
disabled: Yup.boolean(),
});
export type DisableNewUsersFormValues = Yup.InferType<typeof validationSchema>;
export default function DisableNewUsersSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetAuthenticationSettingsDocument],
});
const { data, loading, error } = useGetAuthSettingsQuery({
variables: {
id: currentApplication.id,
},
const { data, loading, error } = useGetAuthenticationSettingsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const form = useForm<DisableNewUsersFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
authDisableNewUsers: data?.app?.authDisableNewUsers,
disabled: !!data?.config?.auth?.signUp?.enabled,
},
});
useEffect(() => {
form.reset(() => ({
authDisableNewUsers: data?.app?.authDisableNewUsers,
}));
}, [data?.app?.authDisableNewUsers, form, form.reset]);
if (loading) {
return (
<ActivityIndicator
@@ -55,32 +53,41 @@ export default function DisableNewUsersSettings() {
throw error;
}
const { formState, watch } = form;
const authDisableNewUsers = watch('authDisableNewUsers');
const { formState } = form;
const handleDisableNewUsersChange = async (
values: DisableNewUsersFormValues,
) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: {
...values,
appId: currentApplication.id,
config: {
auth: {
signUp: {
enabled: !values.disabled,
},
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `Disabling new user sign ups...`,
success: `New user sign ups have been disabled successfully.`,
error: `An error occurred while trying to disable new user sign ups.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `Disabling new user sign ups...`,
success: `New user sign ups have been disabled successfully.`,
error: getServerError(
`An error occurred while trying to disable new user sign ups.`,
),
},
getToastStyleProps(),
);
form.reset(values);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
@@ -88,14 +95,15 @@ export default function DisableNewUsersSettings() {
<Form onSubmit={handleDisableNewUsersChange}>
<SettingsContainer
title="Disable New Users"
description="If set, newly registered users are disabled and wont be able to sign in."
description="If set, newly registered users are disabled and won't be able to sign in."
docsLink="https://docs.nhost.io/authentication#disable-new-users"
switchId="authDisableNewUsers"
switchId="disabled"
showSwitch
enabled={authDisableNewUsers}
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
submitButton: {
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
className="hidden"
/>

View File

@@ -1,65 +1,63 @@
import ControlledSelect from '@/components/common/ControlledSelect';
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import {
useGetGravatarSettingsQuery,
useUpdateAppMutation,
GetAuthenticationSettingsDocument,
useGetAuthenticationSettingsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Option from '@/ui/v2/Option';
import getServerError from '@/utils/settings/getServerError';
import {
AUTH_GRAVATAR_DEFAULT,
AUTH_GRAVATAR_RATING,
getToastStyleProps,
} from '@/utils/settings/settingsConstants';
import { useEffect } from 'react';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup';
export interface GravatarFormValues {
/**
* Gravatar image to use as default.
*/
authGravatarDefault: string;
/**
* Gravatar image rating.
*/
authGravatarRating: string;
/**
* Enable Gravatar for this project
*/
authGravatarEnabled: boolean;
}
const validationSchema = Yup.object({
enabled: Yup.boolean().label('Enabled'),
default: Yup.string().label('Default Gravatar'),
rating: Yup.string().label('Gravatar Rating'),
});
export type GravatarFormValues = Yup.InferType<typeof validationSchema>;
export default function GravatarSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const { data, loading, error } = useGetGravatarSettingsQuery({
variables: {
id: currentApplication?.id,
},
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetAuthenticationSettingsDocument],
});
const { data, loading, error } = useGetAuthenticationSettingsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const {
default: defaultGravatar,
rating,
enabled,
} = data?.config?.auth?.user?.gravatar || {};
const form = useForm<GravatarFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
authGravatarDefault: data?.app?.authGravatarDefault || '',
authGravatarRating: data?.app?.authGravatarRating || '',
authGravatarEnabled: data?.app?.authGravatarEnabled || false,
default: defaultGravatar || '',
rating: rating || '',
enabled: enabled || false,
},
resolver: yupResolver(validationSchema),
});
useEffect(() => {
form.reset(() => ({
authGravatarDefault: data?.app?.authGravatarDefault || '',
authGravatarRating: data?.app?.authGravatarRating || '',
authGravatarEnabled: data?.app?.authGravatarEnabled || false,
}));
}, [data?.app, form, form.reset]);
if (loading) {
return (
<ActivityIndicator
@@ -75,29 +73,39 @@ export default function GravatarSettings() {
}
const { register, formState, watch } = form;
const authGravatarEnabled = watch('authGravatarEnabled');
const gravatarEnabled = watch('enabled') ?? false;
const handleGravatarSettingsChange = async (values: GravatarFormValues) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: {
...values,
appId: currentApplication.id,
config: {
auth: {
user: {
gravatar: values,
},
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `Gravatar settings are being updated...`,
success: `Gravatar settings have been updated successfully.`,
error: `An error occurred while trying to update the project's Gravatar settings.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `Gravatar settings are being updated...`,
success: `Gravatar settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update the project's Gravatar settings.`,
),
},
getToastStyleProps(),
);
form.reset(values);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
@@ -106,22 +114,23 @@ export default function GravatarSettings() {
<SettingsContainer
title="Gravatar"
description="Use Gravatars for avatar URLs for users."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
submitButton: {
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/authentication#gravatar"
switchId="authGravatarEnabled"
switchId="enabled"
showSwitch
enabled={authGravatarEnabled}
className={twMerge(
'grid grid-flow-col grid-cols-5 grid-rows-2 gap-y-6',
!authGravatarEnabled && 'hidden',
!gravatarEnabled && 'hidden',
)}
>
<ControlledSelect
{...register('authGravatarDefault')}
id="authGravatarDefault"
{...register('default')}
id="default"
className="col-span-5 lg:col-span-2"
placeholder="Default Gravatar"
hideEmptyHelperText
@@ -135,8 +144,8 @@ export default function GravatarSettings() {
))}
</ControlledSelect>
<ControlledSelect
{...register('authGravatarRating')}
id="authGravatarRating"
{...register('rating')}
id="rating"
className="col-span-5 lg:col-span-2"
placeholder="Gravatar Rating"
hideEmptyHelperText

View File

@@ -1,54 +1,52 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import {
useGetAuthSettingsQuery,
useUpdateAppMutation,
GetAuthenticationSettingsDocument,
useGetAuthenticationSettingsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Input from '@/ui/v2/Input';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { useEffect } from 'react';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup';
export interface MFASettingsFormValues {
/**
* One Time Password issuer
*/
authMfaTotpIssuer: string;
/**
* Enable Multi Factor Authentication for this project
*/
authMfaEnabled: boolean;
}
const validationSchema = Yup.object({
enabled: Yup.boolean().label('Enabled'),
issuer: Yup.string().label('OTP Issuer').nullable().required(),
});
export type MFASettingsFormValues = Yup.InferType<typeof validationSchema>;
export default function MFASettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const { data, loading, error } = useGetAuthSettingsQuery({
variables: {
id: currentApplication?.id,
},
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetAuthenticationSettingsDocument],
});
const { data, loading, error } = useGetAuthenticationSettingsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { enabled, issuer } = data?.config?.auth?.totp || {};
const form = useForm<MFASettingsFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
authMfaTotpIssuer: data?.app?.authMfaTotpIssuer,
authMfaEnabled: data?.app?.authMfaEnabled,
issuer,
enabled,
},
resolver: yupResolver(validationSchema),
});
useEffect(() => {
form.reset(() => ({
authMfaTotpIssuer: data?.app?.authMfaTotpIssuer,
authMfaEnabled: data?.app?.authMfaEnabled,
}));
}, [data?.app, form, form.reset]);
if (loading) {
return (
<ActivityIndicator
@@ -64,29 +62,37 @@ export default function MFASettings() {
}
const { register, formState, watch } = form;
const authMfaEnabled = watch('authMfaEnabled');
const authMfaEnabled = watch('enabled');
const handleMFASettingsChange = async (values: MFASettingsFormValues) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: {
...values,
appId: currentApplication.id,
config: {
auth: {
totp: values,
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `Multi-factor authentication settings are being updated...`,
success: `Multi-factor authentication settings have been updated successfully.`,
error: `An error occurred while trying to update the project's multi-factor authentication settings.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `Multi-factor authentication settings are being updated...`,
success: `Multi-factor authentication settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update the project's multi-factor authentication settings.`,
),
},
getToastStyleProps(),
);
form.reset(values);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
@@ -95,13 +101,14 @@ export default function MFASettings() {
<SettingsContainer
title="Multi-Factor Authentication"
description="Enable users to use MFA to sign in"
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
submitButton: {
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/authentication#multi-factor-authentication"
switchId="authMfaEnabled"
enabled={authMfaEnabled}
switchId="enabled"
showSwitch
className={twMerge(
'grid grid-flow-row lg:grid-cols-5',
@@ -109,14 +116,16 @@ export default function MFASettings() {
)}
>
<Input
{...register('authMfaTotpIssuer')}
name="authMfaTotpIssuer"
id="authMfaTotpIssuer"
{...register('issuer')}
name="issuer"
id="issuer"
label="OTP Issuer"
placeholder="Name of the One Time Password (OTP) issuer"
className="col-span-2"
fullWidth
hideEmptyHelperText
error={!!formState.errors?.issuer}
helperText={formState.errors?.issuer?.message}
/>
</SettingsContainer>
</Form>

View File

@@ -1,5 +1,6 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import type { DialogFormProps } from '@/types/common';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
@@ -7,26 +8,7 @@ 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 {
export interface BaseEnvironmentVariableFormProps extends DialogFormProps {
/**
* Determines the mode of the form.
*
@@ -50,8 +32,11 @@ export interface BaseEnvironmentVariableFormProps {
}
export const baseEnvironmentVariableFormValidationSchema = Yup.object({
id: Yup.string().label('ID'),
name: Yup.string()
.required('This field is required.')
.label('Name')
.nullable()
.required()
.test(
'isEnvVarPermitted',
'This is a reserved name.',
@@ -77,18 +62,24 @@ export const baseEnvironmentVariableFormValidationSchema = Yup.object({
(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),
.test(
'isEnvVarValid',
'A name must start with a letter and can only contain letters, numbers, and underscores.',
(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.'),
value: Yup.string().label('Value').nullable().required(),
});
export type BaseEnvironmentVariableFormValues = Yup.InferType<
typeof baseEnvironmentVariableFormValidationSchema
>;
export default function BaseEnvironmentVariableForm({
mode = 'edit',
onSubmit,
onCancel,
submitButtonText = 'Save',
location,
}: BaseEnvironmentVariableFormProps) {
const { onDirtyStateChange } = useDialog();
const form = useFormContext<BaseEnvironmentVariableFormValues>();
@@ -103,8 +94,8 @@ export default function BaseEnvironmentVariableForm({
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'dialog');
}, [isDirty, onDirtyStateChange]);
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
return (
<div className="grid grid-flow-row gap-6 px-6 pb-6">
@@ -115,21 +106,7 @@ export default function BaseEnvironmentVariableForm({
<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,
'',
);
}
},
})}
{...register('name')}
id="name"
label="Name"
placeholder="EXAMPLE_NAME"
@@ -143,30 +120,18 @@ export default function BaseEnvironmentVariableForm({
/>
<Input
{...register('prodValue')}
id="prodValue"
label="Production Value"
{...register('value')}
id="value"
label="Value"
placeholder="Enter value"
hideEmptyHelperText
error={!!errors.prodValue}
helperText={errors?.prodValue?.message}
error={!!errors.value}
helperText={errors?.value?.message}
fullWidth
autoComplete="off"
autoFocus={mode === 'edit'}
/>
<Input
{...register('devValue')}
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}

View File

@@ -7,17 +7,19 @@ import BaseEnvironmentVariableForm, {
} from '@/components/settings/environmentVariables/BaseEnvironmentVariableForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
GetEnvironmentVariablesDocument,
useGetEnvironmentVariablesQuery,
useInsertEnvironmentVariablesMutation,
useUpdateConfigMutation,
} 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'> {
extends Pick<BaseEnvironmentVariableFormProps, 'onCancel' | 'location'> {
/**
* Function to be called when the form is submitted.
*/
@@ -31,8 +33,7 @@ export default function CreateEnvironmentVariableForm({
const form = useForm<BaseEnvironmentVariableFormValues>({
defaultValues: {
name: '',
devValue: '',
prodValue: '',
value: '',
},
reValidateMode: 'onSubmit',
resolver: yupResolver(baseEnvironmentVariableFormValidationSchema),
@@ -41,14 +42,14 @@ export default function CreateEnvironmentVariableForm({
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetEnvironmentVariablesQuery({
variables: {
id: currentApplication?.id,
},
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const [insertEnvironmentVariables] = useInsertEnvironmentVariablesMutation({
refetchQueries: ['getEnvironmentVariables'],
const availableEnvironmentVariables = data?.config?.global?.environment || [];
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetEnvironmentVariablesDocument],
});
if (loading) {
@@ -68,13 +69,10 @@ export default function CreateEnvironmentVariableForm({
async function handleSubmit({
name,
prodValue,
devValue,
value,
}: BaseEnvironmentVariableFormValues) {
if (
data?.environmentVariables?.some(
(environmentVariable) => environmentVariable.name === name,
)
availableEnvironmentVariables?.some((variable) => variable.name === name)
) {
setError('name', {
message: 'This environment variable already exists.',
@@ -83,20 +81,34 @@ export default function CreateEnvironmentVariableForm({
return;
}
const insertEnvironmentVariablePromise = insertEnvironmentVariables({
const updateConfigPromise = updateConfig({
variables: {
environmentVariables: [
{ appId: currentApplication.id, name, prodValue, devValue },
],
appId: currentApplication?.id,
config: {
global: {
environment: [
...(availableEnvironmentVariables?.map((variable) => ({
name: variable.name,
value: variable.value,
})) || []),
{
name,
value,
},
],
},
},
},
});
await toast.promise(
insertEnvironmentVariablePromise,
updateConfigPromise,
{
loading: 'Creating environment variable...',
success: 'Environment variable has been created successfully.',
error: 'An error occurred while creating the environment variable.',
error: getServerError(
'An error occurred while creating the environment variable.',
),
},
getToastStyleProps(),
);

View File

@@ -8,17 +8,19 @@ import BaseEnvironmentVariableForm, {
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { EnvironmentVariable } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
GetEnvironmentVariablesDocument,
useGetEnvironmentVariablesQuery,
useUpdateEnvironmentVariableMutation,
useUpdateConfigMutation,
} 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'> {
extends Pick<BaseEnvironmentVariableFormProps, 'onCancel' | 'location'> {
/**
* The environment variable to edit.
*/
@@ -38,8 +40,7 @@ export default function EditEnvironmentVariableForm({
defaultValues: {
id: originalEnvironmentVariable.id || '',
name: originalEnvironmentVariable.name || '',
devValue: originalEnvironmentVariable.devValue || '',
prodValue: originalEnvironmentVariable.prodValue || '',
value: originalEnvironmentVariable.value || '',
},
reValidateMode: 'onSubmit',
resolver: yupResolver(baseEnvironmentVariableFormValidationSchema),
@@ -48,14 +49,14 @@ export default function EditEnvironmentVariableForm({
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetEnvironmentVariablesQuery({
variables: {
id: currentApplication?.id,
},
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const [updateEnvironmentVariable] = useUpdateEnvironmentVariableMutation({
refetchQueries: ['getEnvironmentVariables'],
const availableEnvironmentVariables = data?.config?.global?.environment || [];
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetEnvironmentVariablesDocument],
});
if (loading) {
@@ -76,14 +77,13 @@ export default function EditEnvironmentVariableForm({
async function handleSubmit({
id,
name,
prodValue,
devValue,
value,
}: BaseEnvironmentVariableFormValues) {
if (
data?.environmentVariables?.some(
(environmentVariable) =>
environmentVariable.name === name &&
environmentVariable.name !== originalEnvironmentVariable.name,
availableEnvironmentVariables.some(
(variable) =>
variable.name === name &&
variable.name !== originalEnvironmentVariable.name,
)
) {
setError('name', {
@@ -93,22 +93,36 @@ export default function EditEnvironmentVariableForm({
return;
}
const updateEnvironmentVariablePromise = updateEnvironmentVariable({
const updateConfigPromise = updateConfig({
variables: {
id,
environmentVariable: {
prodValue,
devValue,
appId: currentApplication?.id,
config: {
global: {
environment: [
...availableEnvironmentVariables
.filter((variable) => variable.id !== id)
.map((variable) => ({
name: variable.name,
value: variable.value,
})),
{
name,
value,
},
],
},
},
},
});
await toast.promise(
updateEnvironmentVariablePromise,
updateConfigPromise,
{
loading: 'Updating environment variable...',
success: 'Environment variable has been updated successfully.',
error: 'An error occurred while updating the environment variable.',
error: getServerError(
'An error occurred while updating the environment variable.',
),
},
getToastStyleProps(),
);

View File

@@ -1,12 +1,13 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { DialogFormProps } from '@/types/common';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
refetchGetAppInjectedVariablesQuery,
useUpdateApplicationMutation,
GetEnvironmentVariablesDocument,
useUpdateConfigMutation,
} from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup';
import { useEffect } from 'react';
@@ -14,7 +15,7 @@ import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface EditJwtSecretFormProps {
export interface EditJwtSecretFormProps extends DialogFormProps {
/**
* Initial JWT secret.
*/
@@ -39,14 +40,7 @@ export interface EditJwtSecretFormProps {
onCancel?: VoidFunction;
}
export interface EditJwtSecretFormValues {
/**
* JWT secret.
*/
jwtSecret: string;
}
const validationSchema = Yup.object().shape({
const validationSchema = Yup.object({
jwtSecret: Yup.string()
.nullable()
.required('This field is required.')
@@ -60,18 +54,19 @@ const validationSchema = Yup.object().shape({
}),
});
export type EditJwtSecretFormValues = Yup.InferType<typeof validationSchema>;
export default function EditJwtSecretForm({
disabled,
jwtSecret,
onSubmit,
onCancel,
submitButtonText = 'Save',
location,
}: EditJwtSecretFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApplication] = useUpdateApplicationMutation({
refetchQueries: [
refetchGetAppInjectedVariablesQuery({ id: currentApplication?.id }),
],
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetEnvironmentVariablesDocument],
});
const { onDirtyStateChange } = useDialog();
@@ -89,30 +84,42 @@ export default function EditJwtSecretForm({
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'dialog');
}, [isDirty, onDirtyStateChange]);
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
async function handleSubmit(values: EditJwtSecretFormValues) {
const updateAppPromise = updateApplication({
const parsedJwtSecret = JSON.parse(values.jwtSecret);
const isArray = Array.isArray(parsedJwtSecret);
const updateConfigPromise = updateConfig({
variables: {
appId: currentApplication?.id,
app: {
hasuraGraphqlJwtSecret: values.jwtSecret,
config: {
hasura: {
jwtSecrets: isArray ? parsedJwtSecret : [parsedJwtSecret],
},
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Updating JWT secret...',
success: 'JWT secret has been updated successfully.',
error: 'An error occurred while updating the JWT secret.',
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: 'Updating JWT secret...',
success: 'JWT secret has been updated successfully.',
error: (arg: Error) =>
arg?.message
? `Error: ${arg.message}`
: 'An error occurred while updating the JWT secret.',
},
getToastStyleProps(),
);
onSubmit?.();
onSubmit?.();
} catch {
// Note: error is handled above
}
}
return (
@@ -121,7 +128,7 @@ export default function EditJwtSecretForm({
onSubmit={handleSubmit}
className="flex flex-auto flex-col content-between overflow-hidden pb-4"
>
<div className="px-6 overflow-y-auto flex-auto">
<div className="flex-auto overflow-y-auto px-6">
<Input
{...register('jwtSecret')}
error={Boolean(errors.jwtSecret?.message)}

View File

@@ -1,5 +1,8 @@
import { useDialog } from '@/components/common/DialogProvider';
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { EnvironmentVariable } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -13,34 +16,49 @@ 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 getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
useDeleteEnvironmentVariableMutation,
GetEnvironmentVariablesDocument,
useGetEnvironmentVariablesQuery,
useUpdateConfigMutation,
} 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 {
export interface EnvironmentVariableSettingsFormValues {
/**
* Permission variables.
* Environment variables.
*/
environmentVariables: EnvironmentVariable[];
}
export default function EnvironmentVariableSettings() {
const { openDialog, openAlertDialog } = useDialog();
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetEnvironmentVariablesQuery({
variables: {
id: currentApplication?.id,
},
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const [deleteEnvironmentVariable] = useDeleteEnvironmentVariableMutation({
refetchQueries: ['getEnvironmentVariables'],
const availableEnvironmentVariables = [
...(data?.config?.global?.environment || []),
].sort((a, b) => {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
});
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetEnvironmentVariablesDocument],
});
if (loading) {
@@ -57,26 +75,43 @@ export default function EnvironmentVariableSettings() {
}
async function handleDeleteVariable({ id }: EnvironmentVariable) {
const deleteEnvironmentVariablePromise = deleteEnvironmentVariable({
const updateConfigPromise = updateConfig({
variables: {
id,
appId: currentApplication?.id,
config: {
global: {
environment: availableEnvironmentVariables
.filter((variable) => variable.id !== id)
.map((variable) => ({
name: variable.name,
value: variable.value,
})),
},
},
},
});
await toast.promise(
deleteEnvironmentVariablePromise,
{
loading: 'Deleting environment variable...',
success: 'Environment variable has been deleted successfully.',
error: 'An error occurred while deleting the environment variable.',
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: 'Deleting environment variable...',
success: 'Environment variable has been deleted successfully.',
error: getServerError(
'An error occurred while deleting the environment variable.',
),
},
getToastStyleProps(),
);
} catch {
// Note: The toast will handle the error.
}
}
function handleOpenCreator() {
openDialog('CREATE_ENVIRONMENT_VARIABLE', {
openDialog({
title: 'Create Environment Variable',
component: <CreateEnvironmentVariableForm />,
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'gap-2 max-w-sm' },
@@ -85,9 +120,13 @@ export default function EnvironmentVariableSettings() {
}
function handleOpenEditor(originalVariable: EnvironmentVariable) {
openDialog('EDIT_ENVIRONMENT_VARIABLE', {
title: 'Edit Environment Variables',
payload: { originalEnvironmentVariable: originalVariable },
openDialog({
title: 'Edit Environment Variable',
component: (
<EditEnvironmentVariableForm
originalEnvironmentVariable={originalVariable}
/>
),
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'gap-2 max-w-sm' },
@@ -113,12 +152,6 @@ export default function EnvironmentVariableSettings() {
});
}
const availableEnvironmentVariables =
[...data.environmentVariables].sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
) || [];
return (
<SettingsContainer
title="Project Environment Variables"
@@ -134,95 +167,79 @@ export default function EnvironmentVariableSettings() {
>
<Box className="grid grid-cols-2 gap-2 border-b-1 px-4 py-3 lg:grid-cols-3">
<Text className="font-medium">Variable Name</Text>
<Text className="font-medium lg:col-span-2">Updated</Text>
</Box>
<div className="grid grid-flow-row gap-2">
{availableEnvironmentVariables.length > 0 && (
<List>
{availableEnvironmentVariables.map((environmentVariable, index) => {
const timestamp = formatDistanceToNowStrict(
parseISO(environmentVariable.updatedAt),
{ addSuffix: true, roundingMethod: 'floor' },
);
return (
<Fragment key={environmentVariable.id}>
<ListItem.Root
className="grid grid-cols-2 gap-2 px-4 lg:grid-cols-3"
secondaryAction={
<Dropdown.Root>
<Dropdown.Trigger
asChild
hideChevron
className="absolute right-4 top-1/2 -translate-y-1/2"
{availableEnvironmentVariables.map((environmentVariable, index) => (
<Fragment key={environmentVariable.id}>
<ListItem.Root
className="grid grid-cols-2 gap-2 px-4 lg:grid-cols-3"
secondaryAction={
<Dropdown.Root>
<Dropdown.Trigger
asChild
hideChevron
className="absolute right-4 top-1/2 -translate-y-1/2"
>
<IconButton
variant="borderless"
color="secondary"
disabled={maintenanceActive}
>
<IconButton variant="borderless" color="secondary">
<DotsVerticalIcon />
</IconButton>
</Dropdown.Trigger>
<DotsVerticalIcon />
</IconButton>
</Dropdown.Trigger>
<Dropdown.Content
menu
PaperProps={{ className: 'w-32' }}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
<Dropdown.Content
menu
PaperProps={{ className: 'w-32' }}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Dropdown.Item
onClick={() => handleOpenEditor(environmentVariable)}
>
<Dropdown.Item
onClick={() =>
handleOpenEditor(environmentVariable)
}
>
<Text className="font-medium">Edit</Text>
</Dropdown.Item>
<Text className="font-medium">Edit</Text>
</Dropdown.Item>
<Divider component="li" />
<Divider component="li" />
<Dropdown.Item
onClick={() =>
handleConfirmDelete(environmentVariable)
}
>
<Text className="font-medium" color="error">
Delete
</Text>
</Dropdown.Item>
</Dropdown.Content>
</Dropdown.Root>
}
>
<ListItem.Text className="truncate">
{environmentVariable.name}
</ListItem.Text>
<Dropdown.Item
onClick={() =>
handleConfirmDelete(environmentVariable)
}
>
<Text className="font-medium" color="error">
Delete
</Text>
</Dropdown.Item>
</Dropdown.Content>
</Dropdown.Root>
}
>
<ListItem.Text className="truncate">
{environmentVariable.name}
</ListItem.Text>
</ListItem.Root>
<Text
variant="subtitle1"
className="truncate lg:col-span-2"
>
{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>
);
})}
<Divider
component="li"
className={twMerge(
index === availableEnvironmentVariables.length - 1
? '!mt-4'
: '!my-4',
)}
/>
</Fragment>
))}
</List>
)}
@@ -231,6 +248,7 @@ export default function EnvironmentVariableSettings() {
variant="borderless"
startIcon={<PlusIcon />}
onClick={handleOpenCreator}
disabled={maintenanceActive}
>
Create Environment Variable
</Button>

View File

@@ -1,6 +1,8 @@
import { useDialog } from '@/components/common/DialogProvider';
import InlineCode from '@/components/common/InlineCode';
import EditJwtSecretForm from '@/components/settings/environmentVariables/EditJwtSecretForm';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import useIsPlatform from '@/hooks/common/useIsPlatform';
import { useAppClient } from '@/hooks/useAppClient';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
@@ -20,7 +22,8 @@ import generateAppServiceUrl, {
} from '@/utils/common/generateAppServiceUrl';
import { LOCAL_HASURA_URL } from '@/utils/env';
import { generateRemoteAppUrl } from '@/utils/helpers';
import { useGetAppInjectedVariablesQuery } from '@/utils/__generated__/graphql';
import getJwtSecretsWithoutFalsyValues from '@/utils/settings/getJwtSecretsWithoutFalsyValues';
import { useGetEnvironmentVariablesQuery } from '@/utils/__generated__/graphql';
import { Fragment, useState } from 'react';
export default function SystemEnvironmentVariableSettings() {
@@ -28,10 +31,22 @@ export default function SystemEnvironmentVariableSettings() {
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
const { openDialog } = useDialog();
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetAppInjectedVariablesQuery({
variables: { id: currentApplication?.id },
const { data, loading, error } = useGetEnvironmentVariablesQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { jwtSecrets, webhookSecret, adminSecret } = data?.config?.hasura || {};
const jwtSecretsWithoutFalsyValues = getJwtSecretsWithoutFalsyValues(
jwtSecrets || [],
);
const stringifiedJwtSecrets =
jwtSecretsWithoutFalsyValues.length === 1
? JSON.stringify(jwtSecretsWithoutFalsyValues[0], null, 2)
: JSON.stringify(jwtSecretsWithoutFalsyValues, null, 2);
const isPlatform = useIsPlatform();
const appClient = useAppClient();
@@ -50,7 +65,7 @@ export default function SystemEnvironmentVariableSettings() {
}
function showViewJwtSecretModal() {
openDialog('EDIT_JWT_SECRET', {
openDialog({
title: (
<span className="grid grid-flow-row">
<span>Auth JWT Secret</span>
@@ -61,15 +76,14 @@ export default function SystemEnvironmentVariableSettings() {
</Text>
</span>
),
payload: {
disabled: true,
jwtSecret: data?.app?.hasuraGraphqlJwtSecret,
},
component: (
<EditJwtSecretForm disabled jwtSecret={stringifiedJwtSecrets} />
),
});
}
function showEditJwtSecretModal() {
openDialog('EDIT_JWT_SECRET', {
openDialog({
title: (
<span className="grid grid-flow-row">
<span>Edit JWT Secret</span>
@@ -80,9 +94,7 @@ export default function SystemEnvironmentVariableSettings() {
</Text>
</span>
),
payload: {
jwtSecret: data?.app?.hasuraGraphqlJwtSecret,
},
component: <EditJwtSecretForm jwtSecret={stringifiedJwtSecrets} />,
});
}
@@ -134,7 +146,7 @@ export default function SystemEnvironmentVariableSettings() {
<Text className="truncate" color="secondary">
{showAdminSecret ? (
<InlineCode className="!text-sm font-medium">
{currentApplication?.hasuraGraphqlAdminSecret}
{adminSecret}
</InlineCode>
) : (
'●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●'
@@ -167,7 +179,7 @@ export default function SystemEnvironmentVariableSettings() {
<Text className="truncate" color="secondary">
{showWebhookSecret ? (
<InlineCode className="!text-sm font-medium">
{data?.app?.webhookSecret}
{webhookSecret}
</InlineCode>
) : (
'●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●'
@@ -231,6 +243,7 @@ export default function SystemEnvironmentVariableSettings() {
variant="borderless"
onClick={showEditJwtSecretModal}
size="small"
disabled={maintenanceActive}
>
Edit JWT Secret
</Button>

View File

@@ -1,11 +1,13 @@
import Form from '@/components/common/Form';
import InlineCode from '@/components/common/InlineCode';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { useUpdateAppMutation } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Alert } from '@/ui/Alert';
import Input from '@/ui/v2/Input';
import { discordAnnounce } from '@/utils/discordAnnounce';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { useApolloClient } from '@apollo/client';
import { useEffect } from 'react';
@@ -20,6 +22,7 @@ export interface BaseDirectoryFormValues {
}
export default function BaseDirectorySettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const client = useApolloClient();
@@ -54,7 +57,9 @@ export default function BaseDirectorySettings() {
{
loading: `The base directory is being updated...`,
success: `The base directory has been updated successfully.`,
error: `An error occurred while trying to update the project's base directory.`,
error: getServerError(
`An error occurred while trying to update the project's base directory.`,
),
},
getToastStyleProps(),
);
@@ -84,9 +89,11 @@ export default function BaseDirectorySettings() {
<InlineCode className="text-xs">nhost</InlineCode> folder.
</>
}
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
submitButton: {
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/platform/github-integration#base-directory"
className="grid grid-flow-row lg:grid-cols-5"

View File

@@ -1,10 +1,12 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { useUpdateAppMutation } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Alert } from '@/ui/Alert';
import Input from '@/ui/v2/Input';
import { discordAnnounce } from '@/utils/discordAnnounce';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { useApolloClient } from '@apollo/client';
import { useEffect } from 'react';
@@ -19,6 +21,7 @@ export interface DeploymentBranchFormValues {
}
export default function DeploymentBranchSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const client = useApolloClient();
@@ -57,7 +60,9 @@ export default function DeploymentBranchSettings() {
{
loading: `The deployment branch is being updated...`,
success: `The deployment branch has been updated successfully.`,
error: `An error occurred while trying to update the project's deployment branch.`,
error: getServerError(
`An error occurred while trying to update the project's deployment branch.`,
),
},
getToastStyleProps(),
);
@@ -79,9 +84,11 @@ export default function DeploymentBranchSettings() {
<SettingsContainer
title="Deployment Branch"
description="All commits pushed to this deployment branch will trigger a deployment. You can switch to a different branch here."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
submitButton: {
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/platform/github-integration#deployment-branch"
className="grid grid-flow-row lg:grid-cols-5"

View File

@@ -1,5 +1,6 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import type { DialogFormProps } from '@/types/common';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
@@ -7,18 +8,7 @@ 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 {
export interface BasePermissionVariableFormProps extends DialogFormProps {
/**
* Function to be called when the form is submitted.
*/
@@ -36,14 +26,19 @@ export interface BasePermissionVariableFormProps {
}
export const basePermissionVariableValidationSchema = Yup.object({
key: Yup.string().required('This field is required.'),
value: Yup.string().required('This field is required.'),
key: Yup.string().label('Field Name').nullable().required(),
value: Yup.string().label('Path').nullable().required(),
});
export type BasePermissionVariableFormValues = Yup.InferType<
typeof basePermissionVariableValidationSchema
>;
export default function BasePermissionVariableForm({
onSubmit,
onCancel,
submitButtonText = 'Save',
location,
}: BasePermissionVariableFormProps) {
const { onDirtyStateChange } = useDialog();
const form = useFormContext<BasePermissionVariableFormValues>();
@@ -56,8 +51,8 @@ export default function BasePermissionVariableForm({
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'dialog');
}, [isDirty, onDirtyStateChange]);
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
return (
<div className="grid grid-flow-row gap-2 px-6 pb-6">

View File

@@ -7,19 +7,19 @@ import BasePermissionVariableForm, {
} 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 getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetAppCustomClaimsQuery,
useUpdateAppMutation,
GetRolesPermissionsDocument,
useGetRolesPermissionsQuery,
useUpdateConfigMutation,
} 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'> {
extends Pick<BasePermissionVariableFormProps, 'onCancel' | 'location'> {
/**
* Function to be called when the form is submitted.
*/
@@ -32,11 +32,14 @@ export default function CreatePermissionVariableForm({
}: CreatePermissionVariableFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, error, loading } = useGetAppCustomClaimsQuery({
variables: { id: currentApplication?.id },
const { data, error, loading } = useGetRolesPermissionsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { customClaims: permissionVariables } =
data?.config?.auth?.session?.accessToken || {};
const form = useForm<BasePermissionVariableFormValues>({
defaultValues: {
key: '',
@@ -46,8 +49,8 @@ export default function CreatePermissionVariableForm({
resolver: yupResolver(basePermissionVariableValidationSchema),
});
const [updateApp] = useUpdateAppMutation({
refetchQueries: ['getAppCustomClaims'],
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetRolesPermissionsDocument],
});
if (loading) {
@@ -61,9 +64,8 @@ export default function CreatePermissionVariableForm({
}
const { setError } = form;
const availablePermissionVariables = getPermissionVariablesArray(
data?.app?.authJwtCustomClaims,
);
const availablePermissionVariables =
getAllPermissionVariables(permissionVariables);
async function handleSubmit({
key,
@@ -79,26 +81,29 @@ export default function CreatePermissionVariableForm({
return;
}
const permissionVariablesObject = getPermissionVariablesObject(
availablePermissionVariables.filter(
(permissionVariable) => !permissionVariable.isSystemClaim,
),
);
const existingPermissionVariables =
permissionVariables?.map((permissionVariable) => ({
key: permissionVariable.key,
value: permissionVariable.value,
})) || [];
const updateAppPromise = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication?.id,
app: {
authJwtCustomClaims: {
...permissionVariablesObject,
[key]: value,
appId: currentApplication?.id,
config: {
auth: {
session: {
accessToken: {
customClaims: [...existingPermissionVariables, { key, value }],
},
},
},
},
},
});
await toast.promise(
updateAppPromise,
updateConfigPromise,
{
loading: 'Creating permission variable...',
success: 'Permission variable has been created successfully.',

View File

@@ -6,25 +6,25 @@ import BasePermissionVariableForm, {
basePermissionVariableValidationSchema,
} from '@/components/settings/permissions/BasePermissionVariableForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { CustomClaim } from '@/types/application';
import type { PermissionVariable } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getPermissionVariables from '@/utils/settings/getPermissionVariablesArray';
import getPermissionVariablesObject from '@/utils/settings/getPermissionVariablesObject';
import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetAppCustomClaimsQuery,
useUpdateAppMutation,
GetRolesPermissionsDocument,
useGetRolesPermissionsQuery,
useUpdateConfigMutation,
} 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'> {
extends Pick<BasePermissionVariableFormProps, 'onCancel' | 'location'> {
/**
* The permission variable to be edited.
*/
originalVariable: CustomClaim;
originalVariable: PermissionVariable;
/**
* Function to be called when the form is submitted.
*/
@@ -38,11 +38,14 @@ export default function EditPermissionVariableForm({
}: EditPermissionVariableFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, error, loading } = useGetAppCustomClaimsQuery({
variables: { id: currentApplication?.id },
const { data, error, loading } = useGetRolesPermissionsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { customClaims: permissionVariables } =
data?.config?.auth?.session?.accessToken || {};
const form = useForm<BasePermissionVariableFormValues>({
defaultValues: {
key: originalVariable.key || '',
@@ -52,8 +55,8 @@ export default function EditPermissionVariableForm({
resolver: yupResolver(basePermissionVariableValidationSchema),
});
const [updateApp] = useUpdateAppMutation({
refetchQueries: ['getAppCustomClaims'],
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetRolesPermissionsDocument],
});
if (loading) {
@@ -67,9 +70,8 @@ export default function EditPermissionVariableForm({
}
const { setError } = form;
const availablePermissionVariables = getPermissionVariables(
data?.app?.authJwtCustomClaims,
);
const availablePermissionVariables =
getAllPermissionVariables(permissionVariables);
async function handleSubmit({
key,
@@ -92,36 +94,43 @@ export default function EditPermissionVariableForm({
(permissionVariable) => permissionVariable.key === originalVariable.key,
);
const updatedPermissionVariables = availablePermissionVariables.map(
(permissionVariable, index) => {
if (index === originalPermissionVariableIndex) {
return { key, value };
const updatedPermissionVariables = availablePermissionVariables
.map((permissionVariable, index) => {
if (permissionVariable.isSystemVariable) {
return null;
}
return permissionVariable;
},
);
if (index === originalPermissionVariableIndex) {
return {
key,
value,
};
}
const permissionVariablesObject = getPermissionVariablesObject(
updatedPermissionVariables.filter(
(permissionVariable) => !permissionVariable.isSystemClaim,
),
);
return {
key: permissionVariable.key,
value: permissionVariable.value,
};
})
.filter(Boolean);
const updateAppPromise = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication?.id,
app: {
authJwtCustomClaims: {
...permissionVariablesObject,
[key]: value,
appId: currentApplication?.id,
config: {
auth: {
session: {
accessToken: {
customClaims: updatedPermissionVariables,
},
},
},
},
},
});
await toast.promise(
updateAppPromise,
updateConfigPromise,
{
loading: 'Updating permission variable...',
success: 'Permission variable has been updated successfully.',

View File

@@ -1,7 +1,10 @@
import { useDialog } from '@/components/common/DialogProvider';
import CreatePermissionVariableForm from '@/components/settings/permissions/CreatePermissionVariableForm';
import EditPermissionVariableForm from '@/components/settings/permissions/EditPermissionVariableForm';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { CustomClaim } from '@/types/application';
import type { PermissionVariable } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button';
@@ -15,35 +18,33 @@ 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 getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetAppCustomClaimsQuery,
useUpdateAppMutation,
GetRolesPermissionsDocument,
useGetRolesPermissionsQuery,
useUpdateConfigMutation,
} 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 { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { openDialog, openAlertDialog } = useDialog();
const { data, loading, error } = useGetAppCustomClaimsQuery({
variables: {
id: currentApplication?.id,
},
const { data, loading, error } = useGetRolesPermissionsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const [updateApp] = useUpdateAppMutation({
refetchQueries: ['getAppCustomClaims'],
const { customClaims: permissionVariables } =
data?.config?.auth?.session?.accessToken || {};
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetRolesPermissionsDocument],
});
if (loading) {
@@ -56,40 +57,44 @@ export default function PermissionVariableSettings() {
throw error;
}
async function handleDeleteVariable({ key }: CustomClaim) {
const filteredCustomClaims = Object.keys(
data?.app?.authJwtCustomClaims,
).filter((customClaimKey) => customClaimKey !== key);
const updateAppPromise = updateApp({
async function handleDeleteVariable({ id }: PermissionVariable) {
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication?.id,
app: {
authJwtCustomClaims: filteredCustomClaims.reduce(
(customClaims, currentKey) => ({
...customClaims,
[currentKey]: data?.app?.authJwtCustomClaims[currentKey],
}),
{},
),
appId: currentApplication?.id,
config: {
auth: {
session: {
accessToken: {
customClaims: permissionVariables
?.filter((permissionVariable) => permissionVariable.id !== id)
.map((permissionVariable) => ({
key: permissionVariable.key,
value: permissionVariable.value,
})),
},
},
},
},
},
});
await toast.promise(
updateAppPromise,
updateConfigPromise,
{
loading: 'Deleting permission variable...',
success: 'Permission variable has been deleted successfully.',
error: 'An error occurred while trying to delete permission variable.',
error: getServerError(
'An error occurred while trying to delete permission variable.',
),
},
getToastStyleProps(),
);
}
function handleOpenCreator() {
openDialog('CREATE_PERMISSION_VARIABLE', {
openDialog({
title: 'Create Permission Variable',
component: <CreatePermissionVariableForm />,
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'max-w-sm' },
@@ -97,10 +102,12 @@ export default function PermissionVariableSettings() {
});
}
function handleOpenEditor(originalVariable: CustomClaim) {
openDialog('EDIT_PERMISSION_VARIABLE', {
function handleOpenEditor(originalVariable: PermissionVariable) {
openDialog({
title: 'Edit Permission Variable',
payload: { originalVariable },
component: (
<EditPermissionVariableForm originalVariable={originalVariable} />
),
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'max-w-sm' },
@@ -108,7 +115,7 @@ export default function PermissionVariableSettings() {
});
}
function handleConfirmDelete(originalVariable: CustomClaim) {
function handleConfirmDelete(originalVariable: PermissionVariable) {
openAlertDialog({
title: 'Delete Permission Variable',
payload: (
@@ -126,9 +133,8 @@ export default function PermissionVariableSettings() {
});
}
const availablePermissionVariables = getPermissionVariablesArray(
data?.app?.authJwtCustomClaims,
);
const availablePermissionVariables =
getAllPermissionVariables(permissionVariables);
return (
<SettingsContainer
@@ -136,7 +142,7 @@ export default function PermissionVariableSettings() {
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"
className="my-2 px-0"
slotProps={{ submitButton: { className: 'invisible' } }}
>
<Box className="grid grid-cols-2 border-b-1 px-4 py-3">
@@ -146,28 +152,33 @@ export default function PermissionVariableSettings() {
<div className="grid grid-flow-row gap-2">
<List>
{availablePermissionVariables.map((customClaim, index) => (
<Fragment key={customClaim.key}>
{availablePermissionVariables.map((permissionVariable, index) => (
<Fragment key={permissionVariable.id}>
<ListItem.Root
className="px-4 grid grid-cols-2"
className="grid grid-cols-2 px-4"
secondaryAction={
<Dropdown.Root>
<Tooltip
title={
customClaim.isSystemClaim
permissionVariable.isSystemVariable
? "You can't edit system permission variables"
: ''
}
placement="right"
disableHoverListener={!customClaim.isSystemClaim}
hasDisabledChildren={customClaim.isSystemClaim}
disableHoverListener={
!permissionVariable.isSystemVariable
}
hasDisabledChildren={permissionVariable.isSystemVariable}
className="absolute right-4 top-1/2 -translate-y-1/2"
>
<Dropdown.Trigger asChild hideChevron>
<IconButton
variant="borderless"
color="secondary"
disabled={customClaim.isSystemClaim}
disabled={
permissionVariable.isSystemVariable ||
maintenanceActive
}
>
<DotsVerticalIcon />
</IconButton>
@@ -187,7 +198,7 @@ export default function PermissionVariableSettings() {
}}
>
<Dropdown.Item
onClick={() => handleOpenEditor(customClaim)}
onClick={() => handleOpenEditor(permissionVariable)}
>
<Text className="font-medium">Edit</Text>
</Dropdown.Item>
@@ -195,7 +206,7 @@ export default function PermissionVariableSettings() {
<Divider component="li" />
<Dropdown.Item
onClick={() => handleConfirmDelete(customClaim)}
onClick={() => handleConfirmDelete(permissionVariable)}
>
<Text
className="font-medium"
@@ -213,15 +224,17 @@ export default function PermissionVariableSettings() {
<ListItem.Text
primary={
<>
X-Hasura-{customClaim.key}{' '}
{customClaim.isSystemClaim && (
<LockIcon className="w-4 h-4" />
X-Hasura-{permissionVariable.key}{' '}
{permissionVariable.isSystemVariable && (
<LockIcon className="h-4 w-4" />
)}
</>
}
/>
<Text className="font-medium">user.{customClaim.value}</Text>
<Text className="font-medium">
user.{permissionVariable.value}
</Text>
</ListItem.Root>
<Divider
@@ -237,10 +250,11 @@ export default function PermissionVariableSettings() {
</List>
<Button
className="justify-self-start mx-4"
className="mx-4 justify-self-start"
variant="borderless"
startIcon={<PlusIcon />}
onClick={handleOpenCreator}
disabled={maintenanceActive}
>
Create Permission Variable
</Button>

View File

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

View File

@@ -1,5 +1,6 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import type { DialogFormProps } from '@/types/common';
import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
@@ -8,14 +9,7 @@ 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 {
export interface BaseRoleFormProps extends DialogFormProps {
/**
* Function to be called when the form is submitted.
*/
@@ -36,10 +30,15 @@ export const baseRoleFormValidationSchema = Yup.object({
name: Yup.string().required('This field is required.'),
});
export type BaseRoleFormValues = Yup.InferType<
typeof baseRoleFormValidationSchema
>;
export default function BaseRoleForm({
onSubmit,
onCancel,
submitButtonText = 'Save',
location,
}: BaseRoleFormProps) {
const { onDirtyStateChange } = useDialog();
const form = useFormContext<BaseRoleFormValues>();
@@ -52,8 +51,8 @@ export default function BaseRoleForm({
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'dialog');
}, [isDirty, onDirtyStateChange]);
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
return (
<div className="grid grid-flow-row gap-3 px-6 pb-6">

View File

@@ -7,18 +7,20 @@ import BaseRoleForm, {
} from '@/components/settings/roles/BaseRoleForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getServerError from '@/utils/settings/getServerError';
import getUserRoles from '@/utils/settings/getUserRoles';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetRolesQuery,
useUpdateAppMutation,
GetRolesPermissionsDocument,
useGetRolesPermissionsQuery,
useUpdateConfigMutation,
} 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'> {
extends Pick<BaseRoleFormProps, 'onCancel' | 'location'> {
/**
* Function to be called when the form is submitted.
*/
@@ -30,10 +32,11 @@ export default function CreateRoleForm({
...props
}: CreateRoleFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetRolesQuery({
variables: { id: currentApplication?.id },
const { data, loading, error } = useGetRolesPermissionsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { allowed: allowedRoles } = data?.config?.auth?.user?.roles || {};
const form = useForm<BaseRoleFormValues>({
defaultValues: {},
@@ -41,7 +44,9 @@ export default function CreateRoleForm({
resolver: yupResolver(baseRoleFormValidationSchema),
});
const [updateApp] = useUpdateAppMutation({ refetchQueries: ['getRoles'] });
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetRolesPermissionsDocument],
});
if (loading) {
return <ActivityIndicator delay={1000} label="Loading roles..." />;
@@ -52,7 +57,7 @@ export default function CreateRoleForm({
}
const { setError } = form;
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles);
const availableRoles = getUserRoles(allowedRoles);
async function handleSubmit({ name }: BaseRoleFormValues) {
if (availableRoles.some((role) => role.name === name)) {
@@ -61,26 +66,40 @@ export default function CreateRoleForm({
return;
}
const updateAppPromise = updateApp({
const updatedAllowedRoles = allowedRoles ? [...allowedRoles, name] : [name];
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication?.id,
app: {
authUserDefaultAllowedRoles: `${data?.app?.authUserDefaultAllowedRoles},${name}`,
appId: currentApplication?.id,
config: {
auth: {
user: {
roles: {
allowed: updatedAllowedRoles,
},
},
},
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Creating role...',
success: 'Role has been created successfully.',
error: 'An error occurred while trying to create the role.',
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: 'Creating role...',
success: 'Role has been created successfully.',
error: getServerError(
'An error occurred while trying to create the role.',
),
},
getToastStyleProps(),
);
await onSubmit?.();
onSubmit?.();
} catch (updateConfigError) {
console.error(updateConfigError);
}
}
return (

View File

@@ -8,17 +8,20 @@ import BaseRoleForm, {
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { Role } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getServerError from '@/utils/settings/getServerError';
import getUserRoles from '@/utils/settings/getUserRoles';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetRolesQuery,
useUpdateAppMutation,
GetRolesPermissionsDocument,
useGetRolesPermissionsQuery,
useUpdateConfigMutation,
} 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'> {
export interface EditRoleFormProps
extends Pick<BaseRoleFormProps, 'onCancel' | 'location'> {
/**
* The role to be edited.
*/
@@ -35,11 +38,14 @@ export default function EditRoleForm({
...props
}: EditRoleFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetRolesQuery({
variables: { id: currentApplication?.id },
const { data, loading, error } = useGetRolesPermissionsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { allowed: allowedRoles, default: defaultRole } =
data?.config?.auth?.user?.roles || {};
const form = useForm<BaseRoleFormValues>({
defaultValues: {
name: originalRole.name || '',
@@ -48,8 +54,8 @@ export default function EditRoleForm({
resolver: yupResolver(baseRoleFormValidationSchema),
});
const [updateApp] = useUpdateAppMutation({
refetchQueries: ['getRoles'],
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetRolesPermissionsDocument],
});
if (loading) {
@@ -61,7 +67,7 @@ export default function EditRoleForm({
}
const { setError } = form;
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles);
const availableRoles = getUserRoles(allowedRoles);
async function handleSubmit({ name }: BaseRoleFormValues) {
if (
@@ -74,47 +80,55 @@ export default function EditRoleForm({
return;
}
const defaultAllowedRolesList =
data?.app?.authUserDefaultAllowedRoles.split(',') || [];
const defaultAllowedRolesList = allowedRoles || [];
const originalRoleIndex = defaultAllowedRolesList.findIndex(
(role) => role.trim() === originalRole.name,
);
const updatedDefaultAllowedRoles = defaultAllowedRolesList
.map((role, index) => {
const updatedDefaultAllowedRoles = defaultAllowedRolesList.map(
(role, index) => {
if (index === originalRoleIndex) {
return name;
}
return role;
})
.join(',');
},
);
const updateAppPromise = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication?.id,
app: {
authUserDefaultRole:
data?.app?.authUserDefaultRole === originalRole.name
? name
: data?.app?.authUserDefaultRole,
authUserDefaultAllowedRoles: updatedDefaultAllowedRoles,
appId: currentApplication?.id,
config: {
auth: {
user: {
roles: {
default: defaultRole === originalRole.name ? name : defaultRole,
allowed: updatedDefaultAllowedRoles,
},
},
},
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Updating role...',
success: 'Role has been updated successfully.',
error: 'An error occurred while trying to update the role.',
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: 'Updating role...',
success: 'Role has been updated successfully.',
error: getServerError(
'An error occurred while trying to update the role.',
),
},
getToastStyleProps(),
);
await onSubmit?.();
onSubmit?.();
} catch (updateConfigError) {
console.error(updateConfigError);
}
}
return (

View File

@@ -1,5 +1,8 @@
import { useDialog } from '@/components/common/DialogProvider';
import CreateRoleForm from '@/components/settings/roles/CreateRoleForm';
import EditRoleForm from '@/components/settings/roles/EditRoleForm';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { Role } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -15,11 +18,13 @@ 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 getServerError from '@/utils/settings/getServerError';
import getUserRoles from '@/utils/settings/getUserRoles';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetRolesQuery,
useUpdateAppMutation,
GetRolesPermissionsDocument,
useGetRolesPermissionsQuery,
useUpdateConfigMutation,
} from '@/utils/__generated__/graphql';
import { Fragment } from 'react';
import toast from 'react-hot-toast';
@@ -37,15 +42,20 @@ export interface RoleSettingsFormValues {
}
export default function RoleSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { openDialog, openAlertDialog } = useDialog();
const { data, loading, error } = useGetRolesQuery({
variables: { id: currentApplication?.id },
const { data, loading, error } = useGetRolesPermissionsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const [updateApp] = useUpdateAppMutation({
refetchQueries: ['getRoles'],
const { allowed: allowedRoles, default: defaultRole } =
data?.config?.auth?.user?.roles || {};
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetRolesPermissionsDocument],
});
if (loading) {
@@ -57,59 +67,69 @@ export default function RoleSettings() {
}
async function handleSetAsDefault({ name }: Role) {
const updateAppPromise = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication?.id,
app: {
authUserDefaultRole: name,
appId: currentApplication?.id,
config: {
auth: {
user: {
roles: {
allowed: allowedRoles,
default: name,
},
},
},
},
},
});
await toast.promise(
updateAppPromise,
updateConfigPromise,
{
loading: 'Updating default role...',
success: 'Default role has been updated successfully.',
error: 'An error occurred while trying to update the default role.',
error: getServerError(
'An error occurred while trying to update the default role.',
),
},
getToastStyleProps(),
);
}
async function handleDeleteRole({ name }: Role) {
const filteredRoles = data?.app?.authUserDefaultAllowedRoles
.split(',')
.filter((role) => role !== name)
.join(',');
const updateAppPromise = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication?.id,
app: {
authUserDefaultAllowedRoles: filteredRoles,
authUserDefaultRole:
name === data?.app?.authUserDefaultRole
? 'user'
: data?.app?.authUserDefaultRole,
appId: currentApplication?.id,
config: {
auth: {
user: {
roles: {
allowed: allowedRoles.filter((role) => role !== name),
default: name === defaultRole ? 'user' : defaultRole,
},
},
},
},
},
});
await toast.promise(
updateAppPromise,
updateConfigPromise,
{
loading: 'Deleting allowed role...',
success: 'Allowed Role has been deleted successfully.',
error: 'An error occurred while trying to delete the allowed role.',
error: getServerError(
'An error occurred while trying to delete the allowed role.',
),
},
getToastStyleProps(),
);
}
function handleOpenCreator() {
openDialog('CREATE_ROLE', {
openDialog({
title: 'Create Allowed Role',
component: <CreateRoleForm />,
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'max-w-sm' },
@@ -118,9 +138,9 @@ export default function RoleSettings() {
}
function handleOpenEditor(originalRole: Role) {
openDialog('EDIT_ROLE', {
openDialog({
title: 'Edit Allowed Role',
payload: { originalRole },
component: <EditRoleForm originalRole={originalRole} />,
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'max-w-sm' },
@@ -145,9 +165,7 @@ export default function RoleSettings() {
});
}
const availableAllowedRoles = getUserRoles(
data?.app?.authUserDefaultAllowedRoles,
);
const availableAllowedRoles = getUserRoles(allowedRoles);
return (
<SettingsContainer
@@ -155,7 +173,10 @@ export default function RoleSettings() {
description="Allowed roles are roles users get automatically when they sign up."
docsLink="https://docs.nhost.io/authentication/users#allowed-roles"
rootClassName="gap-0"
className="my-2 px-0"
className={twMerge(
'my-2 px-0',
availableAllowedRoles.length === 0 && 'gap-2',
)}
slotProps={{ submitButton: { className: 'invisible' } }}
>
<Box className="border-b-1 px-4 py-3">
@@ -163,103 +184,110 @@ export default function RoleSettings() {
</Box>
<div className="grid grid-flow-row gap-2">
<List>
{availableAllowedRoles.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)}
{availableAllowedRoles.length > 0 && (
<List>
{availableAllowedRoles.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"
>
<Text className="font-medium">Edit</Text>
</Dropdown.Item>
<IconButton
variant="borderless"
color="secondary"
disabled={maintenanceActive}
>
<DotsVerticalIcon />
</IconButton>
</Dropdown.Trigger>
<Divider component="li" />
<Dropdown.Item
disabled={role.isSystemRole}
onClick={() => handleConfirmDelete(role)}
<Dropdown.Content
menu
PaperProps={{ className: 'w-32' }}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Text className="font-medium" color="error">
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}
<Dropdown.Item onClick={() => handleSetAsDefault(role)}>
<Text className="font-medium">Set as Default</Text>
</Dropdown.Item>
{role.isSystemRole && <LockIcon className="h-4 w-4" />}
<Divider component="li" />
{data?.app?.authUserDefaultRole === role.name && (
<Chip
component="span"
color="info"
size="small"
label="Default"
/>
)}
</>
<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" color="error">
Delete
</Text>
</Dropdown.Item>
</Dropdown.Content>
</Dropdown.Root>
}
/>
</ListItem.Root>
>
<ListItem.Text
primaryTypographyProps={{
className:
'inline-grid grid-flow-col gap-1 items-center h-6 font-medium',
}}
primary={
<>
{role.name}
<Divider
component="li"
className={twMerge(
index === availableAllowedRoles.length - 1
? '!mt-4'
: '!my-4',
)}
/>
</Fragment>
))}
</List>
{role.isSystemRole && <LockIcon className="h-4 w-4" />}
{defaultRole === role.name && (
<Chip
component="span"
color="info"
size="small"
label="Default"
/>
)}
</>
}
/>
</ListItem.Root>
<Divider
component="li"
className={twMerge(
index === availableAllowedRoles.length - 1
? '!mt-4'
: '!my-4',
)}
/>
</Fragment>
))}
</List>
)}
<Button
className="mx-4 justify-self-start"
variant="borderless"
startIcon={<PlusIcon />}
onClick={handleOpenCreator}
disabled={maintenanceActive}
>
Create Allowed Role
</Button>

View File

@@ -0,0 +1,113 @@
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 { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import * as Yup from 'yup';
export interface BaseSecretFormProps {
/**
* Determines the mode of the form.
*
* @default 'edit'
*/
mode?: 'edit' | 'create';
/**
* Function to be called when the form is submitted.
*/
onSubmit: (values: BaseSecretFormValues) => void;
/**
* Function to be called when the operation is cancelled.
*/
onCancel?: VoidFunction;
/**
* Submit button text.
*
* @default 'Save'
*/
submitButtonText?: string;
}
export const baseSecretFormValidationSchema = Yup.object({
name: Yup.string()
.required('This field is required.')
.test(
'isSecretValid',
'A name must start with a letter and can only contain letters, numbers, and underscores.',
(value) => /^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/i.test(value),
),
value: Yup.string().required('This field is required.'),
});
export type BaseSecretFormValues = Yup.InferType<
typeof baseSecretFormValidationSchema
>;
export default function BaseSecretForm({
mode = 'edit',
onSubmit,
onCancel,
submitButtonText = 'Save',
}: BaseSecretFormProps) {
const { onDirtyStateChange } = useDialog();
const form = useFormContext<BaseSecretFormValues>();
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">
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
<Input
{...register('name')}
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('value')}
id="value"
label="Value"
placeholder="Enter value"
hideEmptyHelperText
error={!!errors.value}
helperText={errors?.value?.message}
fullWidth
multiline
rows={5}
autoComplete="off"
autoFocus={mode === 'edit'}
/>
<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 './BaseSecretForm';
export { default } from './BaseSecretForm';

View File

@@ -0,0 +1,85 @@
import type {
BaseSecretFormProps,
BaseSecretFormValues,
} from '@/components/settings/secrets/BaseSecretForm';
import BaseSecretForm, {
baseSecretFormValidationSchema,
} from '@/components/settings/secrets/BaseSecretForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
GetSecretsDocument,
useInsertSecretMutation,
} 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 CreateSecretFormProps
extends Pick<BaseSecretFormProps, 'onCancel'> {
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
}
export default function CreateSecretForm({
onSubmit,
...props
}: CreateSecretFormProps) {
const form = useForm<BaseSecretFormValues>({
defaultValues: {
name: '',
value: '',
},
reValidateMode: 'onSubmit',
resolver: yupResolver(baseSecretFormValidationSchema),
});
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [insertSecret] = useInsertSecretMutation({
refetchQueries: [GetSecretsDocument],
});
async function handleSubmit({ name, value }: BaseSecretFormValues) {
const insertSecretPromise = insertSecret({
variables: {
appId: currentApplication?.id,
secret: {
name,
value,
},
},
});
try {
await toast.promise(
insertSecretPromise,
{
loading: 'Creating secret...',
success: 'Secret has been created successfully.',
error: (arg: Error) =>
arg?.message
? `Error: ${arg?.message}`
: 'An error occurred while creating the secret.',
},
getToastStyleProps(),
);
onSubmit?.();
} catch (error) {
console.error(error);
}
}
return (
<FormProvider {...form}>
<BaseSecretForm
mode="create"
submitButtonText="Create"
onSubmit={handleSubmit}
{...props}
/>
</FormProvider>
);
}

View File

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

View File

@@ -0,0 +1,91 @@
import type {
BaseSecretFormProps,
BaseSecretFormValues,
} from '@/components/settings/secrets/BaseSecretForm';
import BaseSecretForm, {
baseSecretFormValidationSchema,
} from '@/components/settings/secrets/BaseSecretForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { Secret } from '@/types/application';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
GetSecretsDocument,
useUpdateSecretMutation,
} 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 EditSecretFormProps
extends Pick<BaseSecretFormProps, 'onCancel'> {
/**
* The secret to edit.
*/
originalSecret: Secret;
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
}
export default function EditSecretForm({
originalSecret,
onSubmit,
...props
}: EditSecretFormProps) {
const form = useForm<BaseSecretFormValues>({
defaultValues: {
name: originalSecret.name,
value: '',
},
reValidateMode: 'onSubmit',
resolver: yupResolver(baseSecretFormValidationSchema),
});
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateSecret] = useUpdateSecretMutation({
refetchQueries: [GetSecretsDocument],
});
async function handleSubmit({ name, value }: BaseSecretFormValues) {
const updateSecretPromise = updateSecret({
variables: {
appId: currentApplication?.id,
secret: {
name,
value,
},
},
});
try {
await toast.promise(
updateSecretPromise,
{
loading: 'Updating secret...',
success: 'Secret has been updated successfully.',
error: (arg: Error) =>
arg?.message
? `Error: ${arg?.message}`
: 'An error occurred while updating the secret.',
},
getToastStyleProps(),
);
onSubmit?.();
} catch (error) {
console.error(error);
}
}
return (
<FormProvider {...form}>
<BaseSecretForm
mode="edit"
submitButtonText="Save"
onSubmit={handleSubmit}
{...props}
/>
</FormProvider>
);
}

View File

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

View File

@@ -1,45 +1,53 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import {
useSignInMethodsQuery,
useUpdateAppMutation,
GetSignInMethodsDocument,
useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface AnonymousSignInFormValues {
/**
* Enables users to register as an anonymous user.
*/
authAnonymousUsersEnabled: boolean;
}
const validationSchema = Yup.object({
enabled: Yup.boolean(),
});
export type AnonymousSignInFormValues = Yup.InferType<typeof validationSchema>;
export default function AnonymousSignInSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({
variables: {
id: currentApplication.id,
},
const { data, loading, error } = useGetSignInMethodsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { enabled } = data?.config?.auth?.method?.anonymous || {};
const form = useForm<AnonymousSignInFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
authAnonymousUsersEnabled: data.app.authAnonymousUsersEnabled,
enabled,
},
resolver: yupResolver(validationSchema),
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading..."
label="Loading anonymous sign-in settings..."
className="justify-center"
/>
);
@@ -52,26 +60,36 @@ export default function AnonymousSignInSettings() {
const handlePasswordProtectionSettingsChange = async (
values: AnonymousSignInFormValues,
) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: {
...values,
appId: currentApplication.id,
config: {
auth: {
method: {
anonymous: values,
},
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `Anonymous sign-in settings are being updated...`,
success: `Anonymous sign-in settings have been updated successfully.`,
error: `An error occurred while trying to update Anonymous sign-in settings.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `Anonymous sign-in settings are being updated...`,
success: `Anonymous sign-in settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update Anonymous sign-in settings.`,
),
},
getToastStyleProps(),
);
form.reset(values);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
@@ -80,14 +98,13 @@ export default function AnonymousSignInSettings() {
<SettingsContainer
title="Anonymous Users"
description="Allow users to sign in anonymously."
primaryActionButtonProps={{
disabled:
form.formState.isSubmitting ||
!form.formState.isValid ||
!form.formState.isDirty,
slotProps={{
submitButton: {
disabled: !form.formState.isDirty || maintenanceActive,
loading: form.formState.isSubmitting,
},
}}
enabled={form.getValues('authAnonymousUsersEnabled')}
switchId="authAnonymousUsersEnabled"
switchId="enabled"
showSwitch
className="hidden"
/>

View File

@@ -1,8 +1,10 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import {
useSignInMethodsQuery,
useUpdateAppMutation,
GetSignInMethodsDocument,
useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -12,60 +14,70 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { useTheme } from '@mui/material';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup';
export interface AppleProviderFormValues {
authAppleEnabled: boolean;
authAppleTeamId: string;
authAppleKeyId: string;
authAppleClientId: string;
authApplePrivateKey: string;
}
const validationSchema = Yup.object({
teamId: Yup.string().label('Team ID').when('enabled', {
is: true,
then: Yup.string().required(),
}),
keyId: Yup.string().label('Key ID').when('enabled', {
is: true,
then: Yup.string().required(),
}),
clientId: Yup.string().label('Client ID').when('enabled', {
is: true,
then: Yup.string().required(),
}),
privateKey: Yup.string().label('Private Key').when('enabled', {
is: true,
then: Yup.string().required(),
}),
enabled: Yup.boolean(),
});
export type AppleProviderFormValues = Yup.InferType<typeof validationSchema>;
export default function AppleProviderSettings() {
const theme = useTheme();
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const {
data: {
app: {
authAppleEnabled,
authAppleTeamId,
authAppleKeyId,
authAppleClientId,
authApplePrivateKey,
},
},
loading,
error,
} = useSignInMethodsQuery({
variables: {
id: currentApplication.id,
},
const { data, loading, error } = useGetSignInMethodsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { clientId, enabled, keyId, privateKey, teamId } =
data?.config?.auth?.method?.oauth?.apple || {};
const form = useForm<AppleProviderFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
authAppleTeamId,
authAppleKeyId,
authAppleClientId,
authApplePrivateKey,
authAppleEnabled,
teamId: teamId || '',
keyId: keyId || '',
clientId: clientId || '',
privateKey: privateKey || '',
enabled: enabled || false,
},
resolver: yupResolver(validationSchema),
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading Apple settings..."
label="Loading settings for Apple..."
className="justify-center"
/>
);
@@ -76,27 +88,44 @@ export default function AppleProviderSettings() {
}
const { register, formState, watch } = form;
const authEnabled = watch('authAppleEnabled');
const authEnabled = watch('enabled');
const handleProviderUpdate = async (values: AppleProviderFormValues) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: values,
appId: currentApplication.id,
config: {
auth: {
method: {
oauth: {
apple: {
...values,
scope: [],
},
},
},
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `Apple settings are being updated...`,
success: `Apple settings have been updated successfully.`,
error: `An error occurred while trying to update the project's Apple settings.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `Apple settings are being updated...`,
success: `Apple settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update the project's Apple settings.`,
),
},
getToastStyleProps(),
);
form.reset(values);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
@@ -107,7 +136,7 @@ export default function AppleProviderSettings() {
description="Allow users to sign in with Apple."
slotProps={{
submitButton: {
disabled: !formState.isValid || !formState.isDirty,
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
@@ -118,55 +147,62 @@ export default function AppleProviderSettings() {
? '/assets/brands/light/apple.svg'
: '/assets/brands/apple.svg'
}
switchId="authAppleEnabled"
switchId="enabled"
showSwitch
enabled={authEnabled}
className={twMerge(
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden',
)}
>
<Input
{...register(`authAppleTeamId`)}
name="authAppleTeamId"
id="authAppleTeamId"
{...register('teamId')}
name="teamId"
id="teamId"
label="Team ID"
placeholder="Apple Team ID"
className="col-span-1"
fullWidth
hideEmptyHelperText
error={!!formState.errors?.teamId}
helperText={formState.errors?.teamId?.message}
/>
<Input
{...register('authAppleClientId')}
name="authAppleClientId"
id="authAppleClientId"
{...register('clientId')}
name="clientId"
id="clientId"
label="Service ID"
placeholder="Apple Service ID"
className="col-span-1"
fullWidth
hideEmptyHelperText
error={!!formState.errors?.clientId}
helperText={formState.errors?.clientId?.message}
/>
<Input
{...register('authAppleKeyId')}
name="authAppleKeyId"
id="authAppleKeyId"
{...register('keyId')}
name="keyId"
id="keyId"
label="Key ID"
placeholder="Apple Key ID"
className="col-span-2"
fullWidth
hideEmptyHelperText
error={!!formState.errors?.keyId}
helperText={formState.errors?.keyId?.message}
/>
<Input
{...register('authApplePrivateKey')}
{...register('privateKey')}
multiline
rows={4}
name="authApplePrivateKey"
id="authApplePrivateKey"
name="privateKey"
id="privateKey"
label="Private Key"
placeholder="Paste Private Key here"
className="col-span-2"
fullWidth
hideEmptyHelperText
error={!!formState.errors?.privateKey}
helperText={formState.errors?.privateKey?.message}
/>
<Input
name="redirectUrl"
@@ -199,7 +235,7 @@ export default function AppleProviderSettings() {
);
}}
>
<CopyIcon className="w-4 h-4" />
<CopyIcon className="h-4 w-4" />
</IconButton>
</InputAdornment>
}

View File

@@ -1,60 +1,59 @@
import Input from '@/ui/v2/Input';
import { useFormContext } from 'react-hook-form';
import * as Yup from 'yup';
export interface BaseProviderSettingsFormValues {
authEnabled: boolean;
authClientId: string;
authClientSecret: string;
export const baseProviderValidationSchema = Yup.object({
clientId: Yup.string().label('Client ID').when('enabled', {
is: true,
then: Yup.string().required(),
}),
clientSecret: Yup.string().label('Client Secret').when('enabled', {
is: true,
then: Yup.string().required(),
}),
enabled: Yup.bool(),
});
export type BaseProviderSettingsFormValues = Yup.InferType<
typeof baseProviderValidationSchema
>;
export interface BaseProviderSettingsProps {
/**
* The name of the provider. Used to provide unique IDs to the inputs.
*/
providerName: string;
}
/**
* Third-party auth providers e.g. Google, GitHub.
*
* @remarks
*
* These providers follow the same API structure in our database and in our GraphQL API:
* In the case of adding a new provider to this list it should contain the configuration in the example below.
*
* ```
* auth<Provider>Enabled
* auth<Provider>ClientId
* auth<Provider>ClientSecret
* ```
*
* @example
*
* ```
* authGithubEnabled
* authGithubClientId
* authGithubClientSecret
* ```
*
* @remarks If the provider has a different configuration (more or less fields) it should be added as its own component
* @see {@link 'src\components\settings\sign-in-methods\ProviderTwitterSettings\ProviderTwitterSettings.tsx'}
*
*/
export default function BaseProviderSettings() {
const { register } = useFormContext<BaseProviderSettingsFormValues>();
export default function BaseProviderSettings({
providerName,
}: BaseProviderSettingsProps) {
const { register, formState } =
useFormContext<BaseProviderSettingsFormValues>();
return (
<>
<Input
{...register(`authClientId`)}
id="authClientId"
{...register('clientId')}
id={`${providerName}-clientId`}
label="Client ID"
placeholder="Enter your Client ID"
className="col-span-1"
fullWidth
hideEmptyHelperText
error={!!formState.errors?.clientId}
helperText={formState.errors?.clientId?.message}
/>
<Input
{...register(`authClientSecret`)}
id="authClientSecret"
{...register('clientSecret')}
id={`${providerName}-clientSecret`}
label="Client Secret"
placeholder="Enter your Client Secret"
className="col-span-1"
fullWidth
hideEmptyHelperText
error={!!formState.errors?.clientSecret}
helperText={formState.errors?.clientSecret?.message}
/>
</>
);

View File

@@ -1,10 +1,14 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings, {
baseProviderValidationSchema,
} from '@/components/settings/signInMethods/BaseProviderSettings';
import { useUI } from '@/context/UIContext';
import {
useSignInMethodsQuery,
useUpdateAppMutation,
GetSignInMethodsDocument,
useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -14,36 +18,43 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
export default function DiscordProviderSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({
variables: {
id: currentApplication.id,
},
const { data, loading, error } = useGetSignInMethodsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { clientId, clientSecret, enabled } =
data?.config?.auth?.method?.oauth?.discord || {};
const form = useForm<BaseProviderSettingsFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
authClientId: data?.app?.authDiscordClientId,
authClientSecret: data?.app?.authDiscordClientSecret,
authEnabled: data?.app?.authDiscordEnabled,
clientId: clientId || '',
clientSecret: clientSecret || '',
enabled: enabled || false,
},
resolver: yupResolver(baseProviderValidationSchema),
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading Discord settings..."
label="Loading settings for Discord..."
className="justify-center"
/>
);
@@ -54,33 +65,46 @@ export default function DiscordProviderSettings() {
}
const { formState, watch } = form;
const authEnabled = watch('authEnabled');
const authEnabled = watch('enabled');
const handleProviderUpdate = async (
values: BaseProviderSettingsFormValues,
) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: {
authDiscordClientId: values.authClientId,
authDiscordClientSecret: values.authClientSecret,
authDiscordEnabled: values.authEnabled,
appId: currentApplication?.id,
config: {
auth: {
method: {
oauth: {
discord: {
...values,
scope: [],
},
},
},
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `Discord settings are being updated...`,
success: `Discord settings have been updated successfully.`,
error: `An error occurred while trying to update the project's Discord settings.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `Discord settings are being updated...`,
success: `Discord settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update the project's Discrod settings.`,
),
},
getToastStyleProps(),
);
form.reset(values);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
@@ -89,22 +113,23 @@ export default function DiscordProviderSettings() {
<SettingsContainer
title="Discord"
description="Allow users to sign in with Discord."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
submitButton: {
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-discord"
docsTitle="how to sign in users with Discord"
icon="/assets/brands/discord.svg"
switchId="authEnabled"
switchId="enabled"
showSwitch
enabled={authEnabled}
className={twMerge(
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden',
)}
>
<BaseProviderSettings />
<BaseProviderSettings providerName="discord" />
<Input
name="redirectUrl"
id="redirectUrl"
@@ -136,7 +161,7 @@ export default function DiscordProviderSettings() {
);
}}
>
<CopyIcon className="w-4 h-4" />
<CopyIcon className="h-4 w-4" />
</IconButton>
</InputAdornment>
}

View File

@@ -1,46 +1,51 @@
import ControlledCheckbox from '@/components/common/ControlledCheckbox';
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import {
useSignInMethodsQuery,
useUpdateAppMutation,
GetSignInMethodsDocument,
useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Text from '@/ui/v2/Text';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface EmailAndPasswordFormValues {
/**
* When enabled, users will need to verify their email by a link sent to their specified email.
*/
authEmailSigninEmailVerifiedRequired: boolean;
/**
* If true, users' passwords will be checked against https://haveibeenpwned.com/Passwords
*/
authPasswordHibpEnabled: boolean;
}
const validationSchema = Yup.object({
emailVerificationRequired: Yup.boolean(),
hibpEnabled: Yup.boolean(),
});
export type EmailAndPasswordFormValues = Yup.InferType<typeof validationSchema>;
export default function EmailAndPasswordSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, error, loading } = useSignInMethodsQuery({
variables: {
id: currentApplication.id,
},
const { data, error, loading } = useGetSignInMethodsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { hibpEnabled, emailVerificationRequired } =
data?.config?.auth?.method?.emailPassword || {};
const form = useForm<EmailAndPasswordFormValues>({
reValidateMode: 'onChange',
defaultValues: {
authPasswordHibpEnabled: data?.app?.authPasswordHibpEnabled || false,
authEmailSigninEmailVerifiedRequired:
data?.app?.authEmailSigninEmailVerifiedRequired || false,
hibpEnabled: hibpEnabled || false,
emailVerificationRequired: emailVerificationRequired || false,
},
resolver: yupResolver(validationSchema),
});
if (loading) {
@@ -62,28 +67,36 @@ export default function EmailAndPasswordSettings() {
const handleEmailAndPasswordSettingsChange = async (
values: EmailAndPasswordFormValues,
) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: {
authPasswordHibpEnabled: values.authPasswordHibpEnabled,
authEmailSigninEmailVerifiedRequired:
values.authEmailSigninEmailVerifiedRequired,
appId: currentApplication.id,
config: {
auth: {
method: {
emailPassword: values,
},
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `Email and password sign-in settings are being updated...`,
success: `Email and password sign-in settings have been updated successfully.`,
error: `An error occurred while trying to update email sign-in settings.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `Email and password sign-in settings are being updated...`,
success: `Email and password sign-in settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update email sign-in settings.`,
),
},
getToastStyleProps(),
);
form.reset(values);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
@@ -98,18 +111,16 @@ export default function EmailAndPasswordSettings() {
showSwitch
enabled
slotProps={{
switch: {
disabled: true,
},
switch: { disabled: true },
submitButton: {
disabled: !formState.isValid || !formState.isDirty,
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
>
<ControlledCheckbox
name="authEmailSigninEmailVerifiedRequired"
id="authEmailSigninEmailVerifiedRequired"
name="emailVerificationRequired"
id="emailVerificationRequired"
label={
<span className="inline-grid grid-flow-row gap-y-0.5 text-sm+">
<Text component="span">Require Verified Emails</Text>
@@ -121,8 +132,8 @@ export default function EmailAndPasswordSettings() {
/>
<ControlledCheckbox
name="authPasswordHibpEnabled"
id="authPasswordHibpEnabled"
name="hibpEnabled"
id="hibpEnabled"
label={
<span className="inline-grid grid-flow-row gap-y-0.5 text-sm+">
<Text component="span">Password Protection</Text>

View File

@@ -1,10 +1,14 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings, {
baseProviderValidationSchema,
} from '@/components/settings/signInMethods/BaseProviderSettings';
import { useUI } from '@/context/UIContext';
import {
useSignInMethodsQuery,
useUpdateAppMutation,
GetSignInMethodsDocument,
useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -14,36 +18,43 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
export default function FacebookProviderSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({
variables: {
id: currentApplication.id,
},
const { data, loading, error } = useGetSignInMethodsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { clientId, clientSecret, enabled } =
data?.config?.auth?.method?.oauth?.facebook || {};
const form = useForm<BaseProviderSettingsFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
authClientId: data?.app?.authFacebookClientId,
authClientSecret: data?.app?.authFacebookClientSecret,
authEnabled: data?.app?.authFacebookEnabled,
clientId: clientId || '',
clientSecret: clientSecret || '',
enabled: enabled || false,
},
resolver: yupResolver(baseProviderValidationSchema),
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading Facebook settings..."
label="Loading settings for Facebook..."
className="justify-center"
/>
);
@@ -54,33 +65,46 @@ export default function FacebookProviderSettings() {
}
const { formState, watch } = form;
const authEnabled = watch('authEnabled');
const authEnabled = watch('enabled');
const handleProviderUpdate = async (
values: BaseProviderSettingsFormValues,
) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: {
authFacebookClientId: values.authClientId,
authFacebookClientSecret: values.authClientSecret,
authFacebookEnabled: values.authEnabled,
appId: currentApplication.id,
config: {
auth: {
method: {
oauth: {
facebook: {
...values,
scope: [],
},
},
},
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `Facebook settings are being updated...`,
success: `Facebook settings have been updated successfully.`,
error: `An error occurred while trying to update the project's Facebook settings.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `Facebook settings are being updated...`,
success: `Facebook settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update the project's Facebook settings.`,
),
},
getToastStyleProps(),
);
form.reset(values);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
@@ -89,22 +113,23 @@ export default function FacebookProviderSettings() {
<SettingsContainer
title="Facebook"
description="Allow users to sign in with Facebook."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
submitButton: {
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-facebook"
docsTitle="how to sign in users with Facebook"
icon="/assets/brands/facebook.svg"
switchId="authEnabled"
switchId="enabled"
showSwitch
enabled={authEnabled}
className={twMerge(
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden',
)}
>
<BaseProviderSettings />
<BaseProviderSettings providerName="facebook" />
<Input
name="redirectUrl"
id="redirectUrl"
@@ -136,7 +161,7 @@ export default function FacebookProviderSettings() {
);
}}
>
<CopyIcon className="w-4 h-4" />
<CopyIcon className="h-4 w-4" />
</IconButton>
</InputAdornment>
}

View File

@@ -1,10 +1,14 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings, {
baseProviderValidationSchema,
} from '@/components/settings/signInMethods/BaseProviderSettings';
import { useUI } from '@/context/UIContext';
import {
useSignInMethodsQuery,
useUpdateAppMutation,
GetSignInMethodsDocument,
useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -14,7 +18,9 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { useTheme } from '@mui/material';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
@@ -22,30 +28,35 @@ import { twMerge } from 'tailwind-merge';
export default function GitHubProviderSettings() {
const theme = useTheme();
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({
variables: {
id: currentApplication.id,
},
const { data, loading, error } = useGetSignInMethodsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { clientId, clientSecret, enabled } =
data?.config?.auth?.method?.oauth?.github || {};
const form = useForm<BaseProviderSettingsFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
authClientId: data?.app?.authGithubClientId,
authClientSecret: data?.app?.authGithubClientSecret,
authEnabled: data?.app?.authGithubEnabled,
clientId: clientId || '',
clientSecret: clientSecret || '',
enabled: enabled || false,
},
resolver: yupResolver(baseProviderValidationSchema),
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading GitHub settings..."
label="Loading settings for GitHub..."
className="justify-center"
/>
);
@@ -56,33 +67,46 @@ export default function GitHubProviderSettings() {
}
const { formState, watch } = form;
const authEnabled = watch('authEnabled');
const authEnabled = watch('enabled');
const handleProviderUpdate = async (
values: BaseProviderSettingsFormValues,
) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: {
authGithubClientId: values.authClientId,
authGithubClientSecret: values.authClientSecret,
authGithubEnabled: values.authEnabled,
appId: currentApplication.id,
config: {
auth: {
method: {
oauth: {
github: {
...values,
scope: [],
},
},
},
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `GitHub settings are being updated...`,
success: `GitHub settings have been updated successfully.`,
error: `An error occurred while trying to update the project's GitHub settings.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `GitHub settings are being updated...`,
success: `GitHub settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update the project's GitHub settings.`,
),
},
getToastStyleProps(),
);
form.reset(values);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
@@ -91,9 +115,11 @@ export default function GitHubProviderSettings() {
<SettingsContainer
title="GitHub"
description="Allow users to sign in with GitHub."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
submitButton: {
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-github"
docsTitle="how to sign in users with GitHub"
@@ -102,15 +128,14 @@ export default function GitHubProviderSettings() {
? '/assets/brands/light/github.svg'
: '/assets/brands/github.svg'
}
switchId="authEnabled"
switchId="enabled"
showSwitch
enabled={authEnabled}
className={twMerge(
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden',
)}
>
<BaseProviderSettings />
<BaseProviderSettings providerName="github" />
<Input
name="redirectUrl"
id="redirectUrl"
@@ -142,7 +167,7 @@ export default function GitHubProviderSettings() {
);
}}
>
<CopyIcon className="w-4 h-4" />
<CopyIcon className="h-4 w-4" />
</IconButton>
</InputAdornment>
}

View File

@@ -1,10 +1,14 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings, {
baseProviderValidationSchema,
} from '@/components/settings/signInMethods/BaseProviderSettings';
import { useUI } from '@/context/UIContext';
import {
useSignInMethodsQuery,
useUpdateAppMutation,
GetSignInMethodsDocument,
useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -14,36 +18,43 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
export default function GoogleProviderSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({
variables: {
id: currentApplication.id,
},
const { data, loading, error } = useGetSignInMethodsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { clientId, clientSecret, enabled } =
data?.config?.auth?.method?.oauth?.google || {};
const form = useForm<BaseProviderSettingsFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
authClientId: data?.app?.authGoogleClientId,
authClientSecret: data?.app?.authGoogleClientSecret,
authEnabled: data?.app?.authGoogleEnabled,
clientId: clientId || '',
clientSecret: clientSecret || '',
enabled: enabled || false,
},
resolver: yupResolver(baseProviderValidationSchema),
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading Google settings..."
label="Loading settings for Google..."
className="justify-center"
/>
);
@@ -54,33 +65,46 @@ export default function GoogleProviderSettings() {
}
const { formState, watch } = form;
const authEnabled = watch('authEnabled');
const authEnabled = watch('enabled');
const handleProviderUpdate = async (
values: BaseProviderSettingsFormValues,
) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: {
authGoogleClientId: values.authClientId,
authGoogleClientSecret: values.authClientSecret,
authGoogleEnabled: values.authEnabled,
appId: currentApplication.id,
config: {
auth: {
method: {
oauth: {
google: {
...values,
scope: [],
},
},
},
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `Google settings are being updated...`,
success: `Google settings have been updated successfully.`,
error: `An error occurred while trying to update the project's Google settings.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `Google settings are being updated...`,
success: `Google settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update the project's Google settings.`,
),
},
getToastStyleProps(),
);
form.reset(values);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
@@ -89,22 +113,23 @@ export default function GoogleProviderSettings() {
<SettingsContainer
title="Google"
description="Allow users to sign in with Google."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
submitButton: {
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-google"
docsTitle="how to sign in users with Google"
icon="/assets/brands/google.svg"
switchId="authEnabled"
switchId="enabled"
showSwitch
enabled={authEnabled}
className={twMerge(
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden',
)}
>
<BaseProviderSettings />
<BaseProviderSettings providerName="google" />
<Input
name="redirectUrl"
id="redirectUrl"
@@ -136,7 +161,7 @@ export default function GoogleProviderSettings() {
);
}}
>
<CopyIcon className="w-4 h-4" />
<CopyIcon className="h-4 w-4" />
</IconButton>
</InputAdornment>
}

View File

@@ -1,10 +1,14 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings, {
baseProviderValidationSchema,
} from '@/components/settings/signInMethods/BaseProviderSettings';
import { useUI } from '@/context/UIContext';
import {
useSignInMethodsQuery,
useUpdateAppMutation,
GetSignInMethodsDocument,
useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -14,36 +18,43 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
export default function LinkedInProviderSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({
variables: {
id: currentApplication.id,
},
const { data, loading, error } = useGetSignInMethodsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { clientId, clientSecret, enabled } =
data?.config?.auth?.method?.oauth?.linkedin || {};
const form = useForm<BaseProviderSettingsFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
authClientId: data?.app?.authLinkedinClientId,
authClientSecret: data?.app?.authLinkedinClientSecret,
authEnabled: data?.app?.authLinkedinEnabled,
clientId: clientId || '',
clientSecret: clientSecret || '',
enabled: enabled || false,
},
resolver: yupResolver(baseProviderValidationSchema),
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading..."
label="Loading settings for LinkedIn..."
className="justify-center"
/>
);
@@ -54,33 +65,46 @@ export default function LinkedInProviderSettings() {
}
const { formState, watch } = form;
const authEnabled = watch('authEnabled');
const authEnabled = watch('enabled');
const handleProviderUpdate = async (
values: BaseProviderSettingsFormValues,
) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: {
authLinkedinClientId: values.authClientId,
authLinkedinClientSecret: values.authClientSecret,
authLinkedinEnabled: values.authEnabled,
appId: currentApplication.id,
config: {
auth: {
method: {
oauth: {
linkedin: {
...values,
scope: [],
},
},
},
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `LinkedIn settings are being updated...`,
success: `LinkedIn settings have been updated successfully.`,
error: `An error occurred while trying to update the project's LinkedIn settings.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `LinkedIn settings are being updated...`,
success: `LinkedIn settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update the project's LinkedIn settings.`,
),
},
getToastStyleProps(),
);
form.reset(values);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
@@ -89,22 +113,23 @@ export default function LinkedInProviderSettings() {
<SettingsContainer
title="LinkedIn"
description="Allow users to sign in with LinkedIn."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
submitButton: {
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-linkedin"
docsTitle="how to sign in users with LinkedIn"
icon="/assets/brands/linkedin.svg"
switchId="authEnabled"
switchId="enabled"
showSwitch
enabled={authEnabled}
className={twMerge(
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden',
)}
>
<BaseProviderSettings />
<BaseProviderSettings providerName="linkedin" />
<Input
name="redirectUrl"
id="redirectUrl"
@@ -136,7 +161,7 @@ export default function LinkedInProviderSettings() {
);
}}
>
<CopyIcon className="w-4 h-4" />
<CopyIcon className="h-4 w-4" />
</IconButton>
</InputAdornment>
}

View File

@@ -1,45 +1,53 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import {
useSignInMethodsQuery,
useUpdateAppMutation,
GetSignInMethodsDocument,
useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface MagicLinkFormValues {
/**
* Enables passwordless authentication by email.
*/
authEmailPasswordlessEnabled: boolean;
}
const validationSchema = Yup.object({
enabled: Yup.boolean(),
});
export type MagicLinkFormValues = Yup.InferType<typeof validationSchema>;
export default function MagicLinkSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({
variables: {
id: currentApplication.id,
},
const { data, loading, error } = useGetSignInMethodsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { enabled } = data?.config?.auth?.method?.emailPasswordless || {};
const form = useForm<MagicLinkFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
authEmailPasswordlessEnabled: data.app.authEmailPasswordlessEnabled,
enabled,
},
resolver: yupResolver(validationSchema),
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading Magic Link settings..."
label="Loading settings for Magic Link..."
className="justify-center"
/>
);
@@ -49,30 +57,39 @@ export default function MagicLinkSettings() {
throw error;
}
const { formState, watch } = form;
const authEmailPasswordlessEnabled = watch('authEmailPasswordlessEnabled');
const { formState } = form;
const handleMagicLinkSettingsUpdate = async (values: MagicLinkFormValues) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: {
...values,
appId: currentApplication.id,
config: {
auth: {
method: {
emailPasswordless: values,
},
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `Magic Link settings are being updated...`,
success: `Magic Link settings have been updated successfully.`,
error: `An error occurred while trying to update the project's Magic Link settings.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `Magic Link settings are being updated...`,
success: `Magic Link settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update the project's Magic Link settings.`,
),
},
getToastStyleProps(),
);
form.reset(values);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
@@ -81,14 +98,15 @@ export default function MagicLinkSettings() {
<SettingsContainer
title="Magic Link"
description="Allow users to sign in with a Magic Link."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
submitButton: {
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/authentication/sign-in-with-magic-link"
docsTitle="how to sign in users with Magic Link"
enabled={authEmailPasswordlessEnabled}
switchId="authEmailPasswordlessEnabled"
switchId="enabled"
showSwitch
className="hidden"
/>

View File

@@ -5,6 +5,7 @@ import Button from '@/ui/v2/Button';
import ArrowSquareOutIcon from '@/ui/v2/icons/ArrowSquareOutIcon';
import Link from '@/ui/v2/Link';
import Text from '@/ui/v2/Text';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { useConfirmProvidersUpdatedMutation } from '@/utils/__generated__/graphql';
import { useState } from 'react';
@@ -27,7 +28,9 @@ export default function ProvidersUpdatedAlert() {
{
loading: 'Confirming...',
success: 'Your settings have been updated successfully.',
error: 'An error occurred while trying to confirm the message.',
error: getServerError(
'An error occurred while trying to confirm the message.',
),
},
getToastStyleProps(),
);
@@ -55,7 +58,7 @@ export default function ProvidersUpdatedAlert() {
}
return (
<Alert className="grid items-center grid-flow-row gap-2 p-4 place-items-center lg:grid-flow-col lg:place-content-between bg-amber-500">
<Alert className="grid grid-flow-row place-items-center items-center gap-2 bg-amber-500 p-4 lg:grid-flow-col lg:place-content-between">
<div className="grid grid-flow-row gap-1 text-left">
<Text className="font-semibold">
Please update the Redirect URL for all providers being used
@@ -74,7 +77,7 @@ export default function ProvidersUpdatedAlert() {
className="font-medium"
>
Read the discussion here.
<ArrowSquareOutIcon className="w-4 h-4 ml-1" />
<ArrowSquareOutIcon className="ml-1 h-4 w-4" />
</Link>
</Text>
</div>

View File

@@ -1,9 +1,10 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import {
GetSmsSettingsDocument,
useSignInMethodsQuery,
useUpdateAppMutation,
GetSignInMethodsDocument,
useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -11,79 +12,119 @@ import Input from '@/ui/v2/Input';
import Option from '@/ui/v2/Option';
import Select from '@/ui/v2/Select';
import Text from '@/ui/v2/Text';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import Image from 'next/image';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup';
export interface SMSSettingsFormValues {
authSmsTwilioAccountSid: string;
authSmsTwilioAuthToken: string;
authSmsTwilioMessagingServiceId: string;
authSmsPasswordlessEnabled: boolean;
}
const validationSchema = Yup.object({
accountSid: Yup.string().label('Account SID').when('enabled', {
is: true,
then: Yup.string().required(),
}),
authToken: Yup.string().label('Auth Token').when('enabled', {
is: true,
then: Yup.string().required(),
}),
messagingServiceId: Yup.string()
.label('Messaging Service ID')
.when('enabled', {
is: true,
then: Yup.string().required(),
}),
enabled: Yup.boolean().label('Enabled'),
});
export type SMSSettingsFormValues = Yup.InferType<typeof validationSchema>;
export default function SMSSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation({
refetchQueries: [GetSmsSettingsDocument],
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading } = useSignInMethodsQuery({
variables: {
id: currentApplication.id,
},
const { data, error, loading } = useGetSignInMethodsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
onError: (error) => {
throw error;
},
});
const { accountSid, authToken, messagingServiceId } =
data?.config?.provider?.sms || {};
const { enabled } = data?.config?.auth?.method?.smsPasswordless || {};
const form = useForm<SMSSettingsFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
authSmsTwilioAccountSid: data.app.authSmsTwilioAccountSid,
authSmsTwilioAuthToken: data.app.authSmsTwilioAuthToken,
authSmsTwilioMessagingServiceId: data.app.authSmsTwilioMessagingServiceId,
authSmsPasswordlessEnabled: data.app.authSmsPasswordlessEnabled,
accountSid: accountSid || '',
authToken: authToken || '',
messagingServiceId: messagingServiceId || '',
enabled: enabled || false,
},
resolver: yupResolver(validationSchema),
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading SMS settings..."
label="Loading settings for the SMS provider..."
className="justify-center"
/>
);
}
if (error) {
throw error;
}
const { register, formState, watch } = form;
const authSmsPasswordlessEnabled = watch('authSmsPasswordlessEnabled');
const authSmsPasswordlessEnabled = watch('enabled');
const handleSMSSettingsChange = async (values: SMSSettingsFormValues) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: {
...values,
appId: currentApplication.id,
config: {
provider: {
sms: {
accountSid: values.accountSid,
authToken: values.authToken,
messagingServiceId: values.messagingServiceId,
},
},
auth: {
method: {
smsPasswordless: {
enabled: values.enabled,
},
},
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `SMS settings are being updated...`,
success: `SMS settings have been updated successfully.`,
error: `An error occurred while trying to update SMS settings.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `SMS settings are being updated...`,
success: `SMS settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update SMS settings.`,
),
},
getToastStyleProps(),
);
form.reset(values);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
@@ -92,12 +133,13 @@ export default function SMSSettings() {
<SettingsContainer
title="Phone Number (SMS)"
description="Allow users to sign in with Phone Number (SMS)."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
submitButton: {
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
switchId="authSmsPasswordlessEnabled"
enabled={authSmsPasswordlessEnabled}
switchId="enabled"
showSwitch
docsLink="https://docs.nhost.io/authentication/sign-in-with-phone-number-sms"
docsTitle="how to sign in users with a phone number (SMS)"
@@ -136,34 +178,40 @@ export default function SMSSettings() {
</Option>
</Select>
<Input
{...register('authSmsTwilioAccountSid')}
name="authSmsTwilioAccountSid"
id="authSmsTwilioAccountSid"
{...register('accountSid')}
name="accountSid"
id="accountSid"
placeholder="Account SID"
className="col-span-2 lg:col-span-1"
fullWidth
hideEmptyHelperText
label="Account SID"
error={!!formState.errors?.accountSid}
helperText={formState.errors?.accountSid?.message}
/>
<Input
{...register('authSmsTwilioAuthToken')}
name="authSmsTwilioAuthToken"
id="authSmsTwilioAuthToken"
{...register('authToken')}
name="authToken"
id="authToken"
placeholder="Auth Token"
className="col-span-2 lg:col-span-1"
fullWidth
hideEmptyHelperText
label="Auth Token"
error={!!formState.errors?.authToken}
helperText={formState.errors?.authToken?.message}
/>
<Input
{...register('authSmsTwilioMessagingServiceId')}
name="authSmsTwilioMessagingServiceId"
id="authSmsTwilioMessagingServiceId"
{...register('messagingServiceId')}
name="messagingServiceId"
id="messagingServiceId"
placeholder="Messaging Service ID"
className="col-span-2 lg:col-span-1"
fullWidth
hideEmptyHelperText
label="Messaging Service ID"
error={!!formState.errors?.messagingServiceId}
helperText={formState.errors?.messagingServiceId?.message}
/>
</SettingsContainer>
</Form>

View File

@@ -1,10 +1,14 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings, {
baseProviderValidationSchema,
} from '@/components/settings/signInMethods/BaseProviderSettings';
import { useUI } from '@/context/UIContext';
import {
useSignInMethodsQuery,
useUpdateAppMutation,
GetSignInMethodsDocument,
useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -14,36 +18,43 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
export default function SpotifyProviderSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({
variables: {
id: currentApplication.id,
},
const { data, loading, error } = useGetSignInMethodsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { clientId, clientSecret, enabled } =
data?.config?.auth?.method?.oauth?.spotify || {};
const form = useForm<BaseProviderSettingsFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
authClientId: data?.app?.authSpotifyClientId,
authClientSecret: data?.app?.authSpotifyClientSecret,
authEnabled: data?.app?.authSpotifyEnabled,
clientId: clientId || '',
clientSecret: clientSecret || '',
enabled: enabled || false,
},
resolver: yupResolver(baseProviderValidationSchema),
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading Spotify settings..."
label="Loading settings for Spotify..."
className="justify-center"
/>
);
@@ -54,33 +65,46 @@ export default function SpotifyProviderSettings() {
}
const { formState, watch } = form;
const authEnabled = watch('authEnabled');
const authEnabled = watch('enabled');
const handleProviderUpdate = async (
values: BaseProviderSettingsFormValues,
) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: {
authSpotifyClientId: values.authClientId,
authSpotifyClientSecret: values.authClientSecret,
authSpotifyEnabled: values.authEnabled,
appId: currentApplication.id,
config: {
auth: {
method: {
oauth: {
spotify: {
...values,
scope: [],
},
},
},
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `Spotify settings are being updated...`,
success: `Spotify settings have been updated successfully.`,
error: `An error occurred while trying to update the project's Spotify settings.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `Spotify settings are being updated...`,
success: `Spotify settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update the project's Spotify settings.`,
),
},
getToastStyleProps(),
);
form.reset(values);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
@@ -89,22 +113,23 @@ export default function SpotifyProviderSettings() {
<SettingsContainer
title="Spotify"
description="Allow users to sign in with Spotify."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
submitButton: {
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-spotify"
docsTitle="how to sign in users with Spotify"
icon="/assets/brands/spotify.svg"
switchId="authEnabled"
switchId="enabled"
showSwitch
enabled={authEnabled}
className={twMerge(
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden',
)}
>
<BaseProviderSettings />
<BaseProviderSettings providerName="spotify" />
<Input
name="redirectUrl"
id="redirectUrl"
@@ -136,7 +161,7 @@ export default function SpotifyProviderSettings() {
);
}}
>
<CopyIcon className="w-4 h-4" />
<CopyIcon className="h-4 w-4" />
</IconButton>
</InputAdornment>
}

View File

@@ -1,10 +1,14 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings, {
baseProviderValidationSchema,
} from '@/components/settings/signInMethods/BaseProviderSettings';
import { useUI } from '@/context/UIContext';
import {
useSignInMethodsQuery,
useUpdateAppMutation,
GetSignInMethodsDocument,
useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -14,7 +18,9 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { useTheme } from '@mui/material';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
@@ -22,30 +28,35 @@ import { twMerge } from 'tailwind-merge';
export default function TwitchProviderSettings() {
const theme = useTheme();
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({
variables: {
id: currentApplication.id,
},
const { data, loading, error } = useGetSignInMethodsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { clientId, clientSecret, enabled } =
data?.config?.auth?.method?.oauth?.twitch || {};
const form = useForm<BaseProviderSettingsFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
authClientId: data?.app?.authTwitchClientId,
authClientSecret: data?.app?.authTwitchClientSecret,
authEnabled: data?.app?.authTwitchEnabled,
clientId: clientId || '',
clientSecret: clientSecret || '',
enabled: enabled || false,
},
resolver: yupResolver(baseProviderValidationSchema),
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading Twitch Settings..."
label="Loading settings for Twitch..."
className="justify-center"
/>
);
@@ -56,33 +67,46 @@ export default function TwitchProviderSettings() {
}
const { formState, watch } = form;
const authEnabled = watch('authEnabled');
const authEnabled = watch('enabled');
const handleProviderUpdate = async (
values: BaseProviderSettingsFormValues,
) => {
const updateAppMutation = updateApp({
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication.id,
app: {
authTwitchClientId: values.authClientId,
authTwitchClientSecret: values.authClientSecret,
authTwitchEnabled: values.authEnabled,
appId: currentApplication.id,
config: {
auth: {
method: {
oauth: {
twitch: {
...values,
scope: [],
},
},
},
},
},
},
});
await toast.promise(
updateAppMutation,
{
loading: `Twitch settings are being updated...`,
success: `Twitch settings have been updated successfully.`,
error: `An error occurred while trying to update the project's Twitch settings.`,
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: `Twitch settings are being updated...`,
success: `Twitch settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update the project's Twitch settings.`,
),
},
getToastStyleProps(),
);
form.reset(values);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
@@ -91,9 +115,11 @@ export default function TwitchProviderSettings() {
<SettingsContainer
title="Twitch"
description="Allow users to sign in with Twitch."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
submitButton: {
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-twitch"
docsTitle="how to sign in users with Twitch"
@@ -102,15 +128,14 @@ export default function TwitchProviderSettings() {
? '/assets/brands/light/twitch.svg'
: '/assets/brands/twitch.svg'
}
switchId="authEnabled"
switchId="enabled"
showSwitch
enabled={authEnabled}
className={twMerge(
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden',
)}
>
<BaseProviderSettings />
<BaseProviderSettings providerName="twitch" />
<Input
name="redirectUrl"
id="redirectUrl"
@@ -142,7 +167,7 @@ export default function TwitchProviderSettings() {
);
}}
>
<CopyIcon className="w-4 h-4" />
<CopyIcon className="h-4 w-4" />
</IconButton>
</InputAdornment>
}

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