Compare commits
64 Commits
@nhost/das
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e113bfb1f | ||
|
|
f1d358d77c | ||
|
|
d558ef9ecf | ||
|
|
75c7ba7f12 | ||
|
|
d62dfe19a2 | ||
|
|
0dbef188b1 | ||
|
|
8857314e22 | ||
|
|
85f1c4a98e | ||
|
|
efa6b5755d | ||
|
|
44f13f6240 | ||
|
|
2b19416787 | ||
|
|
e01cb2ed49 | ||
|
|
388eef041f | ||
|
|
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,36 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 0.7.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 44f13f62: chore(dashboard): cleanup unused files
|
||||
|
||||
## 0.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e01cb2ed: chore(dashboard): change settings sidebar menu item density
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- db342f45: chore(dashboard): refactor Roles and Permissions settings sections
|
||||
- 8b9fa0b1: feat(dashboard): add Environment Variables page
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [66b4f3d0]
|
||||
- Updated dependencies [2e6923dc]
|
||||
- Updated dependencies [ef117c28]
|
||||
- Updated dependencies [aebb8225]
|
||||
- @nhost/core@0.9.4
|
||||
- @nhost/nhost-js@1.6.2
|
||||
- @nhost/nextjs@1.9.1
|
||||
- @nhost/react@0.15.1
|
||||
- @nhost/react-apollo@4.9.1
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.2",
|
||||
"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,107 +0,0 @@
|
||||
import { useWorkspaceContext } from '@/context/workspace-context';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import { inputErrorMessages } from '@/utils/getErrorMessage';
|
||||
import { slugifyString } from '@/utils/helpers';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { updateOwnCache } from '@/utils/updateOwnCache';
|
||||
import { useUpdateApplicationMutation } from '@/utils/__generated__/graphql';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export function ChangeApplicationName({ close }: any) {
|
||||
const [updateAppName, { client }] = useUpdateApplicationMutation({});
|
||||
const { workspaceContext } = useWorkspaceContext();
|
||||
const [name, setName] = useState(workspaceContext.appName);
|
||||
const [applicationError, setApplicationError] = useState<any>('');
|
||||
const { currentWorkspace, currentApplication } =
|
||||
useCurrentWorkspaceAndApplication();
|
||||
const router = useRouter();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const slug = slugifyString(name);
|
||||
if (slug.length < 4 || slug.length > 32) {
|
||||
setApplicationError('Slug should be within 4 and 32 characters.');
|
||||
return;
|
||||
}
|
||||
if (!inputErrorMessages(name, setName, setApplicationError, 'project')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateAppName({
|
||||
variables: {
|
||||
appId: currentApplication.id,
|
||||
app: {
|
||||
name,
|
||||
slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
triggerToast('Project name changed');
|
||||
} catch (error) {
|
||||
await discordAnnounce(
|
||||
`Error trying to delete project: ${currentApplication.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
await updateOwnCache(client);
|
||||
await router.push(`/${currentWorkspace.slug}/${slug}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-modal px-6 py-6 text-left">
|
||||
<div className="flex flex-col">
|
||||
<Text variant="h3" component="h2">
|
||||
Change Project Name
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mt-4 grid grid-flow-row gap-2">
|
||||
<Input
|
||||
label="New Project Name"
|
||||
id="projectName"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setApplicationError('');
|
||||
}}
|
||||
fullWidth
|
||||
autoFocus
|
||||
helperText={`https://app.nhost.io/${
|
||||
currentWorkspace.slug
|
||||
}/${slugifyString(name)}`}
|
||||
/>
|
||||
|
||||
{applicationError && (
|
||||
<Alert severity="error">{applicationError}</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-flow-row gap-2">
|
||||
<Button type="submit" disabled={applicationError}>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={close}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangeApplicationName;
|
||||
@@ -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,327 +0,0 @@
|
||||
import ErrorBoundaryFallback from '@/components/common/ErrorBoundaryFallback';
|
||||
import TwilioIcon from '@/components/icons/TwilioIcon';
|
||||
import {
|
||||
useGetSmsSettingsQuery,
|
||||
useUpdateAppMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { Button, Input } from '@/ui';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import DelayedLoading from '@/ui/DelayedLoading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { showLoadingToast, triggerToast } from '@/utils/toast';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { useEffect } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useForm,
|
||||
useFormContext,
|
||||
} from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export function EditSMSSettingsForm({
|
||||
close,
|
||||
isAlreadyEnabled,
|
||||
}: {
|
||||
close: () => void;
|
||||
isAlreadyEnabled: boolean;
|
||||
}) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useFormContext<EditSMSSettingsFormData>();
|
||||
const { control } = useFormContext<EditSMSSettingsFormData>();
|
||||
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
let toastId: string;
|
||||
|
||||
const client = useApolloClient();
|
||||
const isNotCompleted =
|
||||
!watch('accountSID') ||
|
||||
!watch('authToken') ||
|
||||
!watch('messagingServiceSID');
|
||||
|
||||
const handleEditSMSSettings = async (data: EditSMSSettingsFormData) => {
|
||||
try {
|
||||
toastId = showLoadingToast('Updating SMS settings...');
|
||||
await updateApp({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
authSmsTwilioAccountSid: data.accountSID,
|
||||
authSmsTwilioAuthToken: data.authToken,
|
||||
authSmsTwilioMessagingServiceId: data.messagingServiceSID,
|
||||
authSmsPasswordlessEnabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
await client.refetchQueries({ include: ['getSMSSettings'] });
|
||||
toast.remove(toastId);
|
||||
triggerToast('SMS settings updated successfully.');
|
||||
close();
|
||||
} catch (error) {
|
||||
if (toastId) {
|
||||
toast.remove(toastId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(handleEditSMSSettings)}
|
||||
className="flex w-full flex-col pb-1"
|
||||
autoComplete="off"
|
||||
>
|
||||
{errors &&
|
||||
Object.entries(errors).map(([type, error]) => (
|
||||
<Alert key={type} className="mb-4" severity="error">
|
||||
{error.message}
|
||||
</Alert>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<div className="flex flex-row place-content-between border-t border-b px-2 py-2.5">
|
||||
<div className="flex w-full flex-row">
|
||||
<Text
|
||||
color="greyscaleDark"
|
||||
className="self-center font-medium"
|
||||
size="normal"
|
||||
>
|
||||
Account SID
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex w-full">
|
||||
<Controller
|
||||
name="accountSID"
|
||||
control={control}
|
||||
rules={{
|
||||
required: true,
|
||||
pattern: {
|
||||
value: /^[a-zA-Z0-9-_]+$/,
|
||||
message:
|
||||
'The Account SID must contain only letters, hyphens, and numbers.',
|
||||
},
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id="accountSID"
|
||||
placeholder="Account SID"
|
||||
required
|
||||
value={field.value || ''}
|
||||
onChange={(value: string) => {
|
||||
if (value && !/^[a-zA-Z0-9-_]+$/gi.test(value)) {
|
||||
// prevent the user from entering invalid characters
|
||||
return;
|
||||
}
|
||||
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row place-content-between border-b px-2 py-2.5">
|
||||
<div className="flex w-full flex-row">
|
||||
<Text
|
||||
color="greyscaleDark"
|
||||
className="self-center font-medium"
|
||||
size="normal"
|
||||
>
|
||||
Auth Token
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex w-full">
|
||||
<Controller
|
||||
name="authToken"
|
||||
control={control}
|
||||
rules={{
|
||||
required: true,
|
||||
pattern: {
|
||||
value: /^[a-zA-Z0-9-_]+$/,
|
||||
message:
|
||||
'The Auth Token must contain only letters, hyphens, and numbers.',
|
||||
},
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id="authToken"
|
||||
placeholder="Auth Token"
|
||||
required
|
||||
value={field.value || ''}
|
||||
onChange={(value: string) => {
|
||||
if (value && !/^[a-zA-Z0-9-_/.]+$/gi.test(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row place-content-between border-b px-2 py-2.5">
|
||||
<div className="flex w-full flex-row">
|
||||
<Text
|
||||
color="greyscaleDark"
|
||||
className="self-center font-medium"
|
||||
size="normal"
|
||||
>
|
||||
Messaging Service SID
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex w-full">
|
||||
<Controller
|
||||
name="messagingServiceSID"
|
||||
control={control}
|
||||
rules={{
|
||||
required: true,
|
||||
pattern: {
|
||||
value: /^[+a-zA-Z0-9-_/.]+$/,
|
||||
message:
|
||||
'The Messaging Service SID must either be a valid phone number or contain only letters, hyphens, and numbers.',
|
||||
},
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id="messagingServiceSID"
|
||||
required
|
||||
placeholder="Messaging Service SID"
|
||||
value={field.value || ''}
|
||||
onChange={(value: string) => {
|
||||
if (value && !/^[+a-zA-Z0-9-_/.]+$/gi.test(value)) {
|
||||
return;
|
||||
}
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
className="text-grayscaleDark mt-2 border text-sm+ font-normal"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || isNotCompleted}
|
||||
>
|
||||
{isAlreadyEnabled ? 'Update SMS Settings' : 'Enable SMS'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditSMSSettingsModal({
|
||||
close,
|
||||
isAlreadyEnabled,
|
||||
}: {
|
||||
close: () => void;
|
||||
isAlreadyEnabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-modal px-6 py-4 text-left">
|
||||
<div className="flex flex-col">
|
||||
<div className="mx-auto mt-2.5">
|
||||
<TwilioIcon className=" text-greyscaleDark" />
|
||||
</div>
|
||||
<Text
|
||||
variant="subHeading"
|
||||
color="greyscaleDark"
|
||||
size="large"
|
||||
className="mt-3 text-center"
|
||||
>
|
||||
Set up Twilio SMS Service
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
color="greyscaleDark"
|
||||
size="small"
|
||||
className="mt-0.5 mb-6 text-center font-normal"
|
||||
>
|
||||
SMS messages are sent through Twilio. Create an account and a
|
||||
messaging service at https://console.twilio.com.
|
||||
</Text>
|
||||
<div>
|
||||
<ErrorBoundary fallbackRender={ErrorBoundaryFallback}>
|
||||
<EditSMSSettingsForm
|
||||
close={close}
|
||||
isAlreadyEnabled={isAlreadyEnabled}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface EditSMSSettingsProps {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface EditSMSSettingsFormData {
|
||||
accountSID: string;
|
||||
authToken: string;
|
||||
messagingServiceSID: string;
|
||||
}
|
||||
|
||||
export function EditSMSSettings({ close }: EditSMSSettingsProps) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
|
||||
const form = useForm<EditSMSSettingsFormData>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
accountSID: '',
|
||||
authToken: '',
|
||||
messagingServiceSID: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetSmsSettingsQuery({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue('accountSID', data.app.authSmsTwilioAccountSid);
|
||||
form.setValue('authToken', data.app.authSmsTwilioAuthToken);
|
||||
form.setValue(
|
||||
'messagingServiceSID',
|
||||
data.app.authSmsTwilioMessagingServiceId,
|
||||
);
|
||||
}, [data, form]);
|
||||
|
||||
if (loading) {
|
||||
return <DelayedLoading delay={500} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<EditSMSSettingsModal
|
||||
close={close}
|
||||
isAlreadyEnabled={data.app.authSmsPasswordlessEnabled}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import {
|
||||
useGetSmsSettingsQuery,
|
||||
useUpdateAppMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { Text, Toggle } from '@/ui';
|
||||
import DelayedLoading from '@/ui/DelayedLoading';
|
||||
import { showLoadingToast, triggerToast } from '@/utils/toast';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import clsx from 'clsx';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export function EnableSMSSignIn({ openSMSSettingsModal }: any) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
|
||||
const { data, loading, error } = useGetSmsSettingsQuery({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
},
|
||||
});
|
||||
|
||||
const [enableSMSLoginMethod, setEnableSMSLoginMethod] = useState(false);
|
||||
const client = useApolloClient();
|
||||
let toastId: string;
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEnableSMSLoginMethod(data.app.authSmsPasswordlessEnabled);
|
||||
}, [data]);
|
||||
|
||||
if (loading) {
|
||||
return <DelayedLoading delay={500} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const handleDisable = async () => {
|
||||
try {
|
||||
toastId = showLoadingToast('Disabling SMS login...');
|
||||
await updateApp({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
authSmsPasswordlessEnabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
setEnableSMSLoginMethod(false);
|
||||
await client.refetchQueries({ include: ['getSMSSettings'] });
|
||||
toast.remove(toastId);
|
||||
triggerToast('Passwordless SMS disabled.');
|
||||
} catch (updateError) {
|
||||
if (toastId) {
|
||||
toast.remove(toastId);
|
||||
}
|
||||
|
||||
throw updateError;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-20">
|
||||
<div className="mx-auto font-display">
|
||||
<div className="flex flex-col place-content-between">
|
||||
<div className="">
|
||||
<div className="flex flex-row place-content-between">
|
||||
<div className="relative flex flex-row">
|
||||
<Image
|
||||
src="/assets/SMS.svg"
|
||||
width={24}
|
||||
height={24}
|
||||
alt="Phone Number (SMS)"
|
||||
/>
|
||||
<Text
|
||||
variant="body"
|
||||
size="large"
|
||||
className="ml-2 font-medium"
|
||||
color="greyscaleDark"
|
||||
>
|
||||
Phone Number (SMS)
|
||||
</Text>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
'ml-2 align-bottom text-sm- font-medium text-blue transition-opacity duration-300',
|
||||
!enableSMSLoginMethod && 'invisible opacity-0',
|
||||
enableSMSLoginMethod && 'opacity-100',
|
||||
)}
|
||||
onClick={() => openSMSSettingsModal()}
|
||||
>
|
||||
Edit SMS settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
<Toggle
|
||||
checked={enableSMSLoginMethod}
|
||||
onChange={async () => {
|
||||
if (enableSMSLoginMethod) {
|
||||
await handleDisable();
|
||||
} else {
|
||||
openSMSSettingsModal();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-row self-center align-middle">
|
||||
<Text
|
||||
variant="body"
|
||||
size="normal"
|
||||
color="greyscaleDark"
|
||||
className="self-center"
|
||||
>
|
||||
Sign in users with Phone Number (SMS).
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EnableSMSSignIn;
|
||||
@@ -1,224 +0,0 @@
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { useSubmitState } from '@/hooks/useSubmitState';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import {
|
||||
refetchGetAppInjectedVariablesQuery,
|
||||
useUpdateApplicationMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
export interface EditCustomUserJWTTokenData {
|
||||
customUserJWTToken: string;
|
||||
}
|
||||
|
||||
export type JWTSecretModalState = 'SHOW' | 'EDIT';
|
||||
|
||||
export interface JWTSecretModalProps {
|
||||
close: () => void;
|
||||
data?: any;
|
||||
jwtSecret: string;
|
||||
initialModalState?: JWTSecretModalState;
|
||||
}
|
||||
|
||||
export function EditJWTSecretModal({ close }: any) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { submitState, setSubmitState } = useSubmitState();
|
||||
|
||||
const [updateApplication] = useUpdateApplicationMutation({
|
||||
refetchQueries: [
|
||||
refetchGetAppInjectedVariablesQuery({ id: currentApplication.id }),
|
||||
],
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<EditCustomUserJWTTokenData>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
customUserJWTToken: '',
|
||||
},
|
||||
});
|
||||
|
||||
const handleEditJWTSecret = async (data: EditCustomUserJWTTokenData) => {
|
||||
setSubmitState({
|
||||
error: null,
|
||||
loading: false,
|
||||
fieldsWithError: [],
|
||||
});
|
||||
try {
|
||||
JSON.parse(data.customUserJWTToken);
|
||||
} catch (error) {
|
||||
setSubmitState({
|
||||
error: new Error('The custom JWT token should be valid json.'),
|
||||
loading: false,
|
||||
fieldsWithError: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateApplication({
|
||||
variables: {
|
||||
appId: currentApplication.id,
|
||||
app: {
|
||||
hasuraGraphqlJwtSecret: data.customUserJWTToken,
|
||||
},
|
||||
},
|
||||
});
|
||||
triggerToast(
|
||||
`Successfully added custom JWT token to ${currentApplication.name}.`,
|
||||
);
|
||||
close();
|
||||
} catch (error) {
|
||||
triggerToast(
|
||||
`Error adding custom JWT token to ${currentApplication.name}`,
|
||||
);
|
||||
setSubmitState({ error, loading: false, fieldsWithError: [] });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="w-modal px-6 py-4"
|
||||
onSubmit={handleSubmit(handleEditJWTSecret)}
|
||||
>
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<div className="grid grid-flow-row text-left">
|
||||
<Text variant="h3" component="h2">
|
||||
Add Custom JWT Secret
|
||||
</Text>
|
||||
|
||||
<Text variant="subtitle2">
|
||||
You can add your custom JWT key here. Hasura will use this key to
|
||||
validate the identity of your users.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{submitState.error && (
|
||||
<Alert severity="error">{submitState.error.message}</Alert>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
name="customUserJWTToken"
|
||||
control={control}
|
||||
rules={{
|
||||
required: true,
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Paste your custom JWT token here..."
|
||||
componentsProps={{
|
||||
inputRoot: {
|
||||
className: 'font-mono bg-header',
|
||||
},
|
||||
}}
|
||||
aria-label="Custom JWT token"
|
||||
type="text"
|
||||
value={field.value}
|
||||
onBlur={() =>
|
||||
setSubmitState({
|
||||
error: null,
|
||||
loading: false,
|
||||
fieldsWithError: [],
|
||||
})
|
||||
}
|
||||
multiline
|
||||
rows={6}
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
Save Changes
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShowJWTTokenModal({ JWTKey, editJWTSecret }: any) {
|
||||
return (
|
||||
<div className="w-modal px-6 py-4">
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<div className="grid grid-flow-row text-left">
|
||||
<Text variant="h3" component="h2">
|
||||
Auth JWT Secret
|
||||
</Text>
|
||||
<Text variant="subtitle2">
|
||||
This is the key used for generating JWTs. It's HMAC-SHA-based
|
||||
and the same as configured in Hasura.
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
defaultValue={JWTKey}
|
||||
multiline
|
||||
disabled
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
rows={6}
|
||||
componentsProps={{
|
||||
inputRoot: { className: 'font-mono' },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-sm text-center">
|
||||
<Text variant="subtitle2">
|
||||
Already using a third party auth service? <br />
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 ml-0.5 text-xs font-medium text-blue"
|
||||
onClick={() => {
|
||||
editJWTSecret();
|
||||
}}
|
||||
>
|
||||
Add your custom JWT token
|
||||
</button>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function JWTSecretModal({
|
||||
close,
|
||||
data,
|
||||
jwtSecret,
|
||||
initialModalState,
|
||||
}: any) {
|
||||
const [jwtSecretModalState, setJwtSecretModalState] =
|
||||
useState<JWTSecretModalState>(initialModalState || 'SHOW');
|
||||
|
||||
const editJWTSecret = () => {
|
||||
setJwtSecretModalState('EDIT');
|
||||
};
|
||||
|
||||
if (jwtSecretModalState === 'EDIT') {
|
||||
return <EditJWTSecretModal close={close} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ShowJWTTokenModal
|
||||
JWTKey={jwtSecret || data}
|
||||
editJWTSecret={editJWTSecret}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Avatar } from '@/ui/Avatar';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import Image from 'next/image';
|
||||
|
||||
export function SelectedWorkspaceOnNewApp({ current }: any) {
|
||||
const user = nhost.auth.getUser();
|
||||
|
||||
return (
|
||||
<div className="flex flex-row space-x-2 self-center">
|
||||
{current.name === 'Default Workspace' ? (
|
||||
<Avatar
|
||||
className="h-5 w-5 self-center rounded-full"
|
||||
name={user?.displayName}
|
||||
avatarUrl={user?.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-5 w-5 overflow-hidden rounded-md">
|
||||
<Image src="/logos/new.svg" alt="Nhost Logo" width={20} height={20} />
|
||||
</div>
|
||||
)}
|
||||
<Text size="small" color="greyscaleDark" className="font-normal">
|
||||
{current.name}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default SelectedWorkspaceOnNewApp;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './AppDeployments';
|
||||
@@ -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>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface ErrorComponentProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
function ErrorComponent({ message }: ErrorComponentProps) {
|
||||
return (
|
||||
<div className="my-4 rounded-md bg-warning px-4 py-2 text-dark">
|
||||
<Text className="font-medium text-textOrange">Error</Text>
|
||||
<Text className="pt-2 font-medium text-dimBlack" size="normal">
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default ErrorComponent;
|
||||
@@ -1,22 +0,0 @@
|
||||
function Copy({ stroke = '#21324B', ...props }: any) {
|
||||
return (
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M10.5 10.5h3v-8h-8v3"
|
||||
stroke={stroke}
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.5 5.5h-8v8h8v-8z"
|
||||
stroke={stroke}
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Copy;
|
||||
@@ -1,29 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
function Eye(props: JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Eye;
|
||||
@@ -1,25 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
function EyeOff(
|
||||
props: JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>,
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default EyeOff;
|
||||
@@ -1,14 +0,0 @@
|
||||
export default function Lock(
|
||||
props: JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>,
|
||||
) {
|
||||
return (
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8 1.75a1.5 1.5 0 0 0-1.5 1.5v1.5h3v-1.5A1.5 1.5 0 0 0 8 1.75Zm-3 1.5v1.5H3c-.69 0-1.25.56-1.25 1.25v7c0 .69.56 1.25 1.25 1.25h10c.69 0 1.25-.56 1.25-1.25V6c0-.69-.56-1.25-1.25-1.25h-2v-1.5a3 3 0 0 0-6 0Zm-1.75 3h9.5v6.5h-9.5v-6.5ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"
|
||||
fill="#21324B"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
function Plus(props: any) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 32 32"
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M16 4.75C9.787 4.75 4.75 9.787 4.75 16S9.787 27.25 16 27.25 27.25 22.213 27.25 16 22.213 4.75 16 4.75zM3.25 16C3.25 8.958 8.958 3.25 16 3.25S28.75 8.958 28.75 16 23.042 28.75 16 28.75 3.25 23.042 3.25 16zm12 .75H10v-1.5h5.25V10h1.5v5.25H22v1.5h-5.25V22h-1.5v-5.25z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Plus;
|
||||
@@ -1,20 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
function TwilioIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={108}
|
||||
height={32}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M16.864 0c8.844 0 15.984 7.147 15.984 16s-7.14 16-15.984 16C8.019 32 .88 24.853.88 16S8.02 0 16.864 0Zm0 4.267c-6.5 0-11.722 5.226-11.722 11.733a11.694 11.694 0 0 0 11.722 11.733c6.5 0 11.721-5.226 11.721-11.733A11.694 11.694 0 0 0 16.864 4.267Zm27.173 2.026c.213-.106.426.107.426.214v4.586h8.525c.106 0 .32.214.32.32l.639 2.667.639 2.667.107.32.106-.32 1.599-5.334c0-.213.213-.32.32-.32h4.262c.106 0 .32.214.32.32l1.704 5.654.107-.32 1.385-5.334c0-.213.213-.32.32-.32h10.869c.106 0 .213.214.213.32V25.6c0 .213-.213.32-.32.32h-5.434c-.213 0-.32-.213-.32-.32V12.16l-4.05 13.44c0 .187-.162.292-.275.315l-.044.005H60.98c-.107 0-.32-.213-.32-.32l-.853-2.88-.959-3.093L56.93 25.6c0 .213-.213.32-.32.32h-4.475c-.106 0-.32-.213-.32-.32l-4.049-13.44v3.413c0 .214-.213.32-.32.32h-3.09v3.627c0 1.067.533 1.493 1.492 1.493.426 0 .96-.106 1.492-.32.106-.106.32 0 .32.214v4.266c-.96.534-2.345.854-3.836.854-3.517 0-5.435-1.6-5.435-5.12v-5.014h-1.385c-.213 0-.32-.213-.32-.32V11.52c0-.213.213-.32.32-.32h1.385V8.32c0-.213.106-.32.32-.32l5.328-1.707Zm54.984 4.48c4.689 0 8.099 3.52 8.099 7.574 0 4.053-3.41 7.573-8.205 7.573-4.689 0-8.099-3.52-8.099-7.573 0-4.054 3.41-7.574 8.205-7.574Zm-16.197-4.48c.213 0 .32.107.32.214v18.986c0 .214-.213.32-.32.32H77.39c-.213 0-.32-.213-.32-.32V6.613c0-.213.213-.32.32-.32h5.434Zm7.14 4.8c.213 0 .32.214.32.32v13.974c0 .213-.214.32-.32.32h-5.435c-.213 0-.32-.214-.32-.32V11.413c0-.213.214-.32.32-.32h5.435ZM12.92 16.64a3.322 3.322 0 0 1 3.303 3.307 3.322 3.322 0 0 1-3.303 3.306 3.322 3.322 0 0 1-3.303-3.306 3.322 3.322 0 0 1 3.303-3.307Zm7.886 0a3.322 3.322 0 0 1 3.303 3.307 3.322 3.322 0 0 1-3.303 3.306 3.322 3.322 0 0 1-3.304-3.306 3.322 3.322 0 0 1 3.304-3.307Zm78.214-.747c-1.385 0-2.344 1.174-2.344 2.56 0 1.387 1.066 2.56 2.344 2.56 1.386 0 2.345-1.173 2.345-2.56 0-1.493-1.066-2.666-2.345-2.56ZM20.807 8.747a3.322 3.322 0 0 1 3.303 3.306 3.322 3.322 0 0 1-3.303 3.307 3.322 3.322 0 0 1-3.304-3.307 3.322 3.322 0 0 1 3.304-3.306Zm-7.886 0a3.322 3.322 0 0 1 3.303 3.306 3.322 3.322 0 0 1-3.303 3.307 3.322 3.322 0 0 1-3.303-3.307 3.322 3.322 0 0 1 3.303-3.306Zm62.87-2.454c.107 0 .213.107.32.214V9.92c0 .213-.213.32-.32.32h-5.647c-.213 0-.32-.213-.32-.32V6.613c0-.213.213-.32.32-.32h5.647Zm14.28 0c.213 0 .319.107.319.214V9.92c0 .213-.213.32-.32.32h-5.647c-.213 0-.32-.213-.32-.32V6.613c0-.213.213-.32.32-.32h5.647Z"
|
||||
fill="#F22F46"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default TwilioIcon;
|
||||
@@ -11,7 +11,7 @@ import Input from '@/ui/v2/Input';
|
||||
import InputAdornment from '@/ui/v2/InputAdornment';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import generateRandomDatabasePassword from '@/utils/settings/generateRandomDatabasePassword/generateRandomDatabasePassword';
|
||||
import generateRandomDatabasePassword from '@/utils/settings/generateRandomDatabasePassword';
|
||||
import { resetDatabasePasswordValidationSchema } from '@/utils/settings/resetDatabasePasswordValidationSchema';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -48,6 +48,7 @@ function SettingsNavLink({
|
||||
return (
|
||||
<ListItem.Root>
|
||||
<ListItem.Button
|
||||
dense
|
||||
href={finalUrl}
|
||||
component={NavLink}
|
||||
selected={active}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
export interface DividerProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Determines the vertical margin of the divider.
|
||||
*
|
||||
* @default 'high'
|
||||
*/
|
||||
spacing?: 'low' | 'medium' | 'high';
|
||||
/**
|
||||
* Arbitrary classnames to be added to the divider.
|
||||
*
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Divider({ spacing = 'high', className }: DividerProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'order-3 mx-auto h-[0.25px] w-full self-stretch bg-verydark opacity-20',
|
||||
spacing === 'low' && 'my-12',
|
||||
spacing === 'medium' && 'my-16',
|
||||
spacing === 'high' && 'my-20',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import type {
|
||||
DetailedHTMLProps,
|
||||
ForwardedRef,
|
||||
HTMLProps,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
export interface InputFieldProps
|
||||
extends DetailedHTMLProps<HTMLProps<HTMLInputElement>, HTMLInputElement> {
|
||||
/**
|
||||
* Props to be passed to the input wrapper.
|
||||
*/
|
||||
wrapperProps?: Omit<
|
||||
DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>,
|
||||
'children'
|
||||
>;
|
||||
/**
|
||||
* Props to be passed to the label element.
|
||||
*/
|
||||
labelProps?: Omit<
|
||||
DetailedHTMLProps<HTMLProps<HTMLLabelElement>, HTMLLabelElement>,
|
||||
'htmlFor' | 'children'
|
||||
>;
|
||||
/**
|
||||
* Input label.
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* Start input adornment for this component.
|
||||
*/
|
||||
startAdornment?: ReactNode;
|
||||
/**
|
||||
* Error to be displayed.
|
||||
*/
|
||||
error?: ReactNode;
|
||||
}
|
||||
|
||||
const InlineInput = forwardRef(
|
||||
(
|
||||
{
|
||||
label,
|
||||
labelProps,
|
||||
startAdornment,
|
||||
wrapperProps,
|
||||
className,
|
||||
error,
|
||||
...props
|
||||
}: InputFieldProps,
|
||||
ref: ForwardedRef<HTMLInputElement>,
|
||||
) => {
|
||||
const { className: labelClassName, ...restLabelProps } = labelProps || {};
|
||||
const { className: wrapperClassName, ...restWrapperProps } =
|
||||
wrapperProps || {};
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<div
|
||||
className={clsx(
|
||||
'grid grid-cols-5 items-center gap-y-1 py-1.5',
|
||||
wrapperClassName,
|
||||
)}
|
||||
{...restWrapperProps}
|
||||
>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={props.id}
|
||||
className={clsx(
|
||||
'col-span-2 text-sm+ font-medium text-greyscaleDark',
|
||||
labelClassName,
|
||||
)}
|
||||
{...restLabelProps}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-row place-content-start items-center rounded-sm px-2 py-1',
|
||||
error
|
||||
? 'outline outline-2 outline-red'
|
||||
: 'focus-within:outline focus-within:outline-2 focus-within:outline-blue',
|
||||
label ? 'col-span-3' : 'col-span-5',
|
||||
)}
|
||||
>
|
||||
{startAdornment && (
|
||||
<label className="flex-shrink-0" htmlFor={props.id}>
|
||||
{startAdornment}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<input
|
||||
className={clsx(
|
||||
'h-full w-full rounded-sm+ px-0.5 font-display text-sm+ text-greyscaleDark focus:outline-none',
|
||||
className,
|
||||
)}
|
||||
aria-invalid={Boolean(error)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="col-span-5 text-right text-xs text-red"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
InlineInput.displayName = 'NhostInlineInput';
|
||||
|
||||
export default InlineInput;
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './InlineInput';
|
||||
export { default } from './InlineInput';
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { PrefetchNewAppPlansFragment } from '@/generated/graphql';
|
||||
import { Text } from '@/ui/Text';
|
||||
import Checkbox from '@/ui/v2/Checkbox';
|
||||
import { planDescriptions } from '@/utils/planDescriptions';
|
||||
import { RadioGroup } from '@headlessui/react';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
interface PlanSelectorProps {
|
||||
options: PrefetchNewAppPlansFragment[];
|
||||
value: PrefetchNewAppPlansFragment;
|
||||
onChange:
|
||||
| React.Dispatch<React.SetStateAction<PrefetchNewAppPlansFragment>>
|
||||
| any;
|
||||
}
|
||||
|
||||
export function PlanSelector({ options, value, onChange }: PlanSelectorProps) {
|
||||
return (
|
||||
<RadioGroup value={value} onChange={onChange}>
|
||||
<RadioGroup.Label className="sr-only">Pricing plans</RadioGroup.Label>
|
||||
<div className="relative divide-y-1 border-t-1 border-b-1 bg-white">
|
||||
{options.map((plan) => (
|
||||
<RadioGroup.Option key={plan.name} value={plan}>
|
||||
{({ checked }) => (
|
||||
<div className="cu flex cursor-pointer flex-row place-content-between items-center py-4 font-display">
|
||||
<RadioGroup.Label
|
||||
as="div"
|
||||
className="flex flex-row font-medium"
|
||||
>
|
||||
<Checkbox
|
||||
aria-describedby="plan"
|
||||
checked={plan.name === value.name}
|
||||
/>
|
||||
|
||||
<div className="flex w-80">
|
||||
<div className=" self-center pl-2 text-xs font-medium text-greyscaleDark">
|
||||
<span className="font-bold">{plan.name}:</span>{' '}
|
||||
<span className="leading-4">
|
||||
{planDescriptions[plan.name]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup.Label>
|
||||
<div className="flex">
|
||||
<span
|
||||
className={clsx(
|
||||
'self-center font-medium',
|
||||
checked ? 'text-indigo-900' : 'text-black',
|
||||
)}
|
||||
>
|
||||
<div className="mr-3 self-center text-lg text-greyscaleDark">
|
||||
{plan.isFree ? (
|
||||
'Free'
|
||||
) : (
|
||||
<div className="flex flex-row">
|
||||
$ {plan.price}{' '}
|
||||
<Text
|
||||
size="tiny"
|
||||
className="ml-1 self-center tracking-wide"
|
||||
>
|
||||
/ mo
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlanSelector;
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { ForwardedRef, PropsWithChildren } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
export interface TooltipProps extends PropsWithChildren<unknown> {
|
||||
/**
|
||||
* Title of the tooltip.
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Determine if the tooltip should be shown.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const Tooltip = forwardRef(
|
||||
(
|
||||
{ title, children, disabled }: TooltipProps,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) => (
|
||||
<div className="group relative" ref={ref}>
|
||||
{children}
|
||||
|
||||
{!disabled && (
|
||||
<div className="absolute left-2 bottom-1 z-50 hidden group-hover:block">
|
||||
<svg
|
||||
className="absolute -top-2 left-3 z-50 mr-3 h-3 w-3 -rotate-180 transform text-greyscaleDark"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 255 255"
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<polygon
|
||||
className="border border-greyscaleDark fill-current text-greyscaleDark"
|
||||
points="0,0 127.5,127.5 255,0"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="text-sharp absolute left-0 z-50 mt-1 w-40 origin-top-left rounded-md bg-greyscaleDark p-2 font-display text-sm- font-medium text-white shadow-2xl">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
Tooltip.displayName = 'NhostTooltip';
|
||||
|
||||
export default Tooltip;
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './Tooltip';
|
||||
export { default } from './Tooltip';
|
||||
@@ -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),
|
||||
|
||||
@@ -34,7 +34,7 @@ const StyledListItemButton = styled(MaterialListItemButton)(({ theme }) => ({
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(),
|
||||
[`&.${getListItemButtonUtilityClass('dense')}`]: {
|
||||
padding: theme.spacing(0.75, 1.25),
|
||||
padding: theme.spacing(1, 1.25),
|
||||
},
|
||||
[`&.${listItemButtonClasses.selected}`]: {
|
||||
backgroundColor: theme.palette.primary.light,
|
||||
|
||||
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';
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Avatar } from '@/ui/Avatar';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import Image from 'next/image';
|
||||
|
||||
export function WorkspaceSelectorNewApp({ option }: any) {
|
||||
const user = nhost.auth.getUser();
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center py-0.5">
|
||||
{option.name === 'Default Workspace' ? (
|
||||
<Avatar
|
||||
className="h-6 w-6 rounded-full"
|
||||
name={user?.displayName}
|
||||
avatarUrl={user?.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-6 w-6 overflow-hidden rounded-md">
|
||||
<Image src="/logos/new.svg" alt="Nhost Logo" width={24} height={24} />
|
||||
</div>
|
||||
)}
|
||||
<Text className="ml-2 font-medium" color="greyscaleDark">
|
||||
{option.name}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkspaceSelectorNewApp;
|
||||
@@ -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,15 +0,0 @@
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export const useGetAppURL = (): {
|
||||
workspaceSlug: string;
|
||||
appSlug: string;
|
||||
} => {
|
||||
const router = useRouter();
|
||||
|
||||
const workspaceSlug = router.query.workspaceSlug as string;
|
||||
const appSlug = router.query.appSlug as string;
|
||||
|
||||
return { workspaceSlug, appSlug };
|
||||
};
|
||||
|
||||
export default useGetAppURL;
|
||||
@@ -1,5 +1,5 @@
|
||||
import Container from '@/components/layout/Container';
|
||||
import AllowedEmailDomainsSettings from '@/components/settings/authentication/AllowedEmailSettings/AllowedEmailSettings';
|
||||
import AllowedEmailDomainsSettings from '@/components/settings/authentication/AllowedEmailSettings';
|
||||
import AllowedRedirectURLsSettings from '@/components/settings/authentication/AllowedRedirectURLsSettings';
|
||||
import BlockedEmailSettings from '@/components/settings/authentication/BlockedEmailSettings';
|
||||
import ClientURLSettings from '@/components/settings/authentication/ClientURLSettings';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Container from '@/components/layout/Container';
|
||||
import PermissionVariableSettings from '@/components/settings/permissions/PermissionVariableSettings';
|
||||
import RolesSettings from '@/components/settings/roles/RoleSettings/RoleSettings';
|
||||
import RolesSettings from '@/components/settings/roles/RoleSettings';
|
||||
import SettingsLayout from '@/components/settings/SettingsLayout';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import { getErrorMessage } from '@/utils/getErrorMessage';
|
||||
import { getCurrentEnvironment, slugifyString } from '@/utils/helpers';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import { planDescriptions } from '@/utils/planDescriptions';
|
||||
import generateRandomDatabasePassword from '@/utils/settings/generateRandomDatabasePassword/generateRandomDatabasePassword';
|
||||
import generateRandomDatabasePassword from '@/utils/settings/generateRandomDatabasePassword';
|
||||
import { resetDatabasePasswordValidationSchema } from '@/utils/settings/resetDatabasePasswordValidationSchema';
|
||||
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export type Provider = {
|
||||
name: string;
|
||||
logo: string;
|
||||
active: boolean;
|
||||
docsLink: string;
|
||||
};
|
||||
|
||||
export type Providers = {
|
||||
providers: Provider[];
|
||||
};
|
||||
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
@@ -1,49 +0,0 @@
|
||||
import { resolveProvider } from './resolveProvider';
|
||||
|
||||
export function getDynamicVariables(providerId, vars, prefill = false) {
|
||||
const authEnabled = `auth${resolveProvider(providerId as string)}Enabled`;
|
||||
const authClientId = `auth${resolveProvider(providerId as string)}ClientId`;
|
||||
const authClientSecret = `auth${resolveProvider(
|
||||
providerId as string,
|
||||
)}ClientSecret`;
|
||||
|
||||
// @TODO: check prefill, use only one function: there's another one with the same functionality
|
||||
// in providerId.tsx.
|
||||
if (providerId === 'twitter') {
|
||||
return {
|
||||
authEnabled: 'authTwitterEnabled',
|
||||
authClientId: 'authTwitterConsumerKey',
|
||||
authClientSecret: 'authTwitterConsumerSecret',
|
||||
};
|
||||
}
|
||||
|
||||
if (providerId === 'apple') {
|
||||
return {
|
||||
authEnabled: 'authAppleEnabled',
|
||||
authClientId: 'authAppleKeyId',
|
||||
authClientSecret: 'authApplePrivateKey',
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
authProviderEnabled,
|
||||
authProviderClientId,
|
||||
authProviderClientSecret,
|
||||
} = vars;
|
||||
|
||||
if (prefill) {
|
||||
return {
|
||||
authEnabled,
|
||||
authClientId,
|
||||
authClientSecret,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
[authEnabled]: authProviderEnabled,
|
||||
[authClientId]: authProviderClientId,
|
||||
[authClientSecret]: authProviderClientSecret,
|
||||
};
|
||||
}
|
||||
|
||||
export default getDynamicVariables;
|
||||
@@ -1,15 +0,0 @@
|
||||
import { capitalize } from './helpers';
|
||||
|
||||
export const resolveProvider = (providerId: string) => {
|
||||
if (providerId.toLowerCase() === 'microsoft') {
|
||||
return 'WindowsLive';
|
||||
}
|
||||
|
||||
if (providerId.toLowerCase() === 'workos') {
|
||||
return 'WorkOs';
|
||||
}
|
||||
|
||||
return capitalize(providerId);
|
||||
};
|
||||
|
||||
export default resolveProvider;
|
||||
@@ -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';
|
||||
@@ -1,23 +0,0 @@
|
||||
import validator from 'validator';
|
||||
|
||||
export const validateDomainsInput = (domainsFromInput: string) => {
|
||||
if (domainsFromInput.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const domains: string[] = [];
|
||||
let checkedDomains: boolean[] = [];
|
||||
|
||||
domainsFromInput.split(',').forEach((email) => {
|
||||
domains.push(email.replace(' ', ''));
|
||||
});
|
||||
checkedDomains = domains.map((domain) => {
|
||||
if (!validator.isFQDN(domain)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return checkedDomains.includes(false);
|
||||
};
|
||||
|
||||
export default validateDomainsInput;
|
||||
@@ -1,23 +0,0 @@
|
||||
import validator from 'validator';
|
||||
|
||||
export const validateEmailInputs = (emailsFromInput: string) => {
|
||||
if (emailsFromInput.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const emails: string[] = [];
|
||||
let checkEmails: boolean[] = [];
|
||||
|
||||
emailsFromInput.split(',').forEach((email) => {
|
||||
emails.push(email.replace(' ', ''));
|
||||
});
|
||||
checkEmails = emails.map((email) => {
|
||||
if (!validator.isEmail(email)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return checkEmails.includes(false);
|
||||
};
|
||||
|
||||
export default validateEmailInputs;
|
||||
@@ -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:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user