Compare commits
189 Commits
@nhost/rea
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90e8843314 | ||
|
|
aa5b360932 | ||
|
|
daa4b8b2ad | ||
|
|
a1c5c97a59 | ||
|
|
b338793d6d | ||
|
|
b1fb4b2400 | ||
|
|
f75e023672 | ||
|
|
8e78c1ff00 | ||
|
|
9cbb0b2986 | ||
|
|
363a3b92e5 | ||
|
|
6a078fc972 | ||
|
|
1091e9674a | ||
|
|
9738108d58 | ||
|
|
65951e1d1d | ||
|
|
b4af994a58 | ||
|
|
c6347e10bc | ||
|
|
278a641bc1 | ||
|
|
3320ddd8c8 | ||
|
|
bc9eff6e41 | ||
|
|
258c608882 | ||
|
|
ae84f269d4 | ||
|
|
0327250b19 | ||
|
|
7f56eabd24 | ||
|
|
be110df83a | ||
|
|
361e648daf | ||
|
|
8a72e20e3d | ||
|
|
125ec390ca | ||
|
|
7cc788a373 | ||
|
|
2a04bc9e5d | ||
|
|
f7c2148ace | ||
|
|
78d35eed09 | ||
|
|
c5ff53c622 | ||
|
|
d21714d169 | ||
|
|
0d16ad41b8 | ||
|
|
82c328eeda | ||
|
|
d991cd8c7e | ||
|
|
e469628ebe | ||
|
|
856bc0a4bb | ||
|
|
9b1fb1ce28 | ||
|
|
a4d16f1835 | ||
|
|
3db8644075 | ||
|
|
7f667f6acb | ||
|
|
685dc6c1e4 | ||
|
|
6f7f2b0a65 | ||
|
|
6d0167b33f | ||
|
|
3ffb60f0ae | ||
|
|
97ced73a3c | ||
|
|
39c86cea25 | ||
|
|
d2d590db7e | ||
|
|
3bdbefc015 | ||
|
|
79081b43c2 | ||
|
|
a4b541f100 | ||
|
|
4523020c33 | ||
|
|
2e2248fd44 | ||
|
|
63358eb80b | ||
|
|
ded674fab6 | ||
|
|
85f2f28902 | ||
|
|
b8e9ad831e | ||
|
|
4e0c5dd1d3 | ||
|
|
b874109c6d | ||
|
|
21b926cc07 | ||
|
|
c35cd47d97 | ||
|
|
8dcd801c7c | ||
|
|
e3199be749 | ||
|
|
284b31e036 | ||
|
|
e7593c7de8 | ||
|
|
e6d862ac1b | ||
|
|
f73672372f | ||
|
|
7f12b98d94 | ||
|
|
d79b66314d | ||
|
|
2a58266592 | ||
|
|
44c2c5467d | ||
|
|
142752cb79 | ||
|
|
b05236a23c | ||
|
|
11a46a0db1 | ||
|
|
cedff501d6 | ||
|
|
7c426dafb2 | ||
|
|
57e7f794f5 | ||
|
|
d4b6cb0acf | ||
|
|
5d0cf8814b | ||
|
|
96cf17bbeb | ||
|
|
ed1a8d458e | ||
|
|
8077495c18 | ||
|
|
b617ec7186 | ||
|
|
bb2da11dd4 | ||
|
|
94fa824e7d | ||
|
|
32d1ee124f | ||
|
|
138bf9eb5a | ||
|
|
d8d9310e0b | ||
|
|
67b2c044b8 | ||
|
|
0b7790ca83 | ||
|
|
55267c680e | ||
|
|
4d856f557f | ||
|
|
64c579cf8c | ||
|
|
eae65c715b | ||
|
|
9e69f9f235 | ||
|
|
8b127fbb62 | ||
|
|
86ba2081ec | ||
|
|
7c2c31082a | ||
|
|
60f705b033 | ||
|
|
ea34635eb2 | ||
|
|
2004687044 | ||
|
|
bd025d43ca | ||
|
|
87a05f7374 | ||
|
|
798f147db7 | ||
|
|
62b7fd2376 | ||
|
|
1ee021b4a3 | ||
|
|
6e61dce297 | ||
|
|
bd744e52dc | ||
|
|
85723d740b | ||
|
|
36e79e7b32 | ||
|
|
f61264b319 | ||
|
|
e84d9d2576 | ||
|
|
ea69d4f0f1 | ||
|
|
212d58bee5 | ||
|
|
c3d6b7beec | ||
|
|
5d5d8ef4f3 | ||
|
|
deb61fe97c | ||
|
|
04d36154b0 | ||
|
|
203cfb10b9 | ||
|
|
9690f871fa | ||
|
|
74a6b93971 | ||
|
|
dd4c0d2430 | ||
|
|
83f2ca5cde | ||
|
|
0c49e757c8 | ||
|
|
e90a9d7696 | ||
|
|
00a06466f5 | ||
|
|
8ca9f76cb2 | ||
|
|
78113dd62a | ||
|
|
adb0ee82c6 | ||
|
|
a41bb6cae6 | ||
|
|
1c59c363ee | ||
|
|
1d99f26fec | ||
|
|
49edb0e627 | ||
|
|
f011e71ae1 | ||
|
|
00c363f808 | ||
|
|
0b2f749ae9 | ||
|
|
cf62a1e6e3 | ||
|
|
8df84d782f | ||
|
|
f0deffafe1 | ||
|
|
a291da661d | ||
|
|
66c3193bc9 | ||
|
|
ac7be49cef | ||
|
|
fa79b77093 | ||
|
|
5823947933 | ||
|
|
333837fb57 | ||
|
|
7fae68f6cf | ||
|
|
f2751f4bac | ||
|
|
089acbbe70 | ||
|
|
6e08a82f49 | ||
|
|
6899ef3b39 | ||
|
|
cad3686364 | ||
|
|
8f2c002715 | ||
|
|
b70d61198f | ||
|
|
d29af2ce6f | ||
|
|
cdc992b888 | ||
|
|
205a20de87 | ||
|
|
b092b8fe08 | ||
|
|
2d40cbf624 | ||
|
|
7b591e8c4c | ||
|
|
72b425a5bc | ||
|
|
971ff92ab4 | ||
|
|
b7f801874d | ||
|
|
ff69f30e47 | ||
|
|
cc1932492d | ||
|
|
f45037e79f | ||
|
|
48658e2925 | ||
|
|
b90bb6b924 | ||
|
|
de61f45bd5 | ||
|
|
fd11e5ca2c | ||
|
|
7839c786ef | ||
|
|
a2bcd6a4b6 | ||
|
|
2cd5b26e0e | ||
|
|
559611af70 | ||
|
|
ffb45f5a49 | ||
|
|
451e80ac12 | ||
|
|
c9f8e523f2 | ||
|
|
331ba03768 | ||
|
|
611b26bc7d | ||
|
|
a446c3efca | ||
|
|
24424ae4dc | ||
|
|
2a5b705c26 | ||
|
|
7f3a32d386 | ||
|
|
11fa442aa8 | ||
|
|
5764f46d99 | ||
|
|
78d501801b | ||
|
|
cc8cc8d45d | ||
|
|
61fc83996b | ||
|
|
9ddb37e9bb |
@@ -1,5 +1,61 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 1.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@7.0.1
|
||||
- @nhost/nextjs@2.0.1
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- bc9eff6e4: chore: remove support for using backendUrl when instantiating the Nhost client
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bc9eff6e4]
|
||||
- @nhost/nextjs@2.0.0
|
||||
- @nhost/react-apollo@7.0.0
|
||||
|
||||
## 0.21.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 97ced73a3: fix(dashboard): prevent dashboard from resolving secrets
|
||||
|
||||
## 0.21.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ed1a8d458: Update alert message on increasing PostgreSQL's volume capacity
|
||||
- 2e2248fd4: feat(dashboard): add SQL editor
|
||||
|
||||
## 0.20.28
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7c2c31082: feat: add support for users to delete their account
|
||||
- @nhost/react-apollo@6.0.1
|
||||
- @nhost/nextjs@1.13.40
|
||||
|
||||
## 0.20.27
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fa79b7709: chore(dashboard): tweaks and fixes to the service form and dialog
|
||||
- 8df84d782: fix(dashboard): allow resetting custom domains
|
||||
- @nhost/react-apollo@6.0.0
|
||||
- @nhost/nextjs@1.13.39
|
||||
|
||||
## 0.20.26
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 331ba0376: feat(dashboard): add postgres storage capacity modifier in the settings
|
||||
- b7f801874: feat(dashboard): add new settings page for custom domains
|
||||
|
||||
## 0.20.25
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:16-alpine AS pruner
|
||||
FROM node:18-alpine AS pruner
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
@@ -7,7 +7,7 @@ RUN yarn global add turbo@1.10.11
|
||||
COPY . .
|
||||
RUN turbo prune --scope="@nhost/dashboard" --docker
|
||||
|
||||
FROM node:16-alpine AS builder
|
||||
FROM node:18-alpine AS builder
|
||||
ARG TURBO_TOKEN
|
||||
ARG TURBO_TEAM
|
||||
|
||||
@@ -40,7 +40,7 @@ COPY turbo.json turbo.json
|
||||
COPY config/ config/
|
||||
RUN pnpm build:dashboard
|
||||
|
||||
FROM node:16-alpine AS runner
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.20.25",
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.7.10",
|
||||
"@codemirror/language": "^6.3.0",
|
||||
"@codemirror/lang-sql": "^6.5.4",
|
||||
"@emotion/cache": "^11.10.5",
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/server": "^11.4.0",
|
||||
@@ -44,6 +44,8 @@
|
||||
"@tanstack/react-query": "^4.16.1",
|
||||
"@tanstack/react-table": "^8.5.30",
|
||||
"@tanstack/react-virtual": "^3.0.0-beta.23",
|
||||
"@uiw/codemirror-theme-github": "^4.21.20",
|
||||
"@uiw/react-codemirror": "^4.21.20",
|
||||
"analytics-node": "^6.2.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"clsx": "^1.2.1",
|
||||
@@ -70,6 +72,7 @@
|
||||
"react-is": "18.2.0",
|
||||
"react-loading-skeleton": "^2.2.0",
|
||||
"react-merge-refs": "^1.1.0",
|
||||
"react-resizable-layout": "^0.7.2",
|
||||
"react-syntax-highlighter": "^15.4.5",
|
||||
"react-table": "^7.8.0",
|
||||
"sharp": "^0.32.0",
|
||||
|
||||
BIN
dashboard/public/illustration-unbox.png
Normal file
BIN
dashboard/public/illustration-unbox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,100 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { NhostIcon } from '@/components/presentational/NhostIcon';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { ChangePlanModal } from '@/features/projects/common/components/ChangePlanModal';
|
||||
import { useIsCurrentUserOwner } from '@/features/projects/common/hooks/useIsCurrentUserOwner';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
interface UpgradeToProBannerProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function UpgradeToProBanner({
|
||||
title,
|
||||
description,
|
||||
}: UpgradeToProBannerProps) {
|
||||
const { openDialog, openAlertDialog } = useDialog();
|
||||
const isOwner = useIsCurrentUserOwner();
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ backgroundColor: 'primary.light' }}
|
||||
className="flex flex-col p-4 space-y-4 rounded-md lg:flex-row lg:items-center lg:space-y-0"
|
||||
>
|
||||
<div className="flex flex-col justify-between space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col space-y-2 xs:flex-row xs:space-y-0 xs:space-x-2">
|
||||
<Text>Available with</Text>
|
||||
<div className="flex flex-row space-x-2">
|
||||
<NhostIcon />
|
||||
<Text sx={{ color: 'primary.main' }} className="font-semibold">
|
||||
Nhost Pro
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<Text variant="h3">{title}</Text>
|
||||
<Text>{description}</Text>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2 lg:flex-row lg:items-center lg:space-y-0 lg:space-x-2">
|
||||
<Button
|
||||
className="rounded-md"
|
||||
onClick={() => {
|
||||
if (isOwner) {
|
||||
openDialog({
|
||||
component: <ChangePlanModal />,
|
||||
props: {
|
||||
PaperProps: { className: 'p-0 max-w-xl w-full' },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
openAlertDialog({
|
||||
title: "You can't upgrade this project",
|
||||
payload: (
|
||||
<Text variant="subtitle1" component="span">
|
||||
Ask an owner of this workspace to upgrade the project.
|
||||
</Text>
|
||||
),
|
||||
props: {
|
||||
secondaryButtonText: 'I understand',
|
||||
hidePrimaryAction: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</Button>
|
||||
<Link
|
||||
href="https://nhost.io/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="font-medium text-center"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
See all features
|
||||
<ArrowSquareOutIcon className="w-4 h-4 ml-1" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs mx-auto">
|
||||
<Image
|
||||
src="/illustration-unbox.png"
|
||||
width={400}
|
||||
height={260}
|
||||
objectFit="contain"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as UpgradeToProBanner } from './UpgradeToProBanner';
|
||||
@@ -128,7 +128,11 @@ export default function SettingsContainer({
|
||||
icon}
|
||||
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text className="text-lg font-semibold">{title}</Text>
|
||||
{typeof title === 'string' ? (
|
||||
<Text className="text-lg font-semibold">{title}</Text>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
|
||||
{description && <Text color="secondary">{description}</Text>}
|
||||
</div>
|
||||
|
||||
@@ -200,6 +200,14 @@ export default function SettingsSidebar({
|
||||
>
|
||||
Secrets
|
||||
</SettingsNavLink>
|
||||
|
||||
<SettingsNavLink
|
||||
href="/custom-domains"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Custom Domains
|
||||
</SettingsNavLink>
|
||||
</List>
|
||||
</nav>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ForwardedRef, SVGProps } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
function NhostIcon(
|
||||
props: SVGProps<SVGSVGElement>,
|
||||
ref: ForwardedRef<SVGSVGElement>,
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Logo of Nhost"
|
||||
{...props}
|
||||
>
|
||||
<g clipPath="url(#clip0_9802_20458)">
|
||||
<rect width="24" height="24" fill="#0052CD" />
|
||||
<path
|
||||
d="M17.4656 7.39804L12.4705 4.51369C12.0223 4.25553 11.466 4.25553 11.0169 4.51369C10.5688 4.77276 10.2906 5.25455 10.2906 5.77179V6.14813L9.96517 5.95996C9.51702 5.70179 8.96069 5.70179 8.51163 5.95996C8.06348 6.21903 7.78531 6.70082 7.78531 7.21896V7.5953L7.45988 7.40713C7.01173 7.14897 6.4554 7.14897 6.00634 7.40713C5.55819 7.66621 5.28003 8.14799 5.28003 8.66614V17.7037C5.28003 17.9637 5.43093 18.2055 5.66546 18.3182C5.89908 18.4318 6.1827 18.4009 6.38632 18.24L8.86342 16.2865L12.6832 18.4918C12.7886 18.5527 12.9068 18.5827 13.025 18.5827C13.1431 18.5827 13.2613 18.5518 13.3668 18.4918C13.5777 18.37 13.7086 18.1437 13.7086 17.9001V12.4613C13.7086 11.5687 13.2286 10.7378 12.4559 10.2915L11.2033 9.56789V5.7727C11.2033 5.57998 11.3069 5.4 11.4742 5.30364C11.6414 5.20728 11.8487 5.20728 12.0159 5.30364L17.0111 8.18708C17.5028 8.4707 17.8083 9.00066 17.8083 9.56789V16.3402C17.8083 16.5329 17.7046 16.7129 17.5374 16.8092L16.2138 17.5737V11.0142C16.2138 10.1215 15.7339 9.29064 14.9612 8.84431L11.8859 7.06897V8.12072L14.5058 9.63334C14.9976 9.91696 15.303 10.446 15.303 11.0142V17.9673C15.303 18.21 15.4339 18.4373 15.6448 18.5591C15.7502 18.62 15.8684 18.65 15.9866 18.65C16.1048 18.65 16.2229 18.6191 16.3284 18.5591L17.9937 17.5974C18.4419 17.3383 18.72 16.8565 18.72 16.3383V9.56608C18.7182 8.67614 18.2382 7.84438 17.4656 7.39804ZM11.9987 11.0805C12.4905 11.3641 12.7959 11.8932 12.7959 12.4613V17.5064L9.63246 15.6802L10.6478 14.8803C10.9996 14.603 11.2014 14.1876 11.2014 13.7394V10.6215L11.9987 11.0805ZM10.2906 10.0942V13.7376C10.2906 13.9049 10.2152 14.0603 10.0842 14.163L6.19088 17.2328V8.66523C6.19088 8.47251 6.29451 8.29253 6.46177 8.19617C6.62903 8.09981 6.83629 8.09981 7.00355 8.19617L7.78531 8.64705V15.1057L8.69616 14.3876V7.21896C8.69616 7.02625 8.79979 6.84626 8.96705 6.7499C9.13431 6.65355 9.34157 6.65355 9.50883 6.7499L10.2906 7.20078V9.04157L9.37975 8.51524V9.56789L10.2906 10.0942Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_9802_20458">
|
||||
<rect width="24" height="24" rx="4" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(NhostIcon);
|
||||
@@ -0,0 +1 @@
|
||||
export { default as NhostIcon } from './NhostIcon';
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
|
||||
function ArrowsClockwise(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-label="Update"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M11.0103 6.23227H14.0103V3.23227"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.11084 4.11091C4.62156 3.60019 5.22788 3.19506 5.89517 2.91866C6.56246 2.64226 7.27766 2.5 7.99993 2.5C8.7222 2.5 9.4374 2.64226 10.1047 2.91866C10.772 3.19506 11.3783 3.60019 11.889 4.11091L14.0103 6.23223"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.98975 9.76773H1.98975V12.7677"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M11.8892 11.8891C11.3785 12.3998 10.7722 12.8049 10.1049 13.0813C9.43762 13.3577 8.72242 13.5 8.00015 13.5C7.27788 13.5 6.56269 13.3577 5.89539 13.0813C5.2281 12.8049 4.62179 12.3998 4.11107 11.8891L1.98975 9.76776"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
ArrowsClockwise.displayName = 'NhostArrowsClockwise';
|
||||
|
||||
export default ArrowsClockwise;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ArrowsClockwise } from './ArrowsClockwise';
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function TerminalIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="16"
|
||||
height="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
aria-label="Trash"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M3.49851 3.43968L2.93795 2.94141L1.94141 4.06252L2.50196 4.56079L6.37134 8.00024L2.50196 11.4397L1.94141 11.938L2.93795 13.0591L3.49851 12.5608L7.99851 8.56079C8.15863 8.41847 8.25024 8.21446 8.25024 8.00024C8.25024 7.78601 8.15863 7.582 7.99851 7.43968L3.49851 3.43968ZM7.99987 11.2502H7.24987V12.7502H7.99987H13.9999H14.7499V11.2502H13.9999H7.99987Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
TerminalIcon.displayName = 'NhostTerminalIcon';
|
||||
|
||||
export default TerminalIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as TerminalIcon } from './TerminalIcon';
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Checkbox } from '@/components/ui/v2/Checkbox';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import {
|
||||
useDeleteUserAccountMutation,
|
||||
useGetAllWorkspacesAndProjectsQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { type ApolloError } from '@apollo/client';
|
||||
import { useSignOut, useUserData } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
function ConfirmDeleteAccountModal({
|
||||
close,
|
||||
onDelete,
|
||||
}: {
|
||||
onDelete?: () => Promise<any>;
|
||||
close: () => void;
|
||||
}) {
|
||||
const [remove, setRemove] = useState(false);
|
||||
const [loadingRemove, setLoadingRemove] = useState(false);
|
||||
|
||||
const user = useUserData();
|
||||
|
||||
const { data, loading } = useGetAllWorkspacesAndProjectsQuery({
|
||||
skip: !user,
|
||||
});
|
||||
|
||||
const userHasProjects =
|
||||
!loading && data?.workspaces.some((workspace) => workspace.projects.length);
|
||||
|
||||
const userData = useUserData();
|
||||
|
||||
const [deleteUserAccount] = useDeleteUserAccountMutation({
|
||||
variables: { id: userData?.id },
|
||||
});
|
||||
|
||||
const onClickConfirm = async () => {
|
||||
setLoadingRemove(true);
|
||||
|
||||
await toast.promise(
|
||||
deleteUserAccount(),
|
||||
{
|
||||
loading: 'Deleting your account...',
|
||||
success: `The account has been deleted successfully.`,
|
||||
error: (arg: ApolloError) => {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } = (internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
'An error occurred while deleting your account. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
onDelete?.();
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className={twMerge('w-full rounded-lg p-6 text-left')}>
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text variant="h3" component="h2">
|
||||
Delete Account?
|
||||
</Text>
|
||||
|
||||
{userHasProjects && (
|
||||
<Text
|
||||
variant="subtitle2"
|
||||
className="font-bold"
|
||||
sx={{ color: (theme) => `${theme.palette.error.main} !important` }}
|
||||
>
|
||||
You still have active projects. Please delete your projects before
|
||||
proceeding with the account deletion.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Box className="my-4">
|
||||
<Checkbox
|
||||
id="accept-1"
|
||||
label={`I'm sure I want to delete my account`}
|
||||
className="py-2"
|
||||
checked={remove}
|
||||
onChange={(_event, checked) => setRemove(checked)}
|
||||
aria-label="Confirm Delete Project #1"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
color="error"
|
||||
onClick={onClickConfirm}
|
||||
disabled={userHasProjects}
|
||||
loading={loadingRemove}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeleteAccount() {
|
||||
const router = useRouter();
|
||||
const { signOut } = useSignOut();
|
||||
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
|
||||
const onDelete = async () => {
|
||||
await signOut();
|
||||
await router.push('/signin');
|
||||
};
|
||||
|
||||
const confirmDeleteAccount = async () => {
|
||||
openDialog({
|
||||
component: (
|
||||
<ConfirmDeleteAccountModal close={closeDialog} onDelete={onDelete} />
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsContainer
|
||||
title="Delete Account"
|
||||
description="Please proceed with caution as the removal of your Personal Account and its contents from the Nhost platform is irreversible. This action will permanently delete your account and all associated data."
|
||||
className="px-0"
|
||||
slotProps={{
|
||||
submitButton: { className: 'hidden' },
|
||||
footer: { className: 'hidden' },
|
||||
}}
|
||||
>
|
||||
<Box className="grid grid-flow-row border-t-1">
|
||||
<Button
|
||||
color="error"
|
||||
className="mx-4 mt-4 justify-self-end"
|
||||
onClick={confirmDeleteAccount}
|
||||
>
|
||||
Delete Personal Account
|
||||
</Button>
|
||||
</Box>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DeleteAccount } from './DeleteAccount';
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation deleteUserAccount($id: uuid!) {
|
||||
deleteUser(id: $id) {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
query GetAuthenticationSettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
auth {
|
||||
@@ -24,6 +24,13 @@ query GetAuthenticationSettings($appId: uuid!) {
|
||||
expiresIn
|
||||
}
|
||||
}
|
||||
resources {
|
||||
networking {
|
||||
ingresses {
|
||||
fqdn
|
||||
}
|
||||
}
|
||||
}
|
||||
user {
|
||||
email {
|
||||
allowed
|
||||
|
||||
@@ -17,6 +17,7 @@ import { DotsHorizontalIcon } from '@/components/ui/v2/icons/DotsHorizontalIcon'
|
||||
import { LockIcon } from '@/components/ui/v2/icons/LockIcon';
|
||||
import { PencilIcon } from '@/components/ui/v2/icons/PencilIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TerminalIcon } from '@/components/ui/v2/icons/TerminalIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { UsersIcon } from '@/components/ui/v2/icons/UsersIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
@@ -86,7 +87,9 @@ function DataBrowserSidebarContent({
|
||||
const isGitHubConnected = !!currentProject?.githubRepository;
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
asPath,
|
||||
query: { workspaceSlug, appSlug, dataSourceSlug, schemaSlug, tableSlug },
|
||||
} = router;
|
||||
|
||||
@@ -108,6 +111,8 @@ function DataBrowserSidebarContent({
|
||||
*/
|
||||
const [sidebarMenuTable, setSidebarMenuTable] = useState<string>();
|
||||
|
||||
const sqlEditorHref = `/${workspaceSlug}/${appSlug}/database/browser/default/editor`;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSchema) {
|
||||
return;
|
||||
@@ -258,194 +263,135 @@ function DataBrowserSidebarContent({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-1">
|
||||
{schemas && schemas.length > 0 && (
|
||||
<Select
|
||||
renderValue={(option) => (
|
||||
<span className="grid grid-flow-col items-center gap-1">
|
||||
{option?.label}
|
||||
</span>
|
||||
)}
|
||||
slotProps={{
|
||||
listbox: { className: 'max-w-[220px] min-w-[initial] w-full' },
|
||||
popper: { className: 'max-w-[220px] min-w-[initial] w-full' },
|
||||
}}
|
||||
value={selectedSchema}
|
||||
onChange={(_event, value) => setSelectedSchema(value as string)}
|
||||
>
|
||||
{schemas.map((schema) => (
|
||||
<Option
|
||||
className="grid grid-flow-col items-center gap-1"
|
||||
value={schema.schema_name}
|
||||
key={schema.schema_name}
|
||||
>
|
||||
<Text className="text-sm">
|
||||
<Text component="span" color="disabled">
|
||||
schema.
|
||||
</Text>
|
||||
<Text component="span" className="font-medium">
|
||||
{schema.schema_name}
|
||||
</Text>
|
||||
</Text>
|
||||
{(isSchemaLocked(schema.schema_name) || isGitHubConnected) && (
|
||||
<LockIcon
|
||||
className="h-3 w-3"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{isGitHubConnected && (
|
||||
<Box
|
||||
className="mt-1.5 grid grid-flow-row justify-items-start gap-2 rounded-md p-2"
|
||||
sx={{ backgroundColor: 'grey.200' }}
|
||||
>
|
||||
<Text>
|
||||
Your project is connected to GitHub. Please use the CLI to make
|
||||
schema changes.
|
||||
</Text>
|
||||
|
||||
<Link
|
||||
href="https://docs.nhost.io/platform/github-integration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="grid grid-flow-col items-center justify-start gap-1"
|
||||
<Box className="flex h-full flex-col justify-between">
|
||||
<Box className="flex flex-col px-2">
|
||||
{schemas && schemas.length > 0 && (
|
||||
<Select
|
||||
renderValue={(option) => (
|
||||
<span className="grid grid-flow-col items-center gap-1">
|
||||
{option?.label}
|
||||
</span>
|
||||
)}
|
||||
slotProps={{
|
||||
listbox: { className: 'max-w-[220px] min-w-[initial] w-full' },
|
||||
popper: { className: 'max-w-[220px] min-w-[initial] w-full' },
|
||||
}}
|
||||
value={selectedSchema}
|
||||
onChange={(_event, value) => setSelectedSchema(value as string)}
|
||||
>
|
||||
Learn More <ArrowRightIcon />
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isSelectedSchemaLocked && (
|
||||
<Button
|
||||
variant="borderless"
|
||||
endIcon={<PlusIcon />}
|
||||
className="mt-1 w-full justify-between px-2"
|
||||
onClick={() => {
|
||||
openDrawer({
|
||||
title: 'Create a New Table',
|
||||
component: (
|
||||
<CreateTableForm onSubmit={refetch} schema={selectedSchema} />
|
||||
),
|
||||
});
|
||||
|
||||
onSidebarItemClick();
|
||||
}}
|
||||
disabled={isGitHubConnected}
|
||||
>
|
||||
New Table
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{schemas && schemas.length > 0 && tablesInSelectedSchema.length === 0 && (
|
||||
<Text className="py-1.5 px-2 text-xs" color="disabled">
|
||||
No tables found.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<nav aria-label="Database navigation">
|
||||
{tablesInSelectedSchema.length > 0 && (
|
||||
<List className="grid gap-1 pb-6">
|
||||
{tablesInSelectedSchema.map((table) => {
|
||||
const tablePath = `${table.table_schema}.${table.table_name}`;
|
||||
const isSelected = `${schemaSlug}.${tableSlug}` === tablePath;
|
||||
const isSidebarMenuOpen = sidebarMenuTable === tablePath;
|
||||
|
||||
return (
|
||||
<ListItem.Root
|
||||
className="group"
|
||||
key={tablePath}
|
||||
secondaryAction={
|
||||
<Dropdown.Root
|
||||
id="table-management-menu"
|
||||
onOpen={() => setSidebarMenuTable(tablePath)}
|
||||
onClose={() => setSidebarMenuTable(undefined)}
|
||||
>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
disabled={tablePath === removableTable}
|
||||
{schemas.map((schema) => (
|
||||
<Option
|
||||
className="grid grid-flow-col items-center gap-1"
|
||||
value={schema.schema_name}
|
||||
key={schema.schema_name}
|
||||
>
|
||||
<Text className="text-sm">
|
||||
<Text component="span" color="disabled">
|
||||
schema.
|
||||
</Text>
|
||||
<Text component="span" className="font-medium">
|
||||
{schema.schema_name}
|
||||
</Text>
|
||||
</Text>
|
||||
{(isSchemaLocked(schema.schema_name) || isGitHubConnected) && (
|
||||
<LockIcon
|
||||
className="h-3 w-3"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
{isGitHubConnected && (
|
||||
<Box
|
||||
className="mt-1.5 grid grid-flow-row justify-items-start gap-2 rounded-md p-2"
|
||||
sx={{ backgroundColor: 'grey.200' }}
|
||||
>
|
||||
<Text>
|
||||
Your project is connected to GitHub. Please use the CLI to make
|
||||
schema changes.
|
||||
</Text>
|
||||
<Link
|
||||
href="https://docs.nhost.io/platform/github-integration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="grid grid-flow-col items-center justify-start gap-1"
|
||||
>
|
||||
Learn More <ArrowRightIcon />
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
{!isSelectedSchemaLocked && (
|
||||
<Button
|
||||
variant="borderless"
|
||||
endIcon={<PlusIcon />}
|
||||
className="mt-1 w-full justify-between px-2"
|
||||
onClick={() => {
|
||||
openDrawer({
|
||||
title: 'Create a New Table',
|
||||
component: (
|
||||
<CreateTableForm onSubmit={refetch} schema={selectedSchema} />
|
||||
),
|
||||
});
|
||||
onSidebarItemClick();
|
||||
}}
|
||||
disabled={isGitHubConnected}
|
||||
>
|
||||
New Table
|
||||
</Button>
|
||||
)}
|
||||
{schemas && schemas.length > 0 && tablesInSelectedSchema.length === 0 && (
|
||||
<Text className="py-1.5 px-2 text-xs" color="disabled">
|
||||
No tables found.
|
||||
</Text>
|
||||
)}
|
||||
<nav aria-label="Database navigation">
|
||||
{tablesInSelectedSchema.length > 0 && (
|
||||
<List className="grid gap-1 pb-6">
|
||||
{tablesInSelectedSchema.map((table) => {
|
||||
const tablePath = `${table.table_schema}.${table.table_name}`;
|
||||
const isSelected = `${schemaSlug}.${tableSlug}` === tablePath;
|
||||
const isSidebarMenuOpen = sidebarMenuTable === tablePath;
|
||||
return (
|
||||
<ListItem.Root
|
||||
className="group"
|
||||
key={tablePath}
|
||||
secondaryAction={
|
||||
<Dropdown.Root
|
||||
id="table-management-menu"
|
||||
onOpen={() => setSidebarMenuTable(tablePath)}
|
||||
onClose={() => setSidebarMenuTable(undefined)}
|
||||
>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color={isSelected ? 'primary' : 'secondary'}
|
||||
className={twMerge(
|
||||
!isSelected &&
|
||||
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-active:opacity-100',
|
||||
)}
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
disabled={tablePath === removableTable}
|
||||
>
|
||||
<DotsHorizontalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content menu PaperProps={{ className: 'w-52' }}>
|
||||
{isGitHubConnected ? (
|
||||
<Dropdown.Item
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
onClick={() =>
|
||||
handleEditPermissionClick(
|
||||
table.table_schema,
|
||||
table.table_name,
|
||||
true,
|
||||
)
|
||||
}
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color={isSelected ? 'primary' : 'secondary'}
|
||||
className={twMerge(
|
||||
!isSelected &&
|
||||
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-active:opacity-100',
|
||||
)}
|
||||
>
|
||||
<UsersIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>View Permissions</span>
|
||||
</Dropdown.Item>
|
||||
) : (
|
||||
[
|
||||
!isSelectedSchemaLocked && (
|
||||
<Dropdown.Item
|
||||
key="edit-table"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
onClick={() =>
|
||||
openDrawer({
|
||||
title: 'Edit Table',
|
||||
component: (
|
||||
<EditTableForm
|
||||
onSubmit={async () => {
|
||||
await queryClient.refetchQueries([
|
||||
`${dataSourceSlug}.${table.table_schema}.${table.table_name}`,
|
||||
]);
|
||||
await refetch();
|
||||
}}
|
||||
schema={table.table_schema}
|
||||
table={table}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<PencilIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Edit Table</span>
|
||||
</Dropdown.Item>
|
||||
),
|
||||
!isSelectedSchemaLocked && (
|
||||
<Divider
|
||||
key="edit-table-separator"
|
||||
component="li"
|
||||
/>
|
||||
),
|
||||
<DotsHorizontalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-52' }}
|
||||
>
|
||||
{isGitHubConnected ? (
|
||||
<Dropdown.Item
|
||||
key="edit-permissions"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
onClick={() =>
|
||||
handleEditPermissionClick(
|
||||
table.table_schema,
|
||||
table.table_name,
|
||||
true,
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -453,68 +399,135 @@ function DataBrowserSidebarContent({
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Edit Permissions</span>
|
||||
</Dropdown.Item>,
|
||||
!isSelectedSchemaLocked && (
|
||||
<Divider
|
||||
key="edit-permissions-separator"
|
||||
component="li"
|
||||
/>
|
||||
),
|
||||
!isSelectedSchemaLocked && (
|
||||
<span>View Permissions</span>
|
||||
</Dropdown.Item>
|
||||
) : (
|
||||
[
|
||||
!isSelectedSchemaLocked && (
|
||||
<Dropdown.Item
|
||||
key="edit-table"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
onClick={() =>
|
||||
openDrawer({
|
||||
title: 'Edit Table',
|
||||
component: (
|
||||
<EditTableForm
|
||||
onSubmit={async () => {
|
||||
await queryClient.refetchQueries([
|
||||
`${dataSourceSlug}.${table.table_schema}.${table.table_name}`,
|
||||
]);
|
||||
await refetch();
|
||||
}}
|
||||
schema={table.table_schema}
|
||||
table={table}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<PencilIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
<span>Edit Table</span>
|
||||
</Dropdown.Item>
|
||||
),
|
||||
!isSelectedSchemaLocked && (
|
||||
<Divider
|
||||
key="edit-table-separator"
|
||||
component="li"
|
||||
/>
|
||||
),
|
||||
<Dropdown.Item
|
||||
key="delete-table"
|
||||
key="edit-permissions"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={() =>
|
||||
handleDeleteTableClick(
|
||||
handleEditPermissionClick(
|
||||
table.table_schema,
|
||||
table.table_name,
|
||||
)
|
||||
}
|
||||
>
|
||||
<TrashIcon
|
||||
<UsersIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'error.main' }}
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Delete Table</span>
|
||||
</Dropdown.Item>
|
||||
),
|
||||
]
|
||||
)}
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Button
|
||||
dense
|
||||
selected={isSelected}
|
||||
disabled={tablePath === removableTable}
|
||||
className="group-focus-within:pr-9 group-hover:pr-9 group-active:pr-9"
|
||||
sx={{
|
||||
paddingRight:
|
||||
(isSelected || isSidebarMenuOpen) &&
|
||||
'2.25rem !important',
|
||||
}}
|
||||
component={NavLink}
|
||||
href={`/${workspaceSlug}/${appSlug}/database/browser/default/${table.table_schema}/${table.table_name}`}
|
||||
onClick={() => {
|
||||
if (onSidebarItemClick) {
|
||||
onSidebarItemClick(`default.${tablePath}`);
|
||||
}
|
||||
}}
|
||||
<span>Edit Permissions</span>
|
||||
</Dropdown.Item>,
|
||||
!isSelectedSchemaLocked && (
|
||||
<Divider
|
||||
key="edit-permissions-separator"
|
||||
component="li"
|
||||
/>
|
||||
),
|
||||
!isSelectedSchemaLocked && (
|
||||
<Dropdown.Item
|
||||
key="delete-table"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={() =>
|
||||
handleDeleteTableClick(
|
||||
table.table_schema,
|
||||
table.table_name,
|
||||
)
|
||||
}
|
||||
>
|
||||
<TrashIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'error.main' }}
|
||||
/>
|
||||
<span>Delete Table</span>
|
||||
</Dropdown.Item>
|
||||
),
|
||||
]
|
||||
)}
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Text>{table.table_name}</ListItem.Text>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
<ListItem.Button
|
||||
dense
|
||||
selected={isSelected}
|
||||
disabled={tablePath === removableTable}
|
||||
className="group-focus-within:pr-9 group-hover:pr-9 group-active:pr-9"
|
||||
sx={{
|
||||
paddingRight:
|
||||
(isSelected || isSidebarMenuOpen) &&
|
||||
'2.25rem !important',
|
||||
}}
|
||||
component={NavLink}
|
||||
href={`/${workspaceSlug}/${appSlug}/database/browser/default/${table.table_schema}/${table.table_name}`}
|
||||
onClick={() => {
|
||||
if (onSidebarItemClick) {
|
||||
onSidebarItemClick(`default.${tablePath}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItem.Text>{table.table_name}</ListItem.Text>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</nav>
|
||||
</Box>
|
||||
|
||||
<Box className="border-t">
|
||||
<ListItem.Button
|
||||
dense
|
||||
selected={asPath === sqlEditorHref}
|
||||
className="flex border group-focus-within:pr-9 group-hover:pr-9 group-active:pr-9"
|
||||
component={NavLink}
|
||||
href={sqlEditorHref}
|
||||
>
|
||||
<div className="flex w-full flex-row items-center justify-center space-x-4">
|
||||
<TerminalIcon />
|
||||
<span className="flex">SQL Editor</span>
|
||||
</div>
|
||||
</ListItem.Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -580,7 +593,7 @@ export default function DataBrowserSidebar({
|
||||
<Box
|
||||
component="aside"
|
||||
className={twMerge(
|
||||
'absolute top-0 z-[35] h-full w-full overflow-auto border-r-1 px-2 pt-2 pb-17 motion-safe:transition-transform sm:relative sm:z-0 sm:h-full sm:py-2.5 sm:transition-none',
|
||||
'absolute top-0 z-[35] h-full w-full overflow-auto border-r-1 pt-2 pb-17 motion-safe:transition-transform sm:relative sm:z-0 sm:h-full sm:pt-2.5 sm:pb-0 sm:transition-none',
|
||||
expanded ? 'translate-x-0' : '-translate-x-full sm:translate-x-0',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlayIcon } from '@/components/ui/v2/icons/PlayIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Switch } from '@/components/ui/v2/Switch';
|
||||
import { Table } from '@/components/ui/v2/Table';
|
||||
import { TableBody } from '@/components/ui/v2/TableBody';
|
||||
import { TableCell } from '@/components/ui/v2/TableCell';
|
||||
import { TableHead } from '@/components/ui/v2/TableHead';
|
||||
import { TableRow } from '@/components/ui/v2/TableRow';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useRunSQL } from '@/features/database/dataGrid/hooks/useRunSQL';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { PostgreSQL, sql } from '@codemirror/lang-sql';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { githubDark, githubLight } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useResizable } from 'react-resizable-layout';
|
||||
|
||||
export default function SQLEditor() {
|
||||
const theme = useTheme();
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const [sqlCode, setSQLCode] = useState('');
|
||||
const [track, setTrack] = useState(false);
|
||||
const [cascade, setCascade] = useState(false);
|
||||
const [readOnly, setReadOnly] = useState(false);
|
||||
const [isMigration, setIsMigration] = useState(false);
|
||||
const [migrationName, setMigrationName] = useState('');
|
||||
|
||||
const onChange = useCallback((value: string) => setSQLCode(value), []);
|
||||
|
||||
const { runSQL, loading, errorMessage, commandOk, rows, columns } = useRunSQL(
|
||||
sqlCode,
|
||||
track,
|
||||
cascade,
|
||||
readOnly,
|
||||
isMigration,
|
||||
migrationName,
|
||||
);
|
||||
|
||||
const { position, separatorProps } = useResizable({
|
||||
axis: 'y',
|
||||
initial: 400,
|
||||
min: 50,
|
||||
reverse: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box className="flex flex-1 flex-col justify-center overflow-hidden">
|
||||
<Box className="flex flex-col space-y-2 border-b p-4">
|
||||
<Text className="font-semibold">Raw SQL</Text>
|
||||
<Box className="flex flex-col justify-between space-y-2 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||
<Box className="flex w-full flex-col space-y-2 lg:flex-row lg:space-x-4 lg:space-y-0 xl:h-10">
|
||||
<Box className="flex items-center space-x-2">
|
||||
<Switch
|
||||
label={
|
||||
<Text variant="subtitle1" component="span">
|
||||
Track this
|
||||
</Text>
|
||||
}
|
||||
checked={track}
|
||||
onChange={(event) => setTrack(event.currentTarget.checked)}
|
||||
/>
|
||||
<Tooltip title="If you are creating tables, views or functions, checking this will also expose them over the GraphQL API as top level fields. Functions only intended to be used as computed fields should not be tracked.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Box className="flex items-center space-x-2">
|
||||
<Switch
|
||||
label={
|
||||
<Text variant="subtitle1" component="span">
|
||||
Cascade metadata
|
||||
</Text>
|
||||
}
|
||||
checked={cascade}
|
||||
onChange={(e) => setCascade(e.target.checked)}
|
||||
/>
|
||||
|
||||
<Tooltip title="Cascade actions on all dependent metadata references, like relationships and permissions">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Box className="flex items-center space-x-2">
|
||||
<Switch
|
||||
label={
|
||||
<Text variant="subtitle1" component="span">
|
||||
Read only
|
||||
</Text>
|
||||
}
|
||||
checked={readOnly}
|
||||
onChange={(e) => setReadOnly(e.target.checked)}
|
||||
/>
|
||||
|
||||
<Tooltip title="When set to true, the request will be run in READ ONLY transaction access mode which means only select queries will be successful. This flag ensures that the GraphQL schema is not modified and is hence highly performant.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{!isPlatform && (
|
||||
<Box className="flex flex-col space-x-0 space-y-2 xl:flex-row xl:space-x-4 xl:space-y-0">
|
||||
<Box className="flex items-center space-x-2">
|
||||
<Switch
|
||||
label={
|
||||
<Text variant="subtitle1" component="span">
|
||||
This is a migration
|
||||
</Text>
|
||||
}
|
||||
checked={isMigration}
|
||||
onChange={(e) => setIsMigration(e.target.checked)}
|
||||
/>
|
||||
<Tooltip title="Create a migration file with the SQL statement">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{isMigration && (
|
||||
<Input
|
||||
name="isMigration"
|
||||
id="isMigration"
|
||||
placeholder="migration_name"
|
||||
className="h-auto w-auto max-w-md"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
onChange={(e) => setMigrationName(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
disabled={loading || !sqlCode.trim()}
|
||||
variant="contained"
|
||||
className="self-start"
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={runSQL}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<CodeMirror
|
||||
value={sqlCode}
|
||||
height="100%"
|
||||
className="min-h-[100px] flex-1 overflow-y-auto"
|
||||
theme={theme.palette.mode === 'light' ? githubLight : githubDark}
|
||||
extensions={[sql({ dialect: PostgreSQL })]}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<Box
|
||||
className="h-2 border-t hover:cursor-row-resize"
|
||||
sx={{
|
||||
background: theme.palette.background.default,
|
||||
}}
|
||||
{...separatorProps}
|
||||
/>
|
||||
|
||||
<Box
|
||||
className="flex items-start overflow-auto p-4"
|
||||
style={{ height: position }}
|
||||
>
|
||||
{loading && (
|
||||
<ActivityIndicator
|
||||
className="mx-auto self-center"
|
||||
circularProgressProps={{
|
||||
className: 'w-5 h-5',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<Alert
|
||||
severity="error"
|
||||
className="mx-auto grid grid-flow-row place-content-center gap-2 self-center"
|
||||
>
|
||||
<code>{errorMessage}</code>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !errorMessage && commandOk && (
|
||||
<Alert
|
||||
severity="success"
|
||||
className="mx-auto grid grid-flow-row place-content-center gap-2 self-center"
|
||||
>
|
||||
<code>Success, no rows returned</code>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !errorMessage && (
|
||||
<Table
|
||||
style={{
|
||||
tableLayout: 'auto',
|
||||
}}
|
||||
className="w-auto"
|
||||
>
|
||||
<TableHead
|
||||
sx={{
|
||||
background: theme.palette.background.default,
|
||||
}}
|
||||
>
|
||||
<TableRow>
|
||||
{columns.map((header) => (
|
||||
<TableCell
|
||||
key={header}
|
||||
scope="col"
|
||||
className="whitespace-nowrap border px-6 py-3 font-bold"
|
||||
>
|
||||
{header}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<TableRow
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={String(rowIndex)}
|
||||
// className="px-6 py-4 border whitespace-nowrap"
|
||||
>
|
||||
{row.map((value, valueIndex) => (
|
||||
<TableCell
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`${value}-${valueIndex}`}
|
||||
className="whitespace-nowrap border px-6 py-4"
|
||||
>
|
||||
{value}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SQLEditor } from './SQLEditor';
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useRunSQL } from './useRunSQL';
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
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 { useState } from 'react';
|
||||
|
||||
export default function useRunSQL(
|
||||
sqlCode: string,
|
||||
track: boolean,
|
||||
cascade: boolean,
|
||||
readOnly: boolean,
|
||||
isMigration: boolean,
|
||||
migrationName: string,
|
||||
) {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [commandOk, setCommandOk] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [columns, setColumns] = useState<string[]>([]);
|
||||
const [rows, setRows] = useState<string[][]>([[]]);
|
||||
|
||||
const appUrl = generateAppServiceUrl(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
'hasura',
|
||||
);
|
||||
|
||||
const adminSecret =
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: currentProject?.config?.hasura.adminSecret;
|
||||
|
||||
const toastStyle = getToastStyleProps();
|
||||
|
||||
const createMigration = async (
|
||||
inputSQL: string,
|
||||
migration: string,
|
||||
isCascade: boolean,
|
||||
) => {
|
||||
try {
|
||||
const migrationApiResponse = await fetch(`${appUrl}/apis/migrate`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-hasura-admin-secret': adminSecret },
|
||||
body: JSON.stringify({
|
||||
name: migration,
|
||||
datasource: 'default',
|
||||
up: [
|
||||
{
|
||||
type: 'run_sql',
|
||||
args: {
|
||||
source: 'default',
|
||||
sql: inputSQL,
|
||||
cascade: isCascade,
|
||||
read_only: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
down: [
|
||||
{
|
||||
type: 'run_sql',
|
||||
args: {
|
||||
source: 'default',
|
||||
sql: '-- Could not auto-generate a down migration.',
|
||||
cascade: isCascade,
|
||||
read_only: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!migrationApiResponse.ok) {
|
||||
throw new Error('Migration API call failed');
|
||||
}
|
||||
|
||||
return {
|
||||
error: null,
|
||||
};
|
||||
} catch (createMigrationError) {
|
||||
toast.error('An error happened when calling the migration API', {
|
||||
style: toastStyle.style,
|
||||
...toastStyle.error,
|
||||
});
|
||||
|
||||
return {
|
||||
error: createMigrationError,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const sendSQLToHasura = async (
|
||||
inputSQL: string,
|
||||
isCascade: boolean,
|
||||
isReadOnly: boolean,
|
||||
) => {
|
||||
try {
|
||||
if (!inputSQL) {
|
||||
return {
|
||||
result_type: 'error',
|
||||
columns: [],
|
||||
rows: [],
|
||||
queryApiError: 'No SQL provided',
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${appUrl}/v2/query`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-hasura-admin-secret': adminSecret },
|
||||
body: JSON.stringify({
|
||||
type: 'run_sql',
|
||||
args: {
|
||||
source: 'default',
|
||||
sql: inputSQL,
|
||||
cascade: isCascade,
|
||||
read_only: isReadOnly,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorResponse = await response.json();
|
||||
const queryApiError =
|
||||
errorResponse?.internal?.error?.message || 'Unknown error';
|
||||
return {
|
||||
result_type: 'error',
|
||||
columns: [],
|
||||
rows: [],
|
||||
error: queryApiError,
|
||||
};
|
||||
}
|
||||
|
||||
const responseBody = await response.json();
|
||||
|
||||
if (responseBody?.result_type === 'TuplesOk') {
|
||||
return {
|
||||
result_type: 'TuplesOk',
|
||||
columns: responseBody.result[0],
|
||||
rows: responseBody.result.slice(1),
|
||||
error: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (responseBody?.result_type === 'CommandOk') {
|
||||
return {
|
||||
result_type: 'CommandOk',
|
||||
columns: [],
|
||||
rows: [],
|
||||
error: '',
|
||||
};
|
||||
}
|
||||
|
||||
// If the result_type is neither TuplesOk nor CommandOk
|
||||
return {
|
||||
result_type: 'error',
|
||||
columns: [],
|
||||
rows: [],
|
||||
error: 'Unknown response type',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
result_type: 'error',
|
||||
columns: [],
|
||||
rows: [],
|
||||
error: error.message || 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const updateMetadata = async (inputSQL: string) => {
|
||||
const entities = parseIdentifiersFromSQL(inputSQL);
|
||||
|
||||
const tablesOrViewEntities = entities.filter(
|
||||
(entity) => entity.type !== 'function',
|
||||
);
|
||||
const functionEntities = entities.filter(
|
||||
(entity) => entity.type === 'function',
|
||||
);
|
||||
|
||||
const 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: {},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const metaDataPayload = {
|
||||
source: 'default',
|
||||
type: 'bulk',
|
||||
args: [...trackTablesOrViews, ...trackFunctions],
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('An error happened when calling the metadata API', {
|
||||
style: toastStyle.style,
|
||||
...toastStyle.error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const runSQL = async () => {
|
||||
setLoading(true);
|
||||
setCommandOk(false);
|
||||
setErrorMessage('');
|
||||
|
||||
if (isMigration) {
|
||||
const { error: createMigrationError } = await createMigration(
|
||||
sqlCode,
|
||||
migrationName,
|
||||
cascade,
|
||||
);
|
||||
|
||||
setCommandOk(!createMigrationError);
|
||||
|
||||
if (createMigrationError) {
|
||||
setErrorMessage('An unknown error occurred');
|
||||
}
|
||||
|
||||
// if running the migration fails then we don't update the metadata
|
||||
if (track && !createMigrationError) {
|
||||
await updateMetadata(sqlCode);
|
||||
}
|
||||
} else {
|
||||
const {
|
||||
result_type,
|
||||
error: $error,
|
||||
columns: $columns,
|
||||
rows: $rows,
|
||||
} = await sendSQLToHasura(sqlCode, cascade, readOnly);
|
||||
|
||||
setCommandOk(result_type === 'CommandOk');
|
||||
setColumns($columns);
|
||||
setRows($rows);
|
||||
setErrorMessage($error);
|
||||
|
||||
// if running the sql fails then we don't update the metadata
|
||||
if (track && !$error) {
|
||||
await updateMetadata(sqlCode);
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return {
|
||||
runSQL,
|
||||
loading,
|
||||
errorMessage,
|
||||
commandOk,
|
||||
rows,
|
||||
columns,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { UpgradeNotification } from '@/features/projects/common/components/UpgradeNotification';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
useGetPostgresSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
capacity: Yup.number().required(),
|
||||
});
|
||||
|
||||
export type AuthDomainFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function AuthDomain() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
refetch: refetchPostgresSettings,
|
||||
} = useGetPostgresSettingsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const capacity =
|
||||
data?.config?.postgres?.resources?.storage?.capacity ??
|
||||
currentProject.plan.featureMaxDbSize;
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation();
|
||||
|
||||
const form = useForm<{ capacity: number }>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: { capacity },
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { formState, register, reset } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !loading) {
|
||||
reset({ capacity });
|
||||
}
|
||||
}, [loading, data, reset, capacity]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading Auth Domain Settings..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function handleSubmit(formValues: AuthDomainFormValues) {
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
postgres: {
|
||||
resources: {
|
||||
storage: {
|
||||
capacity: formValues.capacity,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
loading: `Database storage capacity is being updated...`,
|
||||
success: `Database storage capacity has been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the database storage capacity.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchPostgresSettings();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Storage capacity"
|
||||
description="Specify the storage capacity for your PostgreSQL database."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="flex flex-col"
|
||||
>
|
||||
{currentProject.plan.isFree && (
|
||||
<UpgradeNotification message="Unlock by upgrading your project to the Pro plan." />
|
||||
)}
|
||||
<Box className="grid grid-flow-row lg:grid-cols-5">
|
||||
<Input
|
||||
{...register('capacity')}
|
||||
id="capacity"
|
||||
name="capacity"
|
||||
type="number"
|
||||
fullWidth
|
||||
disabled={currentProject.plan.isFree}
|
||||
className="lg:col-span-2"
|
||||
error={Boolean(formState.errors.capacity?.message)}
|
||||
helperText={formState.errors.capacity?.message}
|
||||
slotProps={{
|
||||
inputRoot: {
|
||||
min: capacity,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{!currentProject.plan.isFree && (
|
||||
<Alert severity="info" className="col-span-6 text-left">
|
||||
Note that volumes can only be increased (not decreased). Also, due
|
||||
to an AWS limitation, the same volume can only be increased once
|
||||
every 6 hours.
|
||||
</Alert>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DatabaseStorageCapacity } from './DatabaseStorageCapacity';
|
||||
@@ -4,11 +4,16 @@ query GetPostgresSettings($appId: uuid!) {
|
||||
database
|
||||
}
|
||||
}
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
postgres {
|
||||
version
|
||||
resources {
|
||||
storage {
|
||||
capacity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
query GetHasuraSettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
hasura {
|
||||
@@ -18,6 +18,13 @@ query GetHasuraSettings($appId: uuid!) {
|
||||
events {
|
||||
httpPoolSize
|
||||
}
|
||||
resources {
|
||||
networking {
|
||||
ingresses {
|
||||
fqdn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ProjectFragment } from '@/utils/__generated__/graphql';
|
||||
import { test, vi } from 'vitest';
|
||||
import generateAppServiceUrl, {
|
||||
defaultLocalBackendSlugs,
|
||||
defaultRemoteBackendSlugs,
|
||||
} from './generateAppServiceUrl';
|
||||
|
||||
@@ -138,7 +137,7 @@ test('should be able to override the default remote backend slugs', () => {
|
||||
process.env.NEXT_PUBLIC_ENV = 'production';
|
||||
|
||||
expect(
|
||||
generateAppServiceUrl('test', region, 'hasura', defaultLocalBackendSlugs, {
|
||||
generateAppServiceUrl('test', region, 'hasura', {
|
||||
...defaultRemoteBackendSlugs,
|
||||
hasura: '/lorem-ipsum',
|
||||
}),
|
||||
@@ -187,24 +186,3 @@ test('should construct service URLs based on environment variables', () => {
|
||||
'https://localdev4.nhost.run/v1/functions',
|
||||
);
|
||||
});
|
||||
|
||||
test('should generate a basic subdomain with a custom port if provided', () => {
|
||||
process.env.NEXT_PUBLIC_NHOST_BACKEND_URL = `http://localhost:1338`;
|
||||
process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
|
||||
|
||||
expect(generateAppServiceUrl('test', region, 'auth')).toBe(
|
||||
`http://localhost:1338/v1/auth`,
|
||||
);
|
||||
|
||||
expect(generateAppServiceUrl('test', region, 'storage')).toBe(
|
||||
`http://localhost:1338/v1/files`,
|
||||
);
|
||||
|
||||
expect(generateAppServiceUrl('test', region, 'graphql')).toBe(
|
||||
`http://localhost:1338/v1/graphql`,
|
||||
);
|
||||
|
||||
expect(generateAppServiceUrl('test', region, 'functions')).toBe(
|
||||
`http://localhost:1338/v1/functions`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -62,7 +62,6 @@ export default function generateAppServiceUrl(
|
||||
subdomain: string,
|
||||
region: ProjectFragment['region'],
|
||||
service: NhostService,
|
||||
localBackendSlugs = defaultLocalBackendSlugs,
|
||||
remoteBackendSlugs = defaultRemoteBackendSlugs,
|
||||
) {
|
||||
const IS_PLATFORM = isPlatform();
|
||||
@@ -87,12 +86,6 @@ export default function generateAppServiceUrl(
|
||||
return serviceUrls[service];
|
||||
}
|
||||
|
||||
// This is only used when running the dashboard locally against its own
|
||||
// backend.
|
||||
if (process.env.NEXT_PUBLIC_ENV === 'dev') {
|
||||
return `${process.env.NEXT_PUBLIC_NHOST_BACKEND_URL}${localBackendSlugs[service]}`;
|
||||
}
|
||||
|
||||
const constructedDomain = [
|
||||
subdomain,
|
||||
service,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { VerifyDomain } from '@/features/projects/custom-domains/settings/components/VerifyDomain';
|
||||
import {
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
type ConfigIngressUpdateInput,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
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';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
auth_fqdn: Yup.string(),
|
||||
});
|
||||
|
||||
export type AuthDomainFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function AuthDomain() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const [isVerified, setIsVerified] = useState(false);
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation();
|
||||
|
||||
const form = useForm<{ auth_fqdn: string }>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: { auth_fqdn: null },
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetAuthenticationSettingsQuery({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { networking } = data?.config?.auth?.resources || {};
|
||||
const initialValue = networking?.ingresses?.[0]?.fqdn?.[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && data) {
|
||||
form.reset({ auth_fqdn: initialValue });
|
||||
}
|
||||
}, [data, loading, form, initialValue]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading Auth Domain Settings..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { formState, register, watch } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const auth_fqdn = watch('auth_fqdn');
|
||||
|
||||
async function handleSubmit(formValues: AuthDomainFormValues) {
|
||||
const ingresses: ConfigIngressUpdateInput[] =
|
||||
formValues.auth_fqdn.length > 0 ? [{ fqdn: [formValues.auth_fqdn] }] : [];
|
||||
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
auth: {
|
||||
resources: {
|
||||
networking: {
|
||||
ingresses,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Auth domain is being updated...`,
|
||||
success: `Auth domain has been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the auth domain.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Auth Domain"
|
||||
description="Enter below your custom domain for the authentication service."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled:
|
||||
!isDirty || maintenanceActive || (!isVerified && !initialValue),
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row px-4 gap-y-4 gap-x-4 lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
{...register('auth_fqdn')}
|
||||
id="auth_fqdn"
|
||||
name="auth_fqdn"
|
||||
type="string"
|
||||
fullWidth
|
||||
className="col-span-5 lg:col-span-2"
|
||||
placeholder="auth.mydomain.dev"
|
||||
error={Boolean(formState.errors.auth_fqdn?.message)}
|
||||
helperText={formState.errors.auth_fqdn?.message}
|
||||
slotProps={{ inputRoot: { min: 1, max: 100 } }}
|
||||
/>
|
||||
<div className="col-span-5 row-start-2">
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={auth_fqdn}
|
||||
value={`lb.${currentProject.region.awsName}.${currentProject.region.domain}.`}
|
||||
onHostNameVerified={() => setIsVerified(true)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AuthDomain } from './AuthDomain';
|
||||
@@ -0,0 +1,61 @@
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import { VerifyDomain } from '@/features/projects/custom-domains/settings/components/VerifyDomain';
|
||||
import { useState } from 'react';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
database_fqdn: Yup.string().required(),
|
||||
});
|
||||
|
||||
export type DatabaseDomainFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function DatabaseDomain() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const [dbFQDN, setDbFQDN] = useState('');
|
||||
|
||||
const postgresHost = generateAppServiceUrl(
|
||||
currentProject.subdomain,
|
||||
currentProject.region,
|
||||
'db',
|
||||
).replace('https://', '');
|
||||
|
||||
return (
|
||||
<SettingsContainer
|
||||
title="Database Domain"
|
||||
description="Enter below your custom domain for the PostgreSQL database to verify. Once verified there is no need to save this value as no configuration on our end is required."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
hidden: true,
|
||||
},
|
||||
footer: {
|
||||
className: 'hidden',
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row px-4 gap-y-4 gap-x-4 lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
id="database_fqdn"
|
||||
name="database_fqdn"
|
||||
type="string"
|
||||
fullWidth
|
||||
className="col-span-5 lg:col-span-2"
|
||||
placeholder="db.mydomain.dev"
|
||||
onChange={(e) => {
|
||||
setDbFQDN(e.target.value);
|
||||
}}
|
||||
slotProps={{ inputRoot: { min: 1, max: 100 } }}
|
||||
/>
|
||||
<div className="col-span-5 row-start-2">
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={dbFQDN}
|
||||
value={`${postgresHost}.`}
|
||||
/>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DatabaseDomain } from './DatabaseDomain';
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { VerifyDomain } from '@/features/projects/custom-domains/settings/components/VerifyDomain';
|
||||
import {
|
||||
useGetHasuraSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
type ConfigIngressUpdateInput,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
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';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
hasura_fqdn: Yup.string(),
|
||||
});
|
||||
|
||||
export type HasuraDomainFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function HasuraDomain() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const [isVerified, setIsVerified] = useState(false);
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation();
|
||||
|
||||
const form = useForm<{ hasura_fqdn: string }>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: { hasura_fqdn: null },
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetHasuraSettingsQuery({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { networking } = data?.config?.hasura?.resources || {};
|
||||
const initialValue = networking?.ingresses?.[0]?.fqdn?.[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && data) {
|
||||
form.reset({ hasura_fqdn: initialValue });
|
||||
}
|
||||
}, [data, loading, form, initialValue]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={0}
|
||||
label="Loading Hasura Domain..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { formState, register, watch } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const hasura_fqdn = watch('hasura_fqdn');
|
||||
|
||||
async function handleSubmit(formValues: HasuraDomainFormValues) {
|
||||
const ingresses: ConfigIngressUpdateInput[] =
|
||||
formValues.hasura_fqdn.length > 0
|
||||
? [{ fqdn: [formValues.hasura_fqdn] }]
|
||||
: [];
|
||||
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
hasura: {
|
||||
resources: {
|
||||
networking: {
|
||||
ingresses,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Hasura domain is being updated...`,
|
||||
success: `Hasura domain has been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the Hasura domain.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Hasura Domain"
|
||||
description="Enter below your custom domain for the Hasura/GraphQL service."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled:
|
||||
!isDirty || maintenanceActive || (!isVerified && !initialValue),
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row px-4 gap-y-4 gap-x-4 lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
{...register('hasura_fqdn')}
|
||||
id="hasura_fqdn"
|
||||
name="hasura_fqdn"
|
||||
type="string"
|
||||
fullWidth
|
||||
className="col-span-5 lg:col-span-2"
|
||||
placeholder="auth.mydomain.dev"
|
||||
error={Boolean(formState.errors.hasura_fqdn?.message)}
|
||||
helperText={formState.errors.hasura_fqdn?.message}
|
||||
slotProps={{ inputRoot: { min: 1, max: 100 } }}
|
||||
/>
|
||||
<div className="col-span-5 row-start-2">
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={hasura_fqdn}
|
||||
value={`lb.${currentProject.region.awsName}.${currentProject.region.domain}.`}
|
||||
onHostNameVerified={() => setIsVerified(true)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as HasuraDomain } from './HasuraDomain';
|
||||
@@ -0,0 +1,88 @@
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { RunServicePortDomain } from '@/features/projects/custom-domains/settings/components/RunServicePortDomain';
|
||||
import { useGetRunServicesQuery } from '@/utils/__generated__/graphql';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export default function RunServiceDomains() {
|
||||
const { currentProject, currentWorkspace } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
// refetch: refetchServices, // TODO refetch after update
|
||||
} = useGetRunServicesQuery({
|
||||
variables: {
|
||||
appID: currentProject.id,
|
||||
resolve: false,
|
||||
limit: 1000, // TODO consider pagination
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const services = useMemo(
|
||||
() => data?.app?.runServices.map((service) => service) ?? [],
|
||||
[data],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading Run Services Domains..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{services
|
||||
.filter((service) => service.config?.ports?.length > 0)
|
||||
.map((service) => (
|
||||
<SettingsContainer
|
||||
key={service.id}
|
||||
title={
|
||||
<div className="flex flex-row items-center">
|
||||
<Text className="text-lg font-semibold">
|
||||
{service.config?.name ?? 'unset'}
|
||||
</Text>
|
||||
<Link
|
||||
href={`/${currentWorkspace.slug}/${currentProject.slug}/services`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="font-medium"
|
||||
>
|
||||
<ArrowSquareOutIcon className="mb-1 ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
description="Enter below your custom domain for the published ports."
|
||||
docsTitle={service.config?.name ?? 'unset'}
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
hidden: true,
|
||||
},
|
||||
footer: {
|
||||
className: 'hidden',
|
||||
},
|
||||
}}
|
||||
className="grid gap-y-4 gap-x-4 px-4"
|
||||
>
|
||||
{service.config?.ports?.map((port) => (
|
||||
<RunServicePortDomain
|
||||
key={String(port.port)}
|
||||
service={service}
|
||||
port={port.port}
|
||||
/>
|
||||
))}
|
||||
</SettingsContainer>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as RunServiceDomains } from './RunServiceDomains';
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { VerifyDomain } from '@/features/projects/custom-domains/settings/components/VerifyDomain';
|
||||
import { useUpdateRunServiceConfigMutation } from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { type RunService } from 'pages/[workspaceSlug]/[appSlug]/services';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
interface RunServicePortProps {
|
||||
service: RunService;
|
||||
port: number;
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
runServicePortFQDN: Yup.string(),
|
||||
});
|
||||
|
||||
export type RunServicePortFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function RunServicePortDomain({
|
||||
service,
|
||||
port,
|
||||
}: RunServicePortProps) {
|
||||
const { maintenanceActive } = useUI();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isVerified, setIsVerified] = useState(false);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const [updateRunServiceConfig] = useUpdateRunServiceConfigMutation();
|
||||
|
||||
const runServicePort = service.config.ports.find((p) => p.port === port);
|
||||
const initialValue = runServicePort?.ingresses?.[0]?.fqdn?.[0];
|
||||
|
||||
const form = useForm<{ runServicePortFQDN: string }>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
runServicePortFQDN: initialValue,
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { formState, register, watch } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const runServicePortFQDN = watch('runServicePortFQDN');
|
||||
|
||||
async function handleSubmit(formValues: RunServicePortFormValues) {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
await toast.promise(
|
||||
updateRunServiceConfig({
|
||||
variables: {
|
||||
appID: currentProject.id,
|
||||
serviceID: service.id,
|
||||
config: {
|
||||
ports: service?.config?.ports?.map((p) => {
|
||||
// exclude the `__typename` because the mutation will fail otherwise
|
||||
const { __typename, ...rest } = p;
|
||||
|
||||
if (rest.port === port) {
|
||||
return {
|
||||
...rest,
|
||||
ingresses:
|
||||
formValues.runServicePortFQDN.length > 0
|
||||
? [{ fqdn: [formValues.runServicePortFQDN] }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
// exclude the `__typename` because the mutation will fail otherwise
|
||||
ingresses: rest.ingresses.map((item) => ({
|
||||
fqdn: item.fqdn,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
loading: `Port ${port} is being updated...`,
|
||||
success: `Port ${port} has been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update Port ${port}.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
// TODO refetch the service config
|
||||
// await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Text className="text-sm font-semibold">{`${runServicePort.type} <--> ${runServicePort.port}`}</Text>
|
||||
<div className="flex flex-row space-x-4">
|
||||
<Input
|
||||
{...register('runServicePortFQDN')}
|
||||
id="runServicePortFQDN"
|
||||
name="runServicePortFQDN"
|
||||
type="string"
|
||||
fullWidth
|
||||
className=""
|
||||
placeholder={`${service.config?.name ?? 'unset'}-${
|
||||
runServicePort.port
|
||||
}.mydomain.dev`}
|
||||
error={Boolean(formState.errors.runServicePortFQDN?.message)}
|
||||
helperText={formState.errors.runServicePortFQDN?.message}
|
||||
slotProps={{
|
||||
inputRoot: { min: 1, max: 100 },
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
type="submit"
|
||||
disabled={
|
||||
loading ||
|
||||
!isDirty ||
|
||||
maintenanceActive ||
|
||||
(!isVerified && !initialValue)
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-5 row-start-2 mt-4">
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={runServicePortFQDN}
|
||||
value={`lb.${currentProject.region.awsName}.${currentProject.region.domain}.`}
|
||||
onHostNameVerified={() => setIsVerified(true)}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as RunServicePortDomain } from './RunServicePortDomain';
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { VerifyDomain } from '@/features/projects/custom-domains/settings/components/VerifyDomain';
|
||||
import {
|
||||
useGetServerlessFunctionsSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
type ConfigIngressUpdateInput,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
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';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
functions_fqdn: Yup.string(),
|
||||
});
|
||||
|
||||
export type ServerlessFunctionsDomainFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
export default function ServerlessFunctionsDomain() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const [isVerified, setIsVerified] = useState(false);
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation();
|
||||
|
||||
const form = useForm<{ functions_fqdn: string }>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: { functions_fqdn: null },
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetServerlessFunctionsSettingsQuery({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { networking } = data?.config?.functions?.resources || {};
|
||||
const initialValue = networking?.ingresses?.[0]?.fqdn?.[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && data) {
|
||||
form.reset({ functions_fqdn: initialValue });
|
||||
}
|
||||
}, [data, loading, form, initialValue]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading Serverless Functions Domain Settings..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { formState, register, watch } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const functions_fqdn = watch('functions_fqdn');
|
||||
|
||||
async function handleSubmit(formValues: ServerlessFunctionsDomainFormValues) {
|
||||
const ingresses: ConfigIngressUpdateInput[] =
|
||||
formValues.functions_fqdn.length > 0
|
||||
? [{ fqdn: [formValues.functions_fqdn] }]
|
||||
: [];
|
||||
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
functions: {
|
||||
resources: {
|
||||
networking: {
|
||||
ingresses,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Serverless Functions domain is being updated...`,
|
||||
success: `Serverless Functions domain has been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the Serverless Functions domain.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Serverless Functions Domain"
|
||||
description="Enter below your custom domain for Serverless Functions."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled:
|
||||
!isDirty || maintenanceActive || (!isVerified && !initialValue),
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row px-4 gap-y-4 gap-x-4 lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
{...register('functions_fqdn')}
|
||||
id="functions_fqdn"
|
||||
name="functions_fqdn"
|
||||
type="string"
|
||||
fullWidth
|
||||
className="col-span-5 lg:col-span-2"
|
||||
placeholder="functions.mydomain.dev"
|
||||
error={Boolean(formState.errors.functions_fqdn?.message)}
|
||||
helperText={formState.errors.functions_fqdn?.message}
|
||||
slotProps={{ inputRoot: { min: 1, max: 100 } }}
|
||||
/>
|
||||
<div className="col-span-5 row-start-2">
|
||||
<VerifyDomain
|
||||
recordType="CNAME"
|
||||
hostname={functions_fqdn}
|
||||
value={`lb.${currentProject.region.awsName}.${currentProject.region.domain}.`}
|
||||
onHostNameVerified={() => setIsVerified(true)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ServerlessFunctionsDomain } from './ServerlessFunctionsDomain';
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { useDnsLookupCnameLazyQuery } from '@/utils/__generated__/graphql';
|
||||
import { ApolloError } from '@apollo/client';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface VerifyDomainProps {
|
||||
recordType: string;
|
||||
hostname: string;
|
||||
value: string;
|
||||
onHostNameVerified?: () => void;
|
||||
}
|
||||
|
||||
export default function VerifyDomain({
|
||||
recordType,
|
||||
hostname,
|
||||
value,
|
||||
onHostNameVerified,
|
||||
}: VerifyDomainProps) {
|
||||
const [verificationFailed, setVerificationFailed] = useState(false);
|
||||
const [verificationSucceeded, setVerificationSucceeded] = useState(false);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fireLookupCNAME] = useDnsLookupCnameLazyQuery();
|
||||
|
||||
const handleVerifyDomain = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
fireLookupCNAME({
|
||||
variables: {
|
||||
hostname,
|
||||
},
|
||||
}).then(({ data: { dnsLookupCNAME } }) => {
|
||||
if (dnsLookupCNAME !== value) {
|
||||
throw new Error(`Could not verify ${hostname}`);
|
||||
}
|
||||
}),
|
||||
{
|
||||
loading: `Verifying ${hostname} ...`,
|
||||
success: () => {
|
||||
setVerificationFailed(false);
|
||||
setVerificationSucceeded(true);
|
||||
setLoading(false);
|
||||
onHostNameVerified?.();
|
||||
return `${hostname} has been verified.`;
|
||||
},
|
||||
error: (arg: Error | ApolloError) => {
|
||||
setVerificationFailed(true);
|
||||
setVerificationSucceeded(false);
|
||||
setLoading(false);
|
||||
|
||||
if (arg instanceof ApolloError) {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } =
|
||||
(internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
`An error occurred while trying to verify ${hostname}. Please try again.`
|
||||
);
|
||||
}
|
||||
|
||||
return arg.message;
|
||||
},
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
} catch (error) {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={[
|
||||
{ backgroundColor: 'primary.light' },
|
||||
verificationFailed && {
|
||||
backgroundColor: 'error.light',
|
||||
color: 'error.main',
|
||||
},
|
||||
verificationSucceeded && {
|
||||
backgroundColor: 'success.light',
|
||||
color: 'success.dark',
|
||||
},
|
||||
]}
|
||||
className="flex flex-col p-4 space-y-4 rounded-md"
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
{!verificationFailed && !verificationSucceeded && (
|
||||
<Text>
|
||||
Add the record below in your DNS provider to verify {hostname}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{verificationSucceeded && (
|
||||
<Text>
|
||||
<span className="font-semibold">{hostname}</span> was verified
|
||||
successfully. Hit save to apply.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{verificationFailed && (
|
||||
<Text>
|
||||
An error occurred while trying to verify{' '}
|
||||
<span className="font-semibold">{hostname}</span>. Make sure you
|
||||
correctly added the <span className="font-semibold">CNAME</span> and
|
||||
try again.
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col text-slate-500">
|
||||
<div className="flex space-x-2">
|
||||
<Text>Record type: </Text>
|
||||
<Text className="font-bold">{recordType}</Text>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Text>Host:</Text>
|
||||
<Text className="font-bold">{hostname}</Text>
|
||||
</div>
|
||||
<div className="flex flex-row space-x-2">
|
||||
<Text>Value:</Text>
|
||||
<Text className="font-bold">{value}</Text>
|
||||
<IconButton
|
||||
aria-label="Copy Personal Access Token"
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
onClick={() => copy(value, 'CNAME Value')}
|
||||
>
|
||||
<CopyIcon className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Button
|
||||
disabled={loading || !hostname}
|
||||
onClick={handleVerifyDomain}
|
||||
className="mt-4 sm:absolute sm:bottom-0 sm:right-0 sm:mt-0"
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VerifyDomain } from './VerifyDomain';
|
||||
@@ -16,7 +16,6 @@ import { useAppClient } from '@/features/projects/common/hooks/useAppClient';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import {
|
||||
defaultLocalBackendSlugs,
|
||||
defaultRemoteBackendSlugs,
|
||||
generateAppServiceUrl,
|
||||
} from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
@@ -110,7 +109,6 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
'hasura',
|
||||
defaultLocalBackendSlugs,
|
||||
{ ...defaultRemoteBackendSlugs, hasura: '/console' },
|
||||
),
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ fragment ServiceResources on ConfigConfig {
|
||||
}
|
||||
|
||||
query GetResources($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
...ServiceResources
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
query GetServerlessFunctionsSettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
functions {
|
||||
resources {
|
||||
networking {
|
||||
ingresses {
|
||||
fqdn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Form } from '@/components/form/Form';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowsClockwise } from '@/components/ui/v2/icons/ArrowsClockwise';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
@@ -344,7 +345,7 @@ export default function ServiceForm({
|
||||
<Tooltip title="Name of the service, must be unique per project.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
className="w-4 h-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -384,7 +385,7 @@ export default function ServiceForm({
|
||||
>
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
className="w-4 h-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -415,7 +416,7 @@ export default function ServiceForm({
|
||||
<Tooltip title="Command to run when to start the service. This is optional as the image may already have a baked-in command.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
className="w-4 h-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -459,7 +460,7 @@ export default function ServiceForm({
|
||||
{createServiceFormError && (
|
||||
<Alert
|
||||
severity="error"
|
||||
className="grid grid-flow-col items-center justify-between px-4 py-3"
|
||||
className="grid items-center justify-between grid-flow-col px-4 py-3"
|
||||
>
|
||||
<span className="text-left">
|
||||
<strong>Error:</strong> {createServiceFormError.message}
|
||||
@@ -482,7 +483,7 @@ export default function ServiceForm({
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
startIcon={<PlusIcon />}
|
||||
startIcon={serviceID ? <ArrowsClockwise /> : <PlusIcon />}
|
||||
>
|
||||
{serviceID ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
|
||||
@@ -55,7 +55,8 @@ export default function ServiceDetailsDialog({
|
||||
.filter((port) => port.publish)
|
||||
.map((port) => (
|
||||
<InfoCard
|
||||
title={`${port.type}:${port.port}`}
|
||||
key={String(port.port)}
|
||||
title={`${port.type} <--> ${port.port}`}
|
||||
value={getPortURL(port.port)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
query GetStorageSettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
storage {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
query getProjectLocales($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
auth {
|
||||
user {
|
||||
locale {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
query dnsLookupCNAME($hostname: String!) {
|
||||
dnsLookupCNAME(hostname: $hostname)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ fragment JWTSecret on ConfigJWTSecret {
|
||||
}
|
||||
|
||||
query GetEnvironmentVariables($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
global {
|
||||
|
||||
@@ -5,7 +5,7 @@ fragment PermissionVariable on ConfigAuthsessionaccessTokenCustomClaims {
|
||||
}
|
||||
|
||||
query GetRolesPermissions($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
auth {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
query GetSignInMethods($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
provider {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
query GetSmtpSettings($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
config(appID: $appId, resolve: false) {
|
||||
id: __typename
|
||||
__typename
|
||||
provider {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
mutation UpdateConfig($appId: uuid!, $config: ConfigConfigUpdateInput!) {
|
||||
updateConfig(appID: $appId, config: $config) {
|
||||
id: __typename
|
||||
postgres {
|
||||
resources {
|
||||
storage {
|
||||
capacity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ fragment Project on apps {
|
||||
name
|
||||
price
|
||||
isFree
|
||||
featureMaxDbSize
|
||||
}
|
||||
githubRepository {
|
||||
fullName
|
||||
|
||||
@@ -36,10 +36,12 @@ query getRunServices(
|
||||
port
|
||||
type
|
||||
publish
|
||||
ingresses {
|
||||
fqdn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runServices_aggregate {
|
||||
aggregate {
|
||||
count
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
mutation insertFeedbackOne($feedback: feedback_insert_input!) {
|
||||
insertFeedbackOne(object: $feedback) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { LoadingScreen } from '@/components/presentational/LoadingScreen';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
// import { useTablePath } from '@/features/database/common/hooks/useTablePath';
|
||||
import { DataBrowserLayout } from '@/features/database/dataGrid/components/DataBrowserLayout';
|
||||
import { SQLEditor } from '@/features/database/dataGrid/components/SQLEditor';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export default function Editor() {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
if (isPlatform && !currentProject?.config?.hasura.adminSecret) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<RetryableErrorBoundary>
|
||||
<SQLEditor />
|
||||
</RetryableErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
Editor.getLayout = function getLayout(page: ReactElement) {
|
||||
return <DataBrowserLayout>{page}</DataBrowserLayout>;
|
||||
};
|
||||
@@ -11,7 +11,6 @@ import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import {
|
||||
defaultLocalBackendSlugs,
|
||||
defaultRemoteBackendSlugs,
|
||||
generateAppServiceUrl,
|
||||
} from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
@@ -39,7 +38,6 @@ export default function HasuraPage() {
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
'hasura',
|
||||
defaultLocalBackendSlugs,
|
||||
{ ...defaultRemoteBackendSlugs, hasura: '/console' },
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { UpgradeToProBanner } from '@/components/common/UpgradeToProBanner';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { SettingsLayout } from '@/components/layout/SettingsLayout';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { AuthDomain } from '@/features/projects/custom-domains/settings/components/AuthDomain';
|
||||
import { DatabaseDomain } from '@/features/projects/custom-domains/settings/components/DatabaseDomain';
|
||||
import { HasuraDomain } from '@/features/projects/custom-domains/settings/components/HasuraDomain';
|
||||
import { RunServiceDomains } from '@/features/projects/custom-domains/settings/components/RunServiceDomains';
|
||||
import { ServerlessFunctionsDomain } from '@/features/projects/custom-domains/settings/components/ServerlessFunctionsDomain';
|
||||
import { type ReactElement } from 'react';
|
||||
|
||||
export default function CustomDomains() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
if (currentProject.plan.isFree) {
|
||||
return (
|
||||
<Container
|
||||
className="grid grid-flow-row gap-6 bg-transparent"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<UpgradeToProBanner
|
||||
title="Upgrade to Nhost Pro to unlock custom domains"
|
||||
description="In publishing and graphic design, Lorem ipsum is a placeholder text
|
||||
commonly used to demonstrate the visual form of a document or a
|
||||
typeface without relying on meaningful content."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
className="grid max-w-5xl grid-flow-row gap-6 bg-transparent"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<Box className="flex flex-row items-center gap-4 p-4 overflow-hidden rounded-lg border-1">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Text className="text-lg font-semibold">Custom Domains</Text>
|
||||
|
||||
<Text color="secondary">
|
||||
Add a custom domain to Auth, Hasura, PostgreSQL, and your Run
|
||||
services for only a $10 flat fee 🚀 <br /> Learn more about
|
||||
<Link
|
||||
href="https://docs.nhost.io/platform/custom-domains"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="ml-1 font-medium"
|
||||
>
|
||||
Custom Domains
|
||||
<ArrowSquareOutIcon className="w-4 h-4 ml-1" />
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<AuthDomain />
|
||||
<HasuraDomain />
|
||||
<DatabaseDomain />
|
||||
|
||||
<ServerlessFunctionsDomain />
|
||||
<RunServiceDomains />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
CustomDomains.getLayout = function getLayout(page: ReactElement) {
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { SettingsLayout } from '@/components/layout/SettingsLayout';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { DatabaseConnectionInfo } from '@/features/database/settings/components/DatabaseConnectionInfo';
|
||||
import { DatabaseServiceVersionSettings } from '@/features/database/settings/components/DatabaseServiceVersionSettings';
|
||||
import { DatabaseStorageCapacity } from '@/features/database/settings/components/DatabaseStorageCapacity';
|
||||
import { ResetDatabasePasswordSettings } from '@/features/database/settings/components/ResetDatabasePasswordSettings';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useGetPostgresSettingsQuery } from '@/generated/graphql';
|
||||
@@ -36,6 +37,7 @@ export default function DatabaseSettingsPage() {
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<DatabaseServiceVersionSettings />
|
||||
<DatabaseStorageCapacity />
|
||||
<DatabaseConnectionInfo />
|
||||
<ResetDatabasePasswordSettings />
|
||||
</Container>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { PasswordSettings } from '@/features/account/settings/components/PasswordSettings';
|
||||
import { PATSettings } from '@/features/account/settings/components/PATSettings';
|
||||
import type { ReactElement } from 'react';
|
||||
@@ -18,6 +19,8 @@ export default function AccountSettingsPage() {
|
||||
<RetryableErrorBoundary>
|
||||
<PATSettings />
|
||||
</RetryableErrorBoundary>
|
||||
|
||||
<DeleteAccount />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ export const mockApplication: Project = {
|
||||
name: 'Starter',
|
||||
isFree: true,
|
||||
price: 0,
|
||||
featureMaxDbSize: 1,
|
||||
},
|
||||
config: {
|
||||
observability: {
|
||||
|
||||
1401
dashboard/src/utils/__generated__/graphql.ts
generated
1401
dashboard/src/utils/__generated__/graphql.ts
generated
File diff suppressed because it is too large
Load Diff
10
dashboard/src/utils/env/env.ts
vendored
10
dashboard/src/utils/env/env.ts
vendored
@@ -5,16 +5,6 @@ export function isPlatform() {
|
||||
return process.env.NEXT_PUBLIC_NHOST_PLATFORM === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend URL for the locally running instance. This is only used when running
|
||||
* the Nhost Dashboard locally.
|
||||
*/
|
||||
export function getLocalBackendUrl() {
|
||||
return `http://localhost:${
|
||||
process.env.NEXT_PUBLIC_NHOST_LOCAL_SERVICES_PORT || '1337'
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin secret for Hasura.
|
||||
*/
|
||||
|
||||
@@ -9,16 +9,11 @@ import { NhostClient } from '@nhost/nextjs';
|
||||
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
const nhost = isPlatform()
|
||||
? new NhostClient({ backendUrl: process.env.NEXT_PUBLIC_NHOST_BACKEND_URL })
|
||||
: getAuthServiceUrl() &&
|
||||
getGraphqlServiceUrl() &&
|
||||
getStorageServiceUrl() &&
|
||||
getFunctionsServiceUrl()
|
||||
? new NhostClient({
|
||||
authUrl: getAuthServiceUrl(),
|
||||
graphqlUrl: getGraphqlServiceUrl(),
|
||||
storageUrl: getStorageServiceUrl(),
|
||||
functionsUrl: getFunctionsServiceUrl(),
|
||||
storageUrl: getStorageServiceUrl(),
|
||||
})
|
||||
: new NhostClient({ subdomain: 'local' });
|
||||
|
||||
|
||||
53
dashboard/src/utils/sql/index.ts
Normal file
53
dashboard/src/utils/sql/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// The parsing was inspired by code from the hasura/graphql-engine repo
|
||||
|
||||
export interface ParsedSQLEntity {
|
||||
type: string;
|
||||
name: string;
|
||||
schema: string;
|
||||
}
|
||||
|
||||
const sanitizeValue = (value: string) => {
|
||||
let val = value;
|
||||
|
||||
if (!/^".*"$/.test(value)) {
|
||||
val = value?.toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
return val.replace(/['"]+/g, '');
|
||||
};
|
||||
|
||||
const stripComments = (sql: string) => {
|
||||
const regExp = /(--[^\r\n]*)|(\/\*[\w\W]*?(?=\*\/)\*\/)/; // eslint-disable-line
|
||||
const comments = sql.match(new RegExp(regExp, 'gmi'));
|
||||
|
||||
if (!comments?.length) {
|
||||
return sql;
|
||||
}
|
||||
|
||||
return comments.reduce(
|
||||
(acc: string, comment: string) => acc.replace(comment, ''),
|
||||
sql,
|
||||
);
|
||||
};
|
||||
|
||||
export const parseIdentifiersFromSQL = (sql: string): ParsedSQLEntity[] => {
|
||||
const objects: ParsedSQLEntity[] = [];
|
||||
const sanitizedSql = stripComments(sql);
|
||||
|
||||
const regExp =
|
||||
/create\s*(?:|or\s*replace)\s*(?<type>view|table|function)\s*(?:\s*if*\s*not\s*exists\s*)?((?<schema>\"?\w+\"?)\.(?<nameWithSchema>\"?\w+\"?)|(?<name>\"?\w+\"?))\s*(?<partition>partition\s*of)?/gim; // eslint-disable-line
|
||||
|
||||
Array.from(sanitizedSql.matchAll(regExp)).forEach((result) => {
|
||||
const { type, schema, name, nameWithSchema } = result.groups ?? {};
|
||||
|
||||
if (type && (name || nameWithSchema)) {
|
||||
objects.push({
|
||||
type: type.toLowerCase(),
|
||||
schema: sanitizeValue(schema || 'public'),
|
||||
name: sanitizeValue(name || nameWithSchema),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return objects;
|
||||
};
|
||||
@@ -1,5 +1,39 @@
|
||||
# @nhost/docs
|
||||
|
||||
## 0.7.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2a04bc9e5: added functions to custom domains documentation
|
||||
|
||||
## 0.7.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3ffb60f0a: fixed typo in Run deploy example script
|
||||
|
||||
## 0.7.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 138bf9eb5: fix: add instructions for enabling Sign In with LinkedIn using OpenID Connect
|
||||
|
||||
## 0.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1ee021b4a: remove custom domains from roadmap
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 5764f46d9: Add docs for Custom Domains
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cc8cc8d45: database: added extension http
|
||||
|
||||
## 0.6.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -36,6 +36,14 @@ Follow this guide to sign in users with LinkedIn.
|
||||
- Copy and paste the **OAuth Callback URL** from Nhost.
|
||||
- Click **Update**.
|
||||
|
||||
## Enable Sign In with LinkedIn using OpenID Connect
|
||||
|
||||
- Click on **Products** in the top menu.
|
||||
- Scroll down and look for **Sign In with LinkedIn using OpenID Connect**.
|
||||
- Click **Request Access**.
|
||||
- Click the checkbox **I have read and agree to these terms**.
|
||||
- Click **Request Access**.
|
||||
|
||||
## Configure Nhost
|
||||
|
||||
- Copy and paste the **Client ID** and **Client Secret** from LinkedIn to your Nhost OAuth settings for LinkedIn.
|
||||
|
||||
@@ -157,3 +157,28 @@ DROP EXTENSION pg_stat_statements;
|
||||
### Resources
|
||||
|
||||
* [Documentation](https://www.postgresql.org/docs/14/pgstatstatements.html)
|
||||
|
||||
|
||||
## http
|
||||
|
||||
HTTP client for PostgreSQL, retrieve a web page from inside the database.
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION http;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION http;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [GitHub](https://github.com/pramsey/pgsql-http)
|
||||
|
||||
67
docs/docs/platform/custom-domains.mdx
Normal file
67
docs/docs/platform/custom-domains.mdx
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: 'Custom Domains'
|
||||
sidebar_position: 10
|
||||
image: /img/og/platform/metrics.png
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
Custom domains empower you to offer a tailored and branded experience for your users. Available only as an add-on for projects on the pro and enterprise plans, custom domains not only enhance your brand's visibility but also provide a more professional appearance compared to using Nhost's default domain.
|
||||
|
||||
You can configure Custom Domains for Auth, Hasura, PostgreSQL, and your own Run services using both the Nhost Dashboard or the Config file.
|
||||
|
||||
The following examples assume we are configuring custom domains at `*.custom-domain.com`.
|
||||
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="dashboard" label="dashboard" default>
|
||||
|
||||
Follow the instructions in the **Custom Domain** section of your project's settings:
|
||||
|
||||
1. Add a CNAME record in your DNS provider for each of the services you want a custom domain for, and click "Verify". The verification might take a few seconds to succeed.
|
||||
2. Once the verification succeeds, click "Save" to update your project.
|
||||
|
||||

|
||||
|
||||
</TabItem>
|
||||
<TabItem value="toml" label="toml">
|
||||
|
||||
The first step is to add a CNAME record in your DNS provider for each of the services you want a custom domain for. You can find the instructions in the **dashboard** tab.
|
||||
|
||||
For Hasura, Auth, Functions, and PostgreSQL, custom domains are defined in the default `./nhost/config.toml` as follows:
|
||||
|
||||
```
|
||||
[[hasura.resources.networking.ingresses]]
|
||||
fqdn = ['hasura.custom-domain.com']
|
||||
|
||||
[[auth.resources.networking.ingresses]]
|
||||
fqdn = ['auth.custom-domain.com']
|
||||
|
||||
[[postgres.resources.networking.ingresses]]
|
||||
fqdn = ['postgres.custom-domain.com']
|
||||
|
||||
[[functions.resources.networking.ingresses]]
|
||||
fqdn = ['functions.custom-domain.com']
|
||||
```
|
||||
|
||||
For Run services, typically in `nhost-service.toml` specific to the service:
|
||||
|
||||
```
|
||||
name = 'my-service'
|
||||
|
||||
[image]
|
||||
image = 'docker.io/nhost/my-service'
|
||||
|
||||
[[ports]]
|
||||
port = 8080
|
||||
type= 'http'
|
||||
publish = true
|
||||
|
||||
[[ports.ingresses]]
|
||||
fqdn = ['my-service.custom-domain.com']
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
@@ -36,7 +36,7 @@ docker buildx build \
|
||||
.
|
||||
|
||||
nhost run config-deploy \
|
||||
--config $CONFIGURATION \
|
||||
--config $CONFIGURATION_FILE \
|
||||
--service-id $SERVICE_ID
|
||||
```
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ Nhost Run works with container images built for the **arm architecture**. Images
|
||||
|
||||
Some missing functionality we are currently working on and should be added soon:
|
||||
|
||||
1. Custom domains
|
||||
2. Run services with the CLI alongside your project
|
||||
3. Ability to connect services to repositories for automated building and deployment (currently this needs to be done via a third party CI, see [Deployment via CI](/run/ci) for more details).
|
||||
4. Expose TCP/UDP ports
|
||||
1. Run services with the CLI alongside your project
|
||||
2. Ability to connect services to repositories for automated building and deployment (currently this needs to be done via a third party CI, see [Deployment via CI](/run/ci) for more details).
|
||||
3. Expose TCP/UDP ports
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/docs",
|
||||
"version": "0.6.2",
|
||||
"version": "0.7.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
|
||||
BIN
docs/static/img/custom-domains/custom-domains.png
vendored
Normal file
BIN
docs/static/img/custom-domains/custom-domains.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 241 KiB |
@@ -25,7 +25,7 @@ httpPoolSize = 100
|
||||
|
||||
[functions]
|
||||
[functions.node]
|
||||
version = 16
|
||||
version = 18
|
||||
|
||||
[auth]
|
||||
version = '0.20.2'
|
||||
|
||||
@@ -25,7 +25,7 @@ httpPoolSize = 100
|
||||
|
||||
[functions]
|
||||
[functions.node]
|
||||
version = 16
|
||||
version = 18
|
||||
|
||||
[auth]
|
||||
version = '0.20.2'
|
||||
|
||||
@@ -25,7 +25,7 @@ httpPoolSize = 100
|
||||
|
||||
[functions]
|
||||
[functions.node]
|
||||
version = 16
|
||||
version = 18
|
||||
|
||||
[auth]
|
||||
version = '0.20.2'
|
||||
|
||||
@@ -25,7 +25,7 @@ httpPoolSize = 100
|
||||
|
||||
[functions]
|
||||
[functions.node]
|
||||
version = 16
|
||||
version = 18
|
||||
|
||||
[auth]
|
||||
version = '0.20.2'
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @nhost-examples/docker-compose
|
||||
|
||||
## 0.0.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a1c5c97a5: Clarify instructions for running the Nhost dashboard with Docker Compose
|
||||
|
||||
## 0.0.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -25,10 +25,12 @@ The following endpoints are now exposed:
|
||||
|
||||
## Running the Nhost dashboard locally
|
||||
|
||||
In order to use the Nhost dashboard, you need to run the [Hasura console locally from the Hasura CLI](https://hasura.io/docs/latest/hasura-cli/commands/hasura_console/):
|
||||
In order for you to be able to make edits to the database from the Nhost dashboard, you need to run the [Hasura console locally from the Hasura CLI](https://hasura.io/docs/latest/hasura-cli/commands/hasura_console/):
|
||||
|
||||
```sh
|
||||
hasura console
|
||||
```
|
||||
|
||||
The Nhost Dashboard also requires the Hasura admin secret to `nhost-admin-secret`. This will change in the future. If you can't wait, don't hesitate to contribute.
|
||||
The Nhost Dashboard [uses](https://github.com/nhost/nhost/discussions/2398) the [Hasura migrations API](https://hasura.io/docs/latest/hasura-cli/commands/hasura_console/#options) in order to make edits to the database. It runs over port 9693 and is only accessible through running the Hasura console from the CLI. Because the Docker compose still only uses the graphql-engine Hasura Docker image and does not include the CLI image, that is why you need to run it locally. See https://github.com/nhost/nhost/issues/1220. Users are welcome to contibute a Docker compose that includes the CLI image to resolve this.
|
||||
|
||||
The Nhost Dashboard also requires the Hasura admin secret to `nhost-admin-secret` specified in the `.env` file.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost-examples/docker-compose",
|
||||
"version": "0.0.5",
|
||||
"version": "0.0.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"e2e": "vitest run"
|
||||
|
||||
@@ -25,7 +25,7 @@ httpPoolSize = 100
|
||||
|
||||
[functions]
|
||||
[functions.node]
|
||||
version = 16
|
||||
version = 18
|
||||
|
||||
[auth]
|
||||
version = '0.20.2'
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# @nhost-examples/multi-tenant-one-to-many
|
||||
|
||||
## 2.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- bc9eff6e4: chore: remove support for using backendUrl when instantiating the Nhost client
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bc9eff6e4]
|
||||
- @nhost/nhost-js@3.0.0
|
||||
|
||||
## 1.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -25,7 +25,7 @@ httpPoolSize = 100
|
||||
|
||||
[functions]
|
||||
[functions.node]
|
||||
version = 16
|
||||
version = 18
|
||||
|
||||
[auth]
|
||||
version = '0.20.2'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@nhost-examples/multi-tenant-one-to-many",
|
||||
"private": true,
|
||||
"version": "1.0.4",
|
||||
"version": "2.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NhostClient } from "@nhost/nhost-js";
|
||||
import { NhostClient } from '@nhost/nhost-js'
|
||||
|
||||
const nhost = new NhostClient({
|
||||
backendUrl: "http://localhost:1337",
|
||||
});
|
||||
subdomain: 'local'
|
||||
})
|
||||
|
||||
export { nhost };
|
||||
export { nhost }
|
||||
|
||||
@@ -25,7 +25,7 @@ httpPoolSize = 100
|
||||
|
||||
[functions]
|
||||
[functions.node]
|
||||
version = 16
|
||||
version = 18
|
||||
|
||||
[auth]
|
||||
version = '0.20.2'
|
||||
|
||||
@@ -25,7 +25,7 @@ httpPoolSize = 100
|
||||
|
||||
[functions]
|
||||
[functions.node]
|
||||
version = 16
|
||||
version = 18
|
||||
|
||||
[auth]
|
||||
version = '0.20.1'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module.exports = {
|
||||
extends: ['../../config/.eslintrc.js', 'plugin:@next/next/recommended'],
|
||||
extends: ['../../../config/.eslintrc.js', 'plugin:@next/next/recommended'],
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off'
|
||||
}
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# @nhost-examples/nextjs-server-components
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/nhost-js@3.0.1
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bc9eff6e4]
|
||||
- @nhost/nhost-js@3.0.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e469628eb: fix: resolve issue with WebAuthn authentication
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [8b127fbb6]
|
||||
- @nhost/nhost-js@2.2.18
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost-examples/nextjs-server-components",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -16,6 +16,7 @@
|
||||
"eslint": "8.48.0",
|
||||
"eslint-config-next": "13.4.19",
|
||||
"form-data": "^4.0.0",
|
||||
"graphql": "16.7.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"next": "13.4.19",
|
||||
"postcss": "8.4.29",
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function SignInWithSecurityKey() {
|
||||
}
|
||||
|
||||
if (session) {
|
||||
Cookies.set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { sameSite: 'strict' })
|
||||
Cookies.set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { path: '/' })
|
||||
router.push('/protected/todos')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function SignUpWebAuthn() {
|
||||
})
|
||||
|
||||
if (session) {
|
||||
Cookies.set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { sameSite: 'strict' })
|
||||
Cookies.set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { path: '/' })
|
||||
router.push('/protected/todos')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getNhost } from '@utils/nhost'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
|
||||
const PAT = async ({
|
||||
const PATs = async ({
|
||||
params
|
||||
}: {
|
||||
params: {
|
||||
@@ -100,4 +100,4 @@ const PAT = async ({
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuthAsync(PAT)
|
||||
export default withAuthAsync(PATs)
|
||||
|
||||
@@ -54,6 +54,7 @@ const TodoItem = ({ todo }: { todo: Todo }) => {
|
||||
<Link
|
||||
className="w-6 h-6"
|
||||
target="_blank"
|
||||
passHref
|
||||
href={nhost.storage.getPublicUrl({ fileId: todo.attachment.id })}
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { manageAuthSession } from '@utils/nhost'
|
||||
|
||||
// eslint-disable-next-line @next/next/no-server-import-in-page
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { AuthErrorPayload, NhostClient, NhostSession } from '@nhost/nhost-js'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
// eslint-disable-next-line @next/next/no-server-import-in-page
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { type StateFrom } from 'xstate/lib/types'
|
||||
import { waitFor } from 'xstate/lib/waitFor'
|
||||
|
||||
export const NHOST_SESSION_KEY = 'nhost-session'
|
||||
export const NHOST_SESSION_KEY = 'nhostSession'
|
||||
|
||||
export const getNhost = async (request?: NextRequest) => {
|
||||
const $cookies = request?.cookies || cookies()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user