Compare commits

..

43 Commits

Author SHA1 Message Date
Szilárd Dóró
fe3c462099 Merge pull request #1217 from nhost/fix/vercel-pipeline
fix(changesets): add missing `pnpm` command, pre-build project
2022-11-26 11:49:08 +01:00
Szilárd Dóró
f8b082cb02 chore(changesets): split Vercel CLI command 2022-11-25 16:59:56 +01:00
Pilou
e2c4ca85b3 Merge pull request #1219 from nhost/docs/docker-compose-dashboard
docs(docker-compose): add the dashboard to the docker-compose example
2022-11-25 16:38:54 +01:00
Szilárd Dóró
0165b998c2 chore(changesets): create separate step for Vercel 2022-11-25 16:33:09 +01:00
Pierre-Louis Mercereau
5d970cc229 feat(docs): add the dashboard to the docker-compose example 2022-11-25 16:14:46 +01:00
Szilárd Dóró
69db1594cc fix(changesets): add missing pnpm command, pre-build project 2022-11-25 14:36:54 +01:00
Szilárd Dóró
158cf0da49 Merge pull request #1216 from nhost/changeset-release/main
chore: update versions
2022-11-25 14:12:10 +01:00
github-actions[bot]
7992fc3baa chore: update versions 2022-11-25 13:09:54 +00:00
Szilárd Dóró
16d383516e Merge pull request #1206 from nhost/feat/settings-roles-and-permissions
feat(dashboard): Roles and Permissions
2022-11-25 14:08:13 +01:00
Szilárd Dóró
29cdf6b125 Merge pull request #1214 from nhost/elitan-patch-3
Update serverless-functions.mdx
2022-11-25 13:51:24 +01:00
Johan Eliasson
6b67c9996a Update serverless-functions.mdx 2022-11-25 12:50:39 +01:00
Szilárd Dóró
23274dee41 chore(docs): add changeset 2022-11-25 11:59:42 +01:00
Szilárd Dóró
a5b55c2667 chore(docs): update permission variables image 2022-11-25 11:58:46 +01:00
Szilárd Dóró
1263676eb3 fix(dashboard): permission variable validation 2022-11-25 11:49:46 +01:00
Szilárd Dóró
b1b647ad96 fix(dashboard): reset default role on role removal 2022-11-25 11:34:51 +01:00
Szilárd Dóró
21bbaf5e95 feat(dashboard): add docs link to roles section 2022-11-25 10:56:14 +01:00
Szilárd Dóró
eef9c91403 feat(dashboard): add support for default roles
- remove unnecessary helper labels from roles and permissions
2022-11-25 10:55:20 +01:00
Johan Eliasson
1742cb444d Merge pull request #1212 from nhost/szilarddoro-patch-1
chore(docs): add WorkOS to Authentication page
2022-11-25 10:49:45 +01:00
Szilárd Dóró
c4f374d7f3 Merge remote-tracking branch 'origin/main' into feat/settings-roles-and-permissions 2022-11-25 09:36:57 +01:00
Szilárd Dóró
369ec13070 chore(docs): add WorkOS to Authentication page 2022-11-25 09:36:21 +01:00
Szilárd Dóró
101129eef2 Update dashboard/src/components/settings/permissions/PermissionVariableSettings/PermissionVariableSettings.tsx
Co-authored-by: Nuno Pato <nunopato@gmail.com>
2022-11-25 09:33:33 +01:00
Szilárd Dóró
228fda0364 Merge pull request #1210 from nhost/fix/vercel-deployment 2022-11-25 09:05:57 +01:00
Szilárd Dóró
74085c67a2 fix(dashboard): correct production deployment 2022-11-24 22:31:11 +01:00
Szilárd Dóró
a273725419 Merge pull request #1209 from nhost/fix/precommit-hook
fix(docgen): prevent docgen from breaking the pre-commit hook
2022-11-24 21:07:40 +01:00
Pierre-Louis Mercereau
c5240f8d74 docs: ✏️ remove irrelevant workaround to fixed docgen git hook 2022-11-24 20:53:17 +01:00
Szilárd Dóró
4490068257 chore(docgen): copy binary to node_modules
execute `pnpm i` to copy the `docgen` binary to every package
2022-11-24 20:41:59 +01:00
Szilárd Dóró
3d151c448c chore(precommit): extend precommit hook with docgen build 2022-11-24 19:42:23 +01:00
Szilárd Dóró
fdd417ed25 feat(dashboard): add router cancellation to permission form
- chore(dashboard): update terminology
2022-11-24 18:03:04 +01:00
Szilárd Dóró
4416ceb9cf chore(dashboard): cleanup unused files 2022-11-24 17:00:35 +01:00
Szilárd Dóró
4762ebf61e fix(dashboard): correct original value for role editing 2022-11-24 16:54:01 +01:00
Szilárd Dóró
73e28b5831 feat(dashboard): simplify role management form 2022-11-24 16:52:26 +01:00
Szilárd Dóró
2a7dc5060f feat(dashboard): add support for permission variable management 2022-11-24 16:25:14 +01:00
Szilárd Dóró
9b8ede40a9 feat(dashboard): prevent invalid characters for variables 2022-11-24 15:50:14 +01:00
Szilárd Dóró
f005c20d99 feat(dashboard): add modals for permission variable management 2022-11-24 15:23:30 +01:00
Szilárd Dóró
4adfd613b6 feat(dashboard): support variable listing
- chore(dashboard): rename RolesSettings to RoleSettings
2022-11-24 15:01:51 +01:00
Szilárd Dóró
b6da82c8e3 fix(dashboard): wrong tooltip appearance 2022-11-24 14:25:49 +01:00
Szilárd Dóró
816456edc4 fix(dashboard): remove unnecessary manual focus 2022-11-24 14:24:07 +01:00
Szilárd Dóró
deaf0e86d4 fix(dashboard): changed role form's logic 2022-11-24 14:16:02 +01:00
Szilárd Dóró
23f8206f18 feat(dashboard): add support for role deletion 2022-11-24 12:46:41 +01:00
Szilárd Dóró
9dde4d7988 feat(dashboard): add support for role editing 2022-11-24 12:31:48 +01:00
Szilárd Dóró
26385b9cf9 fix(dashboard): fix ESLint working directories 2022-11-24 12:17:50 +01:00
Szilárd Dóró
6d318206ef feat(dashboard): finalize Create Role modal
- fix(dashboard): lint errors
- chore(dashboard): reduce max allowed linter warnings
2022-11-24 12:11:41 +01:00
Szilárd Dóró
4d727b78a1 feat(dashboard): updated Roles and Permissions page
- created modal for role creation
2022-11-24 11:46:56 +01:00
64 changed files with 1302 additions and 1921 deletions

View File

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

4
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,11 @@
# @nhost/dashboard
## 0.6.0
### Minor Changes
- eef9c914: feat(dashboard): add Roles and Permissions page
## 0.5.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/dashboard",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -8,7 +8,7 @@
"build": "next build --no-lint",
"analyze": "ANALYZE=true pnpm build --no-lint",
"start": "next start",
"lint": "next lint --max-warnings 6",
"lint": "next lint --max-warnings 3",
"test": "vitest",
"codegen": "graphql-codegen --config graphql.config.yaml --errors-only",
"nhost:dev": "nhost dev -d",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,9 @@ export type DialogType =
| 'CREATE_TABLE'
| 'EDIT_TABLE'
| 'CREATE_FOREIGN_KEY'
| 'EDIT_FOREIGN_KEY';
| 'EDIT_FOREIGN_KEY'
| 'MANAGE_ROLE'
| 'MANAGE_PERMISSION_VARIABLE';
export interface DialogConfig<TPayload = unknown> {
/**

View File

@@ -1,6 +1,8 @@
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
import { CreateForeignKeyForm } from '@/components/data-browser/CreateForeignKeyForm';
import { EditForeignKeyForm } from '@/components/data-browser/EditForeignKeyForm';
import CreateForeignKeyForm from '@/components/data-browser/CreateForeignKeyForm';
import EditForeignKeyForm from '@/components/data-browser/EditForeignKeyForm';
import PermissionVariableForm from '@/components/settings/permissions/PermissionVariableForm';
import RoleForm from '@/components/settings/roles/RoleForm';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import AlertDialog from '@/ui/v2/AlertDialog';
import { BaseDialog } from '@/ui/v2/Dialog';
@@ -8,6 +10,7 @@ import Drawer from '@/ui/v2/Drawer';
import dynamic from 'next/dynamic';
import type { BaseSyntheticEvent, PropsWithChildren } from 'react';
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import type { DialogConfig, DialogType } from './DialogContext';
import DialogContext from './DialogContext';
import {
@@ -249,7 +252,13 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
open={dialogOpen}
onClose={closeDialogWithDirtyGuard}
TransitionProps={{ onExited: clearDialogContent, unmountOnExit: false }}
PaperProps={{ className: 'max-w-md w-full' }}
PaperProps={{
...dialogProps?.PaperProps,
className: twMerge(
'max-w-md w-full',
dialogProps?.PaperProps?.className,
),
}}
>
<RetryableErrorBoundary
errorMessageProps={{ className: 'pt-0 pb-5 px-6' }}
@@ -258,7 +267,7 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
<CreateForeignKeyForm
{...dialogPayload}
onSubmit={async (values) => {
await dialogPayload?.onSubmit(values);
await dialogPayload?.onSubmit?.(values);
closeDialog();
}}
@@ -270,7 +279,31 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
<EditForeignKeyForm
{...dialogPayload}
onSubmit={async (values) => {
await dialogPayload?.onSubmit(values);
await dialogPayload?.onSubmit?.(values);
closeDialog();
}}
onCancel={closeDialogWithDirtyGuard}
/>
)}
{activeDialogType === 'MANAGE_ROLE' && (
<RoleForm
{...dialogPayload}
onSubmit={async (values) => {
await dialogPayload?.onSubmit?.(values);
closeDialog();
}}
onCancel={closeDialogWithDirtyGuard}
/>
)}
{activeDialogType === 'MANAGE_PERMISSION_VARIABLE' && (
<PermissionVariableForm
{...dialogPayload}
onSubmit={async (values) => {
await dialogPayload?.onSubmit?.(values);
closeDialog();
}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,7 +33,7 @@ export default function SettingsLayout({
{...sidebarProps}
/>
<div className="flex w-full flex-auto flex-col overflow-x-hidden">
<div className="flex w-full flex-auto flex-col overflow-x-hidden bg-[#fafafa]">
{children}
</div>
</ProjectLayout>

View File

@@ -0,0 +1,176 @@
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 * as Yup from 'yup';
export interface PermissionVariableFormValues {
/**
* Permission variable key.
*/
key: string;
/**
* Permission variable value.
*/
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;
/**
* Function to be called when the form is submitted.
*/
onSubmit: (values: PermissionVariableFormValues) => void;
/**
* Function to be called when the operation is cancelled.
*/
onCancel?: VoidFunction;
/**
* Submit button text.
*
* @default 'Save'
*/
submitButtonText?: string;
}
const validationSchema = Yup.object({
key: Yup.string().required('This field is required.'),
value: Yup.string().required('This field is required.'),
});
export default function PermissionVariableForm({
availableVariables,
originalVariable,
onSubmit,
onCancel,
submitButtonText = 'Save',
}: PermissionVariableFormProps) {
const { onDirtyStateChange } = useDialog();
const form = useForm<PermissionVariableFormValues>({
defaultValues: {
key: originalVariable?.key || '',
value: originalVariable?.value || '',
},
resolver: yupResolver(validationSchema),
});
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"
>
<Input
{...register('key', {
onChange: (event) => {
if (
event.target.value &&
!/^[a-zA-Z-]+$/gi.test(event.target.value)
) {
// we need to prevent invalid characters from being entered
// eslint-disable-next-line no-param-reassign
event.target.value = event.target.value.replace(
/[^a-zA-Z-]/gi,
'',
);
}
},
})}
id="key"
label="Field Name"
hideEmptyHelperText
error={!!errors.key}
helperText={errors?.key?.message}
fullWidth
autoComplete="off"
autoFocus
slotProps={{ input: { className: '!pl-px' } }}
startAdornment={
<Text className="shrink-0 pl-2 text-greyscaleGrey">X-Hasura-</Text>
}
/>
<Input
{...register('value', {
onChange: (event) => {
if (
event.target.value &&
!/^[a-zA-Z-_.[\]]+$/gi.test(event.target.value)
) {
// we need to prevent invalid characters from being entered
// eslint-disable-next-line no-param-reassign
event.target.value = event.target.value.replace(
/[^a-zA-Z-.[\]]/gi,
'',
);
}
},
})}
id="value"
label="Path"
hideEmptyHelperText
error={!!errors.value}
helperText={errors?.value?.message}
fullWidth
autoComplete="off"
slotProps={{ input: { className: '!pl-px' } }}
startAdornment={
<Text className="shrink-0 pl-2 text-greyscaleGrey">user.</Text>
}
/>
<div className="grid grid-flow-row gap-2">
<Button type="submit" loading={isSubmitting}>
{submitButtonText}
</Button>
<Button variant="outlined" color="secondary" onClick={onCancel}>
Cancel
</Button>
</div>
</Form>
</FormProvider>
);
}

View File

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

View File

@@ -0,0 +1,356 @@
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';
import Button from '@/ui/v2/Button';
import Divider from '@/ui/v2/Divider';
import { Dropdown } from '@/ui/v2/Dropdown';
import IconButton from '@/ui/v2/IconButton';
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
import LockIcon from '@/ui/v2/icons/LockIcon';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
import Tooltip from '@/ui/v2/Tooltip';
import { 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 toast from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
export interface PermissionVariableSettingsFormValues {
/**
* Permission variables.
*/
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();
const { data, loading, error } = useGetAppCustomClaimsQuery({
variables: {
id: currentApplication?.id,
},
});
const [updateApp] = useUpdateAppMutation({
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..." />
);
}
if (error) {
throw error;
}
const { setValue, formState, watch } = form;
const availableCustomClaims = watch('authJwtCustomClaims');
function handleAddVariable({ key, value }: PermissionVariableFormValues) {
setValue(
'authJwtCustomClaims',
[...availableCustomClaims, { key, value }],
{ shouldDirty: true },
);
}
function handleEditVariable(
{ key, value }: PermissionVariableFormValues,
originalVariable: CustomClaim,
) {
const originalIndex = availableCustomClaims.findIndex(
(customClaim) => customClaim.key === originalVariable.key,
);
const updatedVariables = availableCustomClaims.map((customClaim, index) =>
index === originalIndex ? { key, value } : customClaim,
);
setValue('authJwtCustomClaims', updatedVariables, { shouldDirty: true });
}
function handleRemoveVariable({ key }: CustomClaim) {
const filteredCustomClaims = availableCustomClaims.filter(
(customClaim) => customClaim.key !== key,
);
setValue('authJwtCustomClaims', filteredCustomClaims, {
shouldDirty: true,
});
}
function handleOpenCreator() {
openDialog('MANAGE_PERMISSION_VARIABLE', {
title: (
<span className="grid grid-flow-row">
<span>Add Permission Variable</span>
<Text variant="subtitle1" component="span">
Enter the field name and the path you want to use in this permission
variable.
</Text>
</span>
),
payload: {
availableVariables: availableCustomClaims,
submitButtonText: 'Add',
onSubmit: handleAddVariable,
},
props: { PaperProps: { className: 'max-w-sm' } },
});
}
function handleOpenEditor(originalVariable: CustomClaim) {
openDialog('MANAGE_PERMISSION_VARIABLE', {
title: (
<span className="grid grid-flow-row">
<span>Edit Permission Variable</span>
<Text variant="subtitle1" component="span">
Enter the field name and the path you want to use in this permission
variable.
</Text>
</span>
),
payload: {
availableVariables: availableCustomClaims,
originalVariable,
onSubmit: (values: PermissionVariableFormValues) =>
handleEditVariable(values, originalVariable),
},
props: { PaperProps: { className: 'max-w-sm' } },
});
}
function handleConfirmRemove(originalVariable: CustomClaim) {
openAlertDialog({
title: 'Remove Permission Variable',
payload: (
<Text>
Are you sure you want to remove the &quot;
<strong>X-Hasura-{originalVariable.key}</strong>&quot; permission
variable?
</Text>
),
props: {
onPrimaryAction: () => handleRemoveVariable(originalVariable),
primaryButtonColor: 'error',
primaryButtonText: 'Remove',
},
});
}
async function handleSubmit(values: PermissionVariableSettingsFormValues) {
const updateAppPromise = updateApp({
variables: {
id: currentApplication.id,
app: {
authJwtCustomClaims: values.authJwtCustomClaims
.filter((customClaim) => !customClaim.isSystemClaim)
.reduce(
(authJwtCustomClaims, claim) => ({
...authJwtCustomClaims,
[claim.key]: claim.value,
}),
{},
),
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Updating permission variables...',
success: 'Permission variables have been updated successfully.',
error: 'An error occurred while updating permission variables.',
},
toastStyleProps,
);
}
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>
<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" />
)}
</>
}
/>
<Text className="font-medium">
user.{customClaim.value}
</Text>
</ListItem.Root>
<Divider
component="li"
className={twMerge(
index === availableCustomClaims.length - 1
? '!mt-4'
: '!my-4',
)}
/>
</Fragment>
))}
</List>
<Button
className="justify-self-start mx-4"
variant="borderless"
startIcon={<PlusIcon />}
onClick={handleOpenCreator}
>
Add Permission Variable
</Button>
</div>
</SettingsContainer>
</Form>
</FormProvider>
);
}

View File

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

View File

@@ -0,0 +1,119 @@
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 { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import * as Yup from 'yup';
export interface RoleFormValues {
/**
* 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;
/**
* Function to be called when the form is submitted.
*/
onSubmit: (values: RoleFormValues) => void;
/**
* Function to be called when the operation is cancelled.
*/
onCancel?: VoidFunction;
/**
* Submit button text.
*
* @default 'Save'
*/
submitButtonText?: string;
}
const validationSchema = Yup.object({
name: Yup.string().required('This field is required.'),
});
export default function RoleForm({
availableRoles,
originalRole,
onSubmit,
onCancel,
submitButtonText = 'Save',
}: RoleFormProps) {
const { onDirtyStateChange } = useDialog();
const form = useForm<RoleFormValues>({
defaultValues: {
name: originalRole?.name || '',
},
resolver: yupResolver(validationSchema),
});
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"
>
<Input
{...register('name')}
inputProps={{ maxLength: 100 }}
id="name"
label="Name"
placeholder="Enter value"
hideEmptyHelperText
error={!!errors.name}
helperText={errors?.name?.message}
fullWidth
autoComplete="off"
autoFocus
/>
<div className="grid grid-flow-row gap-2">
<Button type="submit" loading={isSubmitting}>
{submitButtonText}
</Button>
<Button variant="outlined" color="secondary" onClick={onCancel}>
Cancel
</Button>
</div>
</Form>
</FormProvider>
);
}

View File

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

View File

@@ -0,0 +1,353 @@
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';
import Button from '@/ui/v2/Button';
import Chip from '@/ui/v2/Chip';
import Divider from '@/ui/v2/Divider';
import { Dropdown } from '@/ui/v2/Dropdown';
import IconButton from '@/ui/v2/IconButton';
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
import LockIcon from '@/ui/v2/icons/LockIcon';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
import { 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 toast from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
export interface RoleSettingsFormValues {
/**
* Default role.
*/
authUserDefaultRole: string;
/**
* Allowed roles for the project.
*/
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();
const { data, loading, error } = useGetRolesQuery({
variables: { id: currentApplication?.id },
});
const [updateApp] = useUpdateAppMutation({
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..." />;
}
if (error) {
throw error;
}
const { setValue, formState, watch } = form;
const defaultRole = watch('authUserDefaultRole');
const availableRoles = watch('authUserDefaultAllowedRoles');
function handleAddRole({ name }: RoleFormValues) {
setValue(
'authUserDefaultAllowedRoles',
[...availableRoles, { name, isSystemRole: false }],
{ shouldDirty: true },
);
}
function handleEditRole({ name }: RoleFormValues, originalRole: Role) {
const originalIndex = availableRoles.findIndex(
(role) => role.name === originalRole.name,
);
const updatedRoles = availableRoles.map((role, index) =>
index === originalIndex ? { name, isSystemRole: false } : role,
);
setValue('authUserDefaultAllowedRoles', updatedRoles, {
shouldDirty: true,
});
}
function handleRemoveRole({ name }: Role) {
const filteredRoles = availableRoles.filter((role) => role.name !== name);
if (name === defaultRole) {
setValue('authUserDefaultRole', 'user', { shouldDirty: true });
}
setValue('authUserDefaultAllowedRoles', filteredRoles, {
shouldDirty: true,
});
}
function handleOpenCreator() {
openDialog('MANAGE_ROLE', {
title: (
<span className="grid grid-flow-row">
<span>Add Role</span>
<Text variant="subtitle1" component="span">
Enter the name for the role below.
</Text>
</span>
),
payload: {
availableRoles,
submitButtonText: 'Create',
onSubmit: handleAddRole,
},
props: { PaperProps: { className: 'max-w-sm' } },
});
}
function handleOpenEditor(originalRole: Role) {
openDialog('MANAGE_ROLE', {
title: (
<span className="grid grid-flow-row">
<span>Edit Role</span>
<Text variant="subtitle1" component="span">
Enter the name for the role below.
</Text>
</span>
),
payload: {
originalRole,
availableRoles,
onSubmit: (values: RoleFormValues) =>
handleEditRole(values, originalRole),
},
props: { PaperProps: { className: 'max-w-sm' } },
});
}
function handleConfirmRemove(originalRole: Role) {
openAlertDialog({
title: 'Remove Role',
payload: (
<Text>
Are you sure you want to remove the &quot;
<strong>{originalRole.name}</strong>&quot; role?
</Text>
),
props: {
onPrimaryAction: () => handleRemoveRole(originalRole),
primaryButtonColor: 'error',
primaryButtonText: 'Remove',
},
});
}
function handleSetAsDefault(role: Role) {
setValue('authUserDefaultRole', role.name, { shouldDirty: true });
}
async function handleSubmit(values: RoleSettingsFormValues) {
const updateAppPromise = updateApp({
variables: {
id: currentApplication?.id,
app: {
authUserDefaultRole: values.authUserDefaultRole,
authUserDefaultAllowedRoles: values.authUserDefaultAllowedRoles
.map(({ name }) => name)
.join(','),
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Updating roles...',
success: 'Roles have been updated successfully.',
error: 'An error occurred while updating roles.',
},
toastStyleProps,
);
}
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>
<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',
}}
primary={
<>
{role.name}
{role.isSystemRole && (
<LockIcon className="w-4 h-4" />
)}
{defaultRole === 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}
>
Add Role
</Button>
</div>
</SettingsContainer>
</Form>
</FormProvider>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -126,7 +126,7 @@ export default function SettingsGeneralPage() {
return (
<Container
className="grid max-w-5xl grid-flow-row gap-8 bg-transparent"
wrapperClassName="bg-fafafa"
rootClassName="bg-transparent"
>
<FormProvider {...form}>
<Form onSubmit={handleProjectNameChange}>
@@ -194,13 +194,5 @@ export default function SettingsGeneralPage() {
}
SettingsGeneralPage.getLayout = function getLayout(page: ReactElement) {
return (
<SettingsLayout
mainContainerProps={{
className: 'bg-fafafa',
}}
>
{page}
</SettingsLayout>
);
return <SettingsLayout>{page}</SettingsLayout>;
};

View File

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

View File

@@ -1,386 +1,21 @@
import CreatePermissionVariableModal from '@/components/applications/users/permissions/modal/CreatePermissionVariableModal';
import EditPermissionVariableModal from '@/components/applications/users/permissions/modal/EditPermissionVariableModal';
import { PermissionSetting } from '@/components/applications/users/PermissionSetting';
import { RolesTable } from '@/components/applications/users/RolesTable';
import { SettingsSection } from '@/components/applications/users/SettingsSection';
import ErrorBoundaryFallback from '@/components/common/ErrorBoundaryFallback';
import Container from '@/components/layout/Container';
import PermissionVariableSettings from '@/components/settings/permissions/PermissionVariableSettings';
import RolesSettings from '@/components/settings/roles/RoleSettings/RoleSettings';
import SettingsLayout from '@/components/settings/SettingsLayout';
import { useWorkspaceContext } from '@/context/workspace-context';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import useCustomClaims from '@/hooks/useCustomClaims';
import { useSubmitState } from '@/hooks/useSubmitState';
import type { CustomClaim } from '@/types/application';
import { Alert } from '@/ui/Alert';
import Loading from '@/ui/Loading';
import { Modal } from '@/ui/Modal';
import { Text } from '@/ui/Text';
import { showLoadingToast, triggerToast } from '@/utils/toast';
import {
useGetRolesQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { useApolloClient } from '@apollo/client';
import { ChevronRightIcon } from '@heroicons/react/solid';
import clsx from 'clsx';
import Link from 'next/link';
import type { KeyboardEvent, MouseEvent, ReactElement } from 'react';
import { useEffect, useReducer, useRef, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import toast from 'react-hot-toast';
type ModalState = {
visible: boolean;
type: 'create' | 'edit';
payload: CustomClaim;
};
type ModalAction = {
type: 'OPEN_CREATE_MODAL' | 'OPEN_EDIT_MODAL' | 'CLOSE_MODAL';
payload?: CustomClaim;
};
function modalStateReducer(state: ModalState, action: ModalAction): ModalState {
switch (action.type) {
case 'OPEN_CREATE_MODAL':
return { ...state, visible: true, type: 'create', payload: null };
case 'OPEN_EDIT_MODAL':
return { ...state, visible: true, type: 'edit', payload: action.payload };
case 'CLOSE_MODAL':
return { ...state, visible: false };
default:
throw new Error(`Action type ${action.type} is not supported.`);
}
}
function PermissionVariablesTable({ appId }: any) {
const [
{ visible: modalVisible, type: modalType, payload: modalPayload },
dispatch,
] = useReducer(modalStateReducer, {
visible: false,
type: null,
payload: null,
});
const { data: customClaims, loading, error } = useCustomClaims({ appId });
if (loading) {
return <Loading />;
}
if (error) {
throw error;
}
function handlePermissionSelect(
event: MouseEvent<HTMLTableRowElement> | KeyboardEvent<HTMLTableRowElement>,
claim: CustomClaim,
) {
if ('key' in event && event.key !== 'Enter') {
return;
}
dispatch({ type: 'OPEN_EDIT_MODAL', payload: claim });
}
import type { ReactElement } from 'react';
export default function RolesAndPermissionsPage() {
return (
<>
<Modal
showModal={modalVisible}
close={() => dispatch({ type: 'CLOSE_MODAL' })}
>
{modalType === 'create' ? (
<CreatePermissionVariableModal
onClose={() => dispatch({ type: 'CLOSE_MODAL' })}
/>
) : (
<EditPermissionVariableModal
onClose={() => dispatch({ type: 'CLOSE_MODAL' })}
payload={modalPayload}
/>
)}
</Modal>
<table className="w-full table-fixed overflow-x-auto">
<thead>
<tr>
<th className="w-60 p-2 text-left">
<Text className="text-xs font-bold text-greyscaleDark">
Field name
</Text>
</th>
<th className="w-full p-2 text-left">
<Text className="text-xs font-bold text-greyscaleDark">Path</Text>
</th>
</tr>
</thead>
<tbody>
{customClaims.map((claim, index) => (
<tr
role={claim.system ? undefined : 'button'}
tabIndex={claim.system ? undefined : 0}
onClick={
claim.system
? undefined
: (event) => handlePermissionSelect(event, claim)
}
onKeyDown={
claim.system
? undefined
: (event) => handlePermissionSelect(event, claim)
}
aria-label={claim.key}
className="border-t-1 border-solid border-gray-300"
key={claim.key || index}
>
<td className="p-2">
<Text
className={clsx(
claim.system ? 'text-greyscaleGrey' : 'text-greyscaleDark',
'text-sm+ font-medium',
)}
>
X-Hasura-{claim.key}
</Text>
</td>
<td className="flex items-center justify-between p-2">
<Text
className={clsx(
claim.system ? 'text-greyscaleGrey' : 'text-greyscaleDark',
'text-sm+',
)}
>
user.{claim.value}
</Text>
{claim.system ? (
<Text className="text-sm+ font-medium uppercase tracking-wide text-greyscaleGrey">
System
</Text>
) : (
<ChevronRightIcon className="h-4.5 w-4.5 text-greyscaleDark" />
)}
</td>
</tr>
))}
<tr className="border-y-1 border-solid border-gray-300">
<td className="p-2">
<button
type="button"
onClick={() => dispatch({ type: 'OPEN_CREATE_MODAL' })}
>
<Text className="text-sm+ font-medium text-blue">
New Permission Variable
</Text>
</button>
</td>
<td />
</tr>
</tbody>
</table>
</>
);
}
function UserRoles() {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetRolesQuery({
variables: {
id: currentApplication.id,
},
});
if (loading) {
return <Loading />;
}
if (error) {
return (
<div className="mx-auto max-w-2.5xl">
<Alert severity="error">{error.message}</Alert>
</div>
);
}
return (
<SettingsSection
title="Default Allowed Roles"
wrapperProps={{
className: 'mt-12 mb-32',
}}
<Container
className="grid grid-flow-row gap-6 max-w-5xl bg-transparent"
rootClassName="bg-transparent"
>
<RolesTable data={data} />
</SettingsSection>
);
}
function DefaultRoleInAPIRequests() {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const toastId = useRef(null);
const { data, loading, error } = useGetRolesQuery({
variables: {
id: currentApplication.id,
},
});
const [currentDefaultRole, setCurrentDefaultRole] = useState({
id: 'user',
name: 'user',
disabled: false,
slug: 'user',
});
const [currentAvailableRoles, setCurrentAvailableRoles] = useState([
{
id: 'user',
name: 'user',
disabled: false,
slug: 'user',
},
]);
useEffect(() => {
if (!data) {
return;
}
setCurrentDefaultRole({
disabled: false,
id: data.app.authUserDefaultRole,
slug: data.app.authUserDefaultRole,
name: data.app.authUserDefaultRole,
});
setCurrentAvailableRoles(
data.app.authUserDefaultAllowedRoles.split(',').map((role) => ({
disabled: false,
id: role,
slug: role,
name: role,
})),
);
}, [data]);
const { submitState, setSubmitState } = useSubmitState();
const [updateApp] = useUpdateAppMutation();
const client = useApolloClient();
if (loading) {
return <Loading />;
}
if (error) {
return (
<div className="mx-auto max-w-2.5xl">
<Alert severity="error">{error.message}</Alert>
</div>
);
}
return (
<div className="mt-2 flex flex-col divide-y-1 divide-divide border-t border-b">
{submitState.error && (
<Alert severity="error">{submitState.error.message}</Alert>
)}
<PermissionSetting
text="Default Role"
options={currentAvailableRoles}
value={currentDefaultRole}
onChange={async (v: { id: string }) => {
try {
toastId.current = showLoadingToast('Changing default role');
await updateApp({
variables: {
id: currentApplication.id,
app: {
authUserDefaultRole: v.id,
},
},
});
await client.refetchQueries({
include: ['getRoles'],
});
toast.remove(toastId.current);
triggerToast(
`Successfully changed default role to: ${currentApplication.name}`,
);
} catch (appUpdateError) {
if (toastId) {
toast.remove(toastId.current);
}
if (appUpdateError instanceof Error) {
triggerToast(appUpdateError.message);
}
setSubmitState({
loading: false,
error: appUpdateError,
fieldsWithError: ['authUserDefaultRole'],
});
}
}}
/>
</div>
);
}
export default function UsersRolesPage() {
const { workspaceContext } = useWorkspaceContext();
return (
<Container>
<SettingsSection
title="Roles"
wrapperProps={{
className: 'mt-0 mb-20',
}}
>
<DefaultRoleInAPIRequests />
</SettingsSection>
<UserRoles />
<SettingsSection
title={<span>Permission Variables</span>}
titleProps={{
className:
'grid gap-2 grid-flow-col items-center place-content-start',
}}
desc={
<p>
These variables can be used to defined permissions. They are sent
from client to the GraphQL API, and must match the specified
property of a queried user.{' '}
<Link
href="https://docs.nhost.io/platform/graphql/permissions"
passHref
>
<Text
variant="a"
className="font-medium text-blue"
rel="noopener noreferrer"
target="_blank"
>
Read More
</Text>
</Link>
</p>
}
>
<ErrorBoundary fallbackRender={ErrorBoundaryFallback}>
<PermissionVariablesTable appId={workspaceContext.appId} />
</ErrorBoundary>
</SettingsSection>
<RolesSettings />
<PermissionVariableSettings />
</Container>
);
}
UsersRolesPage.getLayout = function getLayout(page: ReactElement) {
RolesAndPermissionsPage.getLayout = function getLayout(page: ReactElement) {
return <SettingsLayout>{page}</SettingsLayout>;
};

View File

@@ -47,8 +47,8 @@ export default function SettingsSignInMethodsPage() {
return (
<Container
className="max-w-5xl space-y-8 bg-fafafa"
wrapperClassName="bg-fafafa"
className="max-w-5xl space-y-8 bg-transparent"
rootClassName="bg-transparent"
>
<EmailAndPasswordSettings />
<MagicLinkSettings />
@@ -71,9 +71,5 @@ export default function SettingsSignInMethodsPage() {
}
SettingsSignInMethodsPage.getLayout = function getLayout(page: ReactElement) {
return (
<SettingsLayout mainContainerProps={{ className: 'bg-fafafa' }}>
{page}
</SettingsLayout>
);
return <SettingsLayout>{page}</SettingsLayout>;
};

View File

@@ -100,8 +100,8 @@ export default function SMTPSettingsPage() {
if (currentApplication.plan.isFree) {
return (
<Container
className="grid max-w-5xl grid-flow-row gap-4 bg-fafafa"
wrapperClassName="bg-fafafa"
className="grid max-w-5xl grid-flow-row gap-4 bg-transparent"
rootClassName="bg-transparent"
>
<UnlockFeatureByUpgrading
message="Unlock SMTP settings by upgrading your project to the Pro plan."
@@ -165,8 +165,8 @@ export default function SMTPSettingsPage() {
return (
<Container
className="grid max-w-5xl grid-flow-row gap-4 bg-fafafa"
wrapperClassName="bg-fafafa"
className="grid max-w-5xl grid-flow-row gap-4 bg-transparent"
rootClassName="bg-transparent"
>
<FormProvider {...form}>
<Form onSubmit={handleEditSMTPSettings}>
@@ -275,13 +275,5 @@ export default function SMTPSettingsPage() {
}
SMTPSettingsPage.getLayout = function getLayout(page: ReactElement) {
return (
<SettingsLayout
mainContainerProps={{
className: 'bg-fafafa',
}}
>
{page}
</SettingsLayout>
);
return <SettingsLayout>{page}</SettingsLayout>;
};

View File

@@ -33,7 +33,7 @@ export const theme = createTheme({
},
palette: {
primary: {
light: '#3787ff',
light: '#ebf3ff',
main: '#0052cd',
dark: '#063799',
},

View File

@@ -79,5 +79,10 @@ export type Application = {
export type CustomClaim = {
key: string;
value: string;
system?: boolean;
isSystemClaim?: boolean;
};
export type Role = {
name: string;
isSystemRole?: boolean;
};

View File

@@ -55,7 +55,6 @@ module.exports = {
log: '#F4F7F9',
input: '#C2CAD6',
ish: '#f6f9fc',
fafafa: '#fafafa',
picker: 'rgba(33, 50, 75, 1)',
divide: 'rgba(0, 35, 88, 0.16)',
greyscaleDark: '#21324B',

View File

@@ -1,5 +1,11 @@
# @nhost/docs
## 0.0.6
### Patch Changes
- 23274dee: chore(docs): update permission variables image
## 0.0.5
### Patch Changes

View File

@@ -21,6 +21,7 @@ Nhost Authentication lets you authenticate users using different sign-in methods
- [LinkedIn](/authentication/sign-in-with-linkedin)
- [Spotify](/authentication/sign-in-with-spotify)
- [Twitch](/authentication/sign-in-with-twitch)
- [WorkOS](/authentication/sign-in-with-workos)
## How it works

View File

@@ -38,9 +38,9 @@ The `x-hasura-user-id` permission variable is always available for all signed-in
You can add custom permission variables in the Nhost console under **Users** and then **Roles and permissions**. These permission variables are then available when creating permissions for your GraphQL API in the Hasura console.
![Permission Variables](/img/permission-variables-preview.svg)
![Permission Variables](/img/permission-variables-preview.png)
**Example:**: Let's say you add a new permission variable `x-hasura-organisation-id` with path `user.profile.organisation.id`. This means that Nhost Auth will get the value for `x-hasura-organisation-id` by internally generating the following GraphQL query:
**Example:** Let's say you add a new permission variable `x-hasura-organisation-id` with path `user.profile.organisation.id`. This means that Nhost Auth will get the value for `x-hasura-organisation-id` by internally generating the following GraphQL query:
```graphql
query {
@@ -110,7 +110,7 @@ When the target value is expected to be an array, it is important to explicitly
## Roles
Every GraphQL request is resolved based on a **single role**. Roles are added in the Hasura Console when selecting a table and clicking **Permisisons**.
Every GraphQL request is resolved based on a **single role**. Roles are added in the Hasura Console when selecting a table and clicking **Permissions**.
If the user is not signed in, the GraphQL API resolves permissions using the `public` role.

View File

@@ -86,7 +86,7 @@ older (`functions/_utils/<utils-files>.js`).
## Examples
We have multiple examples of Serverless Functions in our [Nhost repository](https://github.com/nhost/nhost/tree/main/examples/serverless-functions).
We have multiple examples of Serverless Functions in our [Nhost repository](https://github.com/nhost/nhost/tree/main/examples/serverless-functions/functions).
## Billing

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/docs",
"version": "0.0.5",
"version": "0.0.6",
"private": true,
"scripts": {
"docusaurus": "docusaurus",

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 227 KiB

View File

@@ -1,5 +1,5 @@
POSTGRES_PASSWORD=secret-pg-password-never-use-this-value
HASURA_GRAPHQL_ADMIN_SECRET=hello123
HASURA_GRAPHQL_ADMIN_SECRET=nhost-admin-secret
HASURA_GRAPHQL_JWT_SECRET='{"type":"HS256", "key":"5152fa850c02dc222631cca898ed1485821a70912a6e3649c49076912daa3b62182ba013315915d64f40cddfbb8b58eb5bd11ba225336a6af45bbae07ca873f3","issuer":"hasura-auth"}'
STORAGE_ACCESS_KEY=storage-access-key-never-use-this-value
STORAGE_SECRET_KEY=storage-secret-key-never-use-this-value

View File

@@ -13,11 +13,14 @@ docker-compose up -d
The following endpoints are now exposed:
- `http://localhost:1337`: Hasura Console
- `http://localhost:1337/v1/graphql`: Hasura GraphQL endpoint
- `http://localhost:1337/v1/auth`: Hasura Auth
- `http://localhost:1337/v1/storage`: Hasura Storage
- `http://localhost:1337/v1/functions`: Functions
- `http://localhost:3030`: Nhost Dashboard
- `http://localhost:1337`: Hasura Console
- `http://localhost:8025`: Mailhog SMTP testing dashboard
- `http://localhost:9090`: Traefik dashboad
- `http://localhost:8025`: Mailhog SMTP testing dashboard
**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.

View File

@@ -145,5 +145,9 @@ services:
- 8025:8025
volumes:
- ./data/mailhog:/maildir
dashboard:
image: nhost/dashboard:latest
ports:
- "3030:3000"
volumes:
functions_node_modules:

View File

@@ -38,7 +38,7 @@
"test:dashboard": "turbo run test --filter=@nhost/dashboard",
"e2e": "turbo run e2e --concurrency=1",
"changeset": "changeset",
"docgen": "turbo run build --filter=@nhost/docgen --no-deps && turbo run docgen --filter='@nhost/*' && :",
"docgen": "turbo run build --filter=@nhost/docgen --no-deps && pnpm i && turbo run docgen --filter=!@nhost/docgen --filter=@nhost/* && :",
"sync-versions": "turbo run start --filter=@nhost/sync-versions --no-deps"
},
"workspaces": [