Compare commits

..

55 Commits

Author SHA1 Message Date
Szilárd Dóró
8857314e22 Merge pull request #1237 from nhost/changeset-release/main
chore: update versions
2022-11-29 20:57:22 +01:00
github-actions[bot]
85f1c4a98e chore: update versions 2022-11-29 19:33:07 +00:00
Pilou
efa6b5755d Merge pull request #1248 from nhost/chore/remove-sort-lint-rule
chore: remove eslint plugin simple-import-sort
2022-11-29 20:31:24 +01:00
Pierre-Louis Mercereau
2b19416787 chore: remove eslint plugin simple-import-sort 2022-11-29 20:28:13 +01:00
Szilárd Dóró
4e5d43f300 Merge pull request #1245 from nhost/chore/settings-roles-and-permissions-refactor
chore(dashboard): refactor Roles and Permissions settings sections
2022-11-29 17:00:12 +01:00
Szilárd Dóró
db342f453e chore(dashboard): add changesets 2022-11-29 16:15:45 +01:00
Szilárd Dóró
54386a3b56 chore(dashboard): simplify env var dialog props 2022-11-29 16:14:20 +01:00
Pilou
ff40b99f84 Merge pull request #1242 from nhost/fix/set-access-token
fix: 🐛 Distribute the access token to all the sub-clients
2022-11-29 16:08:00 +01:00
Szilárd Dóró
33f8f1d78a Merge remote-tracking branch 'origin/main' into chore/settings-roles-and-permissions-refactor 2022-11-29 16:07:19 +01:00
Szilárd Dóró
c50fe47ab4 Merge pull request #1215 from nhost/feat/settings-environment-variables
feat(dashboard): add Environment Variables page
2022-11-29 16:04:09 +01:00
Pilou
0580f832c8 Merge pull request #1239 from nhost/chore/version-bumps
chore: 🤖 bump to axios 1.2.0
2022-11-29 15:59:38 +01:00
Szilárd Dóró
7d1eb099c0 fix(dashboard): correct system environment variables 2022-11-29 15:53:11 +01:00
Pierre-Louis Mercereau
e15322296b chore: lint 2022-11-29 15:52:25 +01:00
Pierre-Louis Mercereau
91a2bf905b refactor: simplify 2022-11-29 15:37:31 +01:00
Szilárd Dóró
0f9393fe27 fix(dashboard): change system env vars layout 2022-11-29 15:10:37 +01:00
Szilárd Dóró
aebb822549 feat(dashboard): extend system variables 2022-11-29 15:05:58 +01:00
Pierre-Louis Mercereau
1e2be6fadf Merge branch 'main' into chore/version-bumps 2022-11-29 13:41:00 +01:00
Pilou
aafbf5173d Merge pull request #1240 from nhost/test/nhost-js
test: 💍 `nhost.graphql.request` as an authenticated user
2022-11-29 13:00:46 +01:00
Pierre-Louis Mercereau
01e13e2f8c chore: 🤖 lint 2022-11-29 12:52:34 +01:00
Pierre-Louis Mercereau
4364647501 fix: 🐛 accept any encoding 2022-11-29 12:16:35 +01:00
Pierre-Louis Mercereau
ef117c284e fix: 🐛 Distribute the access token to all the sub-clients 2022-11-29 11:49:59 +01:00
Szilárd Dóró
3f919c0a80 chore(dashboard): refactor system variable layout 2022-11-29 11:30:31 +01:00
Pierre-Louis Mercereau
49e447e7b7 test: 💍 nhost.graphql.request as an authenticated user 2022-11-29 11:04:56 +01:00
Pierre-Louis Mercereau
66b4f3d0be chore: 🤖 bump to axios 1.2.0 2022-11-29 10:29:51 +01:00
Szilárd Dóró
aa7fdafe8b chore(dashboard): split Permission Variable dialog 2022-11-28 21:45:15 +01:00
Pilou
7d6de3b289 Merge pull request #1234 from nhost/chore/bump-hasura-auth
chore: 🤖 bump hasura-auth version
2022-11-28 19:51:10 +01:00
Pilou
57e41f77a9 Merge pull request #1238 from nhost/ci/turbo-team
ci: hardcode turbo team
2022-11-28 19:36:48 +01:00
Pierre-Louis Mercereau
f5c2a0ef4f ci: hardcode turbo team 2022-11-28 18:38:03 +01:00
Szilárd Dóró
d52bc8cca5 chore(dashboard): refactor role delete and default 2022-11-28 16:58:28 +01:00
Pilou
04a3e4c965 Merge pull request #1236 from nhost/refactor/state-snapshots
refactor: use state snapshots
2022-11-28 16:52:27 +01:00
Szilárd Dóró
853c0c5775 chore(dashboard): split RoleForm into multiple forms 2022-11-28 16:44:02 +01:00
Pierre-Louis Mercereau
2e6923dc73 refactor: use state snapshots 2022-11-28 16:41:53 +01:00
Pierre-Louis Mercereau
7d6d70d0c7 chore: 🤖 bump hasura-auth version 2022-11-28 15:59:04 +01:00
Szilárd Dóró
7a2100cc17 fix(dashboard): do not try to autofocus a disable input 2022-11-28 15:19:42 +01:00
Szilárd Dóró
5d55f3fa60 chore(dashboard): remove async form loading 2022-11-28 15:13:16 +01:00
Szilárd Dóró
8b0c44a93c chore(dashboard): rename env var forms
- chore(dashboard): do not convert env vars to uppercase by default
2022-11-28 14:59:27 +01:00
Szilárd Dóró
e0cc7cce0a chore(dashboard): update env var dialog subtitle 2022-11-28 13:40:25 +01:00
Szilárd Dóró
6e7d5e0dd4 fix(dashboard): change env var name placeholder 2022-11-28 13:19:17 +01:00
Szilárd Dóró
54c143ebf6 fix(dashboard): allow lowercase letters, convert variable to uppercase 2022-11-28 13:18:16 +01:00
Szilárd Dóró
8b9fa0b150 feat(dashboard): env var validation 2022-11-28 13:05:42 +01:00
Szilárd Dóró
c3bb79e1dd chore(dashboard): refactor env var forms 2022-11-28 11:15:38 +01:00
Szilárd Dóró
128d21e4ec Merge branch 'main' into feat/settings-environment-variables 2022-11-28 09:21:56 +01:00
Szilárd Dóró
40e503c356 Merge pull request #1227 from nhost/fix/vercel-deployment-token
fix(changesets): add Vercel deployment token to CI
2022-11-28 08:28:33 +01:00
Szilárd Dóró
d007e0ade8 chore(changesets): do not create dedicated env var for deploy token 2022-11-28 08:26:11 +01:00
Pilou
fa32513ba7 Merge pull request #1231 from nhost/docs/docker-compose-dashboard
docs: run hasura console from the cli to run the dashboard
2022-11-27 22:50:14 +01:00
Pierre-Louis Mercereau
8893d9e010 docs: run hasura console from the cli to run the dashboard 2022-11-27 21:37:37 +01:00
Szilárd Dóró
81d2fd865c fix(changesets): add Vercel deployment token to CI 2022-11-27 19:51:07 +01:00
Szilárd Dóró
0c748e6ee6 feat(dashboard): add env var management 2022-11-25 16:57:22 +01:00
Szilárd Dóró
7167170663 feat(dashboard): add env var management dialog 2022-11-25 15:47:34 +01:00
Szilárd Dóró
0f77de2dd0 feat(dashboard): add menu to env vars 2022-11-25 15:13:54 +01:00
Szilárd Dóró
6ae91e48d1 feat(dashboard): list env vars 2022-11-25 14:52:47 +01:00
Szilárd Dóró
85d9596956 Merge branch 'main' into feat/settings-environment-variables 2022-11-25 14:08:46 +01:00
Szilárd Dóró
2ca193ccf3 chore(dashboard): improve inline secrets 2022-11-25 14:07:44 +01:00
Szilárd Dóró
ab8e12003d feat(dashboard): show JWT secret 2022-11-25 14:02:48 +01:00
Szilárd Dóró
41cc3dc5d0 feat(dashboard): settings page for Environment Variables 2022-11-25 13:50:08 +01:00
117 changed files with 3109 additions and 1745 deletions

View File

@@ -13,7 +13,7 @@ on:
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TEAM: nhost
DASHBOARD_PACKAGE: '@nhost/dashboard'
jobs:
@@ -31,8 +31,8 @@ jobs:
- name: Install Node and dependencies
uses: ./.github/actions/install-dependencies
with:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
TURBO_TEAM: ${{ env.TURBO_TEAM }}
- name: Create PR or Publish release
id: changesets
uses: changesets/action@v1
@@ -136,8 +136,8 @@ jobs:
- name: Install Node and dependencies
uses: ./.github/actions/install-dependencies
with:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
TURBO_TEAM: ${{ env.TURBO_TEAM }}
- name: Setup Vercel CLI
run: pnpm add -g vercel
- name: Trigger a Vercel deployment
@@ -145,6 +145,6 @@ jobs:
VERCEL_ORG_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_VERCEL_PROJECT_ID }}
run: |
vercel pull --environment=production
vercel build --prod
vercel deploy --prebuilt --prod
vercel pull --environment=production --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
vercel build --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
vercel deploy --prebuilt --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,24 @@
# @nhost/dashboard
## 0.7.0
### Minor Changes
- db342f45: chore(dashboard): refactor Roles and Permissions settings sections
- 8b9fa0b1: feat(dashboard): add Environment Variables page
### Patch Changes
- Updated dependencies [66b4f3d0]
- Updated dependencies [2e6923dc]
- Updated dependencies [ef117c28]
- Updated dependencies [aebb8225]
- @nhost/core@0.9.4
- @nhost/nhost-js@1.6.2
- @nhost/nextjs@1.9.1
- @nhost/react@0.15.1
- @nhost/react-apollo@4.9.1
## 0.6.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/dashboard",
"version": "0.6.0",
"version": "0.7.0",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -34,11 +34,11 @@
"@mui/material": "^5.10.14",
"@mui/system": "^5.10.14",
"@mui/x-date-pickers": "^5.0.8",
"@nhost/core": "^0.9.3",
"@nhost/nextjs": "^1.9.0",
"@nhost/nhost-js": "^1.6.1",
"@nhost/react": "^0.15.0",
"@nhost/react-apollo": "^4.9.0",
"@nhost/core": "^0.9.4",
"@nhost/nextjs": "^1.9.1",
"@nhost/nhost-js": "^1.6.2",
"@nhost/react": "^0.15.1",
"@nhost/react-apollo": "^4.9.1",
"@segment/snippet": "^4.15.3",
"@tailwindcss/forms": "^0.5.3",
"@tanstack/react-query": "^4.16.1",

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,7 @@ export interface SettingsContainerProps
/**
* Props for the primary action.
*
* @deprecated Use `slotProps.submitButtonProps` instead.
* @deprecated Use `slotProps.submitButton` instead.
*/
primaryActionButtonProps?: ButtonProps;
/**
@@ -75,12 +75,6 @@ export interface SettingsContainerProps
* Custom class names passed to the children wrapper element.
*/
className?: string;
/**
* Props to be passed to the Switch component.
*
* @deprecated Use `slotProps.switchProps` instead.
*/
switchProps?: SwitchProps;
/**
* Props to be passed to different slots inside the component.
*/
@@ -88,19 +82,19 @@ export interface SettingsContainerProps
/**
* Props to be passed to the root element.
*/
rootProps?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
root?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
/**
* Props to be passed to the `<Switch />` component.
*/
switchProps?: SwitchProps;
switch?: SwitchProps;
/**
* Props to be passed to the footer element.
*/
submitButtonProps?: ButtonProps;
submitButton?: ButtonProps;
/**
* Props to be passed to the footer element.
*/
footerProps?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
footer?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
};
}
@@ -118,16 +112,15 @@ export default function SettingsContainer({
switchId,
showSwitch = false,
rootClassName,
switchProps: oldSwitchProps,
docsTitle,
slotProps: { rootProps, switchProps, submitButtonProps, footerProps } = {},
slotProps: { root, switch: switchSlot, submitButton, footer } = {},
}: SettingsContainerProps) {
return (
<div
{...rootProps}
{...root}
className={twMerge(
'grid grid-flow-row gap-4 rounded-lg border-1 border-gray-200 bg-white py-4',
rootProps?.className || rootClassName,
root?.className || rootClassName,
)}
>
<div className="grid grid-flow-col place-content-between gap-3 px-4">
@@ -157,14 +150,14 @@ export default function SettingsContainer({
checked={enabled}
onChange={(e) => onEnabledChange(e.target.checked)}
className="self-center"
{...(switchProps || oldSwitchProps)}
{...switchSlot}
/>
)}
{switchId && showSwitch && (
<ControlledSwitch
className="self-center"
name={switchId}
{...(switchProps || oldSwitchProps)}
{...switchSlot}
/>
)}
</div>
@@ -174,11 +167,11 @@ export default function SettingsContainer({
</div>
<div
{...footerProps}
{...footer}
className={twMerge(
'grid grid-flow-col items-center gap-x-2 border-t border-gray-200 px-4 pt-3.5',
docsLink ? 'place-content-between' : 'justify-end',
footerProps?.className,
footer?.className,
)}
>
{docsLink && (
@@ -201,17 +194,17 @@ export default function SettingsContainer({
<Button
variant={
(submitButtonProps || primaryActionButtonProps)?.disabled
(submitButton || primaryActionButtonProps)?.disabled
? 'outlined'
: 'contained'
}
color={
(submitButtonProps || primaryActionButtonProps)?.disabled
(submitButton || primaryActionButtonProps)?.disabled
? 'secondary'
: 'primary'
}
type="submit"
{...(submitButtonProps || primaryActionButtonProps)}
{...(submitButton || primaryActionButtonProps)}
>
{submitButtonText}
</Button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,13 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import type { CustomClaim } from '@/types/application';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import { yupResolver } from '@hookform/resolvers/yup';
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import * as Yup from 'yup';
export interface PermissionVariableFormValues {
export interface BasePermissionVariableFormValues {
/**
* Permission variable key.
*/
@@ -20,20 +18,11 @@ export interface PermissionVariableFormValues {
value: string;
}
export interface PermissionVariableFormProps {
/**
* List of available permission variables.
*/
availableVariables: CustomClaim[];
/**
* Original permission variable. This is defined only if the form was
* opened to edit an existing permission variable.
*/
originalVariable?: CustomClaim;
export interface BasePermissionVariableFormProps {
/**
* Function to be called when the form is submitted.
*/
onSubmit: (values: PermissionVariableFormValues) => void;
onSubmit: (values: BasePermissionVariableFormValues) => void;
/**
* Function to be called when the operation is cancelled.
*/
@@ -46,62 +35,38 @@ export interface PermissionVariableFormProps {
submitButtonText?: string;
}
const validationSchema = Yup.object({
export const basePermissionVariableValidationSchema = Yup.object({
key: Yup.string().required('This field is required.'),
value: Yup.string().required('This field is required.'),
});
export default function PermissionVariableForm({
availableVariables,
originalVariable,
export default function BasePermissionVariableForm({
onSubmit,
onCancel,
submitButtonText = 'Save',
}: PermissionVariableFormProps) {
}: BasePermissionVariableFormProps) {
const { onDirtyStateChange } = useDialog();
const form = useForm<PermissionVariableFormValues>({
defaultValues: {
key: originalVariable?.key || '',
value: originalVariable?.value || '',
},
resolver: yupResolver(validationSchema),
});
const form = useFormContext<BasePermissionVariableFormValues>();
const {
register,
setError,
formState: { dirtyFields, errors, 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]);
async function handleSubmit(values: PermissionVariableFormValues) {
if (
availableVariables.some(
(variable) =>
variable.key === values.key && variable.key !== originalVariable?.key,
)
) {
setError('key', { message: 'This key is already in use.' });
return;
}
onSubmit?.(values);
}
return (
<FormProvider {...form}>
<Form
onSubmit={handleSubmit}
className="grid grid-flow-row gap-4 px-6 pb-6"
>
<div className="grid grid-flow-row gap-2 px-6 pb-6">
<Text variant="subtitle1" component="span">
Enter the field name and the path you want to use in this permission
variable.
</Text>
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
<Input
{...register('key', {
onChange: (event) => {
@@ -171,6 +136,6 @@ export default function PermissionVariableForm({
</Button>
</div>
</Form>
</FormProvider>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,5 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import type { PermissionVariableFormValues } from '@/components/settings/permissions/PermissionVariableForm';
import SettingsContainer from '@/components/settings/SettingsContainer';
import useLeaveConfirm from '@/hooks/common/useLeaveConfirm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { CustomClaim } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -17,13 +14,13 @@ import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
import Tooltip from '@/ui/v2/Tooltip';
import getPermissionVariables from '@/utils/settings/getPermissionVariablesArray';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetAppCustomClaimsQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { Fragment, useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { Fragment } from 'react';
import toast from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
@@ -34,25 +31,6 @@ export interface PermissionVariableSettingsFormValues {
authJwtCustomClaims: CustomClaim[];
}
function getPermissionVariables(customClaims?: Record<string, any>) {
const systemClaims: CustomClaim[] = [
{ key: 'User-Id', value: 'id', isSystemClaim: true },
];
if (!customClaims) {
return systemClaims;
}
return systemClaims.concat(
Object.keys(customClaims)
.sort()
.map((key) => ({
key,
value: customClaims[key],
})),
);
}
export default function PermissionVariableSettings() {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { openDialog, openAlertDialog } = useDialog();
@@ -67,29 +45,6 @@ export default function PermissionVariableSettings() {
refetchQueries: ['getAppCustomClaims'],
});
const form = useForm<PermissionVariableSettingsFormValues>({
defaultValues: {
authJwtCustomClaims: getPermissionVariables(
data?.app?.authJwtCustomClaims,
),
},
});
const {
reset,
formState: { dirtyFields },
} = form;
useLeaveConfirm({ isDirty: Object.keys(dirtyFields).length > 0 });
useEffect(() => {
reset({
authJwtCustomClaims: getPermissionVariables(
data?.app?.authJwtCustomClaims,
),
});
}, [data, reset]);
if (loading) {
return (
<ActivityIndicator delay={1000} label="Loading permission variables..." />
@@ -100,116 +55,22 @@ export default function PermissionVariableSettings() {
throw error;
}
const { setValue, formState, watch } = form;
const availableCustomClaims = watch('authJwtCustomClaims');
async function handleDeleteVariable({ key }: CustomClaim) {
const filteredCustomClaims = Object.keys(
data?.app?.authJwtCustomClaims,
).filter((customClaimKey) => customClaimKey !== key);
function handleAddVariable({ key, value }: PermissionVariableFormValues) {
setValue(
'authJwtCustomClaims',
[...availableCustomClaims, { key, value }],
{ shouldDirty: true },
);
}
function handleEditVariable(
{ key, value }: PermissionVariableFormValues,
originalVariable: CustomClaim,
) {
const originalIndex = availableCustomClaims.findIndex(
(customClaim) => customClaim.key === originalVariable.key,
);
const updatedVariables = availableCustomClaims.map((customClaim, index) =>
index === originalIndex ? { key, value } : customClaim,
);
setValue('authJwtCustomClaims', updatedVariables, { shouldDirty: true });
}
function handleRemoveVariable({ key }: CustomClaim) {
const filteredCustomClaims = availableCustomClaims.filter(
(customClaim) => customClaim.key !== key,
);
setValue('authJwtCustomClaims', filteredCustomClaims, {
shouldDirty: true,
});
}
function handleOpenCreator() {
openDialog('MANAGE_PERMISSION_VARIABLE', {
title: (
<span className="grid grid-flow-row">
<span>Add Permission Variable</span>
<Text variant="subtitle1" component="span">
Enter the field name and the path you want to use in this permission
variable.
</Text>
</span>
),
payload: {
availableVariables: availableCustomClaims,
submitButtonText: 'Add',
onSubmit: handleAddVariable,
},
props: { PaperProps: { className: 'max-w-sm' } },
});
}
function handleOpenEditor(originalVariable: CustomClaim) {
openDialog('MANAGE_PERMISSION_VARIABLE', {
title: (
<span className="grid grid-flow-row">
<span>Edit Permission Variable</span>
<Text variant="subtitle1" component="span">
Enter the field name and the path you want to use in this permission
variable.
</Text>
</span>
),
payload: {
availableVariables: availableCustomClaims,
originalVariable,
onSubmit: (values: PermissionVariableFormValues) =>
handleEditVariable(values, originalVariable),
},
props: { PaperProps: { className: 'max-w-sm' } },
});
}
function handleConfirmRemove(originalVariable: CustomClaim) {
openAlertDialog({
title: 'Remove Permission Variable',
payload: (
<Text>
Are you sure you want to remove the &quot;
<strong>X-Hasura-{originalVariable.key}</strong>&quot; permission
variable?
</Text>
),
props: {
onPrimaryAction: () => handleRemoveVariable(originalVariable),
primaryButtonColor: 'error',
primaryButtonText: 'Remove',
},
});
}
async function handleSubmit(values: PermissionVariableSettingsFormValues) {
const updateAppPromise = updateApp({
variables: {
id: currentApplication.id,
id: currentApplication?.id,
app: {
authJwtCustomClaims: values.authJwtCustomClaims
.filter((customClaim) => !customClaim.isSystemClaim)
.reduce(
(authJwtCustomClaims, claim) => ({
...authJwtCustomClaims,
[claim.key]: claim.value,
}),
{},
),
authJwtCustomClaims: filteredCustomClaims.reduce(
(customClaims, currentKey) => ({
...customClaims,
[currentKey]: data?.app?.authJwtCustomClaims[currentKey],
}),
{},
),
},
},
});
@@ -217,140 +78,172 @@ export default function PermissionVariableSettings() {
await toast.promise(
updateAppPromise,
{
loading: 'Updating permission variables...',
success: 'Permission variables have been updated successfully.',
error: 'An error occurred while updating permission variables.',
loading: 'Deleting permission variable...',
success: 'Permission variable has been deleted successfully.',
error: 'An error occurred while trying to delete permission variable.',
},
toastStyleProps,
);
}
function handleOpenCreator() {
openDialog('CREATE_PERMISSION_VARIABLE', {
title: 'Create Permission Variable',
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'max-w-sm' },
},
});
}
function handleOpenEditor(originalVariable: CustomClaim) {
openDialog('EDIT_PERMISSION_VARIABLE', {
title: 'Edit Permission Variable',
payload: { originalVariable },
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'max-w-sm' },
},
});
}
function handleConfirmDelete(originalVariable: CustomClaim) {
openAlertDialog({
title: 'Delete Permission Variable',
payload: (
<Text>
Are you sure you want to delete the &quot;
<strong>X-Hasura-{originalVariable.key}</strong>&quot; permission
variable? This cannot be undone.
</Text>
),
props: {
onPrimaryAction: () => handleDeleteVariable(originalVariable),
primaryButtonColor: 'error',
primaryButtonText: 'Delete',
},
});
}
const availablePermissionVariables = getPermissionVariables(
data?.app?.authJwtCustomClaims,
);
return (
<FormProvider {...form}>
<Form onSubmit={handleSubmit}>
<SettingsContainer
title="Permission Variables"
description="Permission variables are used to define permission rules in the GraphQL API."
docsLink="https://docs.nhost.io/graphql/permissions"
rootClassName="gap-0"
className="px-0 my-2"
slotProps={{
submitButtonProps: {
loading: formState.isSubmitting,
disabled: !formState.isValid || !formState.isDirty,
},
}}
>
<div className="grid grid-cols-2 border-b-1 border-gray-200 px-4 py-3">
<Text className="font-medium">Field name</Text>
<Text className="font-medium">Path</Text>
</div>
<SettingsContainer
title="Permission Variables"
description="Permission variables are used to define permission rules in the GraphQL API."
docsLink="https://docs.nhost.io/graphql/permissions"
rootClassName="gap-0"
className="px-0 my-2"
slotProps={{ submitButton: { className: 'invisible' } }}
>
<div className="grid grid-cols-2 border-b-1 border-gray-200 px-4 py-3">
<Text className="font-medium">Field name</Text>
<Text className="font-medium">Path</Text>
</div>
<div className="grid grid-flow-row gap-2">
<List>
{availableCustomClaims.map((customClaim, index) => (
<Fragment key={customClaim.key}>
<ListItem.Root
className="px-4 grid grid-cols-2"
secondaryAction={
<Dropdown.Root>
<Tooltip
title={
customClaim.isSystemClaim
? "You can't edit system permission variables"
: ''
}
placement="right"
disableHoverListener={!customClaim.isSystemClaim}
hasDisabledChildren={customClaim.isSystemClaim}
className="absolute right-4 top-1/2 -translate-y-1/2"
>
<Dropdown.Trigger asChild hideChevron>
<IconButton
variant="borderless"
color="secondary"
disabled={customClaim.isSystemClaim}
>
<DotsVerticalIcon />
</IconButton>
</Dropdown.Trigger>
</Tooltip>
<Dropdown.Content
menu
PaperProps={{ className: 'w-32' }}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Dropdown.Item
onClick={() => handleOpenEditor(customClaim)}
>
<Text className="font-medium">Edit</Text>
</Dropdown.Item>
<Divider component="li" />
<Dropdown.Item
onClick={() => handleConfirmRemove(customClaim)}
>
<Text
className="font-medium"
sx={{
color: (theme) => theme.palette.error.main,
}}
>
Remove
</Text>
</Dropdown.Item>
</Dropdown.Content>
</Dropdown.Root>
}
>
<ListItem.Text
primary={
<>
X-Hasura-{customClaim.key}{' '}
{customClaim.isSystemClaim && (
<LockIcon className="w-4 h-4" />
)}
</>
<div className="grid grid-flow-row gap-2">
<List>
{availablePermissionVariables.map((customClaim, index) => (
<Fragment key={customClaim.key}>
<ListItem.Root
className="px-4 grid grid-cols-2"
secondaryAction={
<Dropdown.Root>
<Tooltip
title={
customClaim.isSystemClaim
? "You can't edit system permission variables"
: ''
}
/>
placement="right"
disableHoverListener={!customClaim.isSystemClaim}
hasDisabledChildren={customClaim.isSystemClaim}
className="absolute right-4 top-1/2 -translate-y-1/2"
>
<Dropdown.Trigger asChild hideChevron>
<IconButton
variant="borderless"
color="secondary"
disabled={customClaim.isSystemClaim}
>
<DotsVerticalIcon />
</IconButton>
</Dropdown.Trigger>
</Tooltip>
<Text className="font-medium">
user.{customClaim.value}
</Text>
</ListItem.Root>
<Dropdown.Content
menu
PaperProps={{ className: 'w-32' }}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Dropdown.Item
onClick={() => handleOpenEditor(customClaim)}
>
<Text className="font-medium">Edit</Text>
</Dropdown.Item>
<Divider
component="li"
className={twMerge(
index === availableCustomClaims.length - 1
? '!mt-4'
: '!my-4',
)}
/>
</Fragment>
))}
</List>
<Divider component="li" />
<Button
className="justify-self-start mx-4"
variant="borderless"
startIcon={<PlusIcon />}
onClick={handleOpenCreator}
>
Add Permission Variable
</Button>
</div>
</SettingsContainer>
</Form>
</FormProvider>
<Dropdown.Item
onClick={() => handleConfirmDelete(customClaim)}
>
<Text
className="font-medium"
sx={{
color: (theme) => theme.palette.error.main,
}}
>
Delete
</Text>
</Dropdown.Item>
</Dropdown.Content>
</Dropdown.Root>
}
>
<ListItem.Text
primary={
<>
X-Hasura-{customClaim.key}{' '}
{customClaim.isSystemClaim && (
<LockIcon className="w-4 h-4" />
)}
</>
}
/>
<Text className="font-medium">user.{customClaim.value}</Text>
</ListItem.Root>
<Divider
component="li"
className={twMerge(
index === availablePermissionVariables.length - 1
? '!mt-4'
: '!my-4',
)}
/>
</Fragment>
))}
</List>
<Button
className="justify-self-start mx-4"
variant="borderless"
startIcon={<PlusIcon />}
onClick={handleOpenCreator}
>
Create Permission Variable
</Button>
</div>
</SettingsContainer>
);
}

View File

@@ -1,34 +1,24 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import type { Role } from '@/types/application';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import { yupResolver } from '@hookform/resolvers/yup';
import Text from '@/ui/v2/Text';
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import * as Yup from 'yup';
export interface RoleFormValues {
export interface BaseRoleFormValues {
/**
* The name of the role.
*/
name: string;
}
export interface RoleFormProps {
/**
* Available roles.
*/
availableRoles: Role[];
/**
* Original role. This is defined only if the form was opened to edit an
* existing role.
*/
originalRole?: Role;
export interface BaseRoleFormProps {
/**
* Function to be called when the form is submitted.
*/
onSubmit: (values: RoleFormValues) => void;
onSubmit: (values: BaseRoleFormValues) => void;
/**
* Function to be called when the operation is cancelled.
*/
@@ -41,55 +31,36 @@ export interface RoleFormProps {
submitButtonText?: string;
}
const validationSchema = Yup.object({
export const baseRoleFormValidationSchema = Yup.object({
name: Yup.string().required('This field is required.'),
});
export default function RoleForm({
availableRoles,
originalRole,
export default function BaseRoleForm({
onSubmit,
onCancel,
submitButtonText = 'Save',
}: RoleFormProps) {
}: BaseRoleFormProps) {
const { onDirtyStateChange } = useDialog();
const form = useForm<RoleFormValues>({
defaultValues: {
name: originalRole?.name || '',
},
resolver: yupResolver(validationSchema),
});
const form = useFormContext<BaseRoleFormValues>();
const {
register,
setError,
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]);
async function handleSubmit(values: RoleFormValues) {
if (availableRoles.some((role) => role.name === values.name)) {
setError('name', { message: 'This role already exists.' });
return;
}
onSubmit?.(values);
}
return (
<FormProvider {...form}>
<Form
onSubmit={handleSubmit}
className="grid grid-flow-row gap-4 px-6 pb-6"
>
<div className="grid grid-flow-row gap-2 px-6 pb-6">
<Text variant="subtitle1" component="span">
Enter the name for the role below.
</Text>
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
<Input
{...register('name')}
inputProps={{ maxLength: 100 }}
@@ -114,6 +85,6 @@ export default function RoleForm({
</Button>
</div>
</Form>
</FormProvider>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,5 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import type { RoleFormValues } from '@/components/settings/roles/RoleForm';
import SettingsContainer from '@/components/settings/SettingsContainer';
import useLeaveConfirm from '@/hooks/common/useLeaveConfirm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { Role } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -17,13 +14,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 getUserRoles from '@/utils/settings/getUserRoles';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetRolesQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { Fragment, useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { Fragment } from 'react';
import toast from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
@@ -38,17 +35,6 @@ export interface RoleSettingsFormValues {
authUserDefaultAllowedRoles: Role[];
}
function getUserRoles(roles?: string) {
if (!roles) {
return [] as Role[];
}
return roles.split(',').map((role) => ({
name: role.trim(),
isSystemRole: role === 'user' || role === 'me',
})) as Role[];
}
export default function RoleSettings() {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { openDialog, openAlertDialog } = useDialog();
@@ -61,31 +47,6 @@ export default function RoleSettings() {
refetchQueries: ['getRoles'],
});
const form = useForm<RoleSettingsFormValues>({
defaultValues: {
authUserDefaultRole: data?.app?.authUserDefaultRole || 'user',
authUserDefaultAllowedRoles: getUserRoles(
data?.app?.authUserDefaultAllowedRoles,
),
},
});
const {
reset,
formState: { dirtyFields },
} = form;
useLeaveConfirm({ isDirty: Object.keys(dirtyFields).length > 0 });
useEffect(() => {
reset({
authUserDefaultRole: data?.app?.authUserDefaultRole || 'user',
authUserDefaultAllowedRoles: getUserRoles(
data?.app?.authUserDefaultAllowedRoles,
),
});
}, [data, reset]);
if (loading) {
return <ActivityIndicator delay={1000} label="Loading user roles..." />;
}
@@ -94,114 +55,12 @@ export default function RoleSettings() {
throw error;
}
const { setValue, formState, watch } = form;
const defaultRole = watch('authUserDefaultRole');
const availableRoles = watch('authUserDefaultAllowedRoles');
function handleAddRole({ name }: RoleFormValues) {
setValue(
'authUserDefaultAllowedRoles',
[...availableRoles, { name, isSystemRole: false }],
{ shouldDirty: true },
);
}
function handleEditRole({ name }: RoleFormValues, originalRole: Role) {
const originalIndex = availableRoles.findIndex(
(role) => role.name === originalRole.name,
);
const updatedRoles = availableRoles.map((role, index) =>
index === originalIndex ? { name, isSystemRole: false } : role,
);
setValue('authUserDefaultAllowedRoles', updatedRoles, {
shouldDirty: true,
});
}
function handleRemoveRole({ name }: Role) {
const filteredRoles = availableRoles.filter((role) => role.name !== name);
if (name === defaultRole) {
setValue('authUserDefaultRole', 'user', { shouldDirty: true });
}
setValue('authUserDefaultAllowedRoles', filteredRoles, {
shouldDirty: true,
});
}
function handleOpenCreator() {
openDialog('MANAGE_ROLE', {
title: (
<span className="grid grid-flow-row">
<span>Add Role</span>
<Text variant="subtitle1" component="span">
Enter the name for the role below.
</Text>
</span>
),
payload: {
availableRoles,
submitButtonText: 'Create',
onSubmit: handleAddRole,
},
props: { PaperProps: { className: 'max-w-sm' } },
});
}
function handleOpenEditor(originalRole: Role) {
openDialog('MANAGE_ROLE', {
title: (
<span className="grid grid-flow-row">
<span>Edit Role</span>
<Text variant="subtitle1" component="span">
Enter the name for the role below.
</Text>
</span>
),
payload: {
originalRole,
availableRoles,
onSubmit: (values: RoleFormValues) =>
handleEditRole(values, originalRole),
},
props: { PaperProps: { className: 'max-w-sm' } },
});
}
function handleConfirmRemove(originalRole: Role) {
openAlertDialog({
title: 'Remove Role',
payload: (
<Text>
Are you sure you want to remove the &quot;
<strong>{originalRole.name}</strong>&quot; role?
</Text>
),
props: {
onPrimaryAction: () => handleRemoveRole(originalRole),
primaryButtonColor: 'error',
primaryButtonText: 'Remove',
},
});
}
function handleSetAsDefault(role: Role) {
setValue('authUserDefaultRole', role.name, { shouldDirty: true });
}
async function handleSubmit(values: RoleSettingsFormValues) {
async function handleSetAsDefault({ name }: Role) {
const updateAppPromise = updateApp({
variables: {
id: currentApplication?.id,
app: {
authUserDefaultRole: values.authUserDefaultRole,
authUserDefaultAllowedRoles: values.authUserDefaultAllowedRoles
.map(({ name }) => name)
.join(','),
authUserDefaultRole: name,
},
},
});
@@ -209,145 +68,203 @@ export default function RoleSettings() {
await toast.promise(
updateAppPromise,
{
loading: 'Updating roles...',
success: 'Roles have been updated successfully.',
error: 'An error occurred while updating roles.',
loading: 'Updating default role...',
success: 'Default role has been updated successfully.',
error: 'An error occurred while trying to update the default role.',
},
toastStyleProps,
);
}
async function handleDeleteRole({ name }: Role) {
const filteredRoles = data?.app?.authUserDefaultAllowedRoles
.split(',')
.filter((role) => role !== name)
.join(',');
const updateAppPromise = updateApp({
variables: {
id: currentApplication?.id,
app: {
authUserDefaultAllowedRoles: filteredRoles,
authUserDefaultRole:
name === data?.app?.authUserDefaultRole
? 'user'
: data?.app?.authUserDefaultRole,
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Deleting role...',
success: 'Role has been deleted successfully.',
error: 'An error occurred while trying to delete the role.',
},
toastStyleProps,
);
}
function handleOpenCreator() {
openDialog('CREATE_ROLE', {
title: 'Create Role',
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'max-w-sm' },
},
});
}
function handleOpenEditor(originalRole: Role) {
openDialog('EDIT_ROLE', {
title: 'Edit Role',
payload: { originalRole },
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'max-w-sm' },
},
});
}
function handleConfirmDelete(originalRole: Role) {
openAlertDialog({
title: 'Delete Role',
payload: (
<Text>
Are you sure you want to delete the &quot;
<strong>{originalRole.name}</strong>&quot; role? This cannot be
undone.
</Text>
),
props: {
onPrimaryAction: () => handleDeleteRole(originalRole),
primaryButtonColor: 'error',
primaryButtonText: 'Delete',
},
});
}
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles);
return (
<FormProvider {...form}>
<Form onSubmit={handleSubmit}>
<SettingsContainer
title="Roles"
description="Roles are used to control access to your application."
docsLink="https://docs.nhost.io/authentication/users#roles"
rootClassName="gap-0"
className="px-0 my-2"
slotProps={{
submitButtonProps: {
loading: formState.isSubmitting,
disabled: !formState.isValid || !formState.isDirty,
},
}}
>
<div className="border-b-1 border-gray-200 px-4 py-3">
<Text className="font-medium">Name</Text>
</div>
<SettingsContainer
title="Roles"
description="Roles are used to control access to your application."
docsLink="https://docs.nhost.io/authentication/users#roles"
rootClassName="gap-0"
className="px-0 my-2"
slotProps={{ submitButton: { className: 'invisible' } }}
>
<div className="border-b-1 border-gray-200 px-4 py-3">
<Text className="font-medium">Name</Text>
</div>
<div className="grid grid-flow-row gap-2">
<List>
{availableRoles.map((role, index) => (
<Fragment key={role.name}>
<ListItem.Root
className="px-4"
secondaryAction={
<Dropdown.Root>
<Dropdown.Trigger
asChild
hideChevron
className="absolute right-4 top-1/2 -translate-y-1/2"
>
<IconButton variant="borderless" color="secondary">
<DotsVerticalIcon />
</IconButton>
</Dropdown.Trigger>
<div className="grid grid-flow-row gap-2">
<List>
{availableRoles.map((role, index) => (
<Fragment key={role.name}>
<ListItem.Root
className="px-4"
secondaryAction={
<Dropdown.Root>
<Dropdown.Trigger
asChild
hideChevron
className="absolute right-4 top-1/2 -translate-y-1/2"
>
<IconButton variant="borderless" color="secondary">
<DotsVerticalIcon />
</IconButton>
</Dropdown.Trigger>
<Dropdown.Content
menu
PaperProps={{ className: 'w-32' }}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Dropdown.Item
onClick={() => handleSetAsDefault(role)}
>
<Text className="font-medium">Set as Default</Text>
</Dropdown.Item>
<Divider component="li" />
<Dropdown.Item
disabled={role.isSystemRole}
onClick={() => handleOpenEditor(role)}
>
<Text className="font-medium">Edit</Text>
</Dropdown.Item>
<Divider component="li" />
<Dropdown.Item
disabled={role.isSystemRole}
onClick={() => handleConfirmRemove(role)}
>
<Text
className="font-medium"
sx={{
color: (theme) => theme.palette.error.main,
}}
>
Remove
</Text>
</Dropdown.Item>
</Dropdown.Content>
</Dropdown.Root>
}
>
<ListItem.Text
primaryTypographyProps={{
className:
'inline-grid grid-flow-col gap-1 items-center h-6 font-medium',
<Dropdown.Content
menu
PaperProps={{ className: 'w-32' }}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
primary={
<>
{role.name}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Dropdown.Item onClick={() => handleSetAsDefault(role)}>
<Text className="font-medium">Set as Default</Text>
</Dropdown.Item>
{role.isSystemRole && (
<LockIcon className="w-4 h-4" />
)}
<Divider component="li" />
{defaultRole === role.name && (
<Chip
component="span"
color="info"
size="small"
label="Default"
/>
)}
</>
}
/>
</ListItem.Root>
<Dropdown.Item
disabled={role.isSystemRole}
onClick={() => handleOpenEditor(role)}
>
<Text className="font-medium">Edit</Text>
</Dropdown.Item>
<Divider
component="li"
className={twMerge(
index === availableRoles.length - 1 ? '!mt-4' : '!my-4',
)}
/>
</Fragment>
))}
</List>
<Divider component="li" />
<Button
className="justify-self-start mx-4"
variant="borderless"
startIcon={<PlusIcon />}
onClick={handleOpenCreator}
>
Add Role
</Button>
</div>
</SettingsContainer>
</Form>
</FormProvider>
<Dropdown.Item
disabled={role.isSystemRole}
onClick={() => handleConfirmDelete(role)}
>
<Text
className="font-medium"
sx={{
color: (theme) => theme.palette.error.main,
}}
>
Delete
</Text>
</Dropdown.Item>
</Dropdown.Content>
</Dropdown.Root>
}
>
<ListItem.Text
primaryTypographyProps={{
className:
'inline-grid grid-flow-col gap-1 items-center h-6 font-medium',
}}
primary={
<>
{role.name}
{role.isSystemRole && <LockIcon className="w-4 h-4" />}
{data?.app?.authUserDefaultRole === role.name && (
<Chip
component="span"
color="info"
size="small"
label="Default"
/>
)}
</>
}
/>
</ListItem.Root>
<Divider
component="li"
className={twMerge(
index === availableRoles.length - 1 ? '!mt-4' : '!my-4',
)}
/>
</Fragment>
))}
</List>
<Button
className="justify-self-start mx-4"
variant="borderless"
startIcon={<PlusIcon />}
onClick={handleOpenCreator}
>
Create Role
</Button>
</div>
</SettingsContainer>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ export const theme = createTheme({
fontFamily: '"Inter", sans-serif',
body1: {
fontSize: '0.9375rem',
lineHeight: '1.375rem',
},
h2: {
fontSize: '1.625rem',
@@ -26,6 +27,7 @@ export const theme = createTheme({
},
subtitle1: {
fontSize: '0.9375rem',
lineHeight: '1.375rem',
},
subtitle2: {
fontSize: '0.75rem',

View File

@@ -1,6 +1,8 @@
// TODO: We should infer the types from GraphQL Codegens and never manually create types like this.
// It's too easy to get types out-of-sync which will generate bugs down the line
import type { GetEnvironmentVariablesQuery } from '@/utils/__generated__/graphql';
/**
* The current state of the application.
*/
@@ -86,3 +88,6 @@ export type Role = {
name: string;
isSystemRole?: boolean;
};
export type EnvironmentVariable =
GetEnvironmentVariablesQuery['environmentVariables'][number];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
import getPermissionVariablesArray from './getPermissionVariablesArray';
test('should convert permission variable object to array', () => {
const permissionVariables = {
'variable-1': 'value-1',
'variable-2': 2,
};
expect(getPermissionVariablesArray(permissionVariables)).toEqual([
{ key: 'User-Id', value: 'id', isSystemClaim: true },
{ key: 'variable-1', value: 'value-1' },
{ key: 'variable-2', value: 2 },
]);
});
test('should only return system variables if no permission variables are provided', () => {
const systemVariables = [
{ key: 'User-Id', value: 'id', isSystemClaim: true },
];
expect(getPermissionVariablesArray()).toEqual(systemVariables);
expect(getPermissionVariablesArray(null)).toEqual(systemVariables);
expect(getPermissionVariablesArray(undefined)).toEqual(systemVariables);
expect(getPermissionVariablesArray({})).toEqual(systemVariables);
});

View File

@@ -0,0 +1,29 @@
import type { CustomClaim } from '@/types/application';
/**
* Converts an object containing permission variables to an array of permission
* variables.
*
* @param customClaims An object containing permission variables
* @returns An array of permission variables
*/
export default function getPermissionVariables(
customClaims?: Record<string, any>,
): CustomClaim[] {
const systemClaims: CustomClaim[] = [
{ key: 'User-Id', value: 'id', isSystemClaim: true },
];
if (!customClaims) {
return systemClaims;
}
return systemClaims.concat(
Object.keys(customClaims)
.sort()
.map((key) => ({
key,
value: customClaims[key],
})),
);
}

View File

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

View File

@@ -0,0 +1,21 @@
import type { CustomClaim } from '@/types/application';
import getPermissionVariablesObject from './getPermissionVariablesObject';
test('should convert permission variable object to array', () => {
const permissionVariables: CustomClaim[] = [
{ key: 'variable-1', value: 'value-1' },
{ key: 'variable-2', value: '2' },
];
expect(getPermissionVariablesObject(permissionVariables)).toEqual({
'variable-1': 'value-1',
'variable-2': '2',
});
});
test('should return an empty object if no permission variables are provided', () => {
expect(getPermissionVariablesObject()).toEqual({});
expect(getPermissionVariablesObject(null)).toEqual({});
expect(getPermissionVariablesObject(undefined)).toEqual({});
expect(getPermissionVariablesObject([])).toEqual({});
});

View File

@@ -0,0 +1,22 @@
import type { CustomClaim } from '@/types/application';
/**
* Converts an array containing permission variables to an array of permission
* variables.
*
* @param customClaims An object containing permission variables
* @returns An array of permission variables
*/
export default function getPermissionVariablesObject(
customClaims?: CustomClaim[],
) {
return (
customClaims?.reduce(
(accumulator, { key: variableKey, value: variableValue }) => ({
...accumulator,
[variableKey]: variableValue,
}),
{},
) || {}
);
}

View File

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

View File

@@ -0,0 +1,25 @@
import { expect, test } from 'vitest';
import getUserRoles from './getUserRoles';
test('should return an empty array if no roles are passed', () => {
expect(getUserRoles()).toEqual([]);
expect(getUserRoles('')).toEqual([]);
expect(getUserRoles(null)).toEqual([]);
expect(getUserRoles(undefined)).toEqual([]);
});
test('should return an array of roles', () => {
expect(getUserRoles('test,test2,test3')).toEqual([
{ name: 'test', isSystemRole: false },
{ name: 'test2', isSystemRole: false },
{ name: 'test3', isSystemRole: false },
]);
});
test('should flag `user` and `me` as system roles', () => {
expect(getUserRoles('user,me,test')).toEqual([
{ name: 'user', isSystemRole: true },
{ name: 'me', isSystemRole: true },
{ name: 'test', isSystemRole: false },
]);
});

View File

@@ -0,0 +1,19 @@
import type { Role } from '@/types/application';
/**
* Convert the list of user roles that is returned by the API to a list of
* roles that are used in the application.
*
* @param roles - Roles in string format
* @returns An array of roles
*/
export default function getUserRoles(roles?: string): Role[] {
if (!roles) {
return [];
}
return roles.split(',').map((role) => ({
name: role.trim(),
isSystemRole: role === 'user' || role === 'me',
}));
}

View File

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

View File

@@ -4,7 +4,7 @@ title: getUrl()
sidebar_label: getUrl()
slug: /reference/javascript/nhost-js/graphql/get-url
description: Use `nhost.graphql.getUrl` to get the GraphQL URL.
custom_edit_url: https://github.com/nhost/nhost/edit/main/packages/nhost-js/src/clients/graphql.ts#L139
custom_edit_url: https://github.com/nhost/nhost/edit/main/packages/nhost-js/src/clients/graphql.ts#L140
---
# `getUrl()`

View File

@@ -4,7 +4,7 @@ title: setAccessToken()
sidebar_label: setAccessToken()
slug: /reference/javascript/nhost-js/graphql/set-access-token
description: Use `nhost.graphql.setAccessToken` to a set an access token to be used in subsequent graphql requests. Note that if you're signin in users with `nhost.auth.signIn()` the access token will be set automatically.
custom_edit_url: https://github.com/nhost/nhost/edit/main/packages/nhost-js/src/clients/graphql.ts#L153
custom_edit_url: https://github.com/nhost/nhost/edit/main/packages/nhost-js/src/clients/graphql.ts#L154
---
# `setAccessToken()`

View File

@@ -5,7 +5,7 @@ services:
environment:
hasura_graphql_enable_remote_schema_permissions: false
auth:
image: nhost/hasura-auth:0.16.1
image: nhost/hasura-auth:0.16.2
storage:
image: nhost/hasura-storage:0.3.0
auth:

View File

@@ -5,7 +5,7 @@ services:
environment:
hasura_graphql_enable_remote_schema_permissions: false
auth:
image: nhost/hasura-auth:0.16.1
image: nhost/hasura-auth:0.16.2
storage:
image: nhost/hasura-storage:0.3.0
auth:

View File

@@ -23,4 +23,12 @@ The following endpoints are now exposed:
- `http://localhost:8025`: Mailhog SMTP testing dashboard
- `http://localhost:9090`: Traefik dashboad
**Note:** The Nhost Dashboard has only been tested to run locally requires the Hasura admin secret to `nhost-admin-secret`. This will change in the future. If you can't wait, don't hesitate to contribute.
## Running the Nhost dashboard locally
In order to use the Nhost dashboard, you need to run the [Hasura console locally from the Hasura CLI](https://hasura.io/docs/latest/hasura-cli/commands/hasura_console/):
```sh
hasura console
```
The Nhost Dashboard also requires the Hasura admin secret to `nhost-admin-secret`. This will change in the future. If you can't wait, don't hesitate to contribute.

View File

@@ -0,0 +1,3 @@
version: 3
endpoint: http://localhost:1337
admin_secret: ${HASURA_GRAPHQL_ADMIN_SECRET}

View File

@@ -5,7 +5,7 @@ services:
environment:
hasura_graphql_enable_remote_schema_permissions: false
auth:
image: nhost/hasura-auth:0.16.1
image: nhost/hasura-auth:0.16.2
storage:
image: nhost/hasura-storage:0.3.0
auth:

View File

@@ -5,7 +5,7 @@ services:
environment:
hasura_graphql_enable_remote_schema_permissions: false
auth:
image: nhost/hasura-auth:0.16.1
image: nhost/hasura-auth:0.16.2
storage:
image: nhost/hasura-storage:0.3.0
auth:

View File

@@ -5,7 +5,7 @@ services:
environment:
hasura_graphql_enable_remote_schema_permissions: false
auth:
image: nhost/hasura-auth:0.16.1
image: nhost/hasura-auth:0.16.2
storage:
image: nhost/hasura-storage:0.3.0
auth:

View File

@@ -44,7 +44,6 @@ select_permissions:
delete_permissions:
- role: user
permission:
backend_only: false
filter:
user_id:
_eq: X-Hasura-User-Id

View File

@@ -92,13 +92,11 @@ update_permissions:
delete_permissions:
- role: anonymous
permission:
backend_only: false
filter:
user_id:
_eq: X-Hasura-User-Id
- role: user
permission:
backend_only: false
filter:
user_id:
_eq: X-Hasura-User-Id

View File

@@ -143,9 +143,7 @@ update_permissions:
delete_permissions:
- role: anonymous
permission:
backend_only: false
filter: {}
- role: user
permission:
backend_only: false
filter: {}

View File

@@ -13,7 +13,7 @@ services:
postgres_password: postgres
postgres_user: postgres
auth:
image: nhost/hasura-auth:0.16.1
image: nhost/hasura-auth:0.16.2
storage:
image: nhost/hasura-storage:0.3.0
auth:

View File

@@ -15,7 +15,7 @@ services:
postgres_password: postgres
postgres_user: postgres
auth:
image: nhost/hasura-auth:0.16.1
image: nhost/hasura-auth:0.16.2
auth:
access_control:
email:

View File

@@ -13,7 +13,7 @@ services:
postgres_password: postgres
postgres_user: postgres
auth:
image: nhost/hasura-auth:0.16.1
image: nhost/hasura-auth:0.16.2
storage:
image: nhost/hasura-storage:0.3.0
auth:

View File

@@ -13,7 +13,7 @@ services:
postgres_password: postgres
postgres_user: postgres
auth:
image: nhost/hasura-auth:0.16.1
image: nhost/hasura-auth:0.16.2
storage:
image: nhost/hasura-storage:0.3.0
auth:

View File

@@ -5,7 +5,7 @@ services:
environment:
hasura_graphql_enable_remote_schema_permissions: false
auth:
image: nhost/hasura-auth:0.16.1
image: nhost/hasura-auth:0.16.2
storage:
image: nhost/hasura-storage:0.3.0
auth:

View File

@@ -1,4 +1,4 @@
# Docker image versions used in the cloud
hasura: v2.15.2
auth: 0.16.1
auth: 0.16.2
storage: 0.3.0

View File

@@ -70,7 +70,6 @@
"eslint-plugin-react": "^7.31.10",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-vue": "^8.7.1",
"husky": "^8.0.1",
"npm-run-all": "^4.1.5",

View File

@@ -1,5 +1,14 @@
# @nhost/apollo
## 0.5.35
### Patch Changes
- Updated dependencies [66b4f3d0]
- Updated dependencies [ef117c28]
- Updated dependencies [aebb8225]
- @nhost/nhost-js@1.6.2
## 0.5.34
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/apollo",
"version": "0.5.34",
"version": "0.5.35",
"description": "Nhost Apollo Client library",
"license": "MIT",
"keywords": [

View File

@@ -1,5 +1,12 @@
# @nhost/core
## 0.9.4
### Patch Changes
- 66b4f3d0: Bump axios to v1.2.0
- 2e6923dc: Refactoring: use xstate's `interpreter.getSnapshot()` instead of `interpreter.state`
## 0.9.3
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/core",
"version": "0.9.3",
"version": "0.9.4",
"description": "Nhost core client library",
"license": "MIT",
"keywords": [
@@ -59,7 +59,7 @@
},
"dependencies": {
"@simplewebauthn/browser": "^6.0.0",
"axios": "^1.1.3",
"axios": "^1.2.0",
"js-cookie": "^3.0.1",
"xstate": "^4.33.5"
},

View File

@@ -54,7 +54,7 @@ export class AuthClient {
// * Ideally, a single tab should autorefresh and share the new jwt
this._channel = new BroadcastChannel('nhost')
this._channel.addEventListener('message', (token) => {
const existingToken = this.interpreter?.state.context.refreshToken.value
const existingToken = this.interpreter?.getSnapshot().context.refreshToken.value
if (this.interpreter && token.data !== existingToken) {
this.interpreter.send('TRY_TOKEN', { token: token.data })
}

View File

@@ -95,7 +95,7 @@ export const createChangeEmailMachine = ({ backendUrl, clientUrl, interpreter }:
},
{
headers: {
authorization: `Bearer ${interpreter?.state.context.accessToken.value}`
authorization: `Bearer ${interpreter?.getSnapshot().context.accessToken.value}`
}
}
)

View File

@@ -89,7 +89,7 @@ export const createChangePasswordMachine = ({ backendUrl, interpreter }: AuthCli
{ newPassword: password, ticket: ticket },
{
headers: {
authorization: `Bearer ${interpreter?.state.context.accessToken.value}`
authorization: `Bearer ${interpreter?.getSnapshot().context.accessToken.value}`
}
}
)

View File

@@ -119,7 +119,7 @@ export const createEnableMfaMachine = ({ backendUrl, interpreter }: AuthClient)
generate: async (_) => {
const { data } = await api.get('/mfa/totp/generate', {
headers: {
authorization: `Bearer ${interpreter?.state.context.accessToken.value}`
authorization: `Bearer ${interpreter?.getSnapshot().context.accessToken.value}`
}
})
return data
@@ -133,7 +133,7 @@ export const createEnableMfaMachine = ({ backendUrl, interpreter }: AuthClient)
},
{
headers: {
authorization: `Bearer ${interpreter?.state.context.accessToken.value}`
authorization: `Bearer ${interpreter?.getSnapshot().context.accessToken.value}`
}
}
)

View File

@@ -27,7 +27,7 @@ export const addSecurityKeyPromise = async (
{},
{
headers: {
authorization: `Bearer ${interpreter?.state.context.accessToken.value}`
authorization: `Bearer ${interpreter?.getSnapshot().context.accessToken.value}`
}
}
)
@@ -42,7 +42,7 @@ export const addSecurityKeyPromise = async (
{ credential, nickname },
{
headers: {
authorization: `Bearer ${interpreter?.state.context.accessToken.value}`
authorization: `Bearer ${interpreter?.getSnapshot().context.accessToken.value}`
}
}
)

View File

@@ -138,7 +138,7 @@ describe(`Activation`, () => {
test(`should not do anything if MFA TOTP has not been generated yet`, () => {
enableMfaService.send('ACTIVATE')
expect(enableMfaService.state.matches('idle')).toBeTruthy()
expect(enableMfaService.getSnapshot().matches('idle')).toBeTruthy()
})
test(`should fail if network is unavailable`, async () => {

View File

@@ -166,7 +166,7 @@ test(`should succeed if the provided MFA ticket and TOTP were valid`, async () =
})
test(`should succeed if MFA ticket is already in context and TOTP was valid`, async () => {
authService.state.context.mfa = { ticket: `mfaTotp:${faker.datatype.uuid()}` }
authService.getSnapshot().context.mfa = { ticket: `mfaTotp:${faker.datatype.uuid()}` }
authService.send({
type: 'SIGNIN_MFA_TOTP',

View File

@@ -272,9 +272,9 @@ describe('General and disabled auto-sign in', () => {
const accessToken = faker.datatype.string(40)
const refreshToken = faker.datatype.uuid()
expect(authService.state.context.user).toBeNull()
expect(authService.state.context.accessToken.value).toBeNull()
expect(authService.state.context.refreshToken.value).toBeNull()
expect(authService.getSnapshot().context.user).toBeNull()
expect(authService.getSnapshot().context.accessToken.value).toBeNull()
expect(authService.getSnapshot().context.refreshToken.value).toBeNull()
authService.send({
type: 'SESSION_UPDATE',

View File

@@ -1,9 +1,4 @@
module.exports = {
extends: '../../config/.eslintrc.js',
plugins: ['@typescript-eslint', 'simple-import-sort'],
rules: {
// we are already using a Prettier formatter and this rule is conflicting
// with it
'simple-import-sort/imports': 'off'
}
plugins: ['@typescript-eslint']
}

View File

@@ -1,5 +1,15 @@
# @nhost/hasura-auth-js
## 1.6.4
### Patch Changes
- 66b4f3d0: Bump axios to v1.2.0
- 2e6923dc: Refactoring: use xstate's `interpreter.getSnapshot()` instead of `interpreter.state`
- Updated dependencies [66b4f3d0]
- Updated dependencies [2e6923dc]
- @nhost/core@0.9.4
## 1.6.3
### Patch Changes

View File

@@ -5,7 +5,7 @@ services:
environment:
hasura_graphql_enable_remote_schema_permissions: false
auth:
image: nhost/hasura-auth:0.16.1
image: nhost/hasura-auth:0.16.2
storage:
image: nhost/hasura-storage:0.3.0
auth:

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/hasura-auth-js",
"version": "1.6.3",
"version": "1.6.4",
"description": "Hasura-auth client",
"license": "MIT",
"keywords": [
@@ -61,7 +61,7 @@
"docgen": "pnpm typedoc && docgen --config ./auth.docgen.json"
},
"dependencies": {
"axios": "^1.1.3",
"axios": "^1.2.0",
"jwt-decode": "^3.1.2",
"xstate": "^4.33.5"
},

View File

@@ -504,7 +504,7 @@ export class HasuraAuthClient {
* @docs https://docs.nhost.io/reference/javascript/auth/is-authenticated
*/
isAuthenticated(): boolean {
return !!this._client.interpreter?.state.matches({ authentication: 'signedIn' })
return !!this._client.interpreter?.getSnapshot().matches({ authentication: 'signedIn' })
}
/**
@@ -526,7 +526,7 @@ export class HasuraAuthClient {
*/
async isAuthenticatedAsync(): Promise<boolean> {
const interpreter = await this.waitUntilReady()
return interpreter.state.matches({ authentication: 'signedIn' })
return interpreter.getSnapshot().matches({ authentication: 'signedIn' })
}
/**
@@ -590,7 +590,7 @@ export class HasuraAuthClient {
* @docs https://docs.nhost.io/reference/javascript/auth/get-access-token
*/
getAccessToken(): string | undefined {
return this._client.interpreter?.state.context.accessToken.value ?? undefined
return this._client.interpreter?.getSnapshot().context.accessToken.value ?? undefined
}
/**
@@ -666,7 +666,7 @@ export class HasuraAuthClient {
try {
const interpreter = await this.waitUntilReady()
return new Promise((resolve) => {
const token = refreshToken || interpreter.state.context.refreshToken.value
const token = refreshToken || interpreter.getSnapshot().context.refreshToken.value
if (!token) {
return resolve({ session: null, error: NO_REFRESH_TOKEN })
}
@@ -704,7 +704,7 @@ export class HasuraAuthClient {
* @docs https://docs.nhost.io/reference/javascript/auth/get-session
*/
getSession() {
return getSession(this._client.interpreter?.state?.context)
return getSession(this._client.interpreter?.getSnapshot()?.context)
}
/**
@@ -719,7 +719,7 @@ export class HasuraAuthClient {
* @docs https://docs.nhost.io/reference/javascript/auth/get-user
*/
getUser() {
return this._client.interpreter?.state?.context?.user || null
return this._client.interpreter?.getSnapshot()?.context?.user || null
}
/**
@@ -732,7 +732,7 @@ export class HasuraAuthClient {
if (!interpreter) {
throw Error('Auth interpreter not set')
}
if (!interpreter.state.hasTag('loading')) {
if (!interpreter.getSnapshot().hasTag('loading')) {
return Promise.resolve(interpreter)
}
return new Promise((resolve, reject) => {
@@ -750,7 +750,7 @@ export class HasuraAuthClient {
}
private isReady() {
return !this._client.interpreter?.state?.hasTag('loading')
return !this._client.interpreter?.getSnapshot()?.hasTag('loading')
}
get client() {

View File

@@ -1,5 +1,14 @@
# @nhost/hasura-storage-js
## 0.7.4
### Patch Changes
- 66b4f3d0: Bump axios to v1.2.0
- Updated dependencies [66b4f3d0]
- Updated dependencies [2e6923dc]
- @nhost/core@0.9.4
## 0.7.3
### Patch Changes

View File

@@ -5,7 +5,7 @@ services:
environment:
hasura_graphql_enable_remote_schema_permissions: false
auth:
image: nhost/hasura-auth:0.16.1
image: nhost/hasura-auth:0.16.2
storage:
image: nhost/hasura-storage:0.3.0
auth:

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/hasura-storage-js",
"version": "0.7.3",
"version": "0.7.4",
"description": "Hasura-storage client",
"license": "MIT",
"keywords": [
@@ -59,7 +59,7 @@
"docgen": "pnpm typedoc && docgen --config ./storage.docgen.json"
},
"dependencies": {
"axios": "^1.1.3",
"axios": "^1.2.0",
"form-data": "^4.0.0",
"xstate": "^4.33.5"
},

View File

@@ -1,5 +1,18 @@
# @nhost/nextjs
## 1.9.1
### Patch Changes
- 2e6923dc: Refactoring: use xstate's `interpreter.getSnapshot()` instead of `interpreter.state`
- Updated dependencies [66b4f3d0]
- Updated dependencies [2e6923dc]
- Updated dependencies [ef117c28]
- Updated dependencies [aebb8225]
- @nhost/core@0.9.4
- @nhost/nhost-js@1.6.2
- @nhost/react@0.15.1
## 1.9.0
### Patch Changes

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