Compare commits

..

89 Commits

Author SHA1 Message Date
Hassan Ben Jobrane
72c81207ff Merge pull request #2201 from nhost/chore/add-missing-changeset
chore: add missing changeset
2023-08-24 16:47:41 +01:00
Hassan Ben Jobrane
5ca2a394e8 chore: sync version in package.json 2023-08-24 16:30:18 +01:00
Hassan Ben Jobrane
e63b8da58a chore: add missing changeset 2023-08-24 16:27:38 +01:00
Hassan Ben Jobrane
bf8543cd34 Merge pull request #2195 from nhost/changeset-release/main
chore: update versions
2023-08-24 13:57:34 +01:00
github-actions[bot]
8a557bbd02 chore: update versions 2023-08-24 12:21:34 +00:00
Hassan Ben Jobrane
327e30b859 Merge pull request #2200 from nhost/chore/ignore-version-update-sveltekit-example
chore: sveltekit-example: changeset ignore dep version update
2023-08-24 13:18:25 +01:00
Hassan Ben Jobrane
bbfaf9732b chore: sveltekit-example: ignore changeset dep version update 2023-08-24 12:44:16 +01:00
Hassan Ben Jobrane
c064a53256 Merge pull request #2199 from nhost/chore/fix-dep-version
chore: fix dep version for sveltekit example
2023-08-24 12:03:57 +01:00
Hassan Ben Jobrane
ebda86f1f0 chore: sync lockfile 2023-08-24 11:53:41 +01:00
Hassan Ben Jobrane
8948be9d3d chore: fix dep version for sveltekit example 2023-08-24 11:50:47 +01:00
Hassan Ben Jobrane
54e9b141f1 Merge pull request #2191 from nhost/dbarroso/react-example
chore: react-apollo-example: add profile to allowedUrls
2023-08-24 10:56:45 +01:00
Hassan Ben Jobrane
dba71483df chore: add changeset 2023-08-24 10:41:58 +01:00
Hassan Ben Jobrane
77ef68232a Merge pull request #2197 from nhost/fix/webauthn-error-handling
fix(hasura-auth-js): make sure CodifiedError works on non v8 browsers
2023-08-24 10:26:46 +01:00
Hassan Ben Jobrane
8fbc7f9f95 Merge pull request #2198 from nhost/chore/remove-facebook-login
chore(react-apollo-example): remove facebook login
2023-08-24 10:26:31 +01:00
Hassan Ben Jobrane
ca9f0f6ae9 chore: show error toast when adding a security key fails 2023-08-23 23:48:45 +01:00
Hassan Ben Jobrane
e819903f1b chore: add changeset 2023-08-23 17:00:30 +01:00
Hassan Ben Jobrane
f780b17581 chore: remove facebook login from react apollo example 2023-08-23 16:59:44 +01:00
Hassan Ben Jobrane
032c0bd217 chore: add changeset 2023-08-23 16:51:14 +01:00
Hassan Ben Jobrane
5d278709cb fix(hasura-auth-js): make sure CodifiedError works on non v8 browsers 2023-08-23 16:25:57 +01:00
Hassan Ben Jobrane
3a012e089a Merge pull request #2182 from nhost/feat/add-sveltekit-example
feat: add sveltekit example
2023-08-23 12:14:38 +01:00
Hassan Ben Jobrane
7aed620e12 chore: fix tests 2023-08-23 11:39:29 +01:00
Hassan Ben Jobrane
d9fd1a54a5 Merge pull request #2192 from nhost/changeset-release/main
chore: update versions
2023-08-23 11:19:48 +01:00
github-actions[bot]
a19b85c8ac chore: update versions 2023-08-23 09:45:31 +00:00
Hassan Ben Jobrane
4e1aaca0ee Merge pull request #2194 from nhost/feat/toggle-av
feat: toggle av
2023-08-23 10:42:20 +01:00
Hassan Ben Jobrane
34ef37cdce Merge pull request #2190 from dddenis/fix/storage-upload-status-error
fix(hasura-storage-js): fix upload response status code check
2023-08-23 10:40:29 +01:00
Hassan Ben Jobrane
5d6b655cb1 fix: make sure AV turns off correctly 2023-08-23 01:40:01 +01:00
Hassan Ben Jobrane
074a0fa111 chore: add changeset 2023-08-22 18:32:34 +01:00
Hassan Ben Jobrane
403d839fca chore: cleanup 2023-08-22 18:30:26 +01:00
Hassan Ben Jobrane
4e3098240b feat(settings): add toggle av settings 2023-08-22 18:28:27 +01:00
Hassan Ben Jobrane
dd0a5cf3c1 chore: fix lock file 2023-08-22 16:55:28 +01:00
Hassan Ben Jobrane
5187fd3a4b chore: dashboard tests 2023-08-22 16:49:26 +01:00
Hassan Ben Jobrane
d8dfd6bf80 Revert "chore: add missing dep for vitest"
This reverts commit 6ea6ad61db.
2023-08-22 16:16:20 +01:00
Hassan Ben Jobrane
6ea6ad61db chore: add missing dep for vitest 2023-08-22 16:04:24 +01:00
Hassan Ben Jobrane
fd0b904ed4 chore: fix dashboard e2e tests 2023-08-22 15:41:36 +01:00
Hassan Ben Jobrane
8989e314a6 fix: ignore conflict with linting and sveltekit build 2023-08-22 14:40:18 +01:00
Hassan Ben Jobrane
5b5a1219c5 fix: make sure linting runs correctly 2023-08-22 14:31:45 +01:00
Hassan Ben Jobrane
07fda9bbb3 Merge pull request #2193 from nhost/fix/distinguish-not-uploaded-files
fix: grey out not uploaded files
2023-08-22 13:11:52 +01:00
Hassan Ben Jobrane
2fa828fef1 chore: cleanup .gitignore file 2023-08-22 13:10:59 +01:00
Hassan Ben Jobrane
d5ec69ac37 chore(examples-sveltekit): add a basic test 2023-08-22 13:07:06 +01:00
Hassan Ben Jobrane
4a7ede11e9 chore: add changeset 2023-08-22 11:33:42 +01:00
Hassan Ben Jobrane
482ae4c4f1 fix: grey not uploaded files 2023-08-22 11:25:23 +01:00
Denis Goncharenko
08fe4cd65f fix(hasura-storage-js): update upload response error details 2023-08-22 11:34:41 +02:00
Hassan Ben Jobrane
5781721bca Merge pull request #2188 from nhost/feat/one-click-run-service
feat: add support for template run services
2023-08-22 10:18:54 +01:00
Denis Goncharenko
39de0063bf chore: add changeset 2023-08-21 20:49:56 +02:00
Hassan Ben Jobrane
202b647234 chore: add changeset 2023-08-21 15:45:43 +01:00
Hassan Ben Jobrane
51c163a268 fix: copy complete link to config 2023-08-21 15:43:46 +01:00
Hassan Ben Jobrane
6e802c9938 fix: handle the case where the config is not set in the URL 2023-08-21 13:38:38 +01:00
Hassan Ben Jobrane
9a46104e37 feat: replace project selector with a searchable list 2023-08-21 13:26:27 +01:00
Hassan Ben Jobrane
655b317c39 fix: keep image field when copying config to clipboard 2023-08-21 13:25:57 +01:00
Hassan Ben Jobrane
d3ad7c9d4a fix: handle error when navigating back when service form is still open 2023-08-21 13:25:01 +01:00
David Barroso
09fc852c3a asd 2023-08-21 13:13:41 +02:00
Hassan Ben Jobrane
ece08d3efd feat: add ability to copy current service config from the editor 2023-08-21 10:47:54 +01:00
Hassan Ben Jobrane
3493442c2d fix: fix command import when value is null 2023-08-21 10:47:26 +01:00
Denis Goncharenko
632a79b9e4 fix(hasura-storage-js): fix upload response status code check 2023-08-20 14:15:24 +02:00
Hassan Ben Jobrane
4a4d85757a fix: use serviceId to determine whether to create or update service 2023-08-19 19:33:45 +01:00
Hassan Ben Jobrane
88a01004b7 feat: parse base64 encoded config from query param 2023-08-19 18:50:46 +01:00
David Barroso
73230eb35a chore: fix and deploy react example (#2183) 2023-08-19 07:57:42 +02:00
Hassan Ben Jobrane
27e1c90624 fix: change env to dynamic 2023-08-18 18:26:09 +01:00
Hassan Ben Jobrane
1cc53d550a chore: add changeset 2023-08-18 18:04:12 +01:00
Hassan Ben Jobrane
22d3f71e02 fix: make sure to include lib folder in sveltekit example 2023-08-18 17:58:29 +01:00
Hassan Ben Jobrane
010b816866 chore: fix README 2023-08-18 17:36:10 +01:00
Hassan Ben Jobrane
4a6e62e673 feat: add sveltekit example 2023-08-18 17:16:13 +01:00
Hassan Ben Jobrane
5cf9dd9bc2 Merge pull request #2173 from nhost/changeset-release/main
chore: update versions
2023-08-14 11:34:45 +01:00
github-actions[bot]
27e74c10d7 chore: update versions 2023-08-10 11:05:13 +00:00
Hassan Ben Jobrane
bd807a5ee1 Merge pull request #2171 from nhost/feat/run-pricing
feat: add pricing info and confirmation dialog
2023-08-10 12:01:04 +01:00
Hassan Ben Jobrane
4093e03a13 Merge pull request #2170 from nhost/feat/multiline-env-input
fix: nhost run enhancements
2023-08-10 12:00:53 +01:00
Hassan Ben Jobrane
29076d0304 Merge pull request #2169 from nhost/fix/null-values-services-form
fix(services): handle null values when editing a service
2023-08-10 12:00:43 +01:00
Hassan Ben Jobrane
ab83fa6b5e fix: make sure vCPUs are shown correctly 2023-08-09 16:20:45 +01:00
Hassan Ben Jobrane
b20761e976 chore: add changeset 2023-08-09 14:55:31 +01:00
Hassan Ben Jobrane
a445e5b786 feat: add pricing info and confirmation dialog 2023-08-09 14:53:05 +01:00
Hassan Ben Jobrane
90df6d81d8 chore: add changeset 2023-08-08 18:04:16 +01:00
Hassan Ben Jobrane
aa85084675 chore: add changeset 2023-08-08 18:02:35 +01:00
Hassan Ben Jobrane
07ad470c0c fix: query service logs correctly 2023-08-08 18:00:25 +01:00
Hassan Ben Jobrane
fa6b58a9c5 feat: enable multiline support for environment value input 2023-08-08 17:50:07 +01:00
Hassan Ben Jobrane
acf55376ba fix(services): handle null values when editing a service 2023-08-08 15:42:09 +01:00
Hassan Ben Jobrane
b0a9798b04 Merge pull request #2168 from nhost/changeset-release/main
chore: update versions
2023-08-08 11:17:25 +01:00
github-actions[bot]
3952e87f01 chore: update versions 2023-08-07 17:53:16 +00:00
Hassan Ben Jobrane
b95ccf873d Merge pull request #2167 from nhost/fix/announcement-banner-text
fix: make announcement text white in dark mode
2023-08-07 18:50:41 +01:00
Hassan Ben Jobrane
8d7f84b8da chore: add changeset 2023-08-07 18:33:17 +01:00
Hassan Ben Jobrane
bd1b69bd75 fix: make announcement text white in dark mode 2023-08-07 12:17:18 +01:00
Hassan Ben Jobrane
84d5436634 Merge pull request #2165 from nhost/changeset-release/main
chore: update versions
2023-08-07 12:09:08 +01:00
github-actions[bot]
2325766c1d chore: update versions 2023-08-04 16:06:40 +00:00
Hassan Ben Jobrane
2c355eaae4 Merge pull request #2162 from nhost/fix/dedicated-resources-modal
fix: show zero values when dedicated resources is disabled
2023-08-04 17:03:08 +01:00
Hassan Ben Jobrane
9e26ed767e Merge pull request #2161 from nhost/fix/announcement-banner
fix: make announcement close properly
2023-08-04 17:02:58 +01:00
Hassan Ben Jobrane
abdb6c56f4 chore: increase e2e CI timeout 2023-08-03 17:18:03 +01:00
Hassan Ben Jobrane
3b75bfce27 chore: add changeset 2023-08-03 17:03:01 +01:00
Hassan Ben Jobrane
f498190758 chore: add changeset 2023-08-03 17:00:10 +01:00
Hassan Ben Jobrane
b4158fa513 fix: show zero values when dedicated resources is disabled 2023-08-03 16:54:12 +01:00
Hassan Ben Jobrane
3d1a177632 fix: make announcement close properly 2023-08-03 15:17:06 +01:00
89 changed files with 3151 additions and 258 deletions

View File

@@ -6,5 +6,5 @@
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
"ignore": ["@nhost-examples/sveltekit"]
}

View File

@@ -146,7 +146,7 @@ jobs:
run: echo "NHOST_TEST_DASHBOARD_URL=https://${{ steps.fetch-dashboard-preview-url.outputs.preview_url }}" >> $GITHUB_ENV
# * Run the `ci` script of the current package of the matrix. Dependencies build is cached by Turborepo
- name: Run e2e tests
timeout-minutes: 7
timeout-minutes: 15
run: pnpm --filter="${{ matrix.package.name }}" run e2e
- id: file-name
if: ${{ failure() }}

3
.gitignore vendored
View File

@@ -19,10 +19,8 @@ logs/
coverage/
dist/
umd/
lib/
node_modules/
tmp/
.docz/
.pnpm-store
.turbo
.env
@@ -32,7 +30,6 @@ out/
# Custom
*.min.js
*.map
todo.md
# Config files that are not part of the repository root anymore. Should be removed in the future.
/.eslintignore

View File

@@ -1,5 +1,44 @@
# @nhost/dashboard
## 0.20.8
### Patch Changes
- @nhost/react-apollo@5.0.34
- @nhost/nextjs@1.13.36
## 0.20.7
### Patch Changes
- 4a7ede11e: fix: distinguish files that were not uploaded
- 202b64723: feat(nhost-run): add support for one-click-install run services
- 074a0fa11: feat(dashboard): add settings toggle to enable/disable antivirus
- @nhost/react-apollo@5.0.33
- @nhost/nextjs@1.13.35
## 0.20.6
### Patch Changes
- b20761e97: feat(services): add pricing info and confirmation dialog
- 90df6d81d: fix(services): handle null values when editing a service
- aa8508467: fix: query service logs correctly
feat: enable multiline support for environment value input
## 0.20.5
### Patch Changes
- 8d7f84b8d: fix: make announcement adapt to theme
## 0.20.4
### Patch Changes
- 3b75bfce2: fix: make announcement close properly
- f49819075: fix: show correct values when dedicated resources are disabled
## 0.20.3
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/dashboard",
"version": "0.20.3",
"version": "0.20.8",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -106,6 +106,7 @@
"@testing-library/user-event": "^14.4.3",
"@types/ace": "^0.0.48",
"@types/bcryptjs": "^2.4.2",
"@types/jest": "^29.5.3",
"@types/lodash.debounce": "^4.0.7",
"@types/node": "^16.11.7",
"@types/pluralize": "^0.0.30",

View File

@@ -1,7 +1,7 @@
import { Button } from '@/components/ui/v2/Button';
import { ArrowRightIcon } from '@/components/ui/v2/icons/ArrowRightIcon';
import { XIcon } from '@/components/ui/v2/icons/XIcon';
import Link from 'next/link';
import { Text } from '@/components/ui/v2/Text';
import { forwardRef, type ForwardedRef } from 'react';
import { twMerge } from 'tailwind-merge';
import AnnouncementContainer, {
@@ -38,11 +38,11 @@ function Announcement(
<span />
<div className="flex items-center self-center truncate">
<Link href={href}>
<span className="cursor-pointer truncate hover:underline">
<a href={href}>
<Text className="cursor-pointer truncate hover:underline">
{children}
</span>
</Link>
</Text>
</a>
<ArrowRightIcon className="ml-1 h-4 w-4 text-white" />
</div>

View File

@@ -1,3 +1,4 @@
import { Divider } from '@/components/ui/v2/Divider';
import {
createContext,
useEffect,
@@ -35,7 +36,12 @@ export interface AnnouncementContextProps {
}
// Note: You can define the active announcement here.
let announcement: AnnouncementType;
const announcement: AnnouncementType = {
id: 'nhost-run',
href: 'https://discord.com/invite/9V7Qb2U',
content:
'Now you can bring custom and third-party OSS services to run alongside your Nhost projects',
};
export const AnnouncementContext = createContext<AnnouncementContextProps>({});
@@ -68,9 +74,16 @@ export default function AnnouncementProvider({ children }: PropsWithChildren) {
return (
<AnnouncementContext.Provider value={announcementValue}>
{announcement && showAnnouncement && (
<Announcement ref={ref} href={announcement.href} onClose={handleClose}>
{announcement.content}
</Announcement>
<>
<Announcement
ref={ref}
href={announcement.href}
onClose={handleClose}
>
{announcement.content}
</Announcement>
<Divider />
</>
)}
{children}

View File

@@ -1,2 +1,3 @@
export * from './Announcement';
export * from './AnnouncementProvider';
export { default as useAnnouncement } from './useAnnouncement';

View File

@@ -0,0 +1,14 @@
import { useContext } from 'react';
import { AnnouncementContext } from './AnnouncementProvider';
export default function useAnnouncement() {
const context = useContext(AnnouncementContext);
if (!context) {
throw new Error(
'useAnnouncement must be used within an AnnouncementProvider',
);
}
return context;
}

View File

@@ -178,6 +178,22 @@ export default function DataGridBody<T extends object>({
}
}
const getBackgroundCellColor = (
row: Row<T>,
column: DataBrowserGridColumn<T>,
) => {
// Grey out files not uploaded
if (!row.values.isUploaded) {
return 'grey.200';
}
if (column.isDisabled) {
return 'grey.100';
}
return 'background.paper';
};
return (
<div {...getTableBodyProps()} ref={bodyRef} {...props}>
{rows.length === 0 && !loading && (
@@ -260,9 +276,7 @@ export default function DataGridBody<T extends object>({
})}
cell={cell}
sx={{
backgroundColor: column.isDisabled
? 'grey.100'
: 'background.paper',
backgroundColor: getBackgroundCellColor(row, column),
color: isCellDisabled ? 'text.secondary' : 'text.primary',
}}
className={twMerge(

View File

@@ -1,4 +1,3 @@
import Announcement from '@/components/common/Announcement/Announcement';
import { FeedbackForm } from '@/components/common/FeedbackForm';
import { NavLink } from '@/components/common/NavLink';
import { AccountMenu } from '@/components/layout/AccountMenu';
@@ -8,7 +7,6 @@ import { MobileNav } from '@/components/layout/MobileNav';
import { Logo } from '@/components/presentational/Logo';
import { Box } from '@/components/ui/v2/Box';
import { Chip } from '@/components/ui/v2/Chip';
import { Divider } from '@/components/ui/v2/Divider';
import { Dropdown } from '@/components/ui/v2/Dropdown';
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
@@ -47,75 +45,66 @@ export default function Header({ className, ...props }: HeaderProps) {
}, [isProjectUpdating, refetchProject]);
return (
<div>
<Announcement href="https://discord.com/invite/9V7Qb2U">
You can now run custom and third-party OSS services alongside your Nhost
projects, please reach out to us to get access to the private beta
</Announcement>
<Box
component="header"
className={twMerge(
'z-40 grid w-full transform-gpu grid-flow-col items-center justify-between gap-2 border-b-1 px-4 py-3',
className,
)}
sx={{ backgroundColor: 'background.paper' }}
{...props}
>
<div className="grid grid-flow-col items-center gap-3 ">
<NavLink href="/" className="w-12">
<Logo className="mx-auto cursor-pointer" />
</NavLink>
<Divider />
<Box
component="header"
className={twMerge(
'z-40 grid w-full transform-gpu grid-flow-col items-center justify-between gap-2 border-b-1 px-4 py-3',
className,
{(router.query.workspaceSlug || router.query.appSlug) && (
<Breadcrumbs aria-label="Workspace breadcrumbs" />
)}
sx={{ backgroundColor: 'background.paper' }}
{...props}
>
<div className="grid grid-flow-col items-center gap-3 ">
<NavLink href="/" className="w-12">
<Logo className="mx-auto cursor-pointer" />
</NavLink>
{(router.query.workspaceSlug || router.query.appSlug) && (
<Breadcrumbs aria-label="Workspace breadcrumbs" />
)}
{isProjectUpdating && (
<Chip size="small" label="Updating" color="warning" />
)}
</div>
{isProjectUpdating && (
<Chip size="small" label="Updating" color="warning" />
)}
</div>
<div className="hidden grid-flow-col items-center gap-2 sm:grid">
{isPlatform && (
<Dropdown.Root>
<Dropdown.Trigger
hideChevron
className="rounded-md px-2.5 py-1.5 text-sm motion-safe:transition-colors"
>
Feedback
</Dropdown.Trigger>
<div className="hidden grid-flow-col items-center gap-2 sm:grid">
{isPlatform && (
<Dropdown.Root>
<Dropdown.Trigger
hideChevron
className="rounded-md px-2.5 py-1.5 text-sm motion-safe:transition-colors"
>
Feedback
</Dropdown.Trigger>
<Dropdown.Content
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<FeedbackForm className="max-w-md" />
</Dropdown.Content>
</Dropdown.Root>
)}
<Dropdown.Content
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<FeedbackForm className="max-w-md" />
</Dropdown.Content>
</Dropdown.Root>
)}
<NavLink
underline="none"
href="https://docs.nhost.io"
className="mr-2 rounded-md px-2.5 py-1.5 text-sm motion-safe:transition-colors"
sx={{
color: 'text.primary',
'&:hover': { backgroundColor: 'grey.200' },
}}
target="_blank"
rel="noopener noreferrer"
>
Docs
</NavLink>
<NavLink
underline="none"
href="https://docs.nhost.io"
className="mr-2 rounded-md px-2.5 py-1.5 text-sm motion-safe:transition-colors"
sx={{
color: 'text.primary',
'&:hover': { backgroundColor: 'grey.200' },
}}
target="_blank"
rel="noopener noreferrer"
>
Docs
</NavLink>
{isPlatform ? <AccountMenu /> : <LocalAccountMenu />}
</div>
{isPlatform ? <AccountMenu /> : <LocalAccountMenu />}
</div>
<MobileNav className="sm:hidden" />
</Box>
</div>
<MobileNav className="sm:hidden" />
</Box>
);
}

View File

@@ -2,8 +2,9 @@ import permissionVariablesQuery from '@/tests/msw/mocks/graphql/permissionVariab
import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
import { render, screen } from '@/tests/testUtils';
import '@testing-library/jest-dom';
import { setupServer } from 'msw/node';
import { test, vi } from 'vitest';
import { afterAll, afterEach, beforeAll, test, vi } from 'vitest';
import ColumnAutocomplete from './ColumnAutocomplete';
const server = setupServer(

View File

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

View File

@@ -0,0 +1,12 @@
import { useEffect, useState } from 'react';
export default function useHostName() {
const [hostName, setHostName] = useState('');
useEffect(() => {
const { port, hostname, protocol } = window.location;
setHostName(`${protocol}//${hostname}:${port}`);
}, []);
return hostName;
}

View File

@@ -154,10 +154,12 @@ export default function LogsHeader({
const services = data.app?.runServices ?? [];
setRunServices(
services.map((s) => ({
label: s.config.name,
value: s.config.name,
})),
services
.filter((s) => !!s.config?.name)
.map((s) => ({
label: s.config.name,
value: `run-${s.config.name}`,
})),
);
}
}, [loading, data]);

View File

@@ -1,4 +1,5 @@
import type { PermissionVariable } from '@/types/application';
import { expect, test } from 'vitest';
import getAllPermissionVariables from './getAllPermissionVariables';
test('should convert permission variable object to array', () => {

View File

@@ -68,11 +68,15 @@ export default function ResourcesConfirmationDialog({
const totalBillableVCPU = formValues.enabled ? billableResources.vcpu : 0;
const totalBillableMemory = formValues.enabled ? billableResources.memory : 0;
const updatedPrice =
Math.max(
priceForTotalAvailableVCPU,
(billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE,
) + proPlan.price;
const { enabled } = formValues;
const updatedPrice = enabled
? Math.max(
priceForTotalAvailableVCPU,
(billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) *
RESOURCE_VCPU_PRICE,
) + proPlan.price
: proPlan.price;
if (!loading && !proPlan) {
return (
@@ -86,18 +90,30 @@ export default function ResourcesConfirmationDialog({
throw error;
}
const databaseVCPU = enabled ? formValues.database.vcpu : 0;
const databaseMemory = enabled ? formValues.database.memory : 0;
const hasuraVCPU = enabled ? formValues.hasura.vcpu : 0;
const hasuraMemory = enabled ? formValues.hasura.memory : 0;
const authVCPU = enabled ? formValues.auth.vcpu : 0;
const authMemory = enabled ? formValues.auth.memory : 0;
const storageVCPU = enabled ? formValues.storage.vcpu : 0;
const storageMemory = enabled ? formValues.storage.memory : 0;
const databaseResources = `${prettifyVCPU(
formValues.database.vcpu,
)} vCPU + ${prettifyMemory(formValues.database.memory)}`;
const hasuraResources = `${prettifyVCPU(
formValues.hasura.vcpu,
)} vCPU + ${prettifyMemory(formValues.hasura.memory)}`;
const authResources = `${prettifyVCPU(
formValues.auth.vcpu,
)} vCPU + ${prettifyMemory(formValues.auth.memory)}`;
databaseVCPU,
)} vCPU + ${prettifyMemory(databaseMemory)}`;
const hasuraResources = `${prettifyVCPU(hasuraVCPU)} vCPU + ${prettifyMemory(
hasuraMemory,
)}`;
const authResources = `${prettifyVCPU(authVCPU)} vCPU + ${prettifyMemory(
authMemory,
)}`;
const storageResources = `${prettifyVCPU(
formValues.storage.vcpu,
)} vCPU + ${prettifyMemory(formValues.storage.memory)}`;
storageVCPU,
)} vCPU + ${prettifyMemory(storageMemory)}`;
return (
<div className="grid grid-flow-row gap-6 px-6 pb-6">

View File

@@ -1,4 +1,4 @@
import { test } from 'vitest';
import { expect, test } from 'vitest';
import getAllocatedResources from './getAllocatedResources';
test('should return the total number of allocated resources', () => {

View File

@@ -155,3 +155,4 @@ export const MIN_SERVICES_CPU = Math.floor(128 / MEM_CPU_RATIO);
export const MIN_SERVICES_MEM = 128;
export const MAX_SERVICES_CPU = 7000;
export const MAX_SERVICES_MEM = Math.floor(MAX_SERVICES_CPU * MEM_CPU_RATIO);
export const COST_PER_VCPU = 0.05;

View File

@@ -3,13 +3,17 @@ 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 { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
import { Input } from '@/components/ui/v2/Input';
import { Text } from '@/components/ui/v2/Text';
import { Tooltip } from '@/components/ui/v2/Tooltip';
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
import { useHostName } from '@/features/projects/common/hooks/useHostName';
import { InfoCard } from '@/features/projects/overview/components/InfoCard';
import {
COST_PER_VCPU,
MAX_SERVICES_CPU,
MAX_SERVICES_MEM,
MAX_SERVICE_REPLICAS,
@@ -22,7 +26,9 @@ import { PortsFormSection } from '@/features/services/components/ServiceForm/com
import { ReplicasFormSection } from '@/features/services/components/ServiceForm/components/ReplicasFormSection';
import { StorageFormSection } from '@/features/services/components/ServiceForm/components/StorageFormSection';
import type { DialogFormProps } from '@/types/common';
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
import { getToastStyleProps } from '@/utils/constants/settings';
import { copy } from '@/utils/copy';
import {
useInsertRunServiceConfigMutation,
useInsertRunServiceMutation,
@@ -36,6 +42,7 @@ import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { parse } from 'shell-quote';
import * as Yup from 'yup';
import { ServiceConfirmationDialog } from './components/ServiceConfirmationDialog';
export enum PortTypes {
HTTP = 'http',
@@ -106,7 +113,8 @@ export default function ServiceForm({
onCancel,
location,
}: ServiceFormProps) {
const { onDirtyStateChange } = useDialog();
const hostName = useHostName();
const { onDirtyStateChange, openDialog, closeDialog } = useDialog();
const [insertRunService] = useInsertRunServiceMutation();
const { currentProject } = useCurrentWorkspaceAndProject();
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation();
@@ -133,6 +141,8 @@ export default function ServiceForm({
formState: { errors, isSubmitting, dirtyFields },
} = form;
const formValues = watch();
const serviceImage = watch('image');
const isDirty = Object.keys(dirtyFields).length > 0;
@@ -141,7 +151,7 @@ export default function ServiceForm({
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
const createOrUpdateService = async (values: ServiceFormValues) => {
const getFormattedConfig = (values: ServiceFormValues) => {
const config: ConfigRunServiceConfigInsertInput = {
name: values.name,
image: {
@@ -171,7 +181,13 @@ export default function ServiceForm({
})),
};
if (initialData) {
return config;
};
const createOrUpdateService = async (values: ServiceFormValues) => {
const config = getFormattedConfig(values);
if (serviceID) {
// Update service config
await replaceRunServiceConfig({
variables: {
@@ -246,10 +262,47 @@ export default function ServiceForm({
}
};
const handleConfirm = (values: ServiceFormValues) => {
openDialog({
title: 'Confirm Resources',
component: (
<ServiceConfirmationDialog
formValues={values}
onCancel={closeDialog}
onSubmit={async () => {
await handleSubmit(formValues);
}}
/>
),
});
};
const pricingExplanation = () => {
const vCPUs = `${formValues.compute.cpu / RESOURCE_VCPU_MULTIPLIER} vCPUs`;
const mem = `${formValues.compute.memory} MiB Mem`;
let details = `${vCPUs} + ${mem}`;
if (formValues.replicas > 1) {
details = `(${details}) x ${formValues.replicas} replicas`;
}
return `Approximate cost for ${details}`;
};
const copyConfig = () => {
const config = getFormattedConfig(formValues);
const base64Config = btoa(JSON.stringify(config));
const link = `${hostName}/run-one-click-install?config=${base64Config}`;
copy(link, 'Service Config');
};
return (
<FormProvider {...form}>
<Form
onSubmit={handleSubmit}
onSubmit={handleConfirm}
className="grid grid-flow-row gap-4 px-6 pb-6"
>
<Input
@@ -346,6 +399,23 @@ export default function ServiceForm({
autoComplete="off"
/>
<Alert
severity="info"
className="flex items-center justify-between space-x-2"
>
<span>{pricingExplanation()}</span>
<b>
$
{parseFloat(
(
formValues.compute.cpu *
formValues.replicas *
COST_PER_VCPU
).toFixed(2),
)}
</b>
</Alert>
<ComputeFormSection />
<ReplicasFormSection />
@@ -378,9 +448,24 @@ export default function ServiceForm({
</Alert>
)}
<div className="grid grid-flow-row gap-2">
<Button type="submit" disabled={isSubmitting}>
{initialData ? 'Update' : 'Create'}
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
type="submit"
disabled={isSubmitting}
startIcon={<PlusIcon />}
>
{serviceID ? 'Update' : 'Create'}
</Button>
<Button
color="secondary"
variant="outlined"
disabled={isSubmitting}
onClick={copyConfig}
startIcon={<CopyIcon />}
>
Copy one-click install link
</Button>
</div>
<Button variant="outlined" color="secondary" onClick={onCancel}>
Cancel

View File

@@ -48,7 +48,8 @@ export default function ComputeFormSection() {
<Box className="space-y-4 rounded border-1 p-4">
<Box className="flex flex-row items-center space-x-2">
<Text variant="h4" className="font-semibold">
vCPUs: {formValues.compute.cpu} / Memory: {formValues.compute.memory}
vCPUs: {formValues.compute.cpu / 1000} / Memory:{' '}
{formValues.compute.memory}
</Text>
<Tooltip

View File

@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/v2/Input';
import { Text } from '@/components/ui/v2/Text';
import { Tooltip } from '@/components/ui/v2/Tooltip';
import type { ServiceFormValues } from '@/features/services/components/ServiceForm';
import { useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
export default function EnvironmentFormSection() {
@@ -15,6 +16,8 @@ export default function EnvironmentFormSection() {
formState: { errors },
} = useFormContext<ServiceFormValues>();
const [focusedInput, setFocusedInput] = useState<string>(null);
const { fields, append, remove } = useFieldArray({
name: 'environment',
});
@@ -49,42 +52,42 @@ export default function EnvironmentFormSection() {
<Box className="flex flex-col space-y-4">
{fields.map((field, index) => (
<Box
key={field.id}
className="flex w-full flex-col space-y-2 xs+:flex-row xs+:space-y-0 xs+:space-x-2"
>
<Input
{...register(`environment.${index}.name`)}
id={`${field.id}-name`}
label={!index && 'Name'}
placeholder={`Key ${index}`}
className="w-full"
hideEmptyHelperText
error={!!errors?.environment?.at(index)}
helperText={errors?.environment?.at(index)?.message}
fullWidth
autoComplete="off"
/>
<Input
{...register(`environment.${index}.value`)}
id={`${field.id}-value`}
label={!index && 'Value'}
placeholder={`Value ${index}`}
className="w-full"
hideEmptyHelperText
error={!!errors?.environment?.at(index)}
helperText={errors?.environment?.at(index)?.message}
fullWidth
autoComplete="off"
/>
<Box key={field.id} className="flex w-full items-center space-x-2">
<div className="flex w-full flex-col space-y-2">
<Input
{...register(`environment.${index}.name`)}
id={`${field.id}-name`}
placeholder={`Key ${index}`}
className="w-full"
hideEmptyHelperText
error={!!errors?.environment?.at(index)}
helperText={errors?.environment?.at(index)?.message}
fullWidth
autoComplete="off"
/>
<Input
{...register(`environment.${index}.value`)}
id={`${field.id}-value`}
placeholder={`Value ${index}`}
className="w-full"
hideEmptyHelperText
error={!!errors?.environment?.at(index)}
helperText={errors?.environment?.at(index)?.message}
fullWidth
autoComplete="off"
multiline
maxRows={focusedInput === `${field.id}-value` ? 1000 : 1}
onFocusCapture={() => setFocusedInput(`${field.id}-value`)}
onBlurCapture={() => setFocusedInput(null)}
/>
</div>
<Button
variant="borderless"
className=""
color="error"
onClick={() => remove(index)}
>
<TrashIcon className="h-4 w-4" />
<TrashIcon className="h-6 w-4" />
</Button>
</Box>
))}

View File

@@ -45,7 +45,7 @@ export default function PortsFormSection() {
const port = Number(_port) > 0 ? Number(_port) : '[port]';
const name = _name && _name.length > 0 ? _name : '[name]';
return `https://${currentProject.subdomain}-${name}-${port}.svc.${currentProject.region.awsName}.${currentProject.region.domain}`;
return `https://${currentProject?.subdomain}-${name}-${port}.svc.${currentProject?.region.awsName}.${currentProject?.region.domain}`;
};
return (

View File

@@ -0,0 +1,87 @@
import { Box } from '@/components/ui/v2/Box';
import { Button } from '@/components/ui/v2/Button';
import { Divider } from '@/components/ui/v2/Divider';
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
import { Text } from '@/components/ui/v2/Text';
import { Tooltip } from '@/components/ui/v2/Tooltip';
import { COST_PER_VCPU } from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
import type { ServiceFormValues } from '@/features/services/components/ServiceForm';
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
export interface ServiceConfirmationDialogProps {
/**
* The updated resources that the user has selected.
*/
formValues: ServiceFormValues;
/**
* Function to be called when the user clicks the cancel button.
*/
onCancel: () => void;
/**
* Function to be called when the user clicks the confirm button.
*/
onSubmit: () => Promise<void>;
}
export default function ServiceConfirmationDialog({
formValues,
onCancel,
onSubmit,
}: ServiceConfirmationDialogProps) {
const approximatePriceForService = parseFloat(
(formValues.compute.cpu * formValues.replicas * COST_PER_VCPU).toFixed(2),
);
return (
<div className="grid grid-flow-row gap-6 px-6 pb-6">
<Box className="grid grid-flow-row gap-4">
<Box className="grid grid-flow-row gap-1.5">
<Box className="grid grid-flow-col items-center justify-between gap-2">
<Box className="grid grid-flow-row gap-0.5">
<Text color="secondary">vCPUs</Text>
</Box>
<Text>{formValues.compute.cpu / RESOURCE_VCPU_MULTIPLIER}</Text>
</Box>
<Box className="grid grid-flow-col items-center justify-between gap-2">
<Box className="grid grid-flow-row gap-0.5">
<Text color="secondary">Memory</Text>
</Box>
<Text>{formValues.compute.memory} MiB</Text>
</Box>
<Box className="grid grid-flow-col items-center justify-between gap-2">
<Box className="grid grid-flow-row gap-0.5">
<Text color="secondary">Replicas</Text>
</Box>
<Text>{formValues.replicas}</Text>
</Box>
</Box>
<Divider />
<Box className="grid grid-flow-col justify-between gap-2">
<Box className="grid grid-flow-col items-center gap-1.5">
<Text className="font-medium">Approximate Cost</Text>
<Tooltip title="$0.0012/minute for every 1 vCPU and 2 GiB of RAM">
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
</Tooltip>
</Box>
<Text>${approximatePriceForService}/mo</Text>
</Box>
</Box>
<Box className="grid grid-flow-row gap-2">
<Button color="primary" onClick={onSubmit} autoFocus>
Confirm
</Button>
<Button variant="borderless" color="secondary" onClick={onCancel}>
Cancel
</Button>
</Box>
</div>
);
}

View File

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

View File

@@ -77,17 +77,17 @@ export default function ServicesList({
...service.config,
image: service.config?.image?.image,
command: service.config?.command?.join(' '),
ports: service.config?.ports.map((item) => ({
ports: service.config?.ports?.map((item) => ({
port: item.port,
type: item.type as PortTypes,
publish: item.publish,
})),
compute: service.config?.resources.compute ?? {
compute: service.config?.resources?.compute ?? {
cpu: 62,
memory: 128,
},
replicas: service.config?.resources.replicas,
storage: service.config?.resources.storage,
replicas: service.config?.resources?.replicas,
storage: service.config?.resources?.storage,
}}
onSubmit={() => onCreateOrUpdate()}
/>

View File

@@ -0,0 +1,121 @@
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 { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
import {
GetHasuraSettingsDocument,
useGetStorageSettingsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { getToastStyleProps } from '@/utils/constants/settings';
import { getServerError } from '@/utils/getServerError';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
const validationSchema = Yup.object({
enabled: Yup.boolean(),
});
export type HasuraStorageAVFormValues = Yup.InferType<typeof validationSchema>;
export default function HasuraStorageAVSettings() {
const { maintenanceActive } = useUI();
const { currentProject, refetch: refetchWorkspaceAndProject } =
useCurrentWorkspaceAndProject();
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetHasuraSettingsDocument],
});
const { data, loading, error } = useGetStorageSettingsQuery({
variables: { appId: currentProject?.id },
fetchPolicy: 'cache-first',
});
const { server } = data?.config?.storage?.antivirus || {};
const form = useForm<HasuraStorageAVFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
enabled: !!server,
},
resolver: yupResolver(validationSchema),
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading AV settings..."
className="justify-center"
/>
);
}
if (error) {
throw error;
}
async function handleSubmit(formValues: HasuraStorageAVFormValues) {
let antivirus = null;
if (formValues.enabled) {
antivirus = {
server: 'tcp://run-clamav:3310',
};
}
const updateConfigPromise = updateConfig({
variables: {
appId: currentProject.id,
config: {
storage: {
antivirus,
},
},
},
});
try {
await toast.promise(
updateConfigPromise,
{
loading: `Antivirus settings are being updated...`,
success: `Antivirus settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update Antivirus settings.`,
),
},
getToastStyleProps(),
);
form.reset(formValues);
await refetchWorkspaceAndProject();
} catch {
// Note: The toast will handle the error.
}
}
return (
<FormProvider {...form}>
<Form onSubmit={handleSubmit}>
<SettingsContainer
title="Antivirus"
description="Enable or disable Antivirus."
slotProps={{
submitButton: {
disabled: !form.formState.isDirty || maintenanceActive,
loading: form.formState.isSubmitting,
},
}}
switchId="enabled"
docsTitle="enabling or disabling Antivirus"
showSwitch
className="hidden"
/>
</Form>
</FormProvider>
);
}

View File

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

View File

@@ -4,6 +4,9 @@ query GetStorageSettings($appId: uuid!) {
__typename
storage {
version
antivirus {
server
}
}
}
}

View File

@@ -13,20 +13,35 @@ import type { GetRunServicesQuery } from '@/utils/__generated__/graphql';
import { useGetRunServicesQuery } from '@/utils/__generated__/graphql';
import { UpgradeNotification } from '@/features/projects/common/components/UpgradeNotification';
import { ServiceForm } from '@/features/services/components/ServiceForm';
import {
ServiceForm,
type PortTypes,
} from '@/features/services/components/ServiceForm';
import ServicesList from '@/features/services/components/ServicesList/ServicesList';
import { useRouter } from 'next/router';
import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
} from 'react';
export type RunService = Omit<
GetRunServicesQuery['app']['runServices'][0],
'__typename'
>;
export type RunServiceConfig = Omit<
GetRunServicesQuery['app']['runServices'][0]['config'],
'__typename'
>;
export default function ServicesPage() {
const limit = useRef(25);
const router = useRouter();
const { openDrawer } = useDialog();
const { openDrawer, openAlertDialog } = useDialog();
const { currentProject } = useCurrentWorkspaceAndProject();
const isPlanFree = currentProject.plan.isFree;
@@ -66,6 +81,63 @@ export default function ServicesPage() {
[data],
);
const checkConfigFromQuery = useCallback(
(base64Config: string) => {
if (router.query?.config) {
try {
const decodedConfig = atob(base64Config);
const parsedConfig: RunServiceConfig = JSON.parse(decodedConfig);
openDrawer({
title: (
<Box className="flex flex-row items-center space-x-2">
<CubeIcon className="h-5 w-5" />
<Text>Create a new run service</Text>
</Box>
),
component: (
<ServiceForm
initialData={{
...parsedConfig,
compute: parsedConfig?.resources?.compute ?? {
cpu: 62,
memory: 128,
},
image: parsedConfig?.image?.image,
command: parsedConfig?.command?.join(' '),
ports: parsedConfig?.ports.map((item) => ({
port: item.port,
type: item.type as PortTypes,
publish: item.publish,
})),
replicas: parsedConfig?.resources?.replicas,
storage: parsedConfig?.resources?.storage,
}}
onSubmit={refetchServices}
/>
),
});
} catch (error) {
openAlertDialog({
title: 'Configuration not set properly',
payload: 'The service configuration was not properly encoded',
props: {
primaryButtonText: 'Ok',
hideSecondaryAction: true,
},
});
}
}
},
[router.query.config, openDrawer, refetchServices, openAlertDialog],
);
useEffect(() => {
if (router.query?.config) {
checkConfigFromQuery(router.query?.config as string);
}
}, [checkConfigFromQuery, router.query]);
const openCreateServiceDialog = () => {
openDrawer({
title: (

View File

@@ -3,6 +3,7 @@ import { SettingsLayout } from '@/components/layout/SettingsLayout';
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
import { StorageServiceVersionSettings } from '@/features/storage/settings/components/HasuraServiceVersionSettings';
import { HasuraStorageAVSettings } from '@/features/storage/settings/components/HasuraStorageAVSettings';
import { useGetStorageSettingsQuery } from '@/utils/__generated__/graphql';
import type { ReactElement } from 'react';
@@ -34,6 +35,7 @@ export default function StorageSettingsPage() {
rootClassName="bg-transparent"
>
<StorageServiceVersionSettings />
<HasuraStorageAVSettings />
</Container>
);
}

View File

@@ -0,0 +1,218 @@
import { useDialog } from '@/components/common/DialogProvider';
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
import { Container } from '@/components/layout/Container';
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
import { Box } from '@/components/ui/v2/Box';
import { Button } from '@/components/ui/v2/Button';
import { Input } from '@/components/ui/v2/Input';
import { List } from '@/components/ui/v2/List';
import { ListItem } from '@/components/ui/v2/ListItem';
import { Text } from '@/components/ui/v2/Text';
import { InfoCard } from '@/features/projects/overview/components/InfoCard';
import {
useGetAllWorkspacesAndProjectsQuery,
type GetAllWorkspacesAndProjectsQuery,
} from '@/utils/__generated__/graphql';
import { Divider } from '@mui/material';
import { useUserData } from '@nhost/nextjs';
import debounce from 'lodash.debounce';
import Image from 'next/image';
import { useRouter } from 'next/router';
import type { ChangeEvent, ReactElement } from 'react';
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
type Workspace = Omit<
GetAllWorkspacesAndProjectsQuery['workspaces'][0],
'__typename'
>;
export default function SelectWorkspaceAndProject() {
const user = useUserData();
const router = useRouter();
const { openAlertDialog } = useDialog();
const { data, loading } = useGetAllWorkspacesAndProjectsQuery({
skip: !user,
});
const workspaces: Workspace[] = data?.workspaces || [];
const projects = workspaces.flatMap((workspace) =>
workspace.projects.map((project) => ({
workspaceName: workspace.name,
projectName: project.name,
value: `${workspace.slug}/${project.slug}`,
isFree: project.plan.isFree,
})),
);
const [filter, setFilter] = useState('');
const handleFilterChange = useMemo(
() =>
debounce((event: ChangeEvent<HTMLInputElement>) => {
setFilter(event.target.value);
}, 200),
[],
);
useEffect(() => () => handleFilterChange.cancel(), [handleFilterChange]);
const checkConfigFromQuery = useCallback(
(base64Config: string) => {
try {
JSON.parse(atob(base64Config));
} catch (error) {
openAlertDialog({
title: 'Configuration not set properly',
payload:
'Either the link is wrong or the configuration is not properly encoded',
props: {
primaryButtonText: 'Ok',
hideSecondaryAction: true,
onPrimaryAction: async () => {
await router.push('/');
},
},
});
}
},
[openAlertDialog, router],
);
useEffect(() => {
checkConfigFromQuery(router.query?.config as string);
}, [checkConfigFromQuery, router.query]);
const goToServices = async (project: {
workspaceName: string;
projectName: string;
value: string;
isFree: boolean;
}) => {
if (!project) {
openAlertDialog({
title: 'Please select a workspace and a project',
payload:
'You must select a workspace and a project before proceeding to create the run service',
props: {
primaryButtonText: 'Ok',
hideSecondaryAction: true,
},
});
return;
}
if (project.isFree) {
openAlertDialog({
title: 'The project must have a pro plan',
payload: 'Creating run services is only availabel for pro projects',
props: {
primaryButtonText: 'Ok',
hideSecondaryAction: true,
},
});
return;
}
await router.push({
pathname: `/${project.value}/services`,
// Keep the same query params that got us here
query: router.query,
});
};
const projectsToDisplay = filter
? projects.filter((project) =>
project.projectName.toLowerCase().includes(filter.toLowerCase()),
)
: projects;
if (loading) {
return (
<ActivityIndicator
delay={500}
label="Loading workspaces and projects..."
/>
);
}
return (
<Container>
<div className="mx-auto grid max-w-[760px] grid-flow-row gap-4 py-6 sm:py-14">
<Text variant="h2" component="h1" className="">
New Run Service
</Text>
<InfoCard
title="Please select the workspace and the project where you want to create the service"
disableCopy
value=""
/>
<div>
<div className="mb-2 flex w-full">
<Input
placeholder="Search..."
onChange={handleFilterChange}
fullWidth
autoFocus
/>
</div>
<RetryableErrorBoundary>
{projectsToDisplay.length === 0 ? (
<Box className="h-import py-2">
<Text variant="subtitle2">No results found.</Text>
</Box>
) : (
<List className="h-import overflow-y-auto">
{projectsToDisplay.map((project, index) => (
<Fragment key={project.value}>
<ListItem.Root
className="grid grid-flow-col justify-start gap-2 py-2.5"
secondaryAction={
<Button
variant="borderless"
color="primary"
onClick={() => goToServices(project)}
>
Proceed
</Button>
}
>
<ListItem.Avatar>
<span className="inline-block h-6 w-6 overflow-hidden rounded-md">
<Image
src="/logos/new.svg"
alt="Nhost Logo"
width={24}
height={24}
/>
</span>
</ListItem.Avatar>
<ListItem.Text
primary={project.projectName}
secondary={`${project.workspaceName} / ${project.projectName}`}
/>
</ListItem.Root>
{index < projects.length - 1 && <Divider component="li" />}
</Fragment>
))}
</List>
)}
</RetryableErrorBoundary>
</div>
</div>
</Container>
);
}
SelectWorkspaceAndProject.getLayout = function getLayout(page: ReactElement) {
return (
<AuthenticatedLayout title="New Run Service">{page}</AuthenticatedLayout>
);
};

View File

@@ -1805,6 +1805,7 @@ export type ConfigStandardOauthProviderWithScopeUpdateInput = {
/** Configuration for storage service */
export type ConfigStorage = {
__typename?: 'ConfigStorage';
antivirus?: Maybe<ConfigStorageAntivirus>;
/** Resources for the service */
resources?: Maybe<ConfigResources>;
/**
@@ -1818,20 +1819,43 @@ export type ConfigStorage = {
version?: Maybe<Scalars['String']>;
};
export type ConfigStorageAntivirus = {
__typename?: 'ConfigStorageAntivirus';
server?: Maybe<Scalars['String']>;
};
export type ConfigStorageAntivirusComparisonExp = {
_and?: InputMaybe<Array<ConfigStorageAntivirusComparisonExp>>;
_not?: InputMaybe<ConfigStorageAntivirusComparisonExp>;
_or?: InputMaybe<Array<ConfigStorageAntivirusComparisonExp>>;
server?: InputMaybe<ConfigStringComparisonExp>;
};
export type ConfigStorageAntivirusInsertInput = {
server?: InputMaybe<Scalars['String']>;
};
export type ConfigStorageAntivirusUpdateInput = {
server?: InputMaybe<Scalars['String']>;
};
export type ConfigStorageComparisonExp = {
_and?: InputMaybe<Array<ConfigStorageComparisonExp>>;
_not?: InputMaybe<ConfigStorageComparisonExp>;
_or?: InputMaybe<Array<ConfigStorageComparisonExp>>;
antivirus?: InputMaybe<ConfigStorageAntivirusComparisonExp>;
resources?: InputMaybe<ConfigResourcesComparisonExp>;
version?: InputMaybe<ConfigStringComparisonExp>;
};
export type ConfigStorageInsertInput = {
antivirus?: InputMaybe<ConfigStorageAntivirusInsertInput>;
resources?: InputMaybe<ConfigResourcesInsertInput>;
version?: InputMaybe<Scalars['String']>;
};
export type ConfigStorageUpdateInput = {
antivirus?: InputMaybe<ConfigStorageAntivirusUpdateInput>;
resources?: InputMaybe<ConfigResourcesUpdateInput>;
version?: InputMaybe<Scalars['String']>;
};
@@ -19750,7 +19774,7 @@ export type GetStorageSettingsQueryVariables = Exact<{
}>;
export type GetStorageSettingsQuery = { __typename?: 'query_root', config?: { __typename: 'ConfigConfig', id: 'ConfigConfig', storage?: { __typename?: 'ConfigStorage', version?: string | null } | null } | null };
export type GetStorageSettingsQuery = { __typename?: 'query_root', config?: { __typename: 'ConfigConfig', id: 'ConfigConfig', storage?: { __typename?: 'ConfigStorage', version?: string | null, antivirus?: { __typename?: 'ConfigStorageAntivirus', server?: string | null } | null } | null } | null };
export type DeleteApplicationMutationVariables = Exact<{
appId: Scalars['uuid'];
@@ -21063,6 +21087,9 @@ export const GetStorageSettingsDocument = gql`
__typename
storage {
version
antivirus {
server
}
}
}
}

View File

@@ -1,5 +1,14 @@
# @nhost-examples/react-apollo
## 0.1.15
### Patch Changes
- dba71483d: chore: react-apollo-example: add profile to allowedUrls
- e819903f1: chore: remove facebook login
- @nhost/react@2.0.30
- @nhost/react-apollo@5.0.34
## 0.1.14
### Patch Changes

View File

@@ -28,10 +28,11 @@ httpPoolSize = 100
version = 16
[auth]
version = '0.20.2'
version = '0.21.2'
[auth.redirections]
clientUrl = 'http://localhost:3000'
clientUrl = 'https://react-apollo.example.nhost.io/'
allowedUrls = ['https://react-apollo.example.nhost.io/profile']
[auth.signUp]
enabled = true
@@ -94,13 +95,17 @@ enabled = false
enabled = false
[auth.method.oauth.github]
enabled = false
enabled = true
clientId = '{{ secrets.GITHUB_CLIENT_ID }}'
clientSecret = '{{ secrets.GITHUB_CLIENT_SECRET }}'
[auth.method.oauth.gitlab]
enabled = false
[auth.method.oauth.google]
enabled = false
enabled = true
clientId = '{{ secrets.GOOGLE_CLIENT_ID }}'
clientSecret = '{{ secrets.GOOGLE_CLIENT_SECRET }}'
[auth.method.oauth.linkedin]
enabled = false
@@ -124,7 +129,11 @@ enabled = false
enabled = false
[auth.method.webauthn]
enabled = false
enabled = true
[auth.method.webauthn.relyingParty]
name = 'apollo-example'
origins = ['https://react-apollo.example.nhost.io']
[auth.method.webauthn.attestation]
timeout = 60000

View File

@@ -0,0 +1,12 @@
[
{
"op": "replace",
"path": "/auth/method/webauthn/relyingParty/origins/0",
"value": "http://localhost:3000"
},
{
"op": "replace",
"path": "/auth/redirections/clientUrl",
"value": "http://localhost:3000"
}
]

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost-examples/react-apollo",
"version": "0.1.14",
"version": "0.1.15",
"private": true,
"dependencies": {
"@apollo/client": "^3.7.14",

View File

@@ -1,11 +1,11 @@
import { FaFacebook, FaGithub, FaGoogle } from 'react-icons/fa/index.js'
import { FaGithub, FaGoogle } from 'react-icons/fa/index.js'
import { useProviderLink } from '@nhost/react'
import AuthLink from './AuthLink'
export default function OauthLinks() {
const { github, google, facebook } = useProviderLink({ redirectTo: window.location.origin })
const { github, google } = useProviderLink({ redirectTo: window.location.origin })
return (
<>
<AuthLink leftIcon={<FaGithub />} link={github} color="#333">
@@ -14,9 +14,6 @@ export default function OauthLinks() {
<AuthLink leftIcon={<FaGoogle />} link={google} color="#de5246">
Continue with Google
</AuthLink>
<AuthLink leftIcon={<FaFacebook />} link={facebook} color="#3b5998">
Continue with Facebook
</AuthLink>
</>
)
}

View File

@@ -5,6 +5,7 @@ import { RemoveSecurityKeyMutation, SecurityKeysQuery } from 'src/generated'
import { gql, useMutation } from '@apollo/client'
import { ActionIcon, Button, Card, SimpleGrid, Table, TextInput, Title } from '@mantine/core'
import { useInputState } from '@mantine/hooks'
import { showNotification } from '@mantine/notifications'
import { useAddSecurityKey, useUserId } from '@nhost/react'
import { useAuthQuery } from '@nhost/react-apollo'
@@ -42,9 +43,14 @@ export const SecurityKeys: React.FC = () => {
const addKey = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const { key, error } = await add(nickname)
if (error) {
const { key, isError, error } = await add(nickname)
if (isError) {
console.log(error)
showNotification({
color: 'red',
title: 'Error',
message: error?.message || null
})
} else {
setNickname('')
}

13
examples/sveltekit/.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/test-results/
/playwright-report/
/playwright/.cache/

View File

@@ -0,0 +1 @@
hoist-pattern[]=!@nhost/nhost-js

View File

@@ -0,0 +1,5 @@
---
'@nhost-examples/sveltekit': minor
---
feat: add nhost with sveltekit example project

View File

@@ -0,0 +1,44 @@
# Nhost with SvelteKit Example
## Get Started
1. Clone the repository
```sh
git clone https://github.com/nhost/nhost
cd nhost
```
2. Install and build dependencies
```sh
pnpm install
pnpm build
```
3. Go to the SvelteKit example folder
```sh
cd examples/sveltekit
```
4. Create a `.env` file and set the subdomain and region of your Nhost project. When running locally with the CLI, set the subdomain to `local`.
```sh
PUBLIC_NHOST_SUBDOMAIN=
PUBLIC_NHOST_REGION=
```
5. Terminal 1: Start Nhost
> Make sure you have the [Nhost CLI installed](https://docs.nhost.io/platform/cli).
```sh
nhost up
```
6. Terminal 2: Start the SvelteKit dev server
```sh
pnpm dev
```

View File

@@ -0,0 +1,17 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias and https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

View File

@@ -0,0 +1,45 @@
{
"name": "@nhost-examples/sveltekit",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
"install-browsers": "pnpm dlx playwright@1.31.0 install --with-deps",
"add-nhost-js": "pnpm add @nhost/nhost-js --ignore-workspace",
"test": "pnpm install-browsers && pnpm add-nhost-js && pnpm dlx playwright@1.31.0 test",
"lint": "eslint .",
"postinstall": "pnpm add-nhost-js"
},
"devDependencies": {
"@nhost/nhost-js": "2.2.13",
"@playwright/test": "^1.31.0",
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/kit": "^1.5.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.26.0",
"postcss": "^8.4.23",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.8.1",
"svelte": "^3.54.0",
"svelte-check": "^3.0.1",
"tailwindcss": "^3.3.2",
"typescript": "^5.0.0",
"vite": "^4.3.0",
"vitest": "^0.25.3"
},
"type": "module",
"dependencies": {
"@apollo/client": "^3.8.1",
"graphql": "^16.7.1",
"graphql-tag": "^2.12.6",
"js-cookie": "^3.0.5",
"playwright": "^1.37.1",
"uuid": "^9.0.0"
}
}

View File

@@ -0,0 +1,57 @@
// @ts-check
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* @see https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:5173',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry'
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] }
}
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI
}
})

179
examples/sveltekit/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,179 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies:
graphql:
specifier: ^16.7.1
version: 16.8.0
devDependencies:
'@nhost/nhost-js':
specifier: 2.2.13
version: 2.2.13(graphql@16.8.0)
packages:
/@graphql-typed-document-node/core@3.2.0(graphql@16.8.0):
resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==}
peerDependencies:
graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
dependencies:
graphql: 16.8.0
dev: true
/@nhost/graphql-js@0.1.4(graphql@16.8.0):
resolution: {integrity: sha512-IPHuGOf4iQrFsxG7Rh5jCCZzPCN9JkvldFww4Fz1lCVi9ZQNEaGaawIP5gBuBHeYIuALeaK1wVYKPc7vJ/euCA==}
peerDependencies:
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0
dependencies:
'@graphql-typed-document-node/core': 3.2.0(graphql@16.8.0)
graphql: 16.8.0
isomorphic-unfetch: 3.1.0
transitivePeerDependencies:
- encoding
dev: true
/@nhost/hasura-auth-js@2.1.7:
resolution: {integrity: sha512-dbi8zrmuE3xSlA7WMNyrZzVPLKYSBUlKtUjob+axhts0+4TsqsB7NvdLXrhM0fYjRY5SFUtN2JLs+EWDGKAoLQ==}
dependencies:
'@simplewebauthn/browser': 6.2.2
fetch-ponyfill: 7.1.0
js-cookie: 3.0.5
jwt-decode: 3.1.2
xstate: 4.38.2
transitivePeerDependencies:
- encoding
dev: true
/@nhost/hasura-storage-js@2.2.2:
resolution: {integrity: sha512-GMeB1m6YKZMZDSO6UtmgXYWxsFUNlphIZH9JeiDX+6UTmbd62UlDmhBcmx2OGy/tvv57D+HbohpV6AY9S6QL4w==}
dependencies:
fetch-ponyfill: 7.1.0
form-data: 4.0.0
xstate: 4.38.2
transitivePeerDependencies:
- encoding
dev: true
/@nhost/nhost-js@2.2.13(graphql@16.8.0):
resolution: {integrity: sha512-HU9eOpkVBGGMHsRThGyl654Y9W8bksSEnyEvDojUg76+3ngOn2ebrasXR/94YoR206soEzq3v4b0OKmn/1jlnQ==}
peerDependencies:
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0
dependencies:
'@nhost/graphql-js': 0.1.4(graphql@16.8.0)
'@nhost/hasura-auth-js': 2.1.7
'@nhost/hasura-storage-js': 2.2.2
graphql: 16.8.0
isomorphic-unfetch: 3.1.0
transitivePeerDependencies:
- encoding
dev: true
/@simplewebauthn/browser@6.2.2:
resolution: {integrity: sha512-VUtne7+s6BmW4usnbitjZEI1VNT/PNh6bYg+AI4OMdfpo5z+yAq+6iVAWBJlIUGVk5InetEQvTUp6OefBam8qg==}
dev: true
/asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
dev: true
/combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
dependencies:
delayed-stream: 1.0.0
dev: true
/delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dev: true
/fetch-ponyfill@7.1.0:
resolution: {integrity: sha512-FhbbL55dj/qdVO3YNK7ZEkshvj3eQ7EuIGV2I6ic/2YiocvyWv+7jg2s4AyS0wdRU75s3tA8ZxI/xPigb0v5Aw==}
dependencies:
node-fetch: 2.6.12
transitivePeerDependencies:
- encoding
dev: true
/form-data@4.0.0:
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
engines: {node: '>= 6'}
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
mime-types: 2.1.35
dev: true
/graphql@16.8.0:
resolution: {integrity: sha512-0oKGaR+y3qcS5mCu1vb7KG+a89vjn06C7Ihq/dDl3jA+A8B3TKomvi3CiEcVLJQGalbu8F52LxkOym7U5sSfbg==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
/isomorphic-unfetch@3.1.0:
resolution: {integrity: sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==}
dependencies:
node-fetch: 2.6.12
unfetch: 4.2.0
transitivePeerDependencies:
- encoding
dev: true
/js-cookie@3.0.5:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
dev: true
/jwt-decode@3.1.2:
resolution: {integrity: sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==}
dev: true
/mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
dev: true
/mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
dependencies:
mime-db: 1.52.0
dev: true
/node-fetch@2.6.12:
resolution: {integrity: sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
dependencies:
whatwg-url: 5.0.0
dev: true
/tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
dev: true
/unfetch@4.2.0:
resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==}
dev: true
/webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
dev: true
/whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
dev: true
/xstate@4.38.2:
resolution: {integrity: sha512-Fba/DwEPDLneHT3tbJ9F3zafbQXszOlyCJyQqqdzmtlY/cwE2th462KK48yaANf98jHlP6lJvxfNtN0LFKXPQg==}
dev: true

View File

@@ -0,0 +1,7 @@
// eslint-disable-next-line import/no-anonymous-default-export
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

13
examples/sveltekit/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {}
interface PageData {}
// interface Platform {}
}
}
export { };

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<script>
/** @type {("button" | "submit" | "reset" | null | undefined)} */
export let type = 'button';
export let disabled = false;
</script>
<button
{disabled}
{type}
class="{disabled == true
? 'bg-indigo-200 hover:bg-grey-700'
: 'bg-indigo-600 hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500'} inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md shadow-sm text-white focus:outline-none"
>
<slot />
</button>

View File

@@ -0,0 +1,40 @@
<script>
export let value = '';
/** @type {string} */
export let id;
/** @type {string} */
export let label;
export let type = 'text';
/** @type {string} */
export let name;
export let required = false;
export let inputRef = null;
/** @param {HTMLInputElement} node */
function setType(node) {
node.type = type;
}
</script>
<div class={$$props.class}>
<label for={id} class="block text-sm font-medium text-gray-700">
{label}
</label>
<div class="mt-1">
<input
use:setType
{name}
{id}
{required}
bind:value
bind:this={inputRef}
class="block w-full p-3 border rounded-md border-slate-300 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>

View File

@@ -0,0 +1,66 @@
<script>
import { createEventDispatcher } from 'svelte';
/** @type {import('@nhost/nhost-js').User | undefined} */
export let user;
$: navigation = [
{
href: '/',
name: 'Home'
},
{
href: '/protected',
name: `${user ? '🔓' : '🔒'} Protected`
}
];
const dispatch = createEventDispatcher();
const handleSignOut = () => {
user = undefined;
dispatch('signout', {
signout: true
});
};
</script>
<header class="bg-indigo-600">
<nav class="container mx-auto">
<div class="flex items-center justify-between w-full py-4">
<div class="flex items-center">
<div class="ml-10 space-x-8">
{#each navigation as link}
<a href={link.href} class="text-lg font-medium text-white hover:text-indigo-50">
{link.name}
</a>
{/each}
</div>
</div>
<div class="ml-10 space-x-4">
{#if user}
<button
on:click={handleSignOut}
class="inline-block px-4 py-2 text-base font-medium text-white bg-indigo-500 border border-transparent rounded-md hover:bg-opacity-75"
>
Sign out
</button>
{:else}
<a
href="/sign-in"
class="inline-block px-4 py-2 text-base font-medium text-white bg-indigo-500 border border-transparent rounded-md hover:bg-opacity-75"
>
Sign in
</a>
<a
href="/sign-up"
class="inline-block px-4 py-2 text-base font-medium text-indigo-600 bg-white border border-transparent rounded-md hover:bg-indigo-50"
>
Sign up
</a>
{/if}
</div>
</div>
</nav>
</header>

View File

@@ -0,0 +1,62 @@
import { invalidate } from '$app/navigation';
import { env } from '$env/dynamic/public';
import { NhostClient } from '@nhost/nhost-js';
import Cookies from 'js-cookie';
export const NHOST_SESSION_KEY = 'nhostSession';
const isBrowser = typeof window !== 'undefined';
/** @type {import('@nhost/nhost-js').NhostClient | null} */
let nhost;
/** @param {import('@nhost/nhost-js').NhostSession | null} session */
export const setNhostSessionInCookie = (session) => {
if (!session) {
Cookies.remove(NHOST_SESSION_KEY);
return;
}
const expires = new Date();
// * Expire the cookie 60 seconds before the token expires
expires.setSeconds(expires.getSeconds() + session.accessTokenExpiresIn - 60);
Cookies.set(NHOST_SESSION_KEY, JSON.stringify(session), {
sameSite: 'strict',
expires
});
};
/** @param {import('@sveltejs/kit').Cookies} cookies */
export const getNhostSessionFromCookie = (cookies) => {
const nhostSessionCookie = cookies.get(NHOST_SESSION_KEY);
return nhostSessionCookie ? JSON.parse(nhostSessionCookie) : null;
};
/** @param {import('@nhost/nhost-js').NhostSession} session */
export const getNhostLoadClient = async (session) => {
if (isBrowser && nhost) {
return nhost;
}
nhost = new NhostClient({
subdomain: env.PUBLIC_NHOST_SUBDOMAIN || 'local',
region: env.PUBLIC_NHOST_REGION,
start: false
});
if (isBrowser) {
nhost.auth.onAuthStateChanged((_, session) => {
setNhostSessionInCookie(session);
invalidate('nhost:auth');
});
nhost.auth.onTokenChanged((session) => {
setNhostSessionInCookie(session);
});
}
nhost.auth.client.start({ initialSession: session });
return nhost;
};

View File

@@ -0,0 +1,22 @@
import { getNhostLoadClient } from '$lib/nhost-auth-sveltekit.js';
import { redirect } from '@sveltejs/kit';
const unProtectedRoutes = ['/', '/sign-in', '/sign-up'];
export const load = async ({ data, depends, route }) => {
depends('nhost:auth');
const nhost = await getNhostLoadClient(data.nhostSession);
const session = nhost.auth.getSession();
if (!unProtectedRoutes.includes(route.id ?? '')) {
if (!session) {
throw redirect(303, '/');
}
}
return {
nhost: nhost,
session: session
};
};

View File

@@ -0,0 +1,8 @@
import { getNhostSessionFromCookie } from '$lib/nhost-auth-sveltekit';
/** @type {import('./$types').LayoutServerLoad} */
export async function load({ cookies }) {
return {
nhostSession: getNhostSessionFromCookie(cookies)
};
}

View File

@@ -0,0 +1,24 @@
<script>
import { goto } from '$app/navigation';
import Navigation from '$lib/components/Navigation.svelte';
import './styles.css';
export let data;
let { nhost } = data;
/**
* @param {{ detail: { signout: any; }; }} event
*/
async function handleSignOut(event) {
await nhost.auth.signOut();
await goto(`/`);
}
</script>
<div class="app">
<Navigation user={data.session?.user} on:signout={handleSignOut} />
<div class="container p-4 mx-auto mt-8 antialiased">
<slot />
</div>
</div>

View File

@@ -0,0 +1,6 @@
<svelte:head>
<title>Login</title>
<meta name="description" content="About this app" />
</svelte:head>
<h1 class="text-2xl text-center">Hi, login/register to get started</h1>

View File

@@ -0,0 +1,44 @@
<script>
export let data;
let { session, nhost } = data;
import { gql } from 'graphql-tag';
const getFiles = async () => {
const response = await nhost.graphql.request(gql`
{
files {
id
name
}
}
`);
return response.data.files;
};
</script>
<svelte:head>
<title>Protected Page</title>
<meta name="description" content="About this app" />
</svelte:head>
<h1 class="text-2xl font-semibold text-center">
Hi! You are registered with email: {session?.user.email}.
</h1>
<h2>Files</h2>
{#await getFiles()}
<p>Loading...</p>
{:then files}
<p>Showing {files.length} files</p>
<ul>
{#each files as file}
<li>
{file.name}
</li>
{/each}
</ul>
{:catch error}
<p>{error.message}</p>
{/await}

View File

@@ -0,0 +1,56 @@
<script>
import Input from '$lib/components/Input.svelte';
import Button from '$lib/components/Button.svelte';
import { goto, invalidate } from '$app/navigation';
export let data;
let { nhost } = data;
/** @type {string}*/
let email;
/** @type {string}*/
let password;
/** @type {import('@nhost/nhost-js').AuthErrorPayload | null} */
let error;
const handleSignIn = async () => {
const { error: signInError } = await nhost.auth.signIn({
email,
password
});
error = signInError;
if (!error) {
await invalidate('nhost:auth');
await goto('/protected');
}
};
</script>
<svelte:head>
<title>Sign In</title>
</svelte:head>
<h1 class="text-2xl font-semibold text-center">Sign In</h1>
{#if error}
<p class="mt-3 font-semibold text-center text-red-500">{error.message}</p>
{/if}
<form class="space-y-5" on:submit={handleSignIn}>
<Input label="Email" id="email" name="email" type="email" bind:value={email} required />
<Input
label="Password"
id="password"
name="password"
type="password"
bind:value={password}
required
/>
<Button type="submit">Sign In</Button>
</form>

View File

@@ -0,0 +1,82 @@
<script>
import Input from '$lib/components/Input.svelte';
import Button from '$lib/components/Button.svelte';
import { goto } from '$app/navigation';
export let data;
let { nhost } = data;
/** @type {string}*/
let firstName;
/** @type {string}*/
let lastName;
/** @type {string}*/
let email;
/** @type {string}*/
let password;
/** @type {import('@nhost/nhost-js').AuthErrorPayload | null} */
let error;
const handleSignUp = async () => {
const { error: signUpError } = await nhost.auth.signUp({
email,
password,
options: {
displayName: `${firstName} ${lastName}`
}
});
error = signUpError;
if (!error) {
await goto('/sign-in');
}
};
</script>
<svelte:head>
<title>Sign Up</title>
</svelte:head>
<h1 class="text-2xl font-semibold text-center">Sign Up</h1>
{#if error}
<p class="mt-3 font-semibold text-center text-red-500">{error.message}</p>
{/if}
<form class="space-y-5" on:submit={handleSignUp}>
<Input
label="First Name"
id="firstName"
name="firstName"
type="text"
bind:value={firstName}
required
/>
<Input
label="Last Name"
id="lastName"
name="lastName"
type="text"
bind:value={lastName}
required
/>
<Input label="Email" id="email" name="email" type="email" bind:value={email} required />
<Input
label="Password"
id="password"
name="password"
type="password"
bind:value={password}
required
/>
<Button type="submit">Sign Up</Button>
</form>

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,13 @@
import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

View File

@@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
// eslint-disable-next-line import/no-anonymous-default-export
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {}
},
plugins: []
}

View File

@@ -0,0 +1,6 @@
import { expect, test } from '@playwright/test'
test('has title', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveTitle(/Login/)
})

View File

@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [sveltekit()]
})

View File

@@ -8,15 +8,15 @@
outputs = { self, nixpkgs, flake-utils, nix-filter }:
flake-utils.lib.eachDefaultSystem (system:
let
version = "v1.1.0";
version = "v1.5.2";
dist = {
aarch64-darwin = rec {
url = "https://github.com/nhost/cli/releases/download/${version}/cli-${version}-darwin-arm64.tar.gz";
sha256 = "sha256-tF40CEkA357yzg2Gmc9ubjHJ5FlI9qQTdVdWNY/+f+Y=";
sha256 = "0rakx9lpbj5m3jfra5r2iw065x800i951843l3mxbwk9m1hzm185";
};
x86_64-linux = rec {
url = "https://github.com/nhost/cli/releases/download/${version}/cli-${version}-linux-amd64.tar.gz";
sha256 = "sha256-KLv06dI7A+5KGJ5F8xM1qC+oqHRmJ4kMaifLvaTFqak=";
sha256 = "19x83fpvazwzpnrxi0qh6mh14wwhlw77qd7vvc3633dxa5hdigc4";
};
};
overlays = [

View File

@@ -1,5 +1,17 @@
# @nhost/apollo
## 5.2.17
### Patch Changes
- @nhost/nhost-js@2.2.15
## 5.2.16
### Patch Changes
- @nhost/nhost-js@2.2.14
## 5.2.15
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/apollo",
"version": "5.2.15",
"version": "5.2.17",
"description": "Nhost Apollo Client library",
"license": "MIT",
"keywords": [

View File

@@ -1,5 +1,19 @@
# @nhost/react-apollo
## 5.0.34
### Patch Changes
- @nhost/apollo@5.2.17
- @nhost/react@2.0.30
## 5.0.33
### Patch Changes
- @nhost/apollo@5.2.16
- @nhost/react@2.0.29
## 5.0.32
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/react-apollo",
"version": "5.0.32",
"version": "5.0.34",
"description": "Nhost React Apollo client",
"license": "MIT",
"keywords": [

View File

@@ -1,5 +1,17 @@
# @nhost/react-urql
## 2.0.31
### Patch Changes
- @nhost/react@2.0.30
## 2.0.30
### Patch Changes
- @nhost/react@2.0.29
## 2.0.29
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/react-urql",
"version": "2.0.29",
"version": "2.0.31",
"description": "Nhost React URQL client",
"license": "MIT",
"keywords": [

View File

@@ -1,5 +1,11 @@
# @nhost/hasura-auth-js
## 2.1.8
### Patch Changes
- 032c0bd21: fix: make sure errors are correctly thrown on non v8 browsers
## 2.1.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/hasura-auth-js",
"version": "2.1.7",
"version": "2.1.8",
"description": "Hasura-auth client",
"license": "MIT",
"keywords": [

View File

@@ -15,7 +15,9 @@ export class CodifiedError extends Error {
error: AuthErrorPayload
constructor(original: Error | AuthErrorPayload) {
super(original.message)
Error.captureStackTrace(this, this.constructor)
if (Error.captureStackTrace) Error.captureStackTrace(this, this.constructor)
if (original instanceof Error) {
this.name = original.name
this.error = {

View File

@@ -1,5 +1,11 @@
# @nhost/hasura-storage-js
## 2.2.3
### Patch Changes
- 39de0063b: fix(hasura-storage-js): fix upload response status code check
## 2.2.2
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/hasura-storage-js",
"version": "2.2.2",
"version": "2.2.3",
"description": "Hasura-storage client",
"license": "MIT",
"keywords": [

View File

@@ -87,10 +87,14 @@ export const fetchUpload = async (
xhr.responseType = 'json'
xhr.onload = () => {
if (xhr.status < 200 && xhr.status >= 300) {
if (xhr.status < 200 || xhr.status >= 300) {
return resolve({
fileMetadata: null,
error: { error: xhr.statusText, message: xhr.statusText, status: xhr.status }
error: {
error: xhr.response?.error ?? xhr.response,
message: xhr.response?.error?.message ?? xhr.response,
status: xhr.status
}
})
}
return resolve({ fileMetadata: xhr.response, error: null })

View File

@@ -1,5 +1,17 @@
# @nhost/nextjs
## 1.13.36
### Patch Changes
- @nhost/react@2.0.30
## 1.13.35
### Patch Changes
- @nhost/react@2.0.29
## 1.13.34
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/nextjs",
"version": "1.13.34",
"version": "1.13.36",
"description": "Nhost NextJS library",
"license": "MIT",
"keywords": [

View File

@@ -1,5 +1,19 @@
# @nhost/nhost-js
## 2.2.15
### Patch Changes
- Updated dependencies [032c0bd21]
- @nhost/hasura-auth-js@2.1.8
## 2.2.14
### Patch Changes
- Updated dependencies [39de0063b]
- @nhost/hasura-storage-js@2.2.3
## 2.2.13
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/nhost-js",
"version": "2.2.13",
"version": "2.2.15",
"description": "Nhost JavaScript SDK",
"license": "MIT",
"keywords": [

View File

@@ -1,5 +1,17 @@
# @nhost/react
## 2.0.30
### Patch Changes
- @nhost/nhost-js@2.2.15
## 2.0.29
### Patch Changes
- @nhost/nhost-js@2.2.14
## 2.0.28
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/react",
"version": "2.0.28",
"version": "2.0.30",
"description": "Nhost React library",
"license": "MIT",
"keywords": [

View File

@@ -1,5 +1,17 @@
# @nhost/vue
## 1.13.35
### Patch Changes
- @nhost/nhost-js@2.2.15
## 1.13.34
### Patch Changes
- @nhost/nhost-js@2.2.14
## 1.13.33
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/vue",
"version": "1.13.33",
"version": "1.13.35",
"description": "Nhost Vue library",
"license": "MIT",
"keywords": [

1332
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff