Compare commits
71 Commits
@nhost/goo
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1230081ce6 | ||
|
|
c195c517de | ||
|
|
6f419be2c1 | ||
|
|
93ebdf844f | ||
|
|
bcd889b53a | ||
|
|
992939cdcd | ||
|
|
3c31657c50 | ||
|
|
a654d536e0 | ||
|
|
91c2bb6f53 | ||
|
|
9f2bf9ec2b | ||
|
|
d62bd0fc9a | ||
|
|
768ca17494 | ||
|
|
943831fe2e | ||
|
|
f242e4b92f | ||
|
|
863b37d313 | ||
|
|
c8a8d4fca3 | ||
|
|
311374e3fb | ||
|
|
e40a4529b4 | ||
|
|
1623e9bd20 | ||
|
|
5c47e8e675 | ||
|
|
9f9f1c64f4 | ||
|
|
981404f0b9 | ||
|
|
4ad27e9d72 | ||
|
|
778946998a | ||
|
|
6c11b75807 | ||
|
|
2dc031d16c | ||
|
|
40bd3e4572 | ||
|
|
6cb2b6331a | ||
|
|
08a7dd9894 | ||
|
|
f0a994a26e | ||
|
|
4fbd6bd4fa | ||
|
|
67fc77486c | ||
|
|
4f3fb3446e | ||
|
|
49a80c22be | ||
|
|
28676f4cdc | ||
|
|
e03f14133c | ||
|
|
150c04a4f4 | ||
|
|
bccd67b1b1 | ||
|
|
b14fd2f14c | ||
|
|
68b3d23cd9 | ||
|
|
d86e5c9c16 | ||
|
|
b2cc1411d7 | ||
|
|
407feeac37 | ||
|
|
7b25c37c26 | ||
|
|
6df4f02e95 | ||
|
|
aaae98f019 | ||
|
|
dc23dc0f49 | ||
|
|
82728da100 | ||
|
|
2d68fee54c | ||
|
|
35010353c7 | ||
|
|
aff059ec71 | ||
|
|
713d53cfc0 | ||
|
|
e0ab6d9a37 | ||
|
|
7baee8a9cc | ||
|
|
3db2999f60 | ||
|
|
3c4dd55045 | ||
|
|
92b434e840 | ||
|
|
13d359602f | ||
|
|
0d8d0eb10f | ||
|
|
ed9df85778 | ||
|
|
41617b970a | ||
|
|
c5c904b716 | ||
|
|
7db095fe92 | ||
|
|
f33e07b191 | ||
|
|
017f1a6c7b | ||
|
|
93957c8af3 | ||
|
|
2505b2e26b | ||
|
|
5f4b4d2acc | ||
|
|
71a8ce4446 | ||
|
|
5ef5189898 | ||
|
|
791b7295fb |
@@ -22,7 +22,7 @@ jobs:
|
||||
- name: Configure aws
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}:role/github-actions-nhost-be
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||
aws-region: eu-central-1
|
||||
|
||||
- uses: nixbuild/nix-quick-install-action@v26
|
||||
|
||||
3
config/.husky/pre-commit
Executable file → Normal file
3
config/.husky/pre-commit
Executable file → Normal file
@@ -1,4 +1,7 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
[ -n "$CI" ] && exit 0
|
||||
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
pnpm dlx lint-staged --config config/.lintstagedrc.js
|
||||
|
||||
@@ -1,5 +1,141 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 1.12.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c195c51: fix: send email upon signin for unverified users
|
||||
|
||||
## 1.12.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 93ebdf8: fix: use service urls when initilizaing NhostClient running local dashboard
|
||||
- @nhost/react-apollo@11.0.1
|
||||
- @nhost/nextjs@2.1.10
|
||||
|
||||
## 1.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- f242e4b: feat: add connect with github to the user's account settings
|
||||
- 768ca17: chore: update dependencies
|
||||
- d62bd0f: fix: "Track this" option within the SQL editor now correctly updates the metadata
|
||||
- 91c2bb6: feat: refactor sign-in and sign-up pages to enforce email verification
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 943831f: fix: resolve an error toast issue when unpausing a project
|
||||
- Updated dependencies [768ca17]
|
||||
- @nhost/react-apollo@11.0.0
|
||||
- @nhost/nextjs@2.1.9
|
||||
|
||||
## 1.11.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@10.0.2
|
||||
- @nhost/nextjs@2.1.8
|
||||
|
||||
## 1.11.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 981404f: fix: set default value for healthCheck field validation
|
||||
|
||||
## 1.11.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 7789469: chore: upgrade dependency `@graphql-codegen/cli` to `5.0.2` to address vulnerability
|
||||
- 6c11b75: feat: add update user displayName section in account settings
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@10.0.1
|
||||
- @nhost/nextjs@2.1.7
|
||||
|
||||
## 1.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 49a80c2: chore: update dependencies
|
||||
- 150c04a: feat: add healthcheck config to run services
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e03f141: fix: allow insert, update and delete on tables in `auth` and `storage` schemas
|
||||
- 28676f4: feat: add min postgres version check to enable the ai service
|
||||
- Updated dependencies [49a80c2]
|
||||
- @nhost/react-apollo@10.0.0
|
||||
- @nhost/nextjs@2.1.6
|
||||
|
||||
## 1.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d86e5c9: feat: add support for filtering the logs using a RegExp
|
||||
|
||||
## 1.8.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@9.0.3
|
||||
- @nhost/nextjs@2.1.5
|
||||
|
||||
## 1.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6df4f02: fix: use custom error toast and show correct message when sending an invite
|
||||
|
||||
## 1.8.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@9.0.2
|
||||
- @nhost/nextjs@2.1.4
|
||||
|
||||
## 1.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 713d53c: feat: add catch-all route for workspace/project - useful for documentation
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3db2999: fix: refresh table list after running SQL using the editor
|
||||
- 3c4dd55: fix: handle `Error` objects properly in the `ErrorToast` component
|
||||
- 92b434e: fix: resolve an issue where the checkbox in the data-grid header did not select all rows
|
||||
- @nhost/react-apollo@9.0.1
|
||||
- @nhost/nextjs@2.1.3
|
||||
|
||||
## 1.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 0d8d0eb: Update docs and dashboard references
|
||||
|
||||
## 1.6.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@9.0.0
|
||||
- @nhost/nextjs@2.1.2
|
||||
|
||||
## 1.6.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@8.0.1
|
||||
- @nhost/nextjs@2.1.1
|
||||
|
||||
## 1.6.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5ef5189: fix: update `@apollo/client` to `3.9.4` to fix a cache bug
|
||||
|
||||
## 1.6.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -4,7 +4,6 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||
});
|
||||
const { version } = require('./package.json');
|
||||
|
||||
|
||||
const cspHeader = `
|
||||
default-src 'self' *.nhost.run ws://*.nhost.run nhost.run ws://nhost.run;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' cdn.segment.com js.stripe.com;
|
||||
@@ -17,9 +16,7 @@ const cspHeader = `
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
frame-src 'self' js.stripe.com;
|
||||
block-all-mixed-content;
|
||||
upgrade-insecure-requests;
|
||||
`
|
||||
`;
|
||||
|
||||
module.exports = withBundleAnalyzer({
|
||||
reactStrictMode: true,
|
||||
@@ -41,15 +38,11 @@ module.exports = withBundleAnalyzer({
|
||||
headers: [
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'SAMEORIGIN'
|
||||
},
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: cspHeader.replace(/\n/g, ''),
|
||||
value: 'SAMEORIGIN',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
];
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "1.6.6",
|
||||
"version": "1.12.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -19,58 +19,58 @@
|
||||
"e2e": "pnpm install-browsers && pnpm playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.9.1",
|
||||
"@codemirror/lang-sql": "^6.5.5",
|
||||
"@apollo/client": "^3.9.9",
|
||||
"@codemirror/lang-sql": "^6.6.2",
|
||||
"@emotion/cache": "^11.11.0",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@fontsource/inter": "^5.0.16",
|
||||
"@fontsource/roboto-mono": "^5.0.16",
|
||||
"@graphiql/react": "^0.20.2",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@fontsource/inter": "^5.0.17",
|
||||
"@fontsource/roboto-mono": "^5.0.17",
|
||||
"@graphiql/react": "^0.20.3",
|
||||
"@graphiql/toolkit": "^0.9.1",
|
||||
"@headlessui/react": "^1.7.18",
|
||||
"@heroicons/react": "^1.0.6",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@mui/base": "5.0.0-beta.31",
|
||||
"@mui/material": "^5.15.7",
|
||||
"@mui/system": "^5.15.7",
|
||||
"@mui/material": "^5.15.14",
|
||||
"@mui/system": "^5.15.14",
|
||||
"@mui/x-date-pickers": "^5.0.20",
|
||||
"@nhost/nextjs": "workspace:*",
|
||||
"@nhost/react-apollo": "workspace:*",
|
||||
"@segment/snippet": "^4.16.2",
|
||||
"@stripe/react-stripe-js": "^2.4.0",
|
||||
"@stripe/react-stripe-js": "^2.6.2",
|
||||
"@stripe/stripe-js": "^1.54.2",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-table": "^8.11.7",
|
||||
"@tanstack/react-virtual": "^3.0.2",
|
||||
"@uiw/codemirror-theme-github": "^4.21.21",
|
||||
"@uiw/react-codemirror": "^4.21.21",
|
||||
"@tanstack/react-table": "^8.15.3",
|
||||
"@tanstack/react-virtual": "^3.2.0",
|
||||
"@uiw/codemirror-theme-github": "^4.21.25",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"analytics-node": "^6.2.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"clsx": "^1.2.1",
|
||||
"date-fns": "^2.30.0",
|
||||
"framer-motion": "^10.18.0",
|
||||
"generate-password": "^1.7.1",
|
||||
"graphiql": "^3.1.0",
|
||||
"graphiql": "^3.1.1",
|
||||
"graphql": "16.8.1",
|
||||
"graphql-request": "^6.1.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"graphql-ws": "^5.14.3",
|
||||
"graphql-ws": "^5.16.0",
|
||||
"just-kebab-case": "^4.2.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"next": "^14.1.0",
|
||||
"next-seo": "^6.4.0",
|
||||
"next": "^14.1.4",
|
||||
"next-seo": "^6.5.0",
|
||||
"node-pg-format": "^1.3.5",
|
||||
"pluralize": "^8.0.0",
|
||||
"react": "18.2.0",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "^4.0.12",
|
||||
"react-hook-form": "^7.50.0",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-hook-form": "^7.51.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-intersection-observer": "^9.5.4",
|
||||
"react-intersection-observer": "^9.8.1",
|
||||
"react-is": "18.2.0",
|
||||
"react-loading-skeleton": "^2.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
@@ -87,13 +87,13 @@
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"utility-types": "^3.11.0",
|
||||
"validator": "^13.11.0",
|
||||
"yup": "^1.3.3",
|
||||
"yup": "^1.4.0",
|
||||
"yup-password": "^0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.23.9",
|
||||
"@babel/core": "^7.24.3",
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@graphql-codegen/cli": "^3.3.1",
|
||||
"@graphql-codegen/cli": "^5.0.2",
|
||||
"@graphql-codegen/typescript": "^3.0.4",
|
||||
"@graphql-codegen/typescript-operations": "^3.0.4",
|
||||
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
|
||||
@@ -106,50 +106,50 @@
|
||||
"@storybook/addon-postcss": "^2.0.0",
|
||||
"@storybook/builder-webpack5": "^6.5.16",
|
||||
"@storybook/manager-webpack5": "^6.5.16",
|
||||
"@storybook/react": "^6.5.16",
|
||||
"@storybook/react": "^7.6.17",
|
||||
"@storybook/testing-library": "^0.2.2",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.12",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^14.2.0",
|
||||
"@testing-library/react": "^14.2.2",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/ace": "^0.0.48",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash.debounce": "^4.0.9",
|
||||
"@types/node": "^16.18.78",
|
||||
"@types/node": "^16.18.93",
|
||||
"@types/pluralize": "^0.0.30",
|
||||
"@types/react": "^18.2.50",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/react-table": "^7.7.19",
|
||||
"@types/react": "^18.2.73",
|
||||
"@types/react-dom": "^18.2.23",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/testing-library__jest-dom": "^5.14.9",
|
||||
"@types/validator": "^13.11.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
||||
"@typescript-eslint/parser": "^6.20.0",
|
||||
"@types/validator": "^13.11.9",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/coverage-v8": "^0.32.4",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"babel-loader": "^8.3.0",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"csstype": "^3.1.3",
|
||||
"dotenv": "^16.4.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"encoding": "^0.1.13",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb": "19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^17.1.0",
|
||||
"eslint-config-next": "^13.5.6",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.8.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react": "^7.34.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"jsdom": "^22.1.0",
|
||||
"lint-staged": "^15.2.1",
|
||||
"msw": "^1.3.2",
|
||||
"lint-staged": "^15.2.2",
|
||||
"msw": "^1.3.3",
|
||||
"msw-storybook-addon": "^1.10.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"postcss": "^8.4.33",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-organize-imports": "^3.2.4",
|
||||
"prettier-plugin-tailwindcss": "^0.4.1",
|
||||
@@ -157,11 +157,11 @@
|
||||
"require-from-string": "^2.0.2",
|
||||
"snake-case": "^3.0.4",
|
||||
"storybook-addon-next-router": "^4.0.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths-webpack-plugin": "^4.1.0",
|
||||
"vite": "^5.0.12",
|
||||
"vite-tsconfig-paths": "^4.3.1",
|
||||
"vite": "^5.2.7",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"vitest": "^0.32.4"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -35,7 +35,7 @@ function InsertPlaceholderTableRow({
|
||||
...props
|
||||
}: InsertPlaceholderTableRowProps) {
|
||||
return (
|
||||
<Box className="h-12 border-r-1 border-b-1" {...props}>
|
||||
<Box className="h-12 border-b-1 border-r-1" {...props}>
|
||||
<Button
|
||||
onClick={onInsertRow}
|
||||
variant="borderless"
|
||||
@@ -209,7 +209,7 @@ export default function DataGridBody<T extends object>({
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
className="inline-flex h-12 items-center border-b-1 border-r-1 py-1.5 px-2 text-xs"
|
||||
className="inline-flex h-12 items-center border-b-1 border-r-1 px-2 py-1.5 text-xs"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
style={{
|
||||
width: allowInsertColumn
|
||||
@@ -281,8 +281,8 @@ export default function DataGridBody<T extends object>({
|
||||
}}
|
||||
className={twMerge(
|
||||
'h-12 font-display text-xs motion-safe:transition-colors',
|
||||
'border-r-1 border-b-1',
|
||||
'scroll-mt-[57px] scroll-ml-8',
|
||||
'border-b-1 border-r-1',
|
||||
'scroll-ml-8 scroll-mt-[57px]',
|
||||
column.id === 'selection' &&
|
||||
'sticky left-0 z-20 justify-center px-0',
|
||||
)}
|
||||
@@ -296,7 +296,7 @@ export default function DataGridBody<T extends object>({
|
||||
})}
|
||||
|
||||
{allowInsertColumn && (
|
||||
<Box className="h-12 w-25 border-r-1 border-b-1" />
|
||||
<Box className="h-12 w-25 border-b-1 border-r-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,7 +8,15 @@ import type {
|
||||
DataBrowserGridCellProps,
|
||||
} from '@/features/database/dataGrid/types/dataBrowser';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import type { FocusEvent, JSXElementConstructor, KeyboardEvent, MouseEvent, ReactElement, ReactNode, ReactPortal } from 'react';
|
||||
import type {
|
||||
FocusEvent,
|
||||
JSXElementConstructor,
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
ReactPortal,
|
||||
} from 'react';
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
@@ -308,7 +316,7 @@ function DataGridCellContent<TData extends object = {}, TValue = unknown>({
|
||||
isEditable &&
|
||||
'focus-within:outline-none focus-within:ring-0 focus:ring-0',
|
||||
isSelected && 'shadow-outline',
|
||||
isEditing ? 'p-0.5 shadow-outline-dark' : 'py-1.5 px-2',
|
||||
isEditing ? 'p-0.5 shadow-outline-dark' : 'px-2 py-1.5',
|
||||
className,
|
||||
)}
|
||||
onFocus={handleFocus}
|
||||
@@ -320,20 +328,28 @@ function DataGridCellContent<TData extends object = {}, TValue = unknown>({
|
||||
sx={{ backgroundColor: 'transparent' }}
|
||||
{...props}
|
||||
>
|
||||
{Children.map(children, (child: ReactNode | ReactPortal | ReactElement<unknown, string | JSXElementConstructor<any>>) => {
|
||||
if (!isValidElement(child)) {
|
||||
return null;
|
||||
}
|
||||
{Children.map(
|
||||
children,
|
||||
(
|
||||
child:
|
||||
| ReactNode
|
||||
| ReactPortal
|
||||
| ReactElement<unknown, string | JSXElementConstructor<any>>,
|
||||
) => {
|
||||
if (!isValidElement(child)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cloneElement(child, {
|
||||
...child.props,
|
||||
onSave: handleSave,
|
||||
optimisticValue,
|
||||
onOptimisticValueChange: setOptimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange: setTemporaryValue,
|
||||
});
|
||||
})}
|
||||
return cloneElement(child, {
|
||||
...child.props,
|
||||
onSave: handleSave,
|
||||
optimisticValue,
|
||||
onOptimisticValueChange: setOptimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange: setTemporaryValue,
|
||||
});
|
||||
},
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
|
||||
@@ -96,45 +96,52 @@ export default function DataGridHeader<T extends object>({
|
||||
}}
|
||||
key={column.id}
|
||||
>
|
||||
<Dropdown.Trigger
|
||||
className={twMerge(
|
||||
'focus:outline-none motion-safe:transition-colors',
|
||||
)}
|
||||
disabled={
|
||||
column.isDisabled ||
|
||||
column.id === 'selection' ||
|
||||
(column.disableSortBy && !onRemoveColumn)
|
||||
}
|
||||
hideChevron
|
||||
>
|
||||
{column.id === 'selection' ? (
|
||||
<span
|
||||
{...headerProps}
|
||||
className="relative grid w-full grid-flow-col items-center justify-between p-2"
|
||||
>
|
||||
{column.render('Header')}
|
||||
|
||||
{allowSort && (
|
||||
<Box component="span" sx={{ color: 'text.primary' }}>
|
||||
{column.isSorted && !column.isSortedDesc && (
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
)}
|
||||
|
||||
{column.isSorted && column.isSortedDesc && (
|
||||
<ArrowDownIcon className="h-3 w-3" />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{allowResize && !column.disableResizing && (
|
||||
) : (
|
||||
<Dropdown.Trigger
|
||||
className={twMerge(
|
||||
'focus:outline-none motion-safe:transition-colors',
|
||||
)}
|
||||
disabled={
|
||||
column.isDisabled || (column.disableSortBy && !onRemoveColumn)
|
||||
}
|
||||
hideChevron
|
||||
>
|
||||
<span
|
||||
{...column.getResizerProps({
|
||||
onClick: (event: Event) => event.stopPropagation(),
|
||||
})}
|
||||
className="absolute top-0 bottom-0 -right-0.5 z-10 h-full w-1.5 group-hover:bg-slate-900 group-hover:bg-opacity-20 group-active:bg-slate-900 group-active:bg-opacity-20 motion-safe:transition-colors"
|
||||
/>
|
||||
)}
|
||||
</Dropdown.Trigger>
|
||||
{...headerProps}
|
||||
className="relative grid w-full grid-flow-col items-center justify-between p-2"
|
||||
>
|
||||
{column.render('Header')}
|
||||
|
||||
{allowSort && (
|
||||
<Box component="span" sx={{ color: 'text.primary' }}>
|
||||
{column.isSorted && !column.isSortedDesc && (
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
)}
|
||||
|
||||
{column.isSorted && column.isSortedDesc && (
|
||||
<ArrowDownIcon className="h-3 w-3" />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{allowResize && !column.disableResizing && (
|
||||
<span
|
||||
{...column.getResizerProps({
|
||||
onClick: (event: Event) => event.stopPropagation(),
|
||||
})}
|
||||
className="absolute -right-0.5 bottom-0 top-0 z-10 h-full w-1.5 group-hover:bg-slate-900 group-hover:bg-opacity-20 group-active:bg-slate-900 group-active:bg-opacity-20 motion-safe:transition-colors"
|
||||
/>
|
||||
)}
|
||||
</Dropdown.Trigger>
|
||||
)}
|
||||
|
||||
<Dropdown.Content
|
||||
menu
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PlusCircleIcon } from '@/components/ui/v2/icons/PlusCircleIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import type { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import type { ButtonProps } from './Button';
|
||||
import Button from './Button';
|
||||
|
||||
@@ -24,9 +24,9 @@ export default {
|
||||
control: { type: 'radio' },
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof Button>;
|
||||
} as Meta<typeof Button>;
|
||||
|
||||
const Template: ComponentStory<typeof Button> = function Template(
|
||||
const Template: StoryFn<ButtonProps> = function TemplateFunction(
|
||||
args: ButtonProps,
|
||||
) {
|
||||
return <Button {...args} />;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { XIcon } from '@/components/ui/v2/icons/XIcon';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { getToastBackgroundColor } from '@/utils/constants/settings';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { type ApolloError } from '@apollo/client';
|
||||
import type { ApolloError } from '@apollo/client';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -20,6 +20,44 @@ interface ErrorDetails {
|
||||
error: any;
|
||||
}
|
||||
|
||||
const getInternalErrorMessage = (
|
||||
error: Error | ApolloError | undefined,
|
||||
): string | null => {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error.name === 'ApolloError') {
|
||||
// @ts-ignore
|
||||
const internalError = error.graphQLErrors?.[0]?.extensions?.internal as {
|
||||
error: { message: string };
|
||||
};
|
||||
return internalError?.error?.message || null;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const errorToObject = (error: ApolloError | Error) => {
|
||||
if (error.name === 'ApolloError') {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
export default function ErrorToast({
|
||||
isVisible,
|
||||
errorMessage,
|
||||
@@ -28,7 +66,7 @@ export default function ErrorToast({
|
||||
}: {
|
||||
isVisible: boolean;
|
||||
errorMessage: string;
|
||||
error: ApolloError;
|
||||
error: ApolloError | Error;
|
||||
close: () => void;
|
||||
}) {
|
||||
const userData = useUserData();
|
||||
@@ -43,19 +81,10 @@ export default function ErrorToast({
|
||||
userId: userData?.id || 'local',
|
||||
url: asPath,
|
||||
},
|
||||
error,
|
||||
error: errorToObject(error),
|
||||
};
|
||||
|
||||
const internalError = error?.graphQLErrors?.at(0)?.extensions?.internal as {
|
||||
error: {
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
const msg =
|
||||
internalError?.error?.message ||
|
||||
error?.graphQLErrors?.at(0).message ||
|
||||
errorMessage;
|
||||
const msg = getInternalErrorMessage(error) || errorMessage;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import type { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import type { SelectProps } from './Select';
|
||||
import Select from './Select';
|
||||
|
||||
@@ -7,11 +7,9 @@ export default {
|
||||
title: 'UI Library / Select',
|
||||
component: Select,
|
||||
argTypes: {},
|
||||
} as ComponentMeta<typeof Select>;
|
||||
} as Meta<typeof Select>;
|
||||
|
||||
const Template: ComponentStory<typeof Select> = function Template(
|
||||
args: SelectProps<any>,
|
||||
) {
|
||||
const Template: StoryFn<SelectProps<any>> = function TemplateFunction(args) {
|
||||
return (
|
||||
<Select className="w-64" {...args}>
|
||||
<Option value="value1">Value 1</Option>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import type { SwitchProps } from './Switch';
|
||||
import Switch from './Switch';
|
||||
|
||||
@@ -6,9 +6,9 @@ export default {
|
||||
title: 'UI Library / Switch',
|
||||
component: Switch,
|
||||
argTypes: {},
|
||||
} as ComponentMeta<typeof Switch>;
|
||||
} as Meta<typeof Switch>;
|
||||
|
||||
const Template: ComponentStory<typeof Switch> = function Template(
|
||||
const Template: StoryFn<SwitchProps> = function TemplateFunction(
|
||||
args: SwitchProps,
|
||||
) {
|
||||
return <Switch label="Accept Rules" {...args} />;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { useUpdateUserDisplayNameMutation } from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
displayName: Yup.string()
|
||||
.label('Display Name')
|
||||
.required('This field is required.'),
|
||||
});
|
||||
|
||||
export type DisplayNameSettingFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
export default function DisplayNameSetting() {
|
||||
const { id: userID, displayName } = useUserData();
|
||||
|
||||
const [updateUserDisplayName] = useUpdateUserDisplayNameMutation();
|
||||
|
||||
const form = useForm<DisplayNameSettingFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
displayName,
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { register, formState } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
async function handleSubmit(formValues: DisplayNameSettingFormValues) {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await updateUserDisplayName({
|
||||
variables: {
|
||||
id: userID,
|
||||
displayName: formValues.displayName,
|
||||
},
|
||||
});
|
||||
|
||||
form.reset({ displayName: formValues.displayName });
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Updating your display name...',
|
||||
successMessage: 'Your display name has been updated successfully.',
|
||||
errorMessage:
|
||||
'An error occurred while trying to update your display name. Please try again.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Update your display name"
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
{...register('displayName')}
|
||||
className="col-span-2"
|
||||
type="text"
|
||||
id="display-name"
|
||||
label="Display Name"
|
||||
fullWidth
|
||||
helperText={formState.errors.displayName?.message}
|
||||
error={Boolean(formState.errors.displayName)}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DisplayNameSetting';
|
||||
export { default as DisplayNameSetting } from './DisplayNameSetting';
|
||||
@@ -0,0 +1,76 @@
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { GitHubIcon } from '@/components/ui/v2/icons/GitHubIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useGetAuthUserProvidersQuery } from '@/utils/__generated__/graphql';
|
||||
import { useProviderLink } from '@nhost/nextjs';
|
||||
import NavLink from 'next/link';
|
||||
|
||||
export default function SocialProvidersSettings() {
|
||||
const { data, loading, error } = useGetAuthUserProvidersQuery();
|
||||
const isGithubConnected = data?.authUserProviders?.some(
|
||||
(item) => item.providerId === 'github',
|
||||
);
|
||||
|
||||
const { github } = useProviderLink({
|
||||
connect: true,
|
||||
redirectTo: `${window.location.origin}/account`,
|
||||
});
|
||||
|
||||
if (!data && loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading personal access tokens..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer
|
||||
title="Authentication providers"
|
||||
description=""
|
||||
rootClassName="gap-0 flex flex-col items-start"
|
||||
className="my-2"
|
||||
slotProps={{
|
||||
submitButton: { className: 'hidden' },
|
||||
footer: { className: 'hidden' },
|
||||
}}
|
||||
>
|
||||
{isGithubConnected ? (
|
||||
<Box
|
||||
sx={{ backgroundColor: 'grey.200' }}
|
||||
className="flex flex-row items-center justify-start space-x-2 rounded-md p-2"
|
||||
>
|
||||
<GitHubIcon />
|
||||
<Text className="font-medium ">Connected</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<NavLink
|
||||
href={github}
|
||||
passHref
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
legacyBehavior
|
||||
>
|
||||
<Button
|
||||
className=""
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
startIcon={<GitHubIcon />}
|
||||
>
|
||||
Connect with GitHub
|
||||
</Button>
|
||||
</NavLink>
|
||||
</Box>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SocialProvidersSettings } from './SocialProvidersSettings';
|
||||
@@ -0,0 +1,6 @@
|
||||
query getAuthUserProviders {
|
||||
authUserProviders {
|
||||
id
|
||||
providerId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mutation updateUserDisplayName($id: uuid!, $displayName: String!) {
|
||||
updateUser(pk_columns: { id: $id }, _set: { displayName: $displayName }) {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
@@ -21,13 +21,17 @@ import {
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
import { DisableAIServiceConfirmationDialog } from './DisableAIServiceConfirmationDialog';
|
||||
|
||||
const MIN_POSTGRES_VERSION_SUPPORTING_AI = '14.6-20231018-1';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
version: Yup.object({
|
||||
label: Yup.string().required(),
|
||||
@@ -54,7 +58,9 @@ export default function AISettings() {
|
||||
const [aiServiceEnabled, setAIServiceEnabled] = useState(true);
|
||||
|
||||
const {
|
||||
data: { config: { ai } = {} } = {},
|
||||
data: {
|
||||
config: { ai, postgres: { version: postgresVersion } = {} } = {},
|
||||
} = {},
|
||||
loading: loadingAiSettings,
|
||||
error: errorGettingAiSettings,
|
||||
} = useGetAiSettingsQuery({
|
||||
@@ -150,6 +156,17 @@ export default function AISettings() {
|
||||
]);
|
||||
|
||||
const toggleAIService = async (enabled: boolean) => {
|
||||
if (postgresVersion < MIN_POSTGRES_VERSION_SUPPORTING_AI) {
|
||||
toast.error(
|
||||
'In order to enable the AI service you need to update your database version to 14.6-20231018-1 or newer.',
|
||||
{
|
||||
style: getToastStyleProps().style,
|
||||
...getToastStyleProps().error,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setAIServiceEnabled(enabled);
|
||||
|
||||
if (!enabled && ai) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
query GetAISettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
postgres {
|
||||
version
|
||||
}
|
||||
ai {
|
||||
version
|
||||
webhookSecret
|
||||
|
||||
@@ -130,7 +130,7 @@ export default function AllowedEmailDomainsSettings() {
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication#allowed-emails-and-domains"
|
||||
docsLink="https://docs.nhost.io/guides/auth/overview#allowed-emails-and-domains"
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
className={twMerge(
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function AllowedRedirectURLsSettings() {
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication#allowed-redirect-urls"
|
||||
docsLink="https://docs.nhost.io/guides/auth/overview#allowed-redirect-urls"
|
||||
className="grid grid-flow-row px-4 lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
|
||||
@@ -141,7 +141,7 @@ export default function AppleProviderSettings() {
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication/sign-in-with-apple"
|
||||
docsLink="https://docs.nhost.io/guides/auth/social/sign-in-apple"
|
||||
docsTitle="how to sign in users with Apple"
|
||||
icon={
|
||||
theme.palette.mode === 'dark'
|
||||
|
||||
@@ -136,7 +136,7 @@ export default function BlockedEmailSettings() {
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication#blocked-emails-and-domains"
|
||||
docsLink="https://docs.nhost.io/guides/auth/overview#allowed-emails-and-domains"
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
className={twMerge(
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function ClientURLSettings() {
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication#client-url"
|
||||
docsLink="https://docs.nhost.io/guides/auth/overview#client-url"
|
||||
className="grid grid-flow-row lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function DisableNewUsersSettings() {
|
||||
<SettingsContainer
|
||||
title="Disable New Users"
|
||||
description="If set, newly registered users are disabled and won't be able to sign in."
|
||||
docsLink="https://docs.nhost.io/authentication#disable-new-users"
|
||||
docsLink="https://docs.nhost.io/guides/auth/overview#disable-new-users"
|
||||
switchId="disabled"
|
||||
showSwitch
|
||||
slotProps={{
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function DiscordProviderSettings() {
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-discord"
|
||||
docsLink="https://docs.nhost.io/guides/auth/social/sign-in-discord"
|
||||
docsTitle="how to sign in users with Discord"
|
||||
icon="/assets/brands/discord.svg"
|
||||
switchId="enabled"
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function EmailAndPasswordSettings() {
|
||||
<SettingsContainer
|
||||
title="Email and Password"
|
||||
description="Allow users to sign in with email and password."
|
||||
docsLink="https://docs.nhost.io/authentication/sign-in-with-email-and-password"
|
||||
docsLink="https://docs.nhost.io/guides/auth/sign-in-email-password"
|
||||
docsTitle="how to sign in users with email and password"
|
||||
className="grid grid-flow-row"
|
||||
showSwitch
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function FacebookProviderSettings() {
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-facebook"
|
||||
docsLink="https://docs.nhost.io/guides/auth/social/sign-in-facebook"
|
||||
docsTitle="how to sign in users with Facebook"
|
||||
icon="/assets/brands/facebook.svg"
|
||||
switchId="enabled"
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function GitHubProviderSettings() {
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-github"
|
||||
docsLink="https://docs.nhost.io/guides/auth/social/sign-in-github"
|
||||
docsTitle="how to sign in users with GitHub"
|
||||
icon={
|
||||
theme.palette.mode === 'dark'
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function GoogleProviderSettings() {
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-google"
|
||||
docsLink="https://docs.nhost.io/guides/auth/social/sign-in-google"
|
||||
docsTitle="how to sign in users with Google"
|
||||
icon="/assets/brands/google.svg"
|
||||
switchId="enabled"
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function GravatarSettings() {
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication#gravatar"
|
||||
docsLink="https://docs.nhost.io/guides/auth/overview#gravatar"
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
className={twMerge(
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function LinkedInProviderSettings() {
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-linkedin"
|
||||
docsLink="https://docs.nhost.io/guides/auth/social/sign-in-linkedin"
|
||||
docsTitle="how to sign in users with LinkedIn"
|
||||
icon="/assets/brands/linkedin.svg"
|
||||
switchId="enabled"
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function MFASettings() {
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication#multi-factor-authentication"
|
||||
docsLink="https://docs.nhost.io/guides/auth/overview#multi-factor-authentication"
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
className={twMerge(
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function MagicLinkSettings() {
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication/sign-in-with-magic-link"
|
||||
docsLink="https://docs.nhost.io/guides/auth/sign-in-magic-link"
|
||||
docsTitle="how to sign in users with Magic Link"
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
|
||||
@@ -137,7 +137,7 @@ export default function SMSSettings() {
|
||||
}}
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
docsLink="https://docs.nhost.io/authentication/sign-in-with-phone-number-sms"
|
||||
docsLink="https://docs.nhost.io/guides/auth/sign-in-phone-number"
|
||||
docsTitle="how to sign in users with a phone number (SMS)"
|
||||
className={twMerge(
|
||||
'grid grid-flow-col grid-cols-2 grid-rows-4 gap-x-3 gap-y-4 px-4 py-2',
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function SpotifyProviderSettings() {
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-spotify"
|
||||
docsLink="https://docs.nhost.io/guides/auth/social/sign-in-spotify"
|
||||
docsTitle="how to sign in users with Spotify"
|
||||
icon="/assets/brands/spotify.svg"
|
||||
switchId="enabled"
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function TwitchProviderSettings() {
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-twitch"
|
||||
docsLink="https://docs.nhost.io/guides/auth/social/sign-in-twitch"
|
||||
docsTitle="how to sign in users with Twitch"
|
||||
icon={
|
||||
theme.palette.mode === 'dark'
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function WebAuthnSettings() {
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication/sign-in-with-security-keys"
|
||||
docsLink="https://docs.nhost.io/guides/auth/sign-in-webauthn"
|
||||
docsTitle="how to sign in users with security keys"
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
|
||||
@@ -137,7 +137,7 @@ export default function WorkOsProviderSettings() {
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication/sign-in-with-workos"
|
||||
docsLink="https://docs.nhost.io/guides/auth/social/sign-in-workos"
|
||||
docsTitle="how to sign in users with WorkOS"
|
||||
icon="/assets/brands/workos.svg"
|
||||
switchId="enabled"
|
||||
|
||||
@@ -275,7 +275,7 @@ export default function DataBrowserGrid({
|
||||
() =>
|
||||
columns
|
||||
.map((column) => ({
|
||||
...createDataGridColumn(column, isSchemaEditable),
|
||||
...createDataGridColumn(column, true),
|
||||
onCellEdit: async (variables: UpdateRecordVariables) => {
|
||||
const result = await updateRow(variables);
|
||||
await queryClient.invalidateQueries([currentTablePath]);
|
||||
@@ -288,7 +288,6 @@ export default function DataBrowserGrid({
|
||||
[
|
||||
columns,
|
||||
currentTablePath,
|
||||
isSchemaEditable,
|
||||
optimisticlyRemovedColumnId,
|
||||
queryClient,
|
||||
removableColumnId,
|
||||
@@ -422,7 +421,7 @@ export default function DataBrowserGrid({
|
||||
loading={status === 'loading'}
|
||||
sortBy={sortBy}
|
||||
className="pb-17 sm:pb-0"
|
||||
onInsertRow={isSchemaEditable ? handleInsertRowClick : undefined}
|
||||
onInsertRow={handleInsertRowClick}
|
||||
onInsertColumn={isSchemaEditable ? handleInsertColumnClick : undefined}
|
||||
onEditColumn={isSchemaEditable ? handleEditColumnClick : undefined}
|
||||
onRemoveColumn={isSchemaEditable ? handleColumnRemoveClick : undefined}
|
||||
@@ -445,7 +444,7 @@ export default function DataBrowserGrid({
|
||||
onInsertColumnClick={
|
||||
isSchemaEditable ? handleInsertColumnClick : undefined
|
||||
}
|
||||
onInsertRowClick={isSchemaEditable ? handleInsertRowClick : undefined}
|
||||
onInsertRowClick={handleInsertRowClick}
|
||||
paginationProps={{
|
||||
currentPage: Math.max(currentPage, 1),
|
||||
totalPages: Math.max(numberOfPages, 1),
|
||||
|
||||
@@ -12,11 +12,9 @@ import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { RowIcon } from '@/components/ui/v2/icons/RowIcon';
|
||||
import { useDeleteRecordMutation } from '@/features/database/dataGrid/hooks/useDeleteRecordMutation';
|
||||
import type { DataBrowserGridColumn } from '@/features/database/dataGrid/types/dataBrowser';
|
||||
import { isSchemaLocked } from '@/features/database/dataGrid/utils/schemaHelpers/isSchemaLocked';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import type { Row } from 'react-table';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -58,11 +56,6 @@ export default function DataBrowserGridControls({
|
||||
const { className: paginationClassName, ...restPaginationProps } =
|
||||
paginationProps || ({} as DataGridPaginationProps);
|
||||
|
||||
const {
|
||||
query: { schemaSlug },
|
||||
} = useRouter();
|
||||
const isSchemaEditable = !isSchemaLocked(schemaSlug as string);
|
||||
|
||||
const {
|
||||
selectedFlatRows: selectedRows,
|
||||
columns,
|
||||
@@ -126,7 +119,7 @@ export default function DataBrowserGridControls({
|
||||
numberOfSelectedRows > 0 ? 'justify-between' : 'justify-end',
|
||||
)}
|
||||
>
|
||||
{isSchemaEditable && numberOfSelectedRows > 0 && (
|
||||
{numberOfSelectedRows > 0 && (
|
||||
<div className="grid grid-flow-col place-content-start items-center gap-2">
|
||||
<Chip
|
||||
size="small"
|
||||
|
||||
@@ -43,7 +43,13 @@ export default async function deleteRecord({
|
||||
(row) =>
|
||||
`(${primaryOrUniqueColumns
|
||||
.map((primaryOrUniqueColumn) =>
|
||||
format('%I=%L', primaryOrUniqueColumn, row[primaryOrUniqueColumn]),
|
||||
row[primaryOrUniqueColumn] === null
|
||||
? format('%I IS NULL', primaryOrUniqueColumn)
|
||||
: format(
|
||||
'%I=%L',
|
||||
primaryOrUniqueColumn,
|
||||
row[primaryOrUniqueColumn],
|
||||
),
|
||||
)
|
||||
.join(' AND ')})`,
|
||||
);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useDatabaseQuery } from '@/features/database/dataGrid/hooks/useDatabaseQuery';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { parseIdentifiersFromSQL } from '@/utils/sql';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function useRunSQL(
|
||||
sqlCode: string,
|
||||
@@ -15,6 +18,7 @@ export default function useRunSQL(
|
||||
migrationName: string,
|
||||
) {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [commandOk, setCommandOk] = useState(false);
|
||||
@@ -22,6 +26,14 @@ export default function useRunSQL(
|
||||
const [columns, setColumns] = useState<string[]>([]);
|
||||
const [rows, setRows] = useState<string[][]>([[]]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
query: { dataSourceSlug },
|
||||
} = router;
|
||||
|
||||
const { refetch } = useDatabaseQuery([dataSourceSlug as string]);
|
||||
|
||||
const appUrl = generateAppServiceUrl(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
@@ -169,8 +181,34 @@ export default function useRunSQL(
|
||||
}
|
||||
};
|
||||
|
||||
const trackAll = async (objects: any[]): Promise<Response[]> => {
|
||||
const apiPath = isPlatform ? '/v1/metadata' : '/apis/migrate';
|
||||
const responses: Response[] = await Promise.all(
|
||||
objects.map((object) =>
|
||||
fetch(`${appUrl}${apiPath}`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-hasura-admin-secret': adminSecret },
|
||||
body: JSON.stringify(object),
|
||||
}).then((response) => {
|
||||
if (!response.ok) {
|
||||
console.error('failed to track:', response);
|
||||
}
|
||||
return response;
|
||||
}),
|
||||
),
|
||||
).catch((error) => {
|
||||
console.error('Error in trackAll:', error);
|
||||
throw error;
|
||||
});
|
||||
|
||||
return responses;
|
||||
};
|
||||
|
||||
const updateMetadata = async (inputSQL: string) => {
|
||||
const entities = parseIdentifiersFromSQL(inputSQL);
|
||||
if (entities.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tablesOrViewEntities = entities.filter(
|
||||
(entity) => entity.type !== 'function',
|
||||
@@ -179,47 +217,75 @@ export default function useRunSQL(
|
||||
(entity) => entity.type === 'function',
|
||||
);
|
||||
|
||||
const trackTablesOrViews = tablesOrViewEntities.map(({ name, schema }) => ({
|
||||
type: 'pg_track_table',
|
||||
args: {
|
||||
source: 'default',
|
||||
table: {
|
||||
name,
|
||||
schema,
|
||||
let trackTablesOrViews: any[] = [];
|
||||
let trackFunctions: any[] = [];
|
||||
if (isPlatform) {
|
||||
// use v2/query
|
||||
trackTablesOrViews = tablesOrViewEntities.map(({ name, schema }) => ({
|
||||
type: 'pg_track_table',
|
||||
args: {
|
||||
source: 'default',
|
||||
table: {
|
||||
name,
|
||||
schema,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const trackFunctions = functionEntities.map(({ name, schema }) => ({
|
||||
type: 'pg_track_function',
|
||||
args: {
|
||||
source: 'default',
|
||||
function: {
|
||||
name,
|
||||
schema,
|
||||
configuration: {},
|
||||
}));
|
||||
trackFunctions = functionEntities.map(({ name, schema }) => ({
|
||||
type: 'pg_track_function',
|
||||
args: {
|
||||
source: 'default',
|
||||
function: {
|
||||
name,
|
||||
schema,
|
||||
configuration: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const metaDataPayload = {
|
||||
source: 'default',
|
||||
type: 'bulk',
|
||||
args: [...trackTablesOrViews, ...trackFunctions],
|
||||
};
|
||||
}));
|
||||
} else {
|
||||
// use apis/migrate
|
||||
trackTablesOrViews = tablesOrViewEntities.map(({ name, schema }) => ({
|
||||
name: `add_existing_table_or_view_${schema}_${name}`,
|
||||
datasource: 'default',
|
||||
down: [],
|
||||
skip_execution: false,
|
||||
up: [
|
||||
{
|
||||
type: 'pg_track_table',
|
||||
args: {
|
||||
table: { name, schema },
|
||||
source: 'default',
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
trackFunctions = functionEntities.map(({ name, schema }) => ({
|
||||
name: `add_existing_function_or_view_${schema}_${name}`,
|
||||
datasource: 'default',
|
||||
down: [],
|
||||
skip_execution: false,
|
||||
up: [
|
||||
{
|
||||
type: 'pg_track_function',
|
||||
args: {
|
||||
function: { name, schema },
|
||||
source: 'default',
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
if (entities.length > 0) {
|
||||
const metadataApiResponse = await fetch(`${appUrl}/v1/metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-hasura-admin-secret': adminSecret },
|
||||
body: JSON.stringify(metaDataPayload),
|
||||
});
|
||||
|
||||
if (!metadataApiResponse.ok) {
|
||||
throw new Error('Metadata API call failed');
|
||||
}
|
||||
}
|
||||
await trackAll([...trackTablesOrViews, ...trackFunctions]).then(
|
||||
(responses) => {
|
||||
responses.forEach((response) => {
|
||||
if (!response.ok) {
|
||||
console.error('Error tracking table or view:', response);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error('An error happened when calling the metadata API', {
|
||||
style: toastStyle.style,
|
||||
@@ -269,6 +335,9 @@ export default function useRunSQL(
|
||||
}
|
||||
}
|
||||
|
||||
// refresh the table list after running the sql
|
||||
await refetch();
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function ApplicationPaused() {
|
||||
async function handleTriggerUnpausing() {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
unpauseApplication({ variables: { appId: currentProject.id } });
|
||||
await unpauseApplication({ variables: { appId: currentProject.id } });
|
||||
await refetchWorkspaceAndProject();
|
||||
},
|
||||
{
|
||||
|
||||
@@ -119,7 +119,7 @@ export function ChangePlanModalWithData({ app, plans, close }: any) {
|
||||
const handleUpdateAppPlan = async () => {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
updateApp({
|
||||
await updateApp({
|
||||
variables: {
|
||||
appId: app.id,
|
||||
app: {
|
||||
|
||||
@@ -131,7 +131,7 @@ export default function LogsBody({ logsData, loading, error }: LogsBodyProps) {
|
||||
count: rows.length,
|
||||
getScrollElement: () => tableRef.current,
|
||||
estimateSize: () => 63,
|
||||
overscan: 5,
|
||||
overscan: 50,
|
||||
});
|
||||
|
||||
if (loading && !error) {
|
||||
@@ -214,7 +214,7 @@ export default function LogsBody({ logsData, loading, error }: LogsBodyProps) {
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
component="td"
|
||||
className="break-words py-2.5 px-2 align-top text-xs- font-normal tracking-tight"
|
||||
className="break-words px-2 py-2.5 align-top text-xs- font-normal tracking-tight"
|
||||
style={{
|
||||
width: cell.column.getSize() || 'auto',
|
||||
minWidth: !cell.column.getSize() ? 300 : 'initial',
|
||||
|
||||
@@ -52,7 +52,7 @@ function LogsDatePicker({
|
||||
<Text
|
||||
htmlFor={label}
|
||||
component="label"
|
||||
className="self-center text-sm+ font-normal"
|
||||
className="min-w-14 self-center text-sm+ font-normal"
|
||||
color="secondary"
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -1,246 +1,240 @@
|
||||
import { ControlledSelect } from '@/components/form/ControlledSelect';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ClockIcon } from '@/components/ui/v2/icons/ClockIcon';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { SearchIcon } from '@/components/ui/v2/icons/SearchIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import { Select } from '@/components/ui/v2/Select';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { LogsDatePicker } from '@/features/projects/logs/components/LogsDatePicker';
|
||||
import type { LogsCustomInterval } from '@/features/projects/logs/utils/constants/intervals';
|
||||
import { LOGS_AVAILABLE_INTERVALS } from '@/features/projects/logs/utils/constants/intervals';
|
||||
import type { AvailableLogsService } from '@/features/projects/logs/utils/constants/services';
|
||||
import { LOGS_AVAILABLE_SERVICES } from '@/features/projects/logs/utils/constants/services';
|
||||
import { useGetRunServicesQuery } from '@/utils/__generated__/graphql';
|
||||
import { LogsRangeSelector } from '@/features/projects/logs/components/LogsRangeSelector';
|
||||
import { AvailableLogsService } from '@/features/projects/logs/utils/constants/services';
|
||||
import { MINUTES_TO_DECREASE_FROM_CURRENT_DATE } from '@/utils/constants/common';
|
||||
import { useGetServiceLabelValuesQuery } from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { subMinutes } from 'date-fns';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface LogsHeaderProps extends Omit<BoxProps, 'children'> {
|
||||
export const validationSchema = Yup.object({
|
||||
from: Yup.date(),
|
||||
to: Yup.date().nullable(),
|
||||
service: Yup.string().oneOf(Object.values(AvailableLogsService)),
|
||||
regexFilter: Yup.string(),
|
||||
});
|
||||
|
||||
export type LogsFilterFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
interface LogsHeaderProps extends Omit<BoxProps, 'children'> {
|
||||
/**
|
||||
* The date to be displayed in the date picker for the from date.
|
||||
* This is used to indicate that a query is currently inflight
|
||||
*/
|
||||
fromDate: Date;
|
||||
loading: boolean;
|
||||
/**
|
||||
* The date to be displayed in the date picker for the to date.
|
||||
*
|
||||
* Function to be called when the user submits the filters form
|
||||
*/
|
||||
toDate: Date | null;
|
||||
/**
|
||||
* Service to where to fetch logs from.
|
||||
*/
|
||||
service: AvailableLogsService;
|
||||
/**
|
||||
* Function to be called when the user changes the from date.
|
||||
*/
|
||||
onFromDateChange: (value: Date) => void;
|
||||
/**
|
||||
* Function to be called when the user changes the `to` date.
|
||||
*/
|
||||
onToDateChange: (value: Date) => void;
|
||||
/**
|
||||
* Function to be called when the user changes service to which to query logs from.
|
||||
*/
|
||||
onServiceChange: (value: AvailableLogsService) => void;
|
||||
}
|
||||
|
||||
type LogsToDatePickerLiveButtonProps = Pick<
|
||||
LogsHeaderProps,
|
||||
'fromDate' | 'toDate' | 'onToDateChange'
|
||||
>;
|
||||
|
||||
function LogsToDatePickerLiveButton({
|
||||
fromDate,
|
||||
toDate,
|
||||
onToDateChange,
|
||||
}: LogsToDatePickerLiveButtonProps) {
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const isLive = !toDate;
|
||||
|
||||
function handleLiveButtonClick() {
|
||||
if (isLive) {
|
||||
return;
|
||||
}
|
||||
|
||||
onToDateChange(null);
|
||||
setCurrentTime(new Date());
|
||||
}
|
||||
|
||||
// if isLive is true, we want to update the current time every second
|
||||
// and set the toDate to the current time.
|
||||
useEffect(() => {
|
||||
let interval = null;
|
||||
|
||||
if (!interval && isLive) {
|
||||
interval = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [isLive, onToDateChange]);
|
||||
|
||||
return (
|
||||
<div className="text-greyscaleMedium grid grid-flow-col">
|
||||
<LogsDatePicker
|
||||
label="To"
|
||||
value={!isLive ? toDate : currentTime}
|
||||
disabled={isLive}
|
||||
onChange={onToDateChange}
|
||||
minDate={fromDate}
|
||||
maxDate={toDate || new Date()}
|
||||
componentsProps={{
|
||||
button: {
|
||||
className: twMerge(
|
||||
'rounded-r-none pr-3',
|
||||
isLive ? 'border-r-0 hover:border-r-0 z-0' : 'z-10',
|
||||
),
|
||||
color: toDate ? 'inherit' : 'secondary',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color={isLive ? 'primary' : 'secondary'}
|
||||
sx={{
|
||||
backgroundColor: (theme) =>
|
||||
!isLive ? `${theme.palette.grey[200]} !important` : 'transparent',
|
||||
color: !isLive ? 'text.secondary' : undefined,
|
||||
}}
|
||||
className={twMerge(
|
||||
'min-w-[77px] rounded-l-none',
|
||||
!isLive ? 'z-0 border-l-0 hover:border-l-0' : 'z-10',
|
||||
)}
|
||||
startIcon={<ClockIcon className="h-4 w-4 self-center align-middle" />}
|
||||
onClick={handleLiveButtonClick}
|
||||
>
|
||||
Live
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
onSubmitFilterValues: (value: LogsFilterFormValues) => void;
|
||||
}
|
||||
|
||||
export default function LogsHeader({
|
||||
fromDate,
|
||||
toDate,
|
||||
service,
|
||||
onFromDateChange,
|
||||
onToDateChange,
|
||||
onServiceChange,
|
||||
loading,
|
||||
onSubmitFilterValues,
|
||||
...props
|
||||
}: LogsHeaderProps) {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const applicationCreationDate = new Date(currentProject.createdAt);
|
||||
|
||||
const [runServices, setRunServices] = useState<
|
||||
{
|
||||
label: string;
|
||||
value: string;
|
||||
}[]
|
||||
const [serviceLabels, setServiceLabels] = useState<
|
||||
{ label: string; value: string }[]
|
||||
>([]);
|
||||
|
||||
const { data, loading } = useGetRunServicesQuery({
|
||||
variables: {
|
||||
appID: currentProject.id,
|
||||
resolve: false,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
const { data, loading: loadingServiceLabelValues } =
|
||||
useGetServiceLabelValuesQuery({
|
||||
variables: { appID: currentProject.id },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
const services = data.app?.runServices ?? [];
|
||||
if (!loadingServiceLabelValues) {
|
||||
const labels = data.getServiceLabelValues ?? [];
|
||||
setServiceLabels(labels.map((l) => ({ label: l, value: l })));
|
||||
}
|
||||
}, [loadingServiceLabelValues, data]);
|
||||
|
||||
setRunServices(
|
||||
services
|
||||
.filter((s) => !!s.config?.name)
|
||||
.map((s) => ({
|
||||
label: s.config.name,
|
||||
value: `run-${s.config.name}`,
|
||||
})),
|
||||
useEffect(() => {
|
||||
if (!loadingServiceLabelValues) {
|
||||
const labels = data.getServiceLabelValues ?? [];
|
||||
|
||||
const labelMappings = {
|
||||
'hasura-auth': 'Auth',
|
||||
'hasura-storage': 'Storage',
|
||||
postgres: 'Postgres',
|
||||
functions: 'Functions',
|
||||
hasura: 'Hasura',
|
||||
grafana: 'Grafana',
|
||||
'job-backup': 'Backup Jobs',
|
||||
ai: 'AI',
|
||||
};
|
||||
|
||||
setServiceLabels(
|
||||
labels.map((l) => ({ label: labelMappings[l] ?? l, value: l })),
|
||||
);
|
||||
}
|
||||
}, [loading, data]);
|
||||
}, [loadingServiceLabelValues, data]);
|
||||
|
||||
/**
|
||||
* Will subtract the `customInterval` time in minutes from the current date.
|
||||
*/
|
||||
function handleIntervalChange({
|
||||
minutesToDecreaseFromCurrentDate,
|
||||
}: LogsCustomInterval) {
|
||||
onFromDateChange(subMinutes(new Date(), minutesToDecreaseFromCurrentDate));
|
||||
onToDateChange(new Date());
|
||||
}
|
||||
const form = useForm<LogsFilterFormValues>({
|
||||
defaultValues: {
|
||||
from: subMinutes(new Date(), MINUTES_TO_DECREASE_FROM_CURRENT_DATE),
|
||||
to: new Date(),
|
||||
regexFilter: '',
|
||||
service: AvailableLogsService.ALL,
|
||||
},
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { register, watch, getValues } = form;
|
||||
|
||||
const service = watch('service');
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitFilterValues(getValues());
|
||||
}, [service, getValues, onSubmitFilterValues]);
|
||||
|
||||
const handleSubmit = (values: LogsFilterFormValues) =>
|
||||
onSubmitFilterValues(values);
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="sticky top-0 z-10 grid w-full grid-flow-row gap-x-6 gap-y-2 border-b py-2.5 px-4 lg:grid-flow-col lg:justify-between"
|
||||
className="sticky top-0 z-10 grid w-full grid-flow-row gap-x-6 gap-y-2 border-b px-4 py-2.5 lg:grid-flow-col"
|
||||
{...props}
|
||||
>
|
||||
<Box className="grid w-full grid-flow-row items-center justify-center gap-2 md:w-[initial] md:grid-flow-col md:gap-3 lg:justify-start">
|
||||
<div className="grid grid-flow-col items-center gap-3 md:justify-start">
|
||||
<LogsDatePicker
|
||||
label="From"
|
||||
value={fromDate}
|
||||
onChange={onFromDateChange}
|
||||
minDate={applicationCreationDate}
|
||||
maxDate={toDate || new Date()}
|
||||
/>
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="grid w-full grid-flow-row items-center gap-2 md:w-[initial] md:grid-flow-col md:gap-3 lg:justify-end"
|
||||
>
|
||||
<Box className="flex flex-row space-x-2">
|
||||
<ControlledSelect
|
||||
{...register('service')}
|
||||
className="w-full text-sm font-normal min-w-fit"
|
||||
placeholder="All Services"
|
||||
aria-label="Select service"
|
||||
hideEmptyHelperText
|
||||
slotProps={{
|
||||
root: {
|
||||
className: 'min-h-[initial] h-10 leading-[initial]',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{[{ label: 'All services', value: '' }, ...serviceLabels].map(
|
||||
({ value, label }) => (
|
||||
<Option
|
||||
key={value}
|
||||
value={value}
|
||||
className="text-sm+ font-medium"
|
||||
>
|
||||
{label}
|
||||
</Option>
|
||||
),
|
||||
)}
|
||||
</ControlledSelect>
|
||||
<div className="w-full min-w-fit">
|
||||
<LogsRangeSelector onSubmitFilterValues={onSubmitFilterValues} />
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<LogsToDatePickerLiveButton
|
||||
fromDate={fromDate}
|
||||
toDate={toDate}
|
||||
onToDateChange={onToDateChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Box className="-my-2.5 px-0 py-2.5 lg:border-l lg:px-3">
|
||||
<Select
|
||||
className="w-full text-sm font-normal"
|
||||
placeholder="All Services"
|
||||
onChange={(_e, value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return;
|
||||
}
|
||||
onServiceChange(value as AvailableLogsService);
|
||||
}}
|
||||
value={service}
|
||||
aria-label="Select service"
|
||||
<Input
|
||||
{...register('regexFilter')}
|
||||
placeholder="Filter logs with a regular expression"
|
||||
hideEmptyHelperText
|
||||
slotProps={{
|
||||
root: { className: 'min-h-[initial] h-9 leading-[initial]' },
|
||||
}}
|
||||
>
|
||||
{[...LOGS_AVAILABLE_SERVICES, ...runServices].map(
|
||||
({ value, label }) => (
|
||||
<Option
|
||||
key={value}
|
||||
value={value}
|
||||
className="text-sm+ font-medium"
|
||||
>
|
||||
{label}
|
||||
</Option>
|
||||
),
|
||||
)}
|
||||
</Select>
|
||||
</Box>
|
||||
</Box>
|
||||
autoComplete="off"
|
||||
fullWidth
|
||||
className="min-w-80"
|
||||
startAdornment={
|
||||
<Tooltip
|
||||
componentsProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
maxWidth: '30rem',
|
||||
},
|
||||
},
|
||||
}}
|
||||
title={
|
||||
<div className="p-2 space-y-4">
|
||||
<h2>Here are some useful regular expressions:</h2>
|
||||
<ul className="pl-3 space-y-2 list-disc">
|
||||
<li>
|
||||
use
|
||||
<code className="px-1 py-px mx-1 rounded-md bg-slate-500 text-slate-100">
|
||||
(?i)error
|
||||
</code>
|
||||
to search for lines with the word <b>error</b> (case
|
||||
insenstive)
|
||||
</li>
|
||||
<li>
|
||||
use
|
||||
<code className="px-1 py-px mx-1 rounded-md bg-slate-500 text-slate-100">
|
||||
error
|
||||
</code>
|
||||
to search for lines with the word <b>error</b> (case
|
||||
sensitive)
|
||||
</li>
|
||||
<li>
|
||||
use
|
||||
<code className="px-1 py-px mx-1 rounded-md bg-slate-500 text-slate-100">
|
||||
/metadata.*error
|
||||
</code>
|
||||
to search for errors in hasura's metadata endpoint
|
||||
</li>
|
||||
<li>
|
||||
See
|
||||
<Link
|
||||
href="https://github.com/google/re2/wiki/Syntax"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="mx-1"
|
||||
>
|
||||
here
|
||||
</Link>
|
||||
for more patterns
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Box className="ml-2 rounded-full cursor-pointer">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-5 h-5"
|
||||
color="info"
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
|
||||
<Box className="hidden grid-flow-col items-center justify-center gap-3 md:grid lg:justify-end">
|
||||
{LOGS_AVAILABLE_INTERVALS.map((logInterval) => (
|
||||
<Button
|
||||
key={logInterval.label}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className="self-center"
|
||||
onClick={() => handleIntervalChange(logInterval)}
|
||||
type="submit"
|
||||
className="h-10"
|
||||
startIcon={
|
||||
loading ? (
|
||||
<ActivityIndicator className="w-4 h-4" />
|
||||
) : (
|
||||
<SearchIcon />
|
||||
)
|
||||
}
|
||||
disabled={loading}
|
||||
>
|
||||
{logInterval.label}
|
||||
Search
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from './LogsHeader';
|
||||
export { default as LogsHeader } from './LogsHeader';
|
||||
export { default as LogsHeader, type LogsFilterFormValues } from './LogsHeader';
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Dropdown, useDropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { ClockIcon } from '@/components/ui/v2/icons/ClockIcon';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { LogsDatePicker } from '@/features/projects/logs/components/LogsDatePicker';
|
||||
import type { LogsFilterFormValues } from '@/features/projects/logs/components/LogsHeader';
|
||||
import {
|
||||
LOGS_AVAILABLE_INTERVALS,
|
||||
type LogsCustomInterval,
|
||||
} from '@/features/projects/logs/utils/constants/intervals';
|
||||
import { useInterval } from '@/hooks/useInterval';
|
||||
import { ChevronDownIcon } from '@graphiql/react';
|
||||
import { formatDistance, subMinutes } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
function LogsToDatePickerLiveButton() {
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
|
||||
const { setValue } = useFormContext<LogsFilterFormValues>();
|
||||
const { from, to } = useWatch<LogsFilterFormValues>();
|
||||
const isLive = !to;
|
||||
|
||||
function handleLiveButtonClick() {
|
||||
if (isLive) {
|
||||
setValue('from', subMinutes(new Date(), 20));
|
||||
setValue('to', new Date());
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('to', null);
|
||||
setCurrentTime(new Date());
|
||||
}
|
||||
|
||||
useInterval(() => setCurrentTime(new Date()), isLive ? 1000 : 0);
|
||||
|
||||
return (
|
||||
<div className="text-greyscaleMedium flex flex-col">
|
||||
{!isLive && (
|
||||
<LogsDatePicker
|
||||
label="To"
|
||||
value={!isLive ? to : currentTime}
|
||||
disabled={isLive}
|
||||
onChange={(date: Date) => setValue('to', date)}
|
||||
minDate={from}
|
||||
maxDate={new Date()}
|
||||
componentsProps={{
|
||||
button: {
|
||||
className: twMerge('rounded-r-none', isLive ? 'z-0' : 'z-10'),
|
||||
color: to ? 'inherit' : 'secondary',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color={isLive ? 'primary' : 'secondary'}
|
||||
sx={{
|
||||
backgroundColor: (theme) =>
|
||||
!isLive ? `${theme.palette.grey[200]} !important` : 'transparent',
|
||||
color: !isLive ? 'text.secondary' : undefined,
|
||||
}}
|
||||
className={twMerge(!isLive ? 'z-0 mt-4' : 'z-10')}
|
||||
startIcon={<ClockIcon className="h-4 w-4 self-center align-middle" />}
|
||||
onClick={handleLiveButtonClick}
|
||||
>
|
||||
Live
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LogsRangeSelectorProps {
|
||||
onSubmitFilterValues: (value: LogsFilterFormValues) => void;
|
||||
}
|
||||
|
||||
function LogsRangeSelectorIntervalPickers({
|
||||
onSubmitFilterValues,
|
||||
}: LogsRangeSelectorProps) {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const applicationCreationDate = new Date(currentProject.createdAt);
|
||||
|
||||
const { setValue, getValues } = useFormContext<LogsFilterFormValues>();
|
||||
const { from } = useWatch<LogsFilterFormValues>();
|
||||
|
||||
const { handleClose } = useDropdown();
|
||||
|
||||
const handleApply = () => {
|
||||
onSubmitFilterValues(getValues());
|
||||
handleClose();
|
||||
};
|
||||
|
||||
/**
|
||||
* Will subtract the `customInterval` time in minutes from the current date.
|
||||
*/
|
||||
function handleIntervalChange({
|
||||
minutesToDecreaseFromCurrentDate,
|
||||
}: LogsCustomInterval) {
|
||||
setValue('from', subMinutes(new Date(), minutesToDecreaseFromCurrentDate));
|
||||
setValue('to', new Date());
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col space-y-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<LogsDatePicker
|
||||
label="From"
|
||||
value={from}
|
||||
onChange={(date) => setValue('from', date)}
|
||||
minDate={applicationCreationDate}
|
||||
maxDate={new Date()}
|
||||
/>
|
||||
|
||||
<LogsToDatePickerLiveButton />
|
||||
</div>
|
||||
|
||||
<Box className="grid grid-cols-2 gap-2">
|
||||
{LOGS_AVAILABLE_INTERVALS.map((logInterval) => (
|
||||
<Button
|
||||
key={logInterval.label}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className="self-center"
|
||||
onClick={() => handleIntervalChange(logInterval)}
|
||||
>
|
||||
Last {logInterval.label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Button color="primary" variant="contained" onClick={handleApply}>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LogsRangeSelector({
|
||||
onSubmitFilterValues,
|
||||
}: LogsRangeSelectorProps) {
|
||||
const { from, to } = useWatch<LogsFilterFormValues>();
|
||||
|
||||
return (
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger hideChevron className="flex w-full rounded-full">
|
||||
<Button
|
||||
component="a"
|
||||
className="h-10 w-full min-w-40 items-center justify-between"
|
||||
variant="outlined"
|
||||
>
|
||||
<span>
|
||||
{to === null
|
||||
? 'Live'
|
||||
: `${formatDistance(to.getTime(), from.getTime())}`}
|
||||
</span>
|
||||
<ChevronDownIcon className="h-3 w-3" />
|
||||
</Button>
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content PaperProps={{ className: 'mt-1 max-w-xs w-full p-3' }}>
|
||||
<LogsRangeSelectorIntervalPickers
|
||||
onSubmitFilterValues={onSubmitFilterValues}
|
||||
/>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as LogsRangeSelector } from './LogsRangeSelector';
|
||||
@@ -89,7 +89,7 @@ function LogsTimePicker({
|
||||
</Button>
|
||||
|
||||
<Button variant="contained" color="primary" onClick={handleApply}>
|
||||
Apply
|
||||
Set
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
@@ -26,4 +26,12 @@ export const LOGS_AVAILABLE_INTERVALS: LogsCustomInterval[] = [
|
||||
label: '60 min',
|
||||
minutesToDecreaseFromCurrentDate: 60,
|
||||
},
|
||||
{
|
||||
label: '12 hours',
|
||||
minutesToDecreaseFromCurrentDate: 720,
|
||||
},
|
||||
{
|
||||
label: '24 hours',
|
||||
minutesToDecreaseFromCurrentDate: 1440,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -138,7 +138,7 @@ export default function PermissionVariableSettings() {
|
||||
<SettingsContainer
|
||||
title="Permission Variables"
|
||||
description="Permission variables are used to define permission rules in the GraphQL API."
|
||||
docsLink="https://docs.nhost.io/graphql/permissions"
|
||||
docsLink="https://docs.nhost.io/guides/api/permissions#permission-variables"
|
||||
rootClassName="gap-0"
|
||||
className="my-2 px-0"
|
||||
slotProps={{ submitButton: { className: 'hidden' } }}
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function ResourcesFormFooter() {
|
||||
<Text>
|
||||
Learn more about{' '}
|
||||
<Link
|
||||
href="https://docs.nhost.io/platform/compute"
|
||||
href="https://docs.nhost.io/platform/compute-resources"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
|
||||
@@ -169,7 +169,7 @@ export default function RoleSettings() {
|
||||
<SettingsContainer
|
||||
title="Default Allowed Roles"
|
||||
description="Default Allowed Roles are roles users get automatically when they sign up."
|
||||
docsLink="https://docs.nhost.io/authentication/users#allowed-roles"
|
||||
docsLink="https://docs.nhost.io/guides/auth/users#allowed-roles"
|
||||
rootClassName="gap-0"
|
||||
className={twMerge(
|
||||
'my-2 px-0',
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useIsCurrentUserOwner } from '@/features/projects/common/hooks/useIsCur
|
||||
import { PendingWorkspaceMemberInvitation } from '@/features/projects/workspaces/components/PendingWorkspaceMemberInvitation';
|
||||
import { WorkspaceMember } from '@/features/projects/workspaces/components/WorkspaceMember';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import { getErrorMessage } from '@/utils/getErrorMessage';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import {
|
||||
refetchGetWorkspaceMembersQuery,
|
||||
@@ -52,38 +52,42 @@ function WorkspaceMemberInviteForm({
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await insertWorkspaceMemberInvite({
|
||||
variables: {
|
||||
workspaceMemberInvite: {
|
||||
workspaceId: currentWorkspace.id,
|
||||
email,
|
||||
memberType: 'member',
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await insertWorkspaceMemberInvite({
|
||||
variables: {
|
||||
workspaceMemberInvite: {
|
||||
workspaceId: currentWorkspace.id,
|
||||
email,
|
||||
memberType: 'member',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
triggerToast(
|
||||
`Invite to join workspace ${currentWorkspace.name} sent to ${email}.`,
|
||||
);
|
||||
} catch (error) {
|
||||
await discordAnnounce(
|
||||
`Error trying to invite to ${email} to ${currentWorkspace.name} ${error.message}`,
|
||||
);
|
||||
if (
|
||||
error.message ===
|
||||
'Foreign key violation. insert or update on table "workspace_member_invites" violates foreign key constraint "workspace_member_invites_email_fkey"'
|
||||
) {
|
||||
setWorkspaceInviteError(
|
||||
'You can only invite users that are already registered at Nhost. Ask the person to register an account, then invite them again.',
|
||||
});
|
||||
|
||||
triggerToast(
|
||||
`Invite to join workspace ${currentWorkspace.name} sent to ${email}.`,
|
||||
);
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Sending invite...',
|
||||
successMessage: 'The invite has been sent successfully.',
|
||||
errorMessage: `Error trying to invite to ${email} to ${currentWorkspace.name}`,
|
||||
onError: async (error) => {
|
||||
await discordAnnounce(
|
||||
`Error trying to invite to ${email} to ${currentWorkspace.name} ${error.message}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkspaceInviteError(getErrorMessage(error, 'invite'));
|
||||
|
||||
return;
|
||||
}
|
||||
if (
|
||||
error.message ===
|
||||
'Foreign key violation. insert or update on table "workspace_member_invites" violates foreign key constraint "workspace_member_invites_email_fkey"'
|
||||
) {
|
||||
setWorkspaceInviteError(
|
||||
'You can only invite users that are already registered at Nhost. Ask the person to register an account, then invite them again.',
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
setEmail('');
|
||||
};
|
||||
@@ -130,8 +134,8 @@ export default function WorkspaceMembers() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-18 max-w-3xl font-display">
|
||||
<div className="mb-2 grid grid-flow-row gap-1">
|
||||
<div className="max-w-3xl mx-auto mt-18 font-display">
|
||||
<div className="grid grid-flow-row gap-1 mb-2">
|
||||
<Text variant="h3">Members</Text>
|
||||
<Text color="secondary" className="text-sm">
|
||||
People in this workspace can manage all projects listed above.
|
||||
|
||||
@@ -38,6 +38,7 @@ import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { parse } from 'shell-quote';
|
||||
import { HealthCheckFormSection } from './components/HealthCheckFormSection';
|
||||
import { ServiceConfirmationDialog } from './components/ServiceConfirmationDialog';
|
||||
import { ServiceDetailsDialog } from './components/ServiceDetailsDialog';
|
||||
|
||||
@@ -118,6 +119,13 @@ export default function ServiceForm({
|
||||
type: item.type,
|
||||
publish: item.publish,
|
||||
})),
|
||||
healthCheck: values.healthCheck
|
||||
? {
|
||||
port: values.healthCheck?.port,
|
||||
initialDelaySeconds: values.healthCheck?.initialDelaySeconds,
|
||||
probePeriodSeconds: values.healthCheck?.probePeriodSeconds,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
return config;
|
||||
@@ -375,6 +383,8 @@ export default function ServiceForm({
|
||||
|
||||
<StorageFormSection />
|
||||
|
||||
<HealthCheckFormSection />
|
||||
|
||||
{createServiceFormError && (
|
||||
<Alert
|
||||
severity="error"
|
||||
|
||||
@@ -41,6 +41,14 @@ export const validationSchema = Yup.object({
|
||||
})
|
||||
.required(),
|
||||
),
|
||||
healthCheck: Yup.object()
|
||||
.shape({
|
||||
port: Yup.number().required(),
|
||||
initialDelaySeconds: Yup.number().required(),
|
||||
probePeriodSeconds: Yup.number().required(),
|
||||
})
|
||||
.nullable()
|
||||
.default(undefined),
|
||||
});
|
||||
|
||||
export type ServiceFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Switch } from '@/components/ui/v2/Switch';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import type { ServiceFormValues } from '@/features/services/components/ServiceForm/ServiceFormTypes';
|
||||
import { useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
export default function HealthCheckFormSection() {
|
||||
const {
|
||||
watch,
|
||||
setValue,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<ServiceFormValues>();
|
||||
|
||||
const healthCheck = watch('healthCheck');
|
||||
const [healthCheckEnabled, setHealthCheckEnabled] = useState(!!healthCheck);
|
||||
|
||||
const toggleHealthCheckEnabled = async (enabled: boolean) => {
|
||||
setHealthCheckEnabled(enabled);
|
||||
|
||||
if (!enabled) {
|
||||
setValue('healthCheck', null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="space-y-4 rounded border-1 p-4">
|
||||
<Box className="flex flex-row items-center justify-between ">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Health Check
|
||||
</Text>
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<span>
|
||||
Monitor the health and availability of a service. Refer to{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.nhost.io/guides/run/health-checks"
|
||||
className="underline"
|
||||
>
|
||||
Health Check
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Switch
|
||||
checked={healthCheckEnabled}
|
||||
onChange={(e) => toggleHealthCheckEnabled(e.target.checked)}
|
||||
className="self-center"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{healthCheckEnabled && (
|
||||
<Box className="flex flex-col space-y-4">
|
||||
<Input
|
||||
{...register(`healthCheck.port`)}
|
||||
id="healthCheck.port"
|
||||
label="Port"
|
||||
placeholder="3000"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.healthCheck?.port}
|
||||
helperText={errors?.healthCheck?.port?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register(`healthCheck.initialDelaySeconds`)}
|
||||
id="healthCheck.initialDelaySeconds"
|
||||
label="Initial delay seconds"
|
||||
placeholder="30"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.healthCheck?.initialDelaySeconds}
|
||||
helperText={errors?.healthCheck?.initialDelaySeconds?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register(`healthCheck.probePeriodSeconds`)}
|
||||
id="healthCheck.probePeriodSeconds"
|
||||
label="Probe period seconds"
|
||||
placeholder="60"
|
||||
className="w-full"
|
||||
hideEmptyHelperText
|
||||
error={!!errors?.healthCheck?.probePeriodSeconds}
|
||||
helperText={errors?.healthCheck?.probePeriodSeconds?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
type="number"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as HealthCheckFormSection } from './HealthCheckFormSection';
|
||||
@@ -1,4 +1,4 @@
|
||||
fragment EnvironmentVariable on ConfigEnvironmentVariable {
|
||||
fragment EnvironmentVariable on ConfigGlobalEnvironmentVariable {
|
||||
id: name
|
||||
name
|
||||
value
|
||||
|
||||
@@ -3,8 +3,15 @@ query getProjectLogs(
|
||||
$service: String
|
||||
$from: Timestamp
|
||||
$to: Timestamp
|
||||
$regexFilter: String
|
||||
) {
|
||||
logs(appID: $appID, service: $service, from: $from, to: $to) {
|
||||
logs(
|
||||
appID: $appID
|
||||
service: $service
|
||||
from: $from
|
||||
to: $to
|
||||
regexFilter: $regexFilter
|
||||
) {
|
||||
log
|
||||
service
|
||||
timestamp
|
||||
@@ -15,8 +22,14 @@ subscription getLogsSubscription(
|
||||
$appID: String!
|
||||
$service: String
|
||||
$from: Timestamp
|
||||
$regexFilter: String
|
||||
) {
|
||||
logs(appID: $appID, service: $service, from: $from) {
|
||||
logs(
|
||||
appID: $appID
|
||||
service: $service
|
||||
from: $from
|
||||
regexFilter: $regexFilter
|
||||
) {
|
||||
log
|
||||
service
|
||||
timestamp
|
||||
|
||||
3
dashboard/src/gql/logs/getServiceLabelValues.gql
Normal file
3
dashboard/src/gql/logs/getServiceLabelValues.gql
Normal file
@@ -0,0 +1,3 @@
|
||||
query getServiceLabelValues($appID: String!) {
|
||||
getServiceLabelValues(appID: $appID)
|
||||
}
|
||||
@@ -40,6 +40,11 @@ query getRunServices(
|
||||
fqdn
|
||||
}
|
||||
}
|
||||
healthCheck {
|
||||
port
|
||||
initialDelaySeconds
|
||||
probePeriodSeconds
|
||||
}
|
||||
}
|
||||
}
|
||||
runServices_aggregate {
|
||||
|
||||
@@ -35,7 +35,24 @@ export default function useRemoteApplicationGQLClientWithSubscriptions() {
|
||||
);
|
||||
|
||||
return new ApolloClient({
|
||||
cache: new InMemoryCache(),
|
||||
cache: new InMemoryCache({
|
||||
typePolicies: {
|
||||
Subscription: {
|
||||
fields: {
|
||||
logs: {
|
||||
keyArgs: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
Query: {
|
||||
fields: {
|
||||
logs: {
|
||||
keyArgs: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
connectToDevTools: true,
|
||||
link: split(
|
||||
({ query }) => {
|
||||
|
||||
@@ -2,49 +2,53 @@ import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { LogsBody } from '@/features/projects/logs/components/LogsBody';
|
||||
import { LogsHeader } from '@/features/projects/logs/components/LogsHeader';
|
||||
import {
|
||||
LogsHeader,
|
||||
type LogsFilterFormValues,
|
||||
} from '@/features/projects/logs/components/LogsHeader';
|
||||
import { AvailableLogsService } from '@/features/projects/logs/utils/constants/services';
|
||||
import { useRemoteApplicationGQLClientWithSubscriptions } from '@/hooks/useRemoteApplicationGQLClientWithSubscriptions';
|
||||
import { MINUTES_TO_DECREASE_FROM_CURRENT_DATE } from '@/utils/constants/common';
|
||||
import {
|
||||
GetLogsSubscriptionDocument,
|
||||
useGetProjectLogsQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { subMinutes } from 'date-fns';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
|
||||
const MINUTES_TO_DECREASE_FROM_CURRENT_DATE = 20;
|
||||
interface LogsFilters {
|
||||
from: Date;
|
||||
to: Date | null;
|
||||
service: AvailableLogsService;
|
||||
regexFilter: string;
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [fromDate, setFromDate] = useState<Date>(
|
||||
subMinutes(new Date(), MINUTES_TO_DECREASE_FROM_CURRENT_DATE),
|
||||
);
|
||||
const [toDate, setToDate] = useState<Date | null>(new Date());
|
||||
const [service, setService] = useState<AvailableLogsService>(
|
||||
AvailableLogsService.ALL,
|
||||
);
|
||||
|
||||
// create a client that sends http requests to Hasura but websocket requests to Bragi
|
||||
const clientWithSplit = useRemoteApplicationGQLClientWithSubscriptions();
|
||||
const subscriptionReturn = useRef(null);
|
||||
|
||||
/**
|
||||
* Will change the specific service from which we query logs.
|
||||
*/
|
||||
function handleServiceChange(value: AvailableLogsService) {
|
||||
setService(value);
|
||||
}
|
||||
const [filters, setFilters] = useState<LogsFilters>({
|
||||
from: subMinutes(new Date(), MINUTES_TO_DECREASE_FROM_CURRENT_DATE),
|
||||
to: new Date(),
|
||||
regexFilter: '',
|
||||
service: AvailableLogsService.ALL,
|
||||
});
|
||||
|
||||
const { data, loading, error, subscribeToMore, client } =
|
||||
const { data, error, subscribeToMore, client, loading, refetch } =
|
||||
useGetProjectLogsQuery({
|
||||
variables: {
|
||||
appID: currentProject.id,
|
||||
from: fromDate,
|
||||
to: toDate,
|
||||
service,
|
||||
},
|
||||
variables: { appID: currentProject.id, ...filters },
|
||||
client: clientWithSplit,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
notifyOnNetworkStatusChange: true,
|
||||
});
|
||||
|
||||
const subscribeToMoreLogs = useCallback(
|
||||
@@ -53,8 +57,9 @@ export default function LogsPage() {
|
||||
document: GetLogsSubscriptionDocument,
|
||||
variables: {
|
||||
appID: currentProject.id,
|
||||
service,
|
||||
from: fromDate,
|
||||
service: filters.service,
|
||||
from: filters.from,
|
||||
regexFilter: filters.regexFilter,
|
||||
},
|
||||
updateQuery: (prev, { subscriptionData }) => {
|
||||
// if there is no new data, just return the previous data
|
||||
@@ -93,40 +98,47 @@ export default function LogsPage() {
|
||||
};
|
||||
},
|
||||
}),
|
||||
[subscribeToMore, currentProject.id, service, fromDate],
|
||||
[subscribeToMore, currentProject.id, filters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (toDate && subscriptionReturn.current !== null) {
|
||||
if (filters.to && subscriptionReturn.current !== null) {
|
||||
subscriptionReturn.current();
|
||||
subscriptionReturn.current = null;
|
||||
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (toDate) {
|
||||
if (filters.to) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (subscriptionReturn.current) {
|
||||
subscriptionReturn.current();
|
||||
subscriptionReturn.current = null;
|
||||
}
|
||||
|
||||
// This will open the websocket connection and it will return a function to close it.
|
||||
subscriptionReturn.current = subscribeToMoreLogs();
|
||||
|
||||
// get rid of the current apollo client instance (will also close the websocket if it's the live status)
|
||||
return () => client.stop();
|
||||
}, [subscribeToMoreLogs, toDate, client]);
|
||||
return () => {};
|
||||
}, [filters, subscribeToMoreLogs, client]);
|
||||
|
||||
const onSubmitFilterValues = useCallback(
|
||||
async (values: LogsFilterFormValues) => {
|
||||
setFilters({ ...(values as LogsFilters) });
|
||||
await refetch();
|
||||
},
|
||||
[setFilters, refetch],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<RetryableErrorBoundary>
|
||||
<LogsHeader
|
||||
fromDate={fromDate}
|
||||
toDate={toDate}
|
||||
service={service}
|
||||
onServiceChange={handleServiceChange}
|
||||
onFromDateChange={setFromDate}
|
||||
onToDateChange={setToDate}
|
||||
loading={loading}
|
||||
onSubmitFilterValues={onSubmitFilterValues}
|
||||
/>
|
||||
|
||||
<LogsBody error={error} loading={loading} logsData={data} />
|
||||
</RetryableErrorBoundary>
|
||||
</div>
|
||||
|
||||
158
dashboard/src/pages/_/_/[...slug].tsx
Normal file
158
dashboard/src/pages/_/_/[...slug].tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import {
|
||||
useGetAllWorkspacesAndProjectsQuery,
|
||||
type GetAllWorkspacesAndProjectsQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { Divider } from '@mui/material';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import debounce from 'lodash.debounce';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ChangeEvent, ReactElement } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type Workspace = Omit<
|
||||
GetAllWorkspacesAndProjectsQuery['workspaces'][0],
|
||||
'__typename'
|
||||
>;
|
||||
|
||||
export default function SelectWorkspaceAndProject() {
|
||||
const user = useUserData();
|
||||
const router = useRouter();
|
||||
|
||||
const { data, loading } = useGetAllWorkspacesAndProjectsQuery({
|
||||
skip: !user,
|
||||
});
|
||||
|
||||
const workspaces: Workspace[] = data?.workspaces || [];
|
||||
|
||||
const projects = workspaces.flatMap((workspace) =>
|
||||
workspace.projects.map((project) => ({
|
||||
workspaceName: workspace.name,
|
||||
projectName: project.name,
|
||||
value: `${workspace.slug}/${project.slug}`,
|
||||
})),
|
||||
);
|
||||
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const handleFilterChange = useMemo(
|
||||
() =>
|
||||
debounce((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setFilter(event.target.value);
|
||||
}, 200),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => () => handleFilterChange.cancel(), [handleFilterChange]);
|
||||
|
||||
const goToProjectPage = async (project: {
|
||||
workspaceName: string;
|
||||
projectName: string;
|
||||
value: string;
|
||||
}) => {
|
||||
const { slug } = router.query;
|
||||
|
||||
await router.push({
|
||||
pathname: `/${project.value}/${
|
||||
Array.isArray(slug) ? slug.join('/') : slug
|
||||
}`,
|
||||
});
|
||||
};
|
||||
|
||||
const projectsToDisplay = filter
|
||||
? projects.filter((project) =>
|
||||
project.projectName.toLowerCase().includes(filter.toLowerCase()),
|
||||
)
|
||||
: projects;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<ActivityIndicator
|
||||
delay={500}
|
||||
label="Loading workspaces and projects..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="mx-auto grid max-w-[760px] grid-flow-row gap-4 py-6 sm:py-14">
|
||||
<Text variant="h2" component="h1" className="">
|
||||
Select a Project
|
||||
</Text>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex w-full">
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
onChange={handleFilterChange}
|
||||
fullWidth
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<RetryableErrorBoundary>
|
||||
{projectsToDisplay.length === 0 ? (
|
||||
<Box className="h-import py-2">
|
||||
<Text variant="subtitle2">No results found.</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<List className="h-import overflow-y-auto">
|
||||
{projectsToDisplay.map((project, index) => (
|
||||
<Fragment key={project.value}>
|
||||
<ListItem.Root
|
||||
className="grid grid-flow-col justify-start gap-2 py-2.5"
|
||||
secondaryAction={
|
||||
<Button
|
||||
variant="borderless"
|
||||
color="primary"
|
||||
onClick={() => goToProjectPage(project)}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ListItem.Avatar>
|
||||
<span className="inline-block h-6 w-6 overflow-hidden rounded-md">
|
||||
<Image
|
||||
src="/logos/new.svg"
|
||||
alt="Nhost Logo"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</span>
|
||||
</ListItem.Avatar>
|
||||
<ListItem.Text
|
||||
primary={project.projectName}
|
||||
secondary={`${project.workspaceName} / ${project.projectName}`}
|
||||
/>
|
||||
</ListItem.Root>
|
||||
|
||||
{index < projects.length - 1 && <Divider component="li" />}
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</RetryableErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
SelectWorkspaceAndProject.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<AuthenticatedLayout title="Select a Project">{page}</AuthenticatedLayout>
|
||||
);
|
||||
};
|
||||
@@ -2,8 +2,10 @@ import { Container } from '@/components/layout/Container';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { AccountSettingsLayout } from '@/features/account/settings/components/AccountSettingsLayout';
|
||||
import { DeleteAccount } from '@/features/account/settings/components/DeleteAccount';
|
||||
import { DisplayNameSetting } from '@/features/account/settings/components/DisplayNameSetting';
|
||||
import { PasswordSettings } from '@/features/account/settings/components/PasswordSettings';
|
||||
import { PATSettings } from '@/features/account/settings/components/PATSettings';
|
||||
import { SocialProvidersSettings } from '@/features/account/settings/components/SocialProvidersSettings';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export default function AccountSettingsPage() {
|
||||
@@ -12,10 +14,18 @@ export default function AccountSettingsPage() {
|
||||
className="grid max-w-5xl grid-flow-row gap-8 bg-transparent"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<RetryableErrorBoundary>
|
||||
<DisplayNameSetting />
|
||||
</RetryableErrorBoundary>
|
||||
|
||||
<RetryableErrorBoundary>
|
||||
<PasswordSettings />
|
||||
</RetryableErrorBoundary>
|
||||
|
||||
<RetryableErrorBoundary>
|
||||
<SocialProvidersSettings />
|
||||
</RetryableErrorBoundary>
|
||||
|
||||
<RetryableErrorBoundary>
|
||||
<PATSettings />
|
||||
</RetryableErrorBoundary>
|
||||
|
||||
94
dashboard/src/pages/email/verify.tsx
Normal file
94
dashboard/src/pages/email/verify.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { UnauthenticatedLayout } from '@/components/layout/UnauthenticatedLayout';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useNhostClient } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState, type ReactElement } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
const router = useRouter();
|
||||
const nhost = useNhostClient();
|
||||
|
||||
const {
|
||||
query: { email },
|
||||
} = router;
|
||||
|
||||
const [resendVerificationEmailLoading, setResendVerificationEmailLoading] =
|
||||
useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!email) {
|
||||
router.push('/signin');
|
||||
}
|
||||
}, [email, router]);
|
||||
|
||||
const resendVerificationEmail = async () => {
|
||||
setResendVerificationEmailLoading(true);
|
||||
|
||||
try {
|
||||
await nhost.auth.sendVerificationEmail({ email: email as string });
|
||||
|
||||
toast.success(
|
||||
`An new email has been sent to ${email}. Please follow the link to verify your email address and to
|
||||
complete your registration.`,
|
||||
getToastStyleProps(),
|
||||
);
|
||||
} catch {
|
||||
toast.error(
|
||||
'An error occurred while sending the verification email. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
} finally {
|
||||
setResendVerificationEmailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text
|
||||
variant="h2"
|
||||
component="h1"
|
||||
className="text-center text-3.5xl font-semibold lg:text-4.5xl"
|
||||
>
|
||||
Verify your email
|
||||
</Text>
|
||||
|
||||
<Box className="grid grid-flow-row gap-4 rounded-md border bg-transparent p-6 lg:p-12">
|
||||
<div className="relative py-2">
|
||||
<Text color="secondary" className="text-center text-sm">
|
||||
Please check your inbox for the verification email. Follow the link
|
||||
to verify your email address and complete your registration.
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
className="!bg-white !text-black disabled:!text-black disabled:!text-opacity-60"
|
||||
size="large"
|
||||
disabled={resendVerificationEmailLoading}
|
||||
loading={resendVerificationEmailLoading}
|
||||
type="button"
|
||||
onClick={resendVerificationEmail}
|
||||
>
|
||||
Resend verification email
|
||||
</Button>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<NavLink href="/signin" color="white" className="font-medium">
|
||||
Sign In
|
||||
</NavLink>
|
||||
</div>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
VerifyEmailPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<UnauthenticatedLayout title="Verify your email">
|
||||
{page}
|
||||
</UnauthenticatedLayout>
|
||||
);
|
||||
};
|
||||
@@ -8,9 +8,9 @@ import { Text } from '@/components/ui/v2/Text';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { styled } from '@mui/material';
|
||||
import { useSignInEmailPassword } from '@nhost/nextjs';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useNhostClient, useSignInEmailPassword } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, type ReactElement } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
@@ -30,6 +30,8 @@ const StyledInput = styled(Input)({
|
||||
});
|
||||
|
||||
export default function EmailSignUpPage() {
|
||||
const router = useRouter();
|
||||
const nhost = useNhostClient();
|
||||
const { signInEmailPassword, error } = useSignInEmailPassword();
|
||||
|
||||
const form = useForm<EmailSignUpFormValues>({
|
||||
@@ -56,7 +58,15 @@ export default function EmailSignUpPage() {
|
||||
|
||||
async function handleSubmit({ email, password }: EmailSignUpFormValues) {
|
||||
try {
|
||||
await signInEmailPassword(email, password);
|
||||
const { needsEmailVerification } = await signInEmailPassword(
|
||||
email,
|
||||
password,
|
||||
);
|
||||
|
||||
if (needsEmailVerification) {
|
||||
await nhost.auth.sendVerificationEmail({ email: email as string });
|
||||
router.push(`/email/verify?email=${email}`);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
'An error occurred while signing in. Please try again.',
|
||||
@@ -75,7 +85,7 @@ export default function EmailSignUpPage() {
|
||||
Sign In
|
||||
</Text>
|
||||
|
||||
<Box className="grid grid-flow-row gap-4 rounded-md border bg-transparent p-6 lg:p-12">
|
||||
<Box className="grid grid-flow-row gap-4 p-6 bg-transparent border rounded-md lg:p-12">
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
@@ -115,7 +125,7 @@ export default function EmailSignUpPage() {
|
||||
<NavLink
|
||||
href="/reset-password"
|
||||
color="white"
|
||||
className="justify-self-start font-semibold"
|
||||
className="font-semibold justify-self-start"
|
||||
>
|
||||
Forgot password?
|
||||
</NavLink>
|
||||
@@ -140,7 +150,7 @@ export default function EmailSignUpPage() {
|
||||
</FormProvider>
|
||||
</Box>
|
||||
|
||||
<Text color="secondary" className="text-center text-base lg:text-lg">
|
||||
<Text color="secondary" className="text-base text-center lg:text-lg">
|
||||
Don't have an account?{' '}
|
||||
<NavLink href="/signup" color="white">
|
||||
Sign Up
|
||||
|
||||
@@ -12,6 +12,7 @@ import { nhost } from '@/utils/nhost';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { styled } from '@mui/material';
|
||||
import { useSignUpEmailPassword } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -36,6 +37,7 @@ const StyledInput = styled(Input)({
|
||||
export default function SignUpPage() {
|
||||
const { signUpEmailPassword, error } = useSignUpEmailPassword();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<SignUpFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
@@ -65,7 +67,15 @@ export default function SignUpPage() {
|
||||
displayName,
|
||||
}: SignUpFormValues) {
|
||||
try {
|
||||
await signUpEmailPassword(email, password, { displayName });
|
||||
const { needsEmailVerification } = await signUpEmailPassword(
|
||||
email,
|
||||
password,
|
||||
{ displayName },
|
||||
);
|
||||
|
||||
if (needsEmailVerification) {
|
||||
router.push(`/email/verify?email=${email}`);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
'An error occurred while signing up. Please try again.',
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Toaster } from 'react-hot-toast';
|
||||
const emotionCache = createEmotionCache();
|
||||
|
||||
process.env = {
|
||||
TEST_MODE: 'true',
|
||||
NODE_ENV: 'development',
|
||||
NEXT_PUBLIC_NHOST_PLATFORM: 'false',
|
||||
NEXT_PUBLIC_ENV: 'dev',
|
||||
|
||||
260
dashboard/src/utils/__generated__/graphql.ts
generated
260
dashboard/src/utils/__generated__/graphql.ts
generated
@@ -196,6 +196,7 @@ export type ConfigAppSystemConfig = {
|
||||
*/
|
||||
export type ConfigAuth = {
|
||||
__typename?: 'ConfigAuth';
|
||||
elevatedPrivileges?: Maybe<ConfigAuthElevatedPrivileges>;
|
||||
method?: Maybe<ConfigAuthMethod>;
|
||||
redirections?: Maybe<ConfigAuthRedirections>;
|
||||
/** Resources for the service */
|
||||
@@ -219,6 +220,7 @@ export type ConfigAuthComparisonExp = {
|
||||
_and?: InputMaybe<Array<ConfigAuthComparisonExp>>;
|
||||
_not?: InputMaybe<ConfigAuthComparisonExp>;
|
||||
_or?: InputMaybe<Array<ConfigAuthComparisonExp>>;
|
||||
elevatedPrivileges?: InputMaybe<ConfigAuthElevatedPrivilegesComparisonExp>;
|
||||
method?: InputMaybe<ConfigAuthMethodComparisonExp>;
|
||||
redirections?: InputMaybe<ConfigAuthRedirectionsComparisonExp>;
|
||||
resources?: InputMaybe<ConfigResourcesComparisonExp>;
|
||||
@@ -229,7 +231,28 @@ export type ConfigAuthComparisonExp = {
|
||||
version?: InputMaybe<ConfigStringComparisonExp>;
|
||||
};
|
||||
|
||||
export type ConfigAuthElevatedPrivileges = {
|
||||
__typename?: 'ConfigAuthElevatedPrivileges';
|
||||
mode?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type ConfigAuthElevatedPrivilegesComparisonExp = {
|
||||
_and?: InputMaybe<Array<ConfigAuthElevatedPrivilegesComparisonExp>>;
|
||||
_not?: InputMaybe<ConfigAuthElevatedPrivilegesComparisonExp>;
|
||||
_or?: InputMaybe<Array<ConfigAuthElevatedPrivilegesComparisonExp>>;
|
||||
mode?: InputMaybe<ConfigStringComparisonExp>;
|
||||
};
|
||||
|
||||
export type ConfigAuthElevatedPrivilegesInsertInput = {
|
||||
mode?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type ConfigAuthElevatedPrivilegesUpdateInput = {
|
||||
mode?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type ConfigAuthInsertInput = {
|
||||
elevatedPrivileges?: InputMaybe<ConfigAuthElevatedPrivilegesInsertInput>;
|
||||
method?: InputMaybe<ConfigAuthMethodInsertInput>;
|
||||
redirections?: InputMaybe<ConfigAuthRedirectionsInsertInput>;
|
||||
resources?: InputMaybe<ConfigResourcesInsertInput>;
|
||||
@@ -808,6 +831,7 @@ export type ConfigAuthTotpUpdateInput = {
|
||||
};
|
||||
|
||||
export type ConfigAuthUpdateInput = {
|
||||
elevatedPrivileges?: InputMaybe<ConfigAuthElevatedPrivilegesUpdateInput>;
|
||||
method?: InputMaybe<ConfigAuthMethodUpdateInput>;
|
||||
redirections?: InputMaybe<ConfigAuthRedirectionsUpdateInput>;
|
||||
resources?: InputMaybe<ConfigResourcesUpdateInput>;
|
||||
@@ -1248,22 +1272,47 @@ export type ConfigFunctionsUpdateInput = {
|
||||
export type ConfigGlobal = {
|
||||
__typename?: 'ConfigGlobal';
|
||||
/** User-defined environment variables that are spread over all services */
|
||||
environment?: Maybe<Array<ConfigEnvironmentVariable>>;
|
||||
environment?: Maybe<Array<ConfigGlobalEnvironmentVariable>>;
|
||||
};
|
||||
|
||||
export type ConfigGlobalComparisonExp = {
|
||||
_and?: InputMaybe<Array<ConfigGlobalComparisonExp>>;
|
||||
_not?: InputMaybe<ConfigGlobalComparisonExp>;
|
||||
_or?: InputMaybe<Array<ConfigGlobalComparisonExp>>;
|
||||
environment?: InputMaybe<ConfigEnvironmentVariableComparisonExp>;
|
||||
environment?: InputMaybe<ConfigGlobalEnvironmentVariableComparisonExp>;
|
||||
};
|
||||
|
||||
export type ConfigGlobalEnvironmentVariable = {
|
||||
__typename?: 'ConfigGlobalEnvironmentVariable';
|
||||
name: Scalars['String'];
|
||||
/** Value of the environment variable */
|
||||
value: Scalars['String'];
|
||||
};
|
||||
|
||||
export type ConfigGlobalEnvironmentVariableComparisonExp = {
|
||||
_and?: InputMaybe<Array<ConfigGlobalEnvironmentVariableComparisonExp>>;
|
||||
_not?: InputMaybe<ConfigGlobalEnvironmentVariableComparisonExp>;
|
||||
_or?: InputMaybe<Array<ConfigGlobalEnvironmentVariableComparisonExp>>;
|
||||
name?: InputMaybe<ConfigStringComparisonExp>;
|
||||
value?: InputMaybe<ConfigStringComparisonExp>;
|
||||
};
|
||||
|
||||
export type ConfigGlobalEnvironmentVariableInsertInput = {
|
||||
name: Scalars['String'];
|
||||
value: Scalars['String'];
|
||||
};
|
||||
|
||||
export type ConfigGlobalEnvironmentVariableUpdateInput = {
|
||||
name?: InputMaybe<Scalars['String']>;
|
||||
value?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type ConfigGlobalInsertInput = {
|
||||
environment?: InputMaybe<Array<ConfigEnvironmentVariableInsertInput>>;
|
||||
environment?: InputMaybe<Array<ConfigGlobalEnvironmentVariableInsertInput>>;
|
||||
};
|
||||
|
||||
export type ConfigGlobalUpdateInput = {
|
||||
environment?: InputMaybe<Array<ConfigEnvironmentVariableUpdateInput>>;
|
||||
environment?: InputMaybe<Array<ConfigGlobalEnvironmentVariableUpdateInput>>;
|
||||
};
|
||||
|
||||
export type ConfigGrafana = {
|
||||
@@ -1404,6 +1453,8 @@ export type ConfigHasuraSettings = {
|
||||
enabledAPIs?: Maybe<Array<Scalars['ConfigHasuraAPIs']>>;
|
||||
/** HASURA_GRAPHQL_LIVE_QUERIES_MULTIPLEXED_REFETCH_INTERVAL */
|
||||
liveQueriesMultiplexedRefetchInterval?: Maybe<Scalars['ConfigUint32']>;
|
||||
/** HASURA_GRAPHQL_STRINGIFY_NUMERIC_TYPES */
|
||||
stringifyNumericTypes?: Maybe<Scalars['Boolean']>;
|
||||
};
|
||||
|
||||
export type ConfigHasuraSettingsComparisonExp = {
|
||||
@@ -1417,6 +1468,7 @@ export type ConfigHasuraSettingsComparisonExp = {
|
||||
enableRemoteSchemaPermissions?: InputMaybe<ConfigBooleanComparisonExp>;
|
||||
enabledAPIs?: InputMaybe<ConfigHasuraApIsComparisonExp>;
|
||||
liveQueriesMultiplexedRefetchInterval?: InputMaybe<ConfigUint32ComparisonExp>;
|
||||
stringifyNumericTypes?: InputMaybe<ConfigBooleanComparisonExp>;
|
||||
};
|
||||
|
||||
export type ConfigHasuraSettingsInsertInput = {
|
||||
@@ -1427,6 +1479,7 @@ export type ConfigHasuraSettingsInsertInput = {
|
||||
enableRemoteSchemaPermissions?: InputMaybe<Scalars['Boolean']>;
|
||||
enabledAPIs?: InputMaybe<Array<Scalars['ConfigHasuraAPIs']>>;
|
||||
liveQueriesMultiplexedRefetchInterval?: InputMaybe<Scalars['ConfigUint32']>;
|
||||
stringifyNumericTypes?: InputMaybe<Scalars['Boolean']>;
|
||||
};
|
||||
|
||||
export type ConfigHasuraSettingsUpdateInput = {
|
||||
@@ -1437,6 +1490,7 @@ export type ConfigHasuraSettingsUpdateInput = {
|
||||
enableRemoteSchemaPermissions?: InputMaybe<Scalars['Boolean']>;
|
||||
enabledAPIs?: InputMaybe<Array<Scalars['ConfigHasuraAPIs']>>;
|
||||
liveQueriesMultiplexedRefetchInterval?: InputMaybe<Scalars['ConfigUint32']>;
|
||||
stringifyNumericTypes?: InputMaybe<Scalars['Boolean']>;
|
||||
};
|
||||
|
||||
export type ConfigHasuraUpdateInput = {
|
||||
@@ -2486,6 +2540,14 @@ export type Metrics = {
|
||||
value: Scalars['float64'];
|
||||
};
|
||||
|
||||
export type StatsDailyLiveFreeApps = {
|
||||
__typename?: 'StatsDailyLiveFreeApps';
|
||||
avg: Scalars['Int'];
|
||||
max: Scalars['Int'];
|
||||
min: Scalars['Int'];
|
||||
raw: Array<Scalars['Int']>;
|
||||
};
|
||||
|
||||
export type StatsLiveApps = {
|
||||
__typename?: 'StatsLiveApps';
|
||||
appID: Array<Scalars['uuid']>;
|
||||
@@ -5238,9 +5300,9 @@ export type AuthUserProviders_Bool_Exp = {
|
||||
export enum AuthUserProviders_Constraint {
|
||||
/** unique or primary key constraint on columns "id" */
|
||||
UserProvidersPkey = 'user_providers_pkey',
|
||||
/** unique or primary key constraint on columns "provider_id", "provider_user_id" */
|
||||
/** unique or primary key constraint on columns "provider_user_id", "provider_id" */
|
||||
UserProvidersProviderIdProviderUserIdKey = 'user_providers_provider_id_provider_user_id_key',
|
||||
/** unique or primary key constraint on columns "provider_id", "user_id" */
|
||||
/** unique or primary key constraint on columns "user_id", "provider_id" */
|
||||
UserProvidersUserIdProviderIdKey = 'user_providers_user_id_provider_id_key'
|
||||
}
|
||||
|
||||
@@ -15774,6 +15836,7 @@ export type Query_Root = {
|
||||
runServiceConfig?: Maybe<ConfigRunServiceConfig>;
|
||||
runServiceConfigRawJSON: Scalars['String'];
|
||||
runServiceConfigs: Array<ConfigRunServiceConfigWithId>;
|
||||
runServiceConfigsAll: Array<ConfigRunServiceConfigWithId>;
|
||||
/** An array relationship */
|
||||
runServices: Array<Run_Service>;
|
||||
/** fetch aggregated fields from the table: "run_service" */
|
||||
@@ -15796,6 +15859,12 @@ export type Query_Root = {
|
||||
softwareVersions: Array<Software_Versions>;
|
||||
/** fetch aggregated fields from the table: "software_versions" */
|
||||
softwareVersionsAggregate: Software_Versions_Aggregate;
|
||||
/**
|
||||
* Returns the per-day number of free live apps in the given time range, as well as the min, max and avg.
|
||||
*
|
||||
* Requests that returned a 4xx or 5xx status code are not counted as live traffic.
|
||||
*/
|
||||
statsDailyLiveFreeApps: StatsDailyLiveFreeApps;
|
||||
/**
|
||||
* Returns lists of apps that have some live traffic in the give time range.
|
||||
* From defaults to 24 hours ago and to defaults to now.
|
||||
@@ -16755,6 +16824,12 @@ export type Query_RootRunServiceConfigRawJsonArgs = {
|
||||
|
||||
|
||||
export type Query_RootRunServiceConfigsArgs = {
|
||||
appID: Scalars['uuid'];
|
||||
resolve: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
|
||||
export type Query_RootRunServiceConfigsAllArgs = {
|
||||
resolve: Scalars['Boolean'];
|
||||
where?: InputMaybe<ConfigRunServiceConfigComparisonExp>;
|
||||
};
|
||||
@@ -16847,6 +16922,12 @@ export type Query_RootSoftwareVersionsAggregateArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type Query_RootStatsDailyLiveFreeAppsArgs = {
|
||||
from?: InputMaybe<Scalars['Timestamp']>;
|
||||
to?: InputMaybe<Scalars['Timestamp']>;
|
||||
};
|
||||
|
||||
|
||||
export type Query_RootStatsLiveAppsArgs = {
|
||||
from?: InputMaybe<Scalars['Timestamp']>;
|
||||
to?: InputMaybe<Scalars['Timestamp']>;
|
||||
@@ -21355,7 +21436,7 @@ export type WorkspaceMemberInvites_Bool_Exp = {
|
||||
|
||||
/** unique or primary key constraints on table "workspace_member_invites" */
|
||||
export enum WorkspaceMemberInvites_Constraint {
|
||||
/** unique or primary key constraint on columns "email", "workspace_id" */
|
||||
/** unique or primary key constraint on columns "workspace_id", "email" */
|
||||
WorkspaceMemberInvitesEmailWorkspaceIdKey = 'workspace_member_invites_email_workspace_id_key',
|
||||
/** unique or primary key constraint on columns "id" */
|
||||
WorkspaceMemberInvitesPkey = 'workspace_member_invites_pkey'
|
||||
@@ -22408,6 +22489,11 @@ export type DeleteUserAccountMutationVariables = Exact<{
|
||||
|
||||
export type DeleteUserAccountMutation = { __typename?: 'mutation_root', deleteUser?: { __typename: 'users' } | null };
|
||||
|
||||
export type GetAuthUserProvidersQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetAuthUserProvidersQuery = { __typename?: 'query_root', authUserProviders: Array<{ __typename?: 'authUserProviders', id: any, providerId: string }> };
|
||||
|
||||
export type GetPersonalAccessTokensQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
@@ -22420,12 +22506,20 @@ export type DeletePersonalAccessTokenMutationVariables = Exact<{
|
||||
|
||||
export type DeletePersonalAccessTokenMutation = { __typename?: 'mutation_root', deletePersonalAccessToken?: { __typename?: 'authRefreshTokens', id: any, metadata?: any | null } | null };
|
||||
|
||||
export type UpdateUserDisplayNameMutationVariables = Exact<{
|
||||
id: Scalars['uuid'];
|
||||
displayName: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type UpdateUserDisplayNameMutation = { __typename?: 'mutation_root', updateUser?: { __typename?: 'users', id: any, displayName: string } | null };
|
||||
|
||||
export type GetAiSettingsQueryVariables = Exact<{
|
||||
appId: Scalars['uuid'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetAiSettingsQuery = { __typename?: 'query_root', config?: { __typename?: 'ConfigConfig', ai?: { __typename?: 'ConfigAI', version?: string | null, webhookSecret: string, autoEmbeddings?: { __typename?: 'ConfigAIAutoEmbeddings', synchPeriodMinutes?: any | null } | null, openai: { __typename?: 'ConfigAIOpenai', apiKey: string, organization?: string | null }, resources: { __typename?: 'ConfigAIResources', compute: { __typename?: 'ConfigComputeResources', cpu: any, memory: any } } } | null } | null };
|
||||
export type GetAiSettingsQuery = { __typename?: 'query_root', config?: { __typename?: 'ConfigConfig', postgres?: { __typename?: 'ConfigPostgres', version?: string | null } | null, ai?: { __typename?: 'ConfigAI', version?: string | null, webhookSecret: string, autoEmbeddings?: { __typename?: 'ConfigAIAutoEmbeddings', synchPeriodMinutes?: any | null } | null, openai: { __typename?: 'ConfigAIOpenai', apiKey: string, organization?: string | null }, resources: { __typename?: 'ConfigAIResources', compute: { __typename?: 'ConfigComputeResources', cpu: any, memory: any } } } | null } | null };
|
||||
|
||||
export type GetAuthenticationSettingsQueryVariables = Exact<{
|
||||
appId: Scalars['uuid'];
|
||||
@@ -22598,7 +22692,7 @@ export type DnsLookupCnameQueryVariables = Exact<{
|
||||
|
||||
export type DnsLookupCnameQuery = { __typename?: 'query_root', dnsLookupCNAME: string };
|
||||
|
||||
export type EnvironmentVariableFragment = { __typename?: 'ConfigEnvironmentVariable', name: string, value: string, id: string };
|
||||
export type EnvironmentVariableFragment = { __typename?: 'ConfigGlobalEnvironmentVariable', name: string, value: string, id: string };
|
||||
|
||||
export type JwtSecretFragment = { __typename?: 'ConfigJWTSecret', issuer?: string | null, key?: string | null, type?: string | null, jwk_url?: any | null, header?: string | null, claims_namespace_path?: string | null, claims_namespace?: string | null, claims_format?: string | null, audience?: string | null, allowed_skew?: any | null };
|
||||
|
||||
@@ -22607,7 +22701,7 @@ export type GetEnvironmentVariablesQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetEnvironmentVariablesQuery = { __typename?: 'query_root', config?: { __typename: 'ConfigConfig', id: 'ConfigConfig', global?: { __typename?: 'ConfigGlobal', environment?: Array<{ __typename?: 'ConfigEnvironmentVariable', name: string, value: string, id: string }> | null } | null, hasura: { __typename?: 'ConfigHasura', adminSecret: string, webhookSecret: string, jwtSecrets?: Array<{ __typename?: 'ConfigJWTSecret', issuer?: string | null, key?: string | null, type?: string | null, jwk_url?: any | null, header?: string | null, claims_namespace_path?: string | null, claims_namespace?: string | null, claims_format?: string | null, audience?: string | null, allowed_skew?: any | null }> | null } } | null };
|
||||
export type GetEnvironmentVariablesQuery = { __typename?: 'query_root', config?: { __typename: 'ConfigConfig', id: 'ConfigConfig', global?: { __typename?: 'ConfigGlobal', environment?: Array<{ __typename?: 'ConfigGlobalEnvironmentVariable', name: string, value: string, id: string }> | null } | null, hasura: { __typename?: 'ConfigHasura', adminSecret: string, webhookSecret: string, jwtSecrets?: Array<{ __typename?: 'ConfigJWTSecret', issuer?: string | null, key?: string | null, type?: string | null, jwk_url?: any | null, header?: string | null, claims_namespace_path?: string | null, claims_namespace?: string | null, claims_format?: string | null, audience?: string | null, allowed_skew?: any | null }> | null } } | null };
|
||||
|
||||
export type PermissionVariableFragment = { __typename?: 'ConfigAuthsessionaccessTokenCustomClaims', key: string, value: string, id: string };
|
||||
|
||||
@@ -22772,6 +22866,7 @@ export type GetProjectLogsQueryVariables = Exact<{
|
||||
service?: InputMaybe<Scalars['String']>;
|
||||
from?: InputMaybe<Scalars['Timestamp']>;
|
||||
to?: InputMaybe<Scalars['Timestamp']>;
|
||||
regexFilter?: InputMaybe<Scalars['String']>;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -22781,11 +22876,19 @@ export type GetLogsSubscriptionSubscriptionVariables = Exact<{
|
||||
appID: Scalars['String'];
|
||||
service?: InputMaybe<Scalars['String']>;
|
||||
from?: InputMaybe<Scalars['Timestamp']>;
|
||||
regexFilter?: InputMaybe<Scalars['String']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type GetLogsSubscriptionSubscription = { __typename?: 'subscription_root', logs: Array<{ __typename?: 'Log', log: string, service: string, timestamp: any }> };
|
||||
|
||||
export type GetServiceLabelValuesQueryVariables = Exact<{
|
||||
appID: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetServiceLabelValuesQuery = { __typename?: 'query_root', getServiceLabelValues: Array<string> };
|
||||
|
||||
export type DeletePaymentMethodMutationVariables = Exact<{
|
||||
paymentMethodId: Scalars['uuid'];
|
||||
}>;
|
||||
@@ -22960,7 +23063,7 @@ export type GetRunServicesQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetRunServicesQuery = { __typename?: 'query_root', app?: { __typename?: 'apps', runServices: Array<{ __typename?: 'run_service', id: any, createdAt: any, updatedAt: any, subdomain: string, config?: { __typename?: 'ConfigRunServiceConfig', name: any, command?: Array<string> | null, image: { __typename?: 'ConfigRunServiceImage', image: string }, resources: { __typename?: 'ConfigRunServiceResources', replicas: any, compute: { __typename?: 'ConfigComputeResources', cpu: any, memory: any }, storage?: Array<{ __typename?: 'ConfigRunServiceResourcesStorage', name: any, path: string, capacity: any }> | null }, environment?: Array<{ __typename?: 'ConfigEnvironmentVariable', name: string, value: string }> | null, ports?: Array<{ __typename?: 'ConfigRunServicePort', port: any, type: string, publish?: boolean | null, ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null }> | null } | null }>, runServices_aggregate: { __typename?: 'run_service_aggregate', aggregate?: { __typename?: 'run_service_aggregate_fields', count: number } | null } } | null };
|
||||
export type GetRunServicesQuery = { __typename?: 'query_root', app?: { __typename?: 'apps', runServices: Array<{ __typename?: 'run_service', id: any, createdAt: any, updatedAt: any, subdomain: string, config?: { __typename?: 'ConfigRunServiceConfig', name: any, command?: Array<string> | null, image: { __typename?: 'ConfigRunServiceImage', image: string }, resources: { __typename?: 'ConfigRunServiceResources', replicas: any, compute: { __typename?: 'ConfigComputeResources', cpu: any, memory: any }, storage?: Array<{ __typename?: 'ConfigRunServiceResourcesStorage', name: any, path: string, capacity: any }> | null }, environment?: Array<{ __typename?: 'ConfigEnvironmentVariable', name: string, value: string }> | null, ports?: Array<{ __typename?: 'ConfigRunServicePort', port: any, type: string, publish?: boolean | null, ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null }> | null, healthCheck?: { __typename?: 'ConfigHealthCheck', port: any, initialDelaySeconds?: number | null, probePeriodSeconds?: number | null } | null } | null }>, runServices_aggregate: { __typename?: 'run_service_aggregate', aggregate?: { __typename?: 'run_service_aggregate_fields', count: number } | null } } | null };
|
||||
|
||||
export type InsertRunServiceMutationVariables = Exact<{
|
||||
object: Run_Service_Insert_Input;
|
||||
@@ -23187,7 +23290,7 @@ export const PrefetchNewAppWorkspaceFragmentDoc = gql`
|
||||
}
|
||||
`;
|
||||
export const EnvironmentVariableFragmentDoc = gql`
|
||||
fragment EnvironmentVariable on ConfigEnvironmentVariable {
|
||||
fragment EnvironmentVariable on ConfigGlobalEnvironmentVariable {
|
||||
id: name
|
||||
name
|
||||
value
|
||||
@@ -23444,6 +23547,44 @@ export function useDeleteUserAccountMutation(baseOptions?: Apollo.MutationHookOp
|
||||
export type DeleteUserAccountMutationHookResult = ReturnType<typeof useDeleteUserAccountMutation>;
|
||||
export type DeleteUserAccountMutationResult = Apollo.MutationResult<DeleteUserAccountMutation>;
|
||||
export type DeleteUserAccountMutationOptions = Apollo.BaseMutationOptions<DeleteUserAccountMutation, DeleteUserAccountMutationVariables>;
|
||||
export const GetAuthUserProvidersDocument = gql`
|
||||
query getAuthUserProviders {
|
||||
authUserProviders {
|
||||
id
|
||||
providerId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useGetAuthUserProvidersQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useGetAuthUserProvidersQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useGetAuthUserProvidersQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useGetAuthUserProvidersQuery({
|
||||
* variables: {
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useGetAuthUserProvidersQuery(baseOptions?: Apollo.QueryHookOptions<GetAuthUserProvidersQuery, GetAuthUserProvidersQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<GetAuthUserProvidersQuery, GetAuthUserProvidersQueryVariables>(GetAuthUserProvidersDocument, options);
|
||||
}
|
||||
export function useGetAuthUserProvidersLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetAuthUserProvidersQuery, GetAuthUserProvidersQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<GetAuthUserProvidersQuery, GetAuthUserProvidersQueryVariables>(GetAuthUserProvidersDocument, options);
|
||||
}
|
||||
export type GetAuthUserProvidersQueryHookResult = ReturnType<typeof useGetAuthUserProvidersQuery>;
|
||||
export type GetAuthUserProvidersLazyQueryHookResult = ReturnType<typeof useGetAuthUserProvidersLazyQuery>;
|
||||
export type GetAuthUserProvidersQueryResult = Apollo.QueryResult<GetAuthUserProvidersQuery, GetAuthUserProvidersQueryVariables>;
|
||||
export function refetchGetAuthUserProvidersQuery(variables?: GetAuthUserProvidersQueryVariables) {
|
||||
return { query: GetAuthUserProvidersDocument, variables: variables }
|
||||
}
|
||||
export const GetPersonalAccessTokensDocument = gql`
|
||||
query GetPersonalAccessTokens {
|
||||
personalAccessTokens: authRefreshTokens(
|
||||
@@ -23521,9 +23662,47 @@ export function useDeletePersonalAccessTokenMutation(baseOptions?: Apollo.Mutati
|
||||
export type DeletePersonalAccessTokenMutationHookResult = ReturnType<typeof useDeletePersonalAccessTokenMutation>;
|
||||
export type DeletePersonalAccessTokenMutationResult = Apollo.MutationResult<DeletePersonalAccessTokenMutation>;
|
||||
export type DeletePersonalAccessTokenMutationOptions = Apollo.BaseMutationOptions<DeletePersonalAccessTokenMutation, DeletePersonalAccessTokenMutationVariables>;
|
||||
export const UpdateUserDisplayNameDocument = gql`
|
||||
mutation updateUserDisplayName($id: uuid!, $displayName: String!) {
|
||||
updateUser(pk_columns: {id: $id}, _set: {displayName: $displayName}) {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type UpdateUserDisplayNameMutationFn = Apollo.MutationFunction<UpdateUserDisplayNameMutation, UpdateUserDisplayNameMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useUpdateUserDisplayNameMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useUpdateUserDisplayNameMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useUpdateUserDisplayNameMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [updateUserDisplayNameMutation, { data, loading, error }] = useUpdateUserDisplayNameMutation({
|
||||
* variables: {
|
||||
* id: // value for 'id'
|
||||
* displayName: // value for 'displayName'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useUpdateUserDisplayNameMutation(baseOptions?: Apollo.MutationHookOptions<UpdateUserDisplayNameMutation, UpdateUserDisplayNameMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<UpdateUserDisplayNameMutation, UpdateUserDisplayNameMutationVariables>(UpdateUserDisplayNameDocument, options);
|
||||
}
|
||||
export type UpdateUserDisplayNameMutationHookResult = ReturnType<typeof useUpdateUserDisplayNameMutation>;
|
||||
export type UpdateUserDisplayNameMutationResult = Apollo.MutationResult<UpdateUserDisplayNameMutation>;
|
||||
export type UpdateUserDisplayNameMutationOptions = Apollo.BaseMutationOptions<UpdateUserDisplayNameMutation, UpdateUserDisplayNameMutationVariables>;
|
||||
export const GetAiSettingsDocument = gql`
|
||||
query GetAISettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
postgres {
|
||||
version
|
||||
}
|
||||
ai {
|
||||
version
|
||||
webhookSecret
|
||||
@@ -25558,8 +25737,14 @@ export function refetchGetGithubRepositoriesQuery(variables?: GetGithubRepositor
|
||||
return { query: GetGithubRepositoriesDocument, variables: variables }
|
||||
}
|
||||
export const GetProjectLogsDocument = gql`
|
||||
query getProjectLogs($appID: String!, $service: String, $from: Timestamp, $to: Timestamp) {
|
||||
logs(appID: $appID, service: $service, from: $from, to: $to) {
|
||||
query getProjectLogs($appID: String!, $service: String, $from: Timestamp, $to: Timestamp, $regexFilter: String) {
|
||||
logs(
|
||||
appID: $appID
|
||||
service: $service
|
||||
from: $from
|
||||
to: $to
|
||||
regexFilter: $regexFilter
|
||||
) {
|
||||
log
|
||||
service
|
||||
timestamp
|
||||
@@ -25583,6 +25768,7 @@ export const GetProjectLogsDocument = gql`
|
||||
* service: // value for 'service'
|
||||
* from: // value for 'from'
|
||||
* to: // value for 'to'
|
||||
* regexFilter: // value for 'regexFilter'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
@@ -25601,8 +25787,8 @@ export function refetchGetProjectLogsQuery(variables: GetProjectLogsQueryVariabl
|
||||
return { query: GetProjectLogsDocument, variables: variables }
|
||||
}
|
||||
export const GetLogsSubscriptionDocument = gql`
|
||||
subscription getLogsSubscription($appID: String!, $service: String, $from: Timestamp) {
|
||||
logs(appID: $appID, service: $service, from: $from) {
|
||||
subscription getLogsSubscription($appID: String!, $service: String, $from: Timestamp, $regexFilter: String) {
|
||||
logs(appID: $appID, service: $service, from: $from, regexFilter: $regexFilter) {
|
||||
log
|
||||
service
|
||||
timestamp
|
||||
@@ -25625,6 +25811,7 @@ export const GetLogsSubscriptionDocument = gql`
|
||||
* appID: // value for 'appID'
|
||||
* service: // value for 'service'
|
||||
* from: // value for 'from'
|
||||
* regexFilter: // value for 'regexFilter'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
@@ -25634,6 +25821,42 @@ export function useGetLogsSubscriptionSubscription(baseOptions: Apollo.Subscript
|
||||
}
|
||||
export type GetLogsSubscriptionSubscriptionHookResult = ReturnType<typeof useGetLogsSubscriptionSubscription>;
|
||||
export type GetLogsSubscriptionSubscriptionResult = Apollo.SubscriptionResult<GetLogsSubscriptionSubscription>;
|
||||
export const GetServiceLabelValuesDocument = gql`
|
||||
query getServiceLabelValues($appID: String!) {
|
||||
getServiceLabelValues(appID: $appID)
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useGetServiceLabelValuesQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useGetServiceLabelValuesQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useGetServiceLabelValuesQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useGetServiceLabelValuesQuery({
|
||||
* variables: {
|
||||
* appID: // value for 'appID'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useGetServiceLabelValuesQuery(baseOptions: Apollo.QueryHookOptions<GetServiceLabelValuesQuery, GetServiceLabelValuesQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<GetServiceLabelValuesQuery, GetServiceLabelValuesQueryVariables>(GetServiceLabelValuesDocument, options);
|
||||
}
|
||||
export function useGetServiceLabelValuesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetServiceLabelValuesQuery, GetServiceLabelValuesQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<GetServiceLabelValuesQuery, GetServiceLabelValuesQueryVariables>(GetServiceLabelValuesDocument, options);
|
||||
}
|
||||
export type GetServiceLabelValuesQueryHookResult = ReturnType<typeof useGetServiceLabelValuesQuery>;
|
||||
export type GetServiceLabelValuesLazyQueryHookResult = ReturnType<typeof useGetServiceLabelValuesLazyQuery>;
|
||||
export type GetServiceLabelValuesQueryResult = Apollo.QueryResult<GetServiceLabelValuesQuery, GetServiceLabelValuesQueryVariables>;
|
||||
export function refetchGetServiceLabelValuesQuery(variables: GetServiceLabelValuesQueryVariables) {
|
||||
return { query: GetServiceLabelValuesDocument, variables: variables }
|
||||
}
|
||||
export const DeletePaymentMethodDocument = gql`
|
||||
mutation deletePaymentMethod($paymentMethodId: uuid!) {
|
||||
deletePaymentMethod(id: $paymentMethodId) {
|
||||
@@ -26577,6 +26800,11 @@ export const GetRunServicesDocument = gql`
|
||||
fqdn
|
||||
}
|
||||
}
|
||||
healthCheck {
|
||||
port
|
||||
initialDelaySeconds
|
||||
probePeriodSeconds
|
||||
}
|
||||
}
|
||||
}
|
||||
runServices_aggregate {
|
||||
|
||||
@@ -66,3 +66,8 @@ export const RESOURCE_VCPU_PRICE_PER_MINUTE = 0.0012;
|
||||
* Maximum number of free projects a user is allowed to have.
|
||||
*/
|
||||
export const MAX_FREE_PROJECTS = 1;
|
||||
|
||||
/**
|
||||
* Default value in minutes to use for querying the logs
|
||||
*/
|
||||
export const MINUTES_TO_DECREASE_FROM_CURRENT_DATE = 20;
|
||||
|
||||
@@ -29,6 +29,7 @@ export default async function execPromiseWithErrorToast(
|
||||
const result = await call();
|
||||
|
||||
toast.dismiss(loadingToastId);
|
||||
|
||||
toast.success(successMessage, {
|
||||
style: toastStyle.style,
|
||||
...toastStyle.success,
|
||||
|
||||
@@ -3,18 +3,14 @@ import {
|
||||
getFunctionsServiceUrl,
|
||||
getGraphqlServiceUrl,
|
||||
getStorageServiceUrl,
|
||||
isPlatform,
|
||||
} from '@/utils/env';
|
||||
import { NhostClient } from '@nhost/nextjs';
|
||||
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
const nhost = isPlatform()
|
||||
? new NhostClient({
|
||||
authUrl: getAuthServiceUrl(),
|
||||
graphqlUrl: getGraphqlServiceUrl(),
|
||||
functionsUrl: getFunctionsServiceUrl(),
|
||||
storageUrl: getStorageServiceUrl(),
|
||||
})
|
||||
: new NhostClient({ subdomain: 'local' });
|
||||
const nhost = new NhostClient({
|
||||
authUrl: getAuthServiceUrl(),
|
||||
graphqlUrl: getGraphqlServiceUrl(),
|
||||
functionsUrl: getFunctionsServiceUrl(),
|
||||
storageUrl: getStorageServiceUrl(),
|
||||
});
|
||||
|
||||
export default nhost;
|
||||
|
||||
@@ -21,6 +21,9 @@ module.exports = {
|
||||
copper: '#DD792D',
|
||||
paper: '#171d26',
|
||||
divider: '#2f363d',
|
||||
'primary-main': '#0052cd',
|
||||
'primary-light': '#ebf3ff',
|
||||
'primary-dark': '#063799',
|
||||
},
|
||||
boxShadow: {
|
||||
outline: 'inset 0 0 0 2px rgba(0, 82, 205, 0.6)',
|
||||
|
||||
@@ -1,5 +1,67 @@
|
||||
# @nhost/docs
|
||||
|
||||
## 2.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 3c31657: chore: update docs with provider connect
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 992939c: feat: added social connect docs
|
||||
|
||||
## 2.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 768ca17: chore: update dependencies
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9f2bf9e: chore: added hasura's authHook settings
|
||||
|
||||
## 2.7.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5c47e8e: feat: added hasura's stringifyNumericTypes setting
|
||||
|
||||
## 2.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6cb2b63: feat: added nhost run env documentation
|
||||
- 40bd3e4: fix: fixed wrong links in documentation
|
||||
|
||||
## 2.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 49a80c2: chore: update dependencies
|
||||
|
||||
## 2.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- dc23dc0: fix: docs run references
|
||||
|
||||
## 2.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 0d8d0eb: Update docs and dashboard references
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 41617b9: feat: added elevated permissions docs
|
||||
- 7db095f: chore: added a note about disk performance and CDN information
|
||||
|
||||
## 2.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 791b729: fix: remove auth method
|
||||
|
||||
## 2.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -9,7 +9,7 @@ If you are using the Nhost CLI for local development, as of [v0.12.0](https://gi
|
||||
|
||||
<Steps>
|
||||
<Step title="Configuring the Service">
|
||||
Follow the steps highlighed in the ["Enabling Service"](enabling-service) guide and don't forget to add the relevant secrets to your `.secrets` file.
|
||||
Follow the steps highlighed in the [Enabling Service](enabling-service) guide and don't forget to add the relevant secrets to your `.secrets` file.
|
||||
</Step>
|
||||
<Step title="Start nhost">
|
||||
Run `nhost up`:
|
||||
|
||||
@@ -44,8 +44,22 @@ Below, you will find the official `CUE` schema and an example on how to configur
|
||||
|
||||
// HASURA_GRAPHQL_LIVE_QUERIES_MULTIPLEXED_REFETCH_INTERVAL
|
||||
liveQueriesMultiplexedRefetchInterval: uint32 | *1000
|
||||
|
||||
// HASURA_GRAPHQL_STRINGIFY_NUMERIC_TYPES
|
||||
stringifyNumericTypes: bool | *false
|
||||
}
|
||||
|
||||
authHook?: {
|
||||
// HASURA_GRAPHQL_AUTH_HOOK
|
||||
url: string
|
||||
|
||||
// HASURA_GRAPHQL_AUTH_HOOK_MODE
|
||||
mode: "GET"|*"POST"
|
||||
|
||||
// HASURA_GRAPHQL_AUTH_HOOK_SEND_REQUEST_BODY
|
||||
sendRequestBody: bool | *true
|
||||
}
|
||||
|
||||
logs: {
|
||||
// HASURA_GRAPHQL_LOG_LEVEL
|
||||
level: "debug" | "info" | "error" | *"warn"
|
||||
@@ -83,6 +97,7 @@ enableConsole = true
|
||||
enableRemoteSchemaPermissions = true
|
||||
enabledAPIs = ['metadata']
|
||||
liveQueriesMultiplexedRefetchInterval = 1000
|
||||
stringifyNumericTypes = false
|
||||
|
||||
[hasura.logs]
|
||||
level = 'warn'
|
||||
|
||||
92
docs/guides/auth/elevated-permissions.mdx
Normal file
92
docs/guides/auth/elevated-permissions.mdx
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: Elevated Permissions
|
||||
sidebarTitle: Elevated Permissions
|
||||
description: Require extra permissions to perform critical operations
|
||||
icon: unlock
|
||||
---
|
||||
|
||||
In some scenarios you may want to add an extra layer of security to perform certain actions or view certain data. For instance, you may wish to allow users to view their profile information after authenticating but you may want to require users to confirm changes to their profile by performing an extra validation step with a [security key](sign-in-webauthn).
|
||||
|
||||

|
||||
|
||||
This is accomplished by adding the claim `x-hasura-auth-elevated: $user-id` to the access token in response to the extra security challenge. With this new claim in mind you can start writing permissions that require this extra step.
|
||||
|
||||
It is important to keep in mind the claim is added to the access token so, for as long as you have that access token, you will have elevated access. Once the access token is renewed (due to expiration or any other reason), the claim will be lost and a new security challenge will be required to add it back.
|
||||
|
||||
# API Endpoints and SDK components
|
||||
|
||||
To implement this functionality [auth](/product/authentication) [0.26.0](https://github.com/nhost/hasura-auth/releases/tag/0.26.0) introduces the new endpoint [/elevate/webauthn](/reference/auth/elevate-webauthn). This endpoint works the same way as [/signin/webauthn](/reference/auth/sign-in-using-email-via-fido2-webauthn-authentication) but it requires an Authorization header with a valid token to add the elevated claim to.
|
||||
|
||||
In addition, a few methods and components have also been added to simplify it's usage:
|
||||
|
||||
- [nhost.auth.elevateEmailSecurityKey](/reference/javascript/auth/elevate-email-security-key)
|
||||
- [React - useElevateSecurityKeyEmail](/reference/react/use-elevate-security-key-email)
|
||||
- [Next.js - useElevateSecurityKeyEmail](/reference/nextjs/use-elevate-security-key-email)
|
||||
- [Vue - useElevateSecurityKeyEmail](/reference/vue/use-elevate-security-key-email)
|
||||
|
||||
# Protecting Hasura data
|
||||
|
||||
You can use the claim `x-hasura-auth-elevated` in exactly the same way you would normally use `X-Hasura-User-Id` for added security. For instance, the following permissions would allow users to see their data without any extra security:
|
||||
|
||||

|
||||
|
||||
While the following permissions would require them first to elevate their access in order to update them:
|
||||
|
||||

|
||||
|
||||
# Protecting Auth data
|
||||
|
||||
Some user information needs to be changed via hasura-auth's API rather than via graphql mutations. These endpoints are:
|
||||
|
||||
- [/user/password](/reference/auth/set-a-new-password) for changing passwords
|
||||
- [/user/email/change](/reference/auth/change-the-current-users-email) for changing emails
|
||||
- [/user/mfa](/reference/auth/activatedeactivate-multi-factor-authentication) for enabling or disabling MFA
|
||||
- [/user/webauthn/add](/reference/auth/initialize-adding-of-a-new-webauthn-security-key-device-browser) for adding security keys
|
||||
- [/pat](/reference/auth/create-personal-access-token-pat) for PAT creation
|
||||
|
||||
To protect these endpoints and require the elevated claim you can use the following configuration option:
|
||||
|
||||
```toml
|
||||
[auth.elevatedPrivileges]
|
||||
mode = 'required'
|
||||
```
|
||||
|
||||
The mode can be one of `disabled` (default), `required`, `recommended`.
|
||||
|
||||
In `disabled` mode the elevated claim isn't required in any of the options above. This is the default behavior.
|
||||
|
||||
In `required` mode, all of the endpoints above will require an access token with the elevated claim. There is only one exception to this rule. If the user has no security key, adding the first security key won't require the elevated claim. However, as the rest of the endpoints do require it, the user won't be able to perform any changes until it adds a security key and gets an access token with the elevated claim. Adding extra security keys after the first one has been added will require the elevated claim. Removing security keys always requires the elevated claim.
|
||||
|
||||
In `recommended` mode, the elevated claim is only required if the user has a security key configured. If a user doesn't have a security key the elevated claim won't be required to perform the actions described above. If the user has one or more keys the elevated claims will be required. This mode provides flexibility for the users to choose if they want the extra security or not.
|
||||
|
||||
<Warning>
|
||||
If you are allowing users to perform changes to auth data directly by performing graphql mutations (i.e. deleting security keys), don't forget to update the permissions to match the desired behavior.
|
||||
</Warning>
|
||||
|
||||
## Example
|
||||
|
||||
To demonstrate this functionality we have implemented them in our [react-apollo](https://react-apollo.example.nhost.io) ([source](https://github.com/nhost/nhost/tree/main/examples/react-apollo)) and [vue-apollo](https://vue-apollo.example.nhost.io) ([source](https://github.com/nhost/nhost/tree/main/examples/vue-apollo)) examples.
|
||||
|
||||
### Secret Notes
|
||||
|
||||
To demonstrate how the elevated claim can work for permissions you can check the "Secret Notes" example. In this example, we are allowing users to see their secret notes by giving the `select` permissions `notes.user_id eq X-Hasura-User-Id`
|
||||
|
||||

|
||||
|
||||
However, we are requiring elevated permissions to `insert`, `update` and `delete` with the permissions `notes.user_id eq x-hasura-auth-elevated`. In our example we automatically initiate the elevation process if the claim isn't already present:
|
||||
|
||||

|
||||
|
||||
Note that after elevating permissions the secret note is added and the elevated claim persists until there is a token refreshed.
|
||||
|
||||

|
||||
|
||||
### Updating profile information
|
||||
|
||||
In addition, to demonstrate the new `auth.elevatedPrivileges` setting, we have set it to `required` in this example requiring elevated access to perform certain changes. For instance, if you try to change your password you will first have to elevate your access:
|
||||
|
||||

|
||||
|
||||
After elevating access the password is changed:
|
||||
|
||||

|
||||
111
docs/guides/auth/overview.mdx
Normal file
111
docs/guides/auth/overview.mdx
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: Overview
|
||||
description: Learn about Nhost Auth
|
||||
icon: hand-wave
|
||||
---
|
||||
|
||||
Nhost Auth is a ready-to-use authentication service seamlessly integrated with the [GraphQL API](/product/graphql) and its [Permission System](/guides/api/permissions) from Hasura. This allows you to easily add user authentication to your application without having to build and maintain your own authentication system.
|
||||
|
||||
## Supported Methods
|
||||
|
||||
<CardGroup cols={4}>
|
||||
<Card title="Email and Password" icon="square-1" href="/guides/auth/sign-in-email-password">
|
||||
</Card>
|
||||
<Card title="Magic Link" icon="square-2" href="/guides/auth/sign-in-magic-link">
|
||||
</Card>
|
||||
<Card title="Phone Number (SMS)" icon="square-3" href="/guides/auth/sign-in-phone-number">
|
||||
</Card>
|
||||
<Card title="Security Keys (WebAuthn)" icon="square-4" href="/guides/auth/sign-in-webauthn">
|
||||
</Card>
|
||||
<Card title="Elevated Permissions" icon="square-5" href="/guides/auth/elevated-permissions">
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
### OAuth Providers
|
||||
|
||||
<CardGroup cols={4}>
|
||||
<Card title="Apple" icon="square-1" href="/guides/auth/social/sign-in-apple">
|
||||
</Card>
|
||||
<Card title="Discord" icon="square-2" href="/guides/auth/social/sign-in-discord">
|
||||
</Card>
|
||||
<Card title="Facebook" icon="square-3" href="/guides/auth/social/sign-in-facebook">
|
||||
</Card>
|
||||
<Card title="GitHub" icon="square-4" href="/guides/auth/social/sign-in-github">
|
||||
</Card>
|
||||
<Card title="Google" icon="square-5" href="/guides/auth/social/sign-in-google">
|
||||
</Card>
|
||||
<Card title="Linkedin" icon="square-6" href="/guides/auth/social/sign-in-linkedin">
|
||||
</Card>
|
||||
<Card title="Spotify" icon="square-7" href="/guides/auth/social/sign-in-spotify">
|
||||
</Card>
|
||||
<Card title="Twitch" icon="square-8" href="/guides/auth/social/sign-in-twitch">
|
||||
</Card>
|
||||
<Card title="WorkOS" icon="square-9" href="/guides/auth/social/sign-in-workos">
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Client URL
|
||||
|
||||
Client URL is the URL of your frontend application. The Client URL is used to redirect the user in certain auth workflows like signing in or resetting a password.
|
||||
|
||||
## Allowed Redirect URLs
|
||||
|
||||
Allowed Redirect URLs are the URLs of your frontend application that users are allowed to be redirected to on specific auth workflows. This is useful if you have multiple applications using the same Nhost backend or if you want to redirect users to a specific URL after certain authentication workflows.
|
||||
|
||||
As an example, for a staging project, you can set the Client URL to `https://staging.example.com` and Allowed Redirect URLs to `https://*.vercel.app`. This way, the user can be redirected to any Vercel deployment of your frontend application.
|
||||
|
||||
## Allowed Emails and Domains
|
||||
|
||||
Allowed Emails and Domains are used to restrict what email adresses and domains are valid when signing up and signing in.
|
||||
|
||||
If both allowed emails and allowed domains are set a user can only sign up if their email address matches one of the allowed emails or one of the allowed domains.
|
||||
|
||||
## Blocked Emails and Domains
|
||||
|
||||
Blocked Emails and Domains are used to block specific email addresses and domains from signing up and signing in.
|
||||
|
||||
Note that even if a user's email address matches any allowed email or domain, they will still be blocked if their email address matches any blocked email or domain.
|
||||
|
||||
## Multi-factor Authentication
|
||||
|
||||
By enabling Multi-Factor Authentication (MFA), you can allow users to verify their identity using a second factor during the sign-in process. We currently support Authenticator Apps (TOTP) for MFA.
|
||||
|
||||
A user can enable MFA for their account by scanning a QR code with their Authenticator App. After that, they will be prompted to enter a code generated by their Authenticator App during the sign-in process.
|
||||
|
||||
## Gravatar
|
||||
|
||||
If Gravatar is enabled, Nhost Auth will use the user's email address to fetch their Gravatar profile picture. If the user doesn't have a Gravatar profile picture, a default image will be used.
|
||||
|
||||
There are two options for Gravatars.
|
||||
|
||||
<Steps>
|
||||
<Step title="Default Image">
|
||||
|
||||
If the user doesn't have a Gravatar profile picture, a default image will be used. You can choose between the following options:
|
||||
|
||||
- `404`: Do not load any image if none is associated with the email hash, instead return an HTTP 404 (File Not Found) response.
|
||||
- `mp`: (mystery-person) a simple, cartoon-style silhouetted outline of a person (does not vary by email hash).
|
||||
- `identicon`: a geometric pattern based on an email hash.
|
||||
- `monsterid`: a generated 'monster' with different colors, faces, etc.
|
||||
- `wavatar`: generated faces with differing features and backgrounds.
|
||||
- `retro`: awesome generated, 8-bit arcade-style pixelated faces.
|
||||
- `robohash`: a generated robot with different colors, faces, etc.
|
||||
- `blank`: a transparent PNG image.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Rating">
|
||||
|
||||
Gravatar images are rated by default. You can choose between the following options:
|
||||
|
||||
- `g`: suitable for display on all websites with any audience type.
|
||||
- `pg`: may contain rude gestures, provocatively dressed individuals, lesser swear words or mild violence.
|
||||
- `r`: may contain such things as harsh profanity, intense violence, nudity, or hard drug use.
|
||||
- `x`: may contain hardcore sexual imagery or extremely disturbing violence.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Disable New Users
|
||||
|
||||
If set, newly registered users are disabled and won't be able to sign in. This is useful if you want to manually approve new users before they can sign in.
|
||||
@@ -86,4 +86,4 @@ await nhost.auth.sendVerificationEmail(
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Tip>It is possible to customize these automatic emails, learn how to [here](/authentication/templates)</Tip>
|
||||
<Tip>It is possible to customize these automatic emails, learn how to [here](/guides/auth/email-templates)</Tip>
|
||||
|
||||
@@ -34,5 +34,5 @@ await nhost.auth.signIn(
|
||||
|
||||
<Tip>Users who have signed up with email and password can also sign in with a Magic Link</Tip>
|
||||
|
||||
<Tip>It is possible to customize the email with the Magic Link, learn how to [here](/authentication/templates)</Tip>
|
||||
<Tip>It is possible to customize the email with the Magic Link, learn how to [here](/guides/auth/email-templates)</Tip>
|
||||
|
||||
|
||||
43
docs/guides/auth/social-connect.mdx
Normal file
43
docs/guides/auth/social-connect.mdx
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: Social Provider Connect
|
||||
sidebarTitle: Social Provider Connect
|
||||
description: Add social sign in mechanism to existing users
|
||||
icon: link
|
||||
---
|
||||
|
||||
With the social provider connect feature, users can link their social authentication method to their account, regardless of the initial sign-up method. It enables users to link different social authentication providers to their accounts, even if the email addresses do not match (e.g., linking a GitHub profile to an account registered with a different email). This feature offers flexibility, allowing users to streamline their login process by connecting multiple authentication methods.
|
||||
|
||||
To add a social authentication method to an existing user you need to call the url `https://${subdomain}.auth.${region}.nhost.run/v1/signin/provider/${provider}?connect=${jwt}`. This is very easy to achieve with our SDK:
|
||||
|
||||
``` js
|
||||
nhost.auth.connectProvider({
|
||||
provider: 'github'
|
||||
})
|
||||
```
|
||||
|
||||
In addition, hooks for react, vue and other frameworks may be provided. Check our [reference](/reference/overview#client-libraries) documentation for more details.
|
||||
|
||||
<Note>
|
||||
Keep in mind that as we need a `JWT` the user needs to be logged in.
|
||||
</Note>
|
||||
|
||||
## Viewing and Deleting Social Provider Authentication Mechanisms
|
||||
|
||||
If you want to allow your users to view and/or delete social provider authentication mechanisms, you can provide the necessary permissions to the table `auth.user_providers` (i.e. `select` and/or `delete`) and then use the appropriate GraphQL query. For example, the following permissions should allow users to list their own social providers:
|
||||
|
||||

|
||||
|
||||
Using the following GraphQL query:
|
||||
|
||||
``` js
|
||||
const { error, data } = await nhost.graphql.request(
|
||||
gql`
|
||||
query getAuthUserProviders {
|
||||
authUserProviders {
|
||||
id
|
||||
providerId
|
||||
}
|
||||
}
|
||||
`,
|
||||
)
|
||||
```
|
||||
155
docs/guides/auth/users.mdx
Normal file
155
docs/guides/auth/users.mdx
Normal file
@@ -0,0 +1,155 @@
|
||||
---
|
||||
title: Users
|
||||
description: Learn about Users managed by Nhost Auth
|
||||
icon: users
|
||||
---
|
||||
|
||||
## Creating Users
|
||||
|
||||
Users are created using the sign-up or sign-in flows described under [Supported Methods](/guides/auth/overview#supported-methods).
|
||||
|
||||
- **Avoid** creating users directly via GraphQL or the database, unless you are [importing users](#import-users) from an external system.
|
||||
- **Avoid** modifying the database schema for the `auth.users` table.
|
||||
- **Avoid** modifying the GraphQL root queries or fields for any of the tables in the `auth` schema.
|
||||
|
||||
You're allowed to:
|
||||
|
||||
- Add and remove your GraphQL relationships for the `users` table and other tables in the `auth` schema.
|
||||
- Create, edit and delete permissions for the `users` table and other tables in the `auth` schema.
|
||||
|
||||
## Roles
|
||||
|
||||
Each user has one **default role** and a list of **allowed roles**. These roles are used to resolve permissions for requests to [GraphQL](/guides/api/permissions) and [Storage](/guides/storage/overview#permissions).
|
||||
|
||||
When the user makes a request, only one role is used to resolve permissions. The default role is used if no role is explicitly specified. Users can only make requests using the default role or one of the allowed roles.
|
||||
|
||||
### Default Role
|
||||
|
||||
The default role is used when no role is specified in the request. By default, users' default role is `user`.
|
||||
|
||||
You can change what the default role for new users should be at **Settings -> Roles and Permissions**.
|
||||
|
||||
### Allowed Roles
|
||||
|
||||
Allowed roles are roles the user is allowed to use when making a request. Usually, you would change the role from `user` (the default role) to some other role because you want to use a different role to resolve permissions for a particular request.
|
||||
|
||||
By default, users have two allowed roles:
|
||||
|
||||
- `user` (default)
|
||||
- `me`
|
||||
|
||||
You can change the default role for new users at **Settings -> Roles and Permissions**.
|
||||
|
||||
#### Assign Allowed Roles
|
||||
|
||||
It's possible to give users a subset of allowed roles during signup.
|
||||
|
||||
**Example:** Only set the `user` role (exclude the `me` role) for the user's allowed roles:
|
||||
|
||||
```js
|
||||
await nhost.auth.signUp({
|
||||
email: 'joe@example.com',
|
||||
password: 'secret-password'
|
||||
options: {
|
||||
allowedRoles: ['user']
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Set Role for GraphQL Requests
|
||||
|
||||
When no role is specified, the user's default role will be used:
|
||||
|
||||
```js
|
||||
await nhost.graphql.request(QUERY, {})
|
||||
```
|
||||
|
||||
If you want to make a GraphQL request using a specific role, you can do so by using the `x-hasura-role` header, like this:
|
||||
|
||||
```js
|
||||
await nhost.graphql.request(
|
||||
QUERY,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'x-hasura-role': 'me'
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
If the request is not part of the user's allowed roles, the request will fail.
|
||||
|
||||
## Metadata
|
||||
|
||||
You can store custom information about the user in the `metadata` column of the `users` table. The `metadata` column is of type JSONB so any JSON data can be stored.
|
||||
|
||||
**Example:** Add metadata to a user during sign-up:
|
||||
|
||||
```js
|
||||
await nhost.auth.signUp({
|
||||
email: 'joe@example.com',
|
||||
password: 'secret-password',
|
||||
options: {
|
||||
metadata: {
|
||||
birthYear: 1989,
|
||||
town: 'Stockholm',
|
||||
likes: ['Postgres', 'GraphQL', 'Hasura', 'Authentication', 'Storage', 'Serverless Functions']
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Get User Information using GraphQL
|
||||
|
||||
**Example:** Get all users.
|
||||
|
||||
```graphql
|
||||
query {
|
||||
users {
|
||||
id
|
||||
displayName
|
||||
email
|
||||
metadata
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example:** Get a single user.
|
||||
|
||||
```graphql
|
||||
query {
|
||||
user(id: "<user-id>") {
|
||||
id
|
||||
displayName
|
||||
email
|
||||
metadata
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Import Users
|
||||
|
||||
If you have users in a different system, you can import them into Nhost. When importing users you should insert the users directly into the database instead of using the authentication endpoints (`/signup/email-password`) to avoid sending unnecessary transactional emails.
|
||||
|
||||
### GraphQL
|
||||
|
||||
Make a GraphQL request to insert a user like this:
|
||||
|
||||
```graphql
|
||||
mutation insertUser($user: users_insert_input!) {
|
||||
insertUser(object: $user) {
|
||||
id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SQL
|
||||
|
||||
Connect directly to the database and insert a user like this:
|
||||
|
||||
```sql
|
||||
INSERT INTO auth.users (id, email, display_name, password_hash, ..) VALUES ('<user-id>', '<email>', '<display-name>', '<password-hash>', ..);
|
||||
```
|
||||
|
||||
Passwords are hashed using [bcrypt](https://en.wikipedia.org/wiki/Bcrypt).
|
||||
@@ -12,78 +12,41 @@ Postgres configuration can be tweaked to customize the **runtime behavior**, **p
|
||||
Postgres.
|
||||
</Warning>
|
||||
|
||||
### Available Settings
|
||||
|
||||
The following `CUE` schema contains all postgres settings available in `nhost.toml`.
|
||||
|
||||
```cue schema
|
||||
#Postgres: {
|
||||
version: string | *"14.6-20230705-1"
|
||||
|
||||
// Resources for the service, optional
|
||||
resources?: #Resources & {
|
||||
replicas: 1
|
||||
}
|
||||
|
||||
// postgres settings of the same name in camelCase, optional
|
||||
settings?: {
|
||||
jit: "off" | "on" | *"on"
|
||||
maxConnections: int32 | *100
|
||||
sharedBuffers: string | *"128MB"
|
||||
effectiveCacheSize: string | *"4GB"
|
||||
maintenanceWorkMem: string | *"64MB"
|
||||
checkpointCompletionTarget: number | *0.9
|
||||
walBuffers: int32 | *-1
|
||||
defaultStatisticsTarget: int32 | *100
|
||||
randomPageCost: number | *4.0
|
||||
effectiveIOConcurrency: int32 | *1
|
||||
workMem: string | *"4MB"
|
||||
hugePages: string | *"try"
|
||||
minWalSize: string | *"80MB"
|
||||
maxWalSize: string | *"1GB"
|
||||
maxWorkerProcesses: int32 | *8
|
||||
maxParallelWorkersPerGather: int32 | *2
|
||||
maxParallelWorkers: int32 | *8
|
||||
maxParallelMaintenanceWorkers: int32 | *2
|
||||
walLevel: string | *"replica"
|
||||
maxWalSenders: int32 | *10
|
||||
maxReplicationSlots: int32 | *10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Example
|
||||
|
||||
To configure your Postgres instance, simply add the relevant settings under `[postgres.settings]` in your project's `nhost.toml` file.
|
||||
|
||||
```toml nhost.toml
|
||||
[postgres]
|
||||
version = '14.6-20230925-1'
|
||||
version = '14-20230312-1'
|
||||
|
||||
[postgres.resources.compute]
|
||||
cpu = 1000
|
||||
memory = 2048
|
||||
cpu = 2000
|
||||
memory = 4096
|
||||
|
||||
[postgres.resources.storage]
|
||||
capacity = 20
|
||||
|
||||
[postgres.settings]
|
||||
jit = "off"
|
||||
jit = 'off'
|
||||
maxConnections = 100
|
||||
sharedBuffers = '256MB'
|
||||
effectiveCacheSize = '768MB'
|
||||
sharedBuffers = '128MB'
|
||||
effectiveCacheSize = '4GB'
|
||||
maintenanceWorkMem = '64MB'
|
||||
checkpointCompletionTarget = 0.9
|
||||
walBuffers = -1
|
||||
walBuffers = '-1'
|
||||
defaultStatisticsTarget = 100
|
||||
randomPageCost = 1.1
|
||||
effectiveIOConcurrency = 200
|
||||
workMem = '1310kB'
|
||||
hugePages = 'off'
|
||||
randomPageCost = 4.0
|
||||
effectiveIOConcurrency = 1
|
||||
workMem = '4MB'
|
||||
hugePages = 'try'
|
||||
minWalSize = '80MB'
|
||||
maxWalSize = '1GB'
|
||||
maxWorkerProcesses = 8
|
||||
maxParallelWorkersPerGather = 2
|
||||
maxParallelWorkers = 8
|
||||
maxParallelMaintenanceWorkers = 2
|
||||
walLevel = "replica"
|
||||
walLevel = 'replica'
|
||||
maxWalSenders = 10
|
||||
maxReplicationSlots = 10
|
||||
```
|
||||
|
||||
@@ -16,6 +16,8 @@ In case your Postgres service is not meeting your performance expectations, you
|
||||
|
||||
4. Evaluate the usage of indexes in your database. Identify queries that could benefit from additional indexes and strategically add them to improve query performance.
|
||||
|
||||
5. Increase the disk size to increase [disk performance](/platform/compute-resources#disk-performance). Keep in mind increasing the disk size isn't reversible and increasing the memory of the service may yield better results. This is mostly useful when your data is very volatile and the postgres cache can't work effectively. Only attempt to increase disk for performance reasons if your reads and writes are very high and increasing memory isn't effective.
|
||||
|
||||
By implementing these steps, you can effectively address performance concerns and enhance the overall performance of your Postgres service.
|
||||
|
||||
## Upgrade to our latest postgres image
|
||||
|
||||
@@ -46,11 +46,11 @@ capacity=1
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Info>Head to [CLI & CI deployments](/run/ci) for more details on how to deploy using a configuration file.</Info>
|
||||
<Info>Head to [CLI & CI deployments](/guides/run/cli-deployments) for more details on how to deploy using a configuration file.</Info>
|
||||
|
||||
The `name` of the service is used as an identifier and to generate URLs when exposing the service to the Internet. You can use any container image publicly available or you can push your own to the [Nhost registry](/run/registry).
|
||||
The `name` of the service is used as an identifier and to generate URLs when exposing the service to the Internet. You can use any container image publicly available or you can push your own to the [Nhost registry](/guides/run/registry).
|
||||
|
||||
All environment variables set here are exclusive to this service and will not be shared with other services or with the Nhost stack. If you are using a configuration file secrets are supported.
|
||||
|
||||
For more details about the `Ports` section head to [networking](/run/networking). You can also head to [resources](/run/resources) for more information about replicas, compute, and storage.
|
||||
For more details about the `Ports` section head to [networking](/guides/run/networking). You can also head to [resources](/guides/run/resources) for more information about replicas, compute, and storage.
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ Then on `New Service`:
|
||||
|
||||

|
||||
|
||||
Now you can fill your [service configuration](/run/configuration):
|
||||
Now you can fill your [service configuration](/guides/run/configuration):
|
||||
|
||||

|
||||
|
||||
As you configure the `Ports` section you can take note of the generated URL. You can find more information about this section under [Networking](/run/networking).
|
||||
As you configure the `Ports` section you can take note of the generated URL. You can find more information about this section under [Networking](/guides/run/networking).
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -24,3 +24,65 @@ Based on the information above, if you want to connect directly to your service
|
||||
<Note>You can also use the environment variable `NHOST_RUN_SERVICE` passing comma-separated values. For instance, the equivalent environment variable for this example would be `NHOST_RUN_SERVICE=../mysvc/nhost-run-service.toml:mysvc,../mysvc/nhost-run-service.toml`</Note>
|
||||
|
||||
<Warning>The Nhost CLI doesn't build services so make sure you build any image that might be needed for running `nhost run --run-service...`</Warning>
|
||||
|
||||
# Quick Development
|
||||
|
||||
While developing your service, you may want to run it locally outside of the Nhost CLI to quickly iterate on it. To simplify this the Nhost CLI includes a command to generate an `.env` file based on your environment variables configuration and secrets. For instance, imagine a service with the following configuration:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="run-service.toml">
|
||||
```toml
|
||||
[[environment]]
|
||||
name = 'HASURA_GRAPHQL_URL'
|
||||
value = 'http://hasura-service:8080/v1/graphql'
|
||||
|
||||
[[environment]]
|
||||
name = 'SOME_CONFIGURATION_PARAMETER'
|
||||
value = 'some-value'
|
||||
|
||||
[[environment]]
|
||||
name = 'SECRET_KEY'
|
||||
value = '{{ secrets.SECRET_KEY }}'
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="overlay">
|
||||
```json
|
||||
[
|
||||
{
|
||||
"value": {
|
||||
"name": "ENVIRONMENT",
|
||||
"value": "dev"
|
||||
},
|
||||
"op": "add",
|
||||
"path": "/environment/-"
|
||||
},
|
||||
{
|
||||
"value": "https://local.graphql.nhost.run/v1/graphql",
|
||||
"op": "replace",
|
||||
"path": "/environment/0/value"
|
||||
}
|
||||
]
|
||||
```
|
||||
</Tab>
|
||||
<Tab title=".secrets">
|
||||
```toml
|
||||
SECRET_KEY = '#asdasd;l;kq23\\n40-0as9d"$\\'
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
We can then generate an `env` file for our service with the folllowing command:
|
||||
|
||||
```
|
||||
$ nhost run env --config ../mysvc/nhost-run-service.toml --overlay-name local-dev > .env
|
||||
$ cat .env
|
||||
HASURA_GRAPHQL_URL="https://local.graphql.nhost.run/v1/graphql"
|
||||
SOME_CONFIGURATION_PARAMETER="some-value"
|
||||
SECRET_KEY="#asdasd;l;kq23\\n40-0as9d\"\$\\"
|
||||
ENVIRONMENT="dev"
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Keep in mind you may need to use different configuration when attempting to connect to other services in the stack. For instance, in the example above we are using `http://hasura-service:8080/v1/graphql` to connect to hasura in production and in the CLI but when running the service in the host machine using the env file we are using an overlay to change the value to `https://local.graphql.nhost.run/v1/graphql`. Refer to the [network](networking) configuration for more details.
|
||||
</Warning>
|
||||
|
||||
@@ -76,7 +76,7 @@ To pause a service, simply set its number of replicas to `0`:
|
||||
<Tab title="dashboard">
|
||||
|
||||
|
||||

|
||||

|
||||
|
||||
</Tab>
|
||||
<Tab title="toml">
|
||||
|
||||
28
docs/guides/storage/cdn.mdx
Normal file
28
docs/guides/storage/cdn.mdx
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
title: CDN
|
||||
description: Serving files lightning fast
|
||||
icon: bolt
|
||||
---
|
||||
|
||||
The [storage](/product/storage) service integrates with a CDN service to cache files and serve them close to users. This leads to faster response times and lower load on the backend service. You can read our initial [announcement](https://nhost.io/blog/launching-nhost-cdn-nhost-storage-is-now-blazing-fast) for some general information about what a CDN is and the performance gains.
|
||||
|
||||
# Security
|
||||
|
||||
The primary function of a CDN is to deliver cached files quickly to users without relying on the backend. While this is effective for public files, we need to handle non-public files differently to ensure that only users with proper permissions can access those files. Instead of serving the file directly, a conditional fetch is performed, including the user's Authorization header. This allows the backend service to verify the user's permissions. By conducting this conditional check, the backend service only needs to confirm to the CDN that the file can be served to the specific user, eliminating the need to serve the file itself.
|
||||
|
||||
# Cache invalidation
|
||||
|
||||
If a file is modified or deleted, we instruct the CDN to immediately invalidate the cached files. This is done automatically by the storage service and requires no special handling.
|
||||
|
||||
# Maximizing HITs
|
||||
|
||||
In CDN terminology, a HIT occurs when a file is found in the cache and can be served to the user. Conversely, a MISS happens when the file is not yet cached and requires a round trip to the backend to retrieve it before it can be served to the user.
|
||||
|
||||
To lower response times and backend load, we want to maximize HITs as much as possible. To do that here are some notes and recommendations:
|
||||
|
||||
1. Links with different query arguments are treated as different files, even if they all point to the same underlying file.
|
||||
2. Due to (1), if you are using the image manipulation feature, each set of options (i.e. different sizes or qualities), will be treated as different files. Having too many different combinations may increase the number of MISSes and be counterproductive.
|
||||
3. Only use presigned URLs if you really must to and reuse if possible. Due to (1) as well, each presigned URL is different, which means they are treated by the CDN as different files, even if they all point to the same file.
|
||||
4. If possible, always prefer public files
|
||||
5. Authenticated files are your second best option. You can use [nhost.storage.download](/reference/javascript/storage/download) to download private files.
|
||||
6. If you are hosting large files, don't be afraid of using the [range header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range). The service should be able to cache and serve partial files too.
|
||||
@@ -0,0 +1,24 @@
|
||||
sequenceDiagram
|
||||
Note over User: User logins as usual
|
||||
User->>App: I want to login, please
|
||||
App->>Backend: /signin/...
|
||||
Backend->>App: Session{...}
|
||||
App->>User: Here is your session
|
||||
|
||||
Note over User: Actions that don't require elevated permissions work as usual
|
||||
User->>App: I want to see my profile data
|
||||
App->>Backend: query getProfileData { ... }
|
||||
Backend->>App: data { ... }
|
||||
App->>User: Here is your profile
|
||||
|
||||
Note over User: Action that requires elevated permissions starts here
|
||||
User->>App: I want to change my address to X
|
||||
App->>Backend: /elevate/webauthn
|
||||
Backend->>App: SecurityChallenge{ ... }
|
||||
App->>User: SecurityChallenge{ ... }
|
||||
User->>App: SecurityChallengeResponse{ ... }
|
||||
App->>+Backend: SecurityChallengeResponse{ ... }
|
||||
Backend->>App: SessionWithElevatedClaim{ ... }
|
||||
App->>Backend: mutation updateAddress { ... }
|
||||
Backend->>App: success { ... }
|
||||
App->>User: Your address has been changed
|
||||
BIN
docs/images/guides/auth/elevated-permissions/overview.png
Normal file
BIN
docs/images/guides/auth/elevated-permissions/overview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
BIN
docs/images/guides/auth/elevated-permissions/password.png
Normal file
BIN
docs/images/guides/auth/elevated-permissions/password.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 878 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 872 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user