Compare commits
55 Commits
@nhost/das
...
@nhost/rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8857314e22 | ||
|
|
85f1c4a98e | ||
|
|
efa6b5755d | ||
|
|
2b19416787 | ||
|
|
4e5d43f300 | ||
|
|
db342f453e | ||
|
|
54386a3b56 | ||
|
|
ff40b99f84 | ||
|
|
33f8f1d78a | ||
|
|
c50fe47ab4 | ||
|
|
0580f832c8 | ||
|
|
7d1eb099c0 | ||
|
|
e15322296b | ||
|
|
91a2bf905b | ||
|
|
0f9393fe27 | ||
|
|
aebb822549 | ||
|
|
1e2be6fadf | ||
|
|
aafbf5173d | ||
|
|
01e13e2f8c | ||
|
|
4364647501 | ||
|
|
ef117c284e | ||
|
|
3f919c0a80 | ||
|
|
49e447e7b7 | ||
|
|
66b4f3d0be | ||
|
|
aa7fdafe8b | ||
|
|
7d6de3b289 | ||
|
|
57e41f77a9 | ||
|
|
f5c2a0ef4f | ||
|
|
d52bc8cca5 | ||
|
|
04a3e4c965 | ||
|
|
853c0c5775 | ||
|
|
2e6923dc73 | ||
|
|
7d6d70d0c7 | ||
|
|
7a2100cc17 | ||
|
|
5d55f3fa60 | ||
|
|
8b0c44a93c | ||
|
|
e0cc7cce0a | ||
|
|
6e7d5e0dd4 | ||
|
|
54c143ebf6 | ||
|
|
8b9fa0b150 | ||
|
|
c3bb79e1dd | ||
|
|
128d21e4ec | ||
|
|
40e503c356 | ||
|
|
d007e0ade8 | ||
|
|
fa32513ba7 | ||
|
|
8893d9e010 | ||
|
|
81d2fd865c | ||
|
|
0c748e6ee6 | ||
|
|
7167170663 | ||
|
|
0f77de2dd0 | ||
|
|
6ae91e48d1 | ||
|
|
85d9596956 | ||
|
|
2ca193ccf3 | ||
|
|
ab8e12003d | ||
|
|
41cc3dc5d0 |
16
.github/workflows/changesets.yaml
vendored
16
.github/workflows/changesets.yaml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/dashboard.yaml
vendored
2
.github/workflows/dashboard.yaml
vendored
@@ -11,7 +11,7 @@ on:
|
||||
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
TURBO_TEAM: nhost
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
|
||||
2
.github/workflows/packages.yaml
vendored
2
.github/workflows/packages.yaml
vendored
@@ -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
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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> {
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './BaseEnvironmentVariableForm';
|
||||
export { default } from './BaseEnvironmentVariableForm';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './CreateEnvironmentVariableForm';
|
||||
export { default } from './CreateEnvironmentVariableForm';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './EditEnvironmentVariableForm';
|
||||
export { default } from './EditEnvironmentVariableForm';
|
||||
@@ -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 "
|
||||
<strong>{originalVariable.name}</strong>" 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './EnvironmentVariableSettings';
|
||||
export { default } from './EnvironmentVariableSettings';
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './SystemEnvironmentVariableSettings';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './BasePermissionVariableForm';
|
||||
export { default } from './BasePermissionVariableForm';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './CreatePermissionVariableForm';
|
||||
export { default } from './CreatePermissionVariableForm';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './EditPermissionVariableForm';
|
||||
export { default } from './EditPermissionVariableForm';
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './PermissionVariableForm';
|
||||
export { default } from './PermissionVariableForm';
|
||||
@@ -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 "
|
||||
<strong>X-Hasura-{originalVariable.key}</strong>" 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 "
|
||||
<strong>X-Hasura-{originalVariable.key}</strong>" 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './BaseRoleForm';
|
||||
export { default } from './BaseRoleForm';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './CreateRoleForm';
|
||||
export { default } from './CreateRoleForm';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './EditRoleForm';
|
||||
export { default } from './EditRoleForm';
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './RoleForm';
|
||||
export { default } from './RoleForm';
|
||||
@@ -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 "
|
||||
<strong>{originalRole.name}</strong>" 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 "
|
||||
<strong>{originalRole.name}</strong>" 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './RoleSettings';
|
||||
export { default } from './RoleSettings';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
36
dashboard/src/components/ui/v2/icons/EyeIcon/EyeIcon.tsx
Normal file
36
dashboard/src/components/ui/v2/icons/EyeIcon/EyeIcon.tsx
Normal 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;
|
||||
1
dashboard/src/components/ui/v2/icons/EyeIcon/index.ts
Normal file
1
dashboard/src/components/ui/v2/icons/EyeIcon/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './EyeIcon';
|
||||
@@ -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;
|
||||
1
dashboard/src/components/ui/v2/icons/EyeOffIcon/index.ts
Normal file
1
dashboard/src/components/ui/v2/icons/EyeOffIcon/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './EyeOffIcon';
|
||||
@@ -0,0 +1,9 @@
|
||||
query getEnvironmentVariables($id: uuid!) {
|
||||
environmentVariables(where: { appId: { _eq: $id } }) {
|
||||
id
|
||||
name
|
||||
updatedAt
|
||||
prodValue
|
||||
devValue
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
fragment EnvironmentVariable on environmentVariables {
|
||||
id
|
||||
name
|
||||
updatedAt
|
||||
prodValue
|
||||
devValue
|
||||
}
|
||||
|
||||
query getEnvironmentVariablesWhere($where: environmentVariables_bool_exp!) {
|
||||
environmentVariables(where: $where) {
|
||||
...EnvironmentVariable
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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];
|
||||
|
||||
738
dashboard/src/utils/__generated__/graphql.ts
generated
738
dashboard/src/utils/__generated__/graphql.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
@@ -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],
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './getPermissionVariablesArray';
|
||||
@@ -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({});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
{},
|
||||
) || {}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './getPermissionVariablesObject';
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
19
dashboard/src/utils/settings/getUserRoles/getUserRoles.ts
Normal file
19
dashboard/src/utils/settings/getUserRoles/getUserRoles.ts
Normal 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',
|
||||
}));
|
||||
}
|
||||
1
dashboard/src/utils/settings/getUserRoles/index.ts
Normal file
1
dashboard/src/utils/settings/getUserRoles/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './getUserRoles';
|
||||
@@ -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()`
|
||||
|
||||
@@ -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()`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
3
examples/docker-compose/config.yaml
Normal file
3
examples/docker-compose/config.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
version: 3
|
||||
endpoint: http://localhost:1337
|
||||
admin_secret: ${HASURA_GRAPHQL_ADMIN_SECRET}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -44,7 +44,6 @@ select_permissions:
|
||||
delete_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
backend_only: false
|
||||
filter:
|
||||
user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -143,9 +143,7 @@ update_permissions:
|
||||
delete_permissions:
|
||||
- role: anonymous
|
||||
permission:
|
||||
backend_only: false
|
||||
filter: {}
|
||||
- role: user
|
||||
permission:
|
||||
backend_only: false
|
||||
filter: {}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/apollo",
|
||||
"version": "0.5.34",
|
||||
"version": "0.5.35",
|
||||
"description": "Nhost Apollo Client library",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user