Compare commits

..

82 Commits

Author SHA1 Message Date
Hassan Ben Jobrane
3ef503ff81 Merge pull request #2298 from nhost/changeset-release/main
chore: update versions
2023-10-04 16:47:51 +02:00
github-actions[bot]
bfcfd236ea chore: update versions 2023-10-04 14:13:38 +00:00
Hassan Ben Jobrane
bfa7033506 Merge pull request #2296 from nhost/feat/query-announcements
feat: dashboard: query announcements
2023-10-04 16:09:48 +02:00
Hassan Ben Jobrane
78c29fcf0e feat: filter expired announcements 2023-10-04 14:40:22 +01:00
Hassan Ben Jobrane
f1b934ed22 chore: remove old announcement provider 2023-10-04 10:52:20 +01:00
Hassan Ben Jobrane
914369c53f feat: add announcements list component 2023-10-03 20:18:41 +01:00
Hassan Ben Jobrane
af379b967e chore: clean up commented code 2023-10-03 17:43:24 +01:00
Hassan Ben Jobrane
c3efb7ec84 chore: add changeset 2023-10-03 17:41:35 +01:00
Hassan Ben Jobrane
27cbd48c8c feat(dashboard): query latest announcement from platform 2023-10-03 17:40:50 +01:00
Hassan Ben Jobrane
236996a903 Merge pull request #2293 from nhost/changeset-release/main
chore: update versions
2023-10-02 13:01:48 +02:00
github-actions[bot]
5d0936bb93 chore: update versions 2023-10-02 10:38:35 +00:00
Hassan Ben Jobrane
733c212f2d Merge pull request #2291 from nhost/chore/announcement/node18
chore: node18 announcement
2023-10-02 12:35:53 +02:00
Hassan Ben Jobrane
8b47549189 Merge pull request #2286 from nhost/chore/ci/disable-github-releases
chore(ci): set createGithubReleases to false
2023-10-02 12:26:10 +02:00
Hassan Ben Jobrane
3c9c1025ce Merge pull request #2287 from nhost/fix/vue-sdk/nested-unref
fix(vue-sdk): correctly unref arrays
2023-10-02 12:25:14 +02:00
Hassan Ben Jobrane
3e46d3873c chore: add changeset 2023-10-02 10:50:46 +01:00
Hassan Ben Jobrane
4cf8820d72 chore: open announcement link in a new tab 2023-10-02 10:39:15 +01:00
Hassan Ben Jobrane
02a11184fb chore: change announcement link 2023-10-02 10:38:04 +01:00
Hassan Ben Jobrane
7214d47cc7 chore: add changeset 2023-09-30 17:54:42 +01:00
Hassan Ben Jobrane
238b77baad fix(vue): correctly unref arrays 2023-09-30 17:53:05 +01:00
Hassan Ben Jobrane
81b8e538b4 chore(ci): set createGithubReleases to false 2023-09-29 17:12:21 +01:00
Hassan Ben Jobrane
563a37e58d Merge pull request #2285 from nhost/changeset-release/main
chore: update versions
2023-09-29 18:05:40 +02:00
github-actions[bot]
bff23720ee chore: update versions 2023-09-29 15:43:43 +00:00
Hassan Ben Jobrane
02cbaeffd2 Merge pull request #2225 from nhost/feat/examples/nextjs-server-components
feat: quickstarts: draft for using server components
2023-09-29 17:40:52 +02:00
Hassan Ben Jobrane
9eb814c79a chore: update readme 2023-09-29 15:57:53 +01:00
Hassan Ben Jobrane
ebc5913bb3 chore: naming consistency 2023-09-29 15:57:53 +01:00
Hassan Ben Jobrane
4fe4a16964 chore: add changeset 2023-09-29 15:57:53 +01:00
Hassan Ben Jobrane
92c475b7a7 chore: add missing refreshToken 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
679b34b031 chore: cleanup 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
d3186aefbd refactor: extract session middleware into helper function 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
fdecac9d69 refactor: add high order component for protected pages 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
5077283028 chore: merge oauth handling in middleware 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
f5f662aad1 chore: refactor server actions 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
735b779af7 chore: clean up database setup 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
4418d6abcf chore: cleanup 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
049e315c30 fix: set correct path on cookie on oauth signin 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
764597538b fix: make sure that hasura-storage-js works on EdgeRuntime 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
c8aea785cc fix: tweak todo item layout 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
e0e44b2ff4 fix: set same path for session cookie 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
12280f7c87 feat: pat list pagination 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
732a4f40ca wip 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
d67fd599e4 feat: todos CRUD 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
a41231927a feat: add signin with pat 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
42ec665950 fix: return refreshToken in getAuthenticationResult 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
7225712a30 chore: update hasura auth 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
6593fdd9bb fix: make sure refreshToken is returned after signin/signup 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
40039fece5 Revert "refactor: make sure to return refresh token"
This reverts commit b31b358ca1898bb4173954b8b33059d92cc8c126.
2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
e5fcfb3cd5 refactor: make sure to return refresh token 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
218ec314fb feat(quickstarts): refactor and organize signup/signin 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
9367e91d45 feat: examples: add other sign in methods
Add sign in with google and webauthn
2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
06c640be2c chore: delete unnecessary files 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
ae45be9816 feat(quickstarts): draft for using server components 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
ec4be590d8 Merge pull request #2284 from nhost/chore/ci/fix-release
chore: ci: fix release worflow
2023-09-29 16:56:12 +02:00
Hassan Ben Jobrane
5c51653aa0 chore: fix release worflow 2023-09-29 13:29:44 +01:00
Hassan Ben Jobrane
7348c15ad1 Merge pull request #2281 from nhost/changeset-release/main
chore: update versions
2023-09-29 13:48:59 +02:00
Hassan Ben Jobrane
44831e32a7 Merge pull request #2282 from nhost/chore/ci/fix-release-workflow
chore(ci): fix release workflow
2023-09-29 13:47:21 +02:00
Hassan Ben Jobrane
ee0f837762 chore(ci): fix release workflow 2023-09-29 12:32:05 +01:00
github-actions[bot]
e040979e91 chore: update versions 2023-09-29 10:59:27 +00:00
Hassan Ben Jobrane
68100d63b9 Merge pull request #2267 from nhost/changeset-release/main
chore: update versions
2023-09-29 12:56:45 +02:00
github-actions[bot]
9b800046d7 chore: update versions 2023-09-29 10:37:52 +00:00
Hassan Ben Jobrane
807d8574b6 Merge pull request #2280 from nhost/feat/dashboard/multiline-variables-input
feat(dashboard): make env value input multiline
2023-09-29 12:34:59 +02:00
Hassan Ben Jobrane
77028e4eef Merge pull request #2265 from nhost/chore/ci/disable-changeset-github-releases
chore(ci): disable automatic GitHub releases
2023-09-29 12:34:36 +02:00
Hassan Ben Jobrane
e0d32aab33 Merge pull request #2235 from nhost/chore/ci/release/recorder-steps
chore(ci): run publish to vercel before docker build
2023-09-29 12:34:16 +02:00
Hassan Ben Jobrane
75c4c8ae36 chore: add changeset 2023-09-29 11:14:53 +01:00
Hassan Ben Jobrane
1d90639e46 feat: make env value input multiline 2023-09-29 11:12:35 +01:00
David Barroso
765b398b21 chore(docs): added jit settings documentation (#2274)
Solves #2273
2023-09-29 10:44:32 +02:00
David Barroso
30aae1557c chore(docs): minor fix to performance documentation (#2272) 2023-09-29 10:44:02 +02:00
David Barroso
a3efc1d131 chore (docs): added storage/antivirus documentation (#2268)
Rearranged the storage section a bit and added:

https://docs-git-docs-storage-nhost.vercel.app/storage/av
2023-09-28 07:27:57 +02:00
David Barroso
612d754965 chore: docs: added more docs (#2264)
https://docs-git-docs-postgres-nhost.vercel.app/database/settings
https://docs-git-docs-postgres-nhost.vercel.app/database/extensions
https://docs-git-docs-postgres-nhost.vercel.app/graphql/settings
https://docs-git-docs-postgres-nhost.vercel.app/database/performance

---------

Co-authored-by: Hassan Ben Jobrane <hsanbenjobrane@gmail.com>
2023-09-28 07:27:15 +02:00
Hassan Ben Jobrane
b2e5f30379 Merge pull request #2269 from nhost/fix/docs/mermaid-theme
fix(docs): mermaid diagrams dark mode
2023-09-27 16:28:53 +01:00
Hassan Ben Jobrane
3b3e83a218 chore: add changeset 2023-09-27 15:42:48 +01:00
Hassan Ben Jobrane
0d5231f1a1 fix(docs): correct rendering of mermaid diagrams in dark mode 2023-09-27 15:41:59 +01:00
Hassan Ben Jobrane
1a8332a3ca Merge pull request #2266 from nhost/fix/settings/compute-resources
fix: make sure dedicated resources pricing follows total resources
2023-09-27 10:06:48 +01:00
Hassan Ben Jobrane
7418105de2 chore: remove commented code 2023-09-26 19:29:48 +01:00
Hassan Ben Jobrane
425d485f85 chore: add changeset 2023-09-26 17:09:08 +01:00
Hassan Ben Jobrane
d8d25b3ea0 fix: make sure dedicated resources pricing follows total resources on the top section 2023-09-26 17:06:51 +01:00
Hassan Ben Jobrane
320513f6f5 chore(ci): disable automatic GitHub releases 2023-09-26 16:34:18 +01:00
Hassan Ben Jobrane
b37053376d Merge pull request #2261 from nhost/changeset-release/main
chore: update versions
2023-09-22 12:00:44 +01:00
github-actions[bot]
c21ba4aebd chore: update versions 2023-09-22 10:46:59 +00:00
Hassan Ben Jobrane
58948c50d4 Merge pull request #2260 from nhost/fix/graphql/remove-unused-fields
fix: dashboard: remove unused fields
2023-09-22 11:44:23 +01:00
Hassan Ben Jobrane
ae324f67fa chore: add changeset 2023-09-22 11:33:51 +01:00
Hassan Ben Jobrane
acabf2b168 fix(dashboard): remove unused fields 2023-09-22 11:31:57 +01:00
Hassan Ben Jobrane
e622ca0d83 chore(ci): run publish to vercel before docker build 2023-09-12 15:09:34 +01:00
227 changed files with 5074 additions and 987 deletions

View File

@@ -42,6 +42,7 @@ jobs:
commit: 'chore: update versions'
title: 'chore: update versions'
publish: pnpm run release
createGithubReleases: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -62,12 +63,39 @@ jobs:
uses: ./.github/workflows/dashboard.yaml
secrets: inherit
publish-vercel:
name: Publish to Vercel
runs-on: ubuntu-latest
needs:
- test
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install Node and dependencies
uses: ./.github/actions/install-dependencies
with:
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
TURBO_TEAM: ${{ env.TURBO_TEAM }}
- name: Setup Vercel CLI
run: pnpm add -g vercel
- name: Trigger a Vercel deployment
env:
VERCEL_ORG_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_VERCEL_PROJECT_ID }}
run: |
vercel pull --environment=production --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
vercel build --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
vercel deploy --prebuilt --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
publish-docker:
name: Publish to Docker Hub
runs-on: ubuntu-latest
needs:
- test
- version
- publish-vercel
steps:
- name: Checkout repository
uses: actions/checkout@v3
@@ -124,32 +152,6 @@ jobs:
if: failure()
run: git push --delete origin ${{ env.DASHBOARD_PACKAGE }}@${{ needs.version.outputs.dashboardVersion }}
publish-vercel:
name: Publish to Vercel
runs-on: ubuntu-latest
needs:
- publish-docker
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install Node and dependencies
uses: ./.github/actions/install-dependencies
with:
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
TURBO_TEAM: ${{ env.TURBO_TEAM }}
- name: Setup Vercel CLI
run: pnpm add -g vercel
- name: Trigger a Vercel deployment
env:
VERCEL_ORG_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_VERCEL_PROJECT_ID }}
run: |
vercel pull --environment=production --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
vercel build --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
vercel deploy --prebuilt --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
bump-cli:
name: Bump Dashboard version in the Nhost CLI
runs-on: ubuntu-latest

View File

@@ -1,5 +1,42 @@
# @nhost/dashboard
## 0.20.22
### Patch Changes
- c3efb7ec8: feat(dashboard): query latest announcement from platform
## 0.20.21
### Patch Changes
- 3e46d3873: chore: update link to node18 announcement
## 0.20.20
### Patch Changes
- @nhost/react-apollo@5.0.36
- @nhost/nextjs@1.13.38
## 0.20.19
### Patch Changes
- 75c4c8ae3: feat(dashboard): make env value input multiline
## 0.20.18
### Patch Changes
- 425d485f8: fix(dashboard): make sure dedicated resources pricing follows total resources
## 0.20.17
### Patch Changes
- ae324f67f: fix(dashboard): remove unused graphql fields
## 0.20.16
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/dashboard",
"version": "0.20.16",
"version": "0.20.22",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",

View File

@@ -1,62 +0,0 @@
import { Button } from '@/components/ui/v2/Button';
import { ArrowRightIcon } from '@/components/ui/v2/icons/ArrowRightIcon';
import { XIcon } from '@/components/ui/v2/icons/XIcon';
import { Text } from '@/components/ui/v2/Text';
import { forwardRef, type ForwardedRef } from 'react';
import { twMerge } from 'tailwind-merge';
import AnnouncementContainer, {
type AnnouncementContainerProps,
} from './AnnouncementContainer';
export interface AnnouncementProps extends AnnouncementContainerProps {
/**
* Function called when the announcement is closed.
*/
onClose?: VoidFunction;
/**
* The href to use for the announcement link.
*/
href: string;
}
function Announcement(
{ children, slotProps, onClose, href, ...props }: AnnouncementProps,
ref: ForwardedRef<HTMLDivElement>,
) {
return (
<AnnouncementContainer
{...props}
ref={ref}
className="grid grid-flow-col justify-between gap-4"
slotProps={{
root: {
...(slotProps?.root || {}),
className: twMerge('w-full py-1.5', slotProps?.root?.className),
},
}}
>
<span />
<div className="flex items-center self-center truncate">
<a href={href}>
<Text className="cursor-pointer truncate hover:underline">
{children}
</Text>
</a>
<ArrowRightIcon className="ml-1 h-4 w-4 text-white" />
</div>
<Button
variant="borderless"
onClick={onClose}
aria-label="Close announcement"
size="small"
className="rounded-sm p-1"
>
<XIcon className="opacity-65 h-4 w-4" />
</Button>
</AnnouncementContainer>
);
}
export default forwardRef(Announcement);

View File

@@ -1,66 +0,0 @@
import {
createElement,
forwardRef,
type DetailedHTMLProps,
type ElementType,
type ForwardedRef,
type HTMLProps,
type PropsWithoutRef,
} from 'react';
import { twMerge } from 'tailwind-merge';
export interface AnnouncementContainerProps
extends PropsWithoutRef<
DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>
> {
/**
* Custom component to render as.
*/
component?: ElementType<any>;
/**
* Props passed to component slots.
*/
slotProps?: {
/**
* Props passed to the root component.
*/
root?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
/**
* Props passed to the content component.
*/
content?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
};
}
function AnnouncementContainer(
{
component = 'div',
className,
children,
slotProps,
...props
}: AnnouncementContainerProps,
ref: ForwardedRef<HTMLDivElement>,
) {
return createElement(
component,
{
...props,
...(slotProps?.root || {}),
ref,
className: twMerge('w-full overflow-hidden', slotProps?.root?.className),
},
<div
{...(slotProps?.content || {})}
className={twMerge(
'mx-auto max-w-7xl px-5',
className,
slotProps?.content?.className,
)}
>
{children}
</div>,
);
}
export default forwardRef(AnnouncementContainer);

View File

@@ -1,92 +0,0 @@
import { Divider } from '@/components/ui/v2/Divider';
import {
createContext,
useEffect,
useMemo,
useState,
type PropsWithChildren,
type ReactNode,
} from 'react';
import { useInView } from 'react-intersection-observer';
import Announcement from './Announcement';
interface AnnouncementType {
id: string;
content: ReactNode;
href: string;
}
export interface AnnouncementContextProps {
/**
* The announcement to show.
*/
announcement?: AnnouncementType;
/**
* Whether or not to show the announcement.
*/
showAnnouncement?: boolean;
/**
* Function to close the announcement.
*/
handleClose?: () => void;
/**
* Whether or not the announcement is in view.
*/
inView?: boolean;
}
// Note: You can define the active announcement here.
const announcement: AnnouncementType = {
id: 'node-18',
href: 'https://github.com/nhost/nhost/discussions/2239',
content:
"Starting October 1st, we're upgrading to Node.js 18 for improved performance, security, and stability. Learn more.",
};
export const AnnouncementContext = createContext<AnnouncementContextProps>({});
export default function AnnouncementProvider({ children }: PropsWithChildren) {
const { ref, inView } = useInView();
const [showAnnouncement, setShowAnnouncement] = useState(false);
useEffect(() => {
if (
typeof window === 'undefined' ||
!announcement ||
window.localStorage.getItem(announcement.id) === '1'
) {
return;
}
setShowAnnouncement(true);
}, []);
function handleClose() {
setShowAnnouncement(false);
window.localStorage.setItem(announcement?.id, '1');
}
const announcementValue = useMemo(
() => ({ showAnnouncement, announcement, handleClose, inView }),
[inView, showAnnouncement],
);
return (
<AnnouncementContext.Provider value={announcementValue}>
{announcement && showAnnouncement && (
<>
<Announcement
ref={ref}
href={announcement.href}
onClose={handleClose}
>
{announcement.content}
</Announcement>
<Divider />
</>
)}
{children}
</AnnouncementContext.Provider>
);
}

View File

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

View File

@@ -1,14 +0,0 @@
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

@@ -0,0 +1,47 @@
import { List } from '@/components/ui/v2/List';
import { ListItem } from '@/components/ui/v2/ListItem';
import { Text } from '@/components/ui/v2/Text';
import { useGetAnnouncementsQuery } from '@/utils/__generated__/graphql';
import formatDistance from 'date-fns/formatDistance';
export default function Announcements() {
const { data, loading, error } = useGetAnnouncementsQuery();
const announcements = data?.announcements || [];
if (loading || error) {
return null;
}
return (
<section>
<Text color="secondary" className="mb-2">
Latest announcements
</Text>
<List className="relative space-y-4 border-l border-gray-200 dark:border-gray-700">
{announcements.map((item) => (
<ListItem.Root key={item.id} className="ml-4">
<div className="flex flex-col">
<time className="mb-1 text-sm font-normal leading-none text-gray-400 dark:text-gray-500">
{formatDistance(new Date(item.createdAt), new Date(), {
addSuffix: true,
})}
</time>
<a href={item.href} target="_blank" rel="noopener noreferrer">
<ListItem.Button
dense
aria-label={`View ${item.content}`}
className="!p-1"
>
<p className="text-sm">{item.content}</p>
</ListItem.Button>
</a>
</div>
<div className="absolute top-[0.15rem] -ml-[1.4rem] h-3 w-3 rounded-full border border-white bg-gray-200 dark:border-gray-900 dark:bg-gray-700" />
</ListItem.Root>
))}
</List>
</section>
);
}

View File

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

View File

@@ -8,6 +8,7 @@ import { PlusCircleIcon } from '@/components/ui/v2/icons/PlusCircleIcon';
import { List } from '@/components/ui/v2/List';
import { ListItem } from '@/components/ui/v2/ListItem';
import { Text } from '@/components/ui/v2/Text';
import { Announcements } from '@/features/projects/common/components/Announcements';
import { EditWorkspaceNameForm } from '@/features/projects/workspaces/components/EditWorkspaceNameForm';
import type { Workspace } from '@/types/application';
import Image from 'next/image';
@@ -38,6 +39,8 @@ export default function WorkspaceSidebar({
)}
{...props}
>
<Announcements />
<section className="grid grid-flow-row gap-2">
<Text color="secondary">My Workspaces</Text>

View File

@@ -111,7 +111,6 @@ export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndP
awsName: null,
domain: null,
},
isProvisioned: true,
createdAt: new Date().toISOString(),
desiredState: ApplicationStatus.Live,
featureFlags: [],

View File

@@ -128,6 +128,8 @@ export default function BaseEnvironmentVariableForm({
error={!!errors.value}
helperText={errors?.value?.message}
fullWidth
multiline
rows={5}
autoComplete="off"
autoFocus={mode === 'edit'}
/>

View File

@@ -4,7 +4,6 @@ import { ArrowRightIcon } from '@/components/ui/v2/icons/ArrowRightIcon';
import { Slider, sliderClasses } from '@/components/ui/v2/Slider';
import { Text } from '@/components/ui/v2/Text';
import { useProPlan } from '@/features/projects/common/hooks/useProPlan';
import { calculateBillableResources } from '@/features/projects/resources/settings/utils/calculateBillableResources';
import { getAllocatedResources } from '@/features/projects/resources/settings/utils/getAllocatedResources';
import { prettifyMemory } from '@/features/projects/resources/settings/utils/prettifyMemory';
import { prettifyVCPU } from '@/features/projects/resources/settings/utils/prettifyVCPU';
@@ -63,34 +62,7 @@ export default function TotalResourcesFormFragment({
(formValues.totalAvailableVCPU / RESOURCE_VCPU_MULTIPLIER) *
RESOURCE_VCPU_PRICE;
const billableResources = calculateBillableResources(
{
replicas: formValues.database?.replicas,
vcpu: formValues.database?.vcpu,
memory: formValues.database?.memory,
},
{
replicas: formValues.hasura?.replicas,
vcpu: formValues.hasura?.vcpu,
memory: formValues.hasura?.memory,
},
{
replicas: formValues.auth?.replicas,
vcpu: formValues.auth?.vcpu,
memory: formValues.auth?.memory,
},
{
replicas: formValues.storage?.replicas,
vcpu: formValues.storage?.vcpu,
memory: formValues.storage?.memory,
},
);
const updatedPrice =
Math.max(
priceForTotalAvailableVCPU,
(billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE,
) + proPlan.price;
const updatedPrice = priceForTotalAvailableVCPU + proPlan.price;
const { vcpu: allocatedVCPU, memory: allocatedMemory } =
getAllocatedResources(formValues);

View File

@@ -4,7 +4,6 @@ fragment Project on apps {
name
repositoryProductionBranch
subdomain
isProvisioned
createdAt
desiredState
nhostBaseFolder

View File

@@ -0,0 +1,14 @@
query getAnnouncements($limit: Int) {
announcements(
order_by: { createdAt: desc }
limit: $limit
where: {
_or: [{ expiresAt: { _is_null: true } }, { expiresAt: { _gt: now } }]
}
) {
id
href
content
createdAt
}
}

View File

@@ -1,4 +1,3 @@
import AnnouncementProvider from '@/components/common/Announcement/AnnouncementProvider';
import { DialogProvider } from '@/components/common/DialogProvider';
import { UIProvider } from '@/components/common/UIProvider';
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
@@ -106,9 +105,7 @@ function MyApp({
>
<RetryableErrorBoundary>
<DialogProvider>
<AnnouncementProvider>
{getLayout(<Component {...pageProps} />)}
</AnnouncementProvider>
{getLayout(<Component {...pageProps} />)}
</DialogProvider>
</RetryableErrorBoundary>
</ThemeProvider>

View File

@@ -48,7 +48,6 @@ export const mockApplication: Project = {
slug: 'test-application',
appStates: [],
subdomain: '',
isProvisioned: true,
region: {
awsName: 'us-east-1',
city: 'New York',

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,21 @@
# @nhost/docs
## 0.6.2
### Patch Changes
- 4fe4a1696: return `refreshToken` immediately after signIn and signUp
## 0.6.1
### Patch Changes
- 612d75496: updated postgres and graphql documentation
- 3b3e83a21: fix(docs): correct rendering of mermaid diagrams in dark mode
- 765b398b2: added jit settings documentation
- 30aae1557: minor fix to performance documentation
- a3efc1d13: docs: added storage/antivirus documentation
## 0.6.0
### Minor Changes

View File

@@ -0,0 +1,159 @@
---
title: 'Extensions'
sidebar_position: 4
---
## postgis
PostGIS extends the capabilities of the PostgreSQL relational database by adding support storing, indexing and querying geographic data.
### Managing
To install the extension you can create a migration with the following contents:
```
SET ROLE postgres;
CREATE EXTENSION postgis;
```
To uninstall it, you can use the following migration:
```
SET ROLE postgres;
DROP EXTENSION postgis;
```
### Resources
* [Official website](https://postgis.net)
## pgvector
Open-source vector similarity search for Postgres. Store your vectors with the rest of your data. Supports:
* exact and approximate nearest neighbor search
* L2 distance, inner product, and cosine distance
* any language with a Postgres client
Plus ACID compliance, point-in-time recovery, JOINs, and all of the other great features of Postgres
### Managing
To install the extension you can create a migration with the following contents:
```
SET ROLE postgres;
CREATE EXTENSION vector;
```
To uninstall it, you can use the following migration:
```
SET ROLE postgres;
DROP EXTENSION vector;
```
### Resources
* [GitHub](https://github.com/pgvector/pgvector)
## pg_cron
pg_cron is a simple cron-based job scheduler for PostgreSQL (10 or higher) that runs inside the database as an extension. It uses the same syntax as regular cron, but it allows you to schedule PostgreSQL commands directly from the database. You can also use '[1-59] seconds' to schedule a job based on an interval.
### Managing
To install the extension you can create a migration with the following contents:
```
SET ROLE postgres;
CREATE EXTENSION pg_cron;
```
To uninstall it, you can use the following migration:
```
SET ROLE postgres;
DROP EXTENSION pg_cron;
```
### Resources
* [GitHub](https://github.com/citusdata/pg_cron)
## hypopg
HypoPG is a PostgreSQL extension adding support for hypothetical indexes.
An hypothetical -- or virtual -- index is an index that doesn't really exists, and thus doesn't cost CPU, disk or any resource to create. They're useful to know if specific indexes can increase performance for problematic queries, since you can know if PostgreSQL will use these indexes or not without having to spend resources to create them.
### Managing
To install the extension you can create a migration with the following contents:
```
SET ROLE postgres;
CREATE EXTENSION hypopg;
```
To uninstall it, you can use the following migration:
```
SET ROLE postgres;
DROP EXTENSION hypopg;
```
### Resources
* [GitHub](https://github.com/HypoPG/hypopg)
* [Documentation](https://hypopg.readthedocs.io)
## timescaledb
TimescaleDB is an open-source database designed to make SQL scalable for time-series data. It is engineered up from PostgreSQL and packaged as a PostgreSQL extension, providing automatic partitioning across time and space (partitioning key), as well as full SQL support.
### Managing
To install the extension you can create a migration with the following contents:
```
SET ROLE postgres;
CREATE EXTENSION timescaledb;
```
To uninstall it, you can use the following migration:
```
SET ROLE postgres;
DROP EXTENSION timescaledb;
```
### Resources
* [GitHub](https://github.com/timescale/timescaledb)
* [Documentation](https://docs.timescale.com)
* [Website](https://www.timescale.com)
## pg_stat_statements
The pg_stat_statements module provides a means for tracking planning and execution statistics of all SQL statements executed by a server.
### Managing
To install the extension you can create a migration with the following contents:
```
SET ROLE postgres;
CREATE EXTENSION pg_stat_statements;
```
To uninstall it, you can use the following migration:
```
SET ROLE postgres;
DROP EXTENSION pg_stat_statements;
```
### Resources
* [Documentation](https://www.postgresql.org/docs/14/pgstatstatements.html)

View File

@@ -0,0 +1,89 @@
---
title: 'Performance'
sidebar_position: 4
---
Ensuring a healthy and performant PostgreSQL service is crucial as it directly impacts the overall response time and stability of your backend. Since Postgres serves as the centerpiece of your backend, prioritize the optimization and maintenance of your Postgres service to achieve the desired performance and reliability.
In case your Postgres service is not meeting your performance expectations, you can explore the following options:
1. Consider upgrading your [dedicated compute](/platform/compute) resources to provide more processing power and memory to the Postgres server.
2. Fine-tune the configuration parameters of Postgres to optimize its performance. Adjust settings such as `shared_buffers`, `work_mem`, and `effective_cache_size` to better align with your workload and server resources.
3. Identify and analyze slow-performing queries using tools like query logs or query monitoring extensions. Optimize or rewrite these queries to improve their efficiency.
4. Evaluate the usage of indexes in your database. Identify queries that could benefit from additional indexes and strategically add them to improve query performance.
By implementing these steps, you can effectively address performance concerns and enhance the overall performance of your Postgres service.
## Upgrade to our latest postgres image
Before trying anything else, always upgrade to our latest postgres image first. You can find our availables images in the dashbhoard, under your database settings.
## Upgrading dedicated compute
Increasing CPU and memory is the simplest way to address performance issues. You can read more about compute resources [here](/platform/compute).
## Fine-tune configuration parameters
When optimizing your Postgres setup, you can consider adjusting various Postgres settings. You can find a list of these parameters [here](/database/settings). Keep in mind that the optimal values for these parameters will depend on factors such as available resources, workload, and data distribution.
To help you get started, you can use [pgtune](https://pgtune.leopard.in.ua) as a reference tool. Pgtune can generate recommended configuration settings based on your system specifications. By providing information about your system, it can suggest parameter values that may be a good starting point for optimization.
However, it's important to note that the generated settings from pgtune are not guaranteed to be the best for your specific environment. It's always recommended to review and customize the suggested settings based on your particular requirements, performance testing, and ongoing monitoring of your Postgres database.
## Identifying slow queries
Monitoring slow queries is a highly effective method for tackling performance issues. Several tools leverage [pg_stat_statements](https://www.postgresql.org/docs/14/pgstatstatements.html), a PostgreSQL extension, to provide constant monitoring. You can employ these tools to identify and address slow queries in real-time.
### pghero
[PgHero](https://github.com/ankane/pghero) is one of such tools you can use to idenfity and address slow queries. You can easily run pghero alongside your postgres with [Nhost Run](/run):
1. First, make sure the extension [pg_stat_statements](/database/extensions#pg_stat_statements) is enabled.
2. Click on this [one-click install link](https://app.nhost.io:/run-one-click-install?config=eyJuYW1lIjoicGdoZXJvIiwiaW1hZ2UiOnsiaW1hZ2UiOiJkb2NrZXIuaW8vYW5rYW5lL3BnaGVybzpsYXRlc3QifSwiY29tbWFuZCI6W10sInJlc291cmNlcyI6eyJjb21wdXRlIjp7ImNwdSI6MTI1LCJtZW1vcnkiOjI1Nn0sInN0b3JhZ2UiOltdLCJyZXBsaWNhcyI6MX0sImVudmlyb25tZW50IjpbeyJuYW1lIjoiREFUQUJBU0VfVVJMIiwidmFsdWUiOiJwb3N0Z3JlczovL3Bvc3RncmVzOltQQVNTV09SRF1AcG9zdGdyZXMtc2VydmljZTo1NDMyL1tTVUJET01BSU5dP3NzbG1vZGU9ZGlzYWJsZSJ9LHsibmFtZSI6IlBHSEVST19VU0VSTkFNRSIsInZhbHVlIjoiW1VTRVJdIn0seyJuYW1lIjoiUEdIRVJPX1BBU1NXT1JEIiwidmFsdWUiOiJbUEFTU1dPUkRdIn1dLCJwb3J0cyI6W3sicG9ydCI6ODA4MCwidHlwZSI6Imh0dHAiLCJwdWJsaXNoIjp0cnVlfV19)
3. Select your project:
![select your project](/img/database/performance/pghero_01.png)
4. Replace the placeholders with your postgres password, subdomain and a user and password to protect your pghero service. Finally, click on create.
![fill run service details](/img/database/performance/pghero_02.png)
5. After confirming the service, copy the URL:
![run service details](/img/database/performance/pghero_03.png)
6. Finally, you can open the link you just copied to access pghero:
![pghero](/img/database/performance/pghero_04.png)
:::info
When you create a new service, it can take a few minutes for the DNS (Domain Name System) to propagate. If your browser displays an error stating that it couldn't find the server or website, simply wait for a couple of minutes and then try again.
:::
After successfully setting up pghero, it will begin displaying slow queries, suggesting index proposals, and offering other valuable information. Utilize this data to enhance your service's performance.
## Adding indexes
Indexes can significantly enhance the speed of data retrieval. However, it's essential to be aware that they introduce additional overhead during mutations. Therefore, understanding your workload is crucial before opting to add an index.
There are tools you can use to help analyze your workload and detect missing indexes.
### pghero
[PgHero](https://github.com/ankane/pghero), in addition to help with slow queries, can also help finding missing and duplicate indexes. See previous section on how to deploy pghero with [Nhost Run](/run).
### dexter
[Dexter](https://github.com/ankane/dexter) can leverage both [pg_stat_statements](https://www.postgresql.org/docs/14/pgstatstatements.html) and [hypopg](https://hypopg.readthedocs.io/en/rel1_stable/) to find and evaluate indexes. You can run dexter directly from your machine:
1. Enable [hypopg](/database/extensions#hypopg)
2. Execute the command `docker run --rm -it ankane/dexter [POSTGRES_CONN_STRING] --pg-stat-statements`
```
$ docker run --rm -it ankane/dexter [POSTGRES_CONN_STRING] --pg-stat-statements
Processing 1631 new query fingerprints
No new indexes found
```

View File

@@ -0,0 +1,84 @@
---
title: 'Settings'
sidebar_position: 3
---
import Tabs from '@theme/Tabs'
import TabItem from '@theme/TabItem'
Below you can find the official schema (cue) and an example to configure your postgres database:
<Tabs groupId="package-manager">
<TabItem value="schema" label="schema">
```cue
#Postgres: {
version: string | *"14.6-20230705-1"
// Resources for the service, optional
resources?: #Resources & {
replicas: 1
}
// postgres settings of the same name in camelCase, optional
settings?: {
jit: "off" | "on" | *"on"
maxConnections: int32 | *100
sharedBuffers: string | *"128MB"
effectiveCacheSize: string | *"4GB"
maintenanceWorkMem: string | *"64MB"
checkpointCompletionTarget: number | *0.9
walBuffers: int32 | *-1
defaultStatisticsTarget: int32 | *100
randomPageCost: number | *4.0
effectiveIOConcurrency: int32 | *1
workMem: string | *"4MB"
hugePages: string | *"try"
minWalSize: string | *"80MB"
maxWalSize: string | *"1GB"
maxWorkerProcesses: int32 | *8
maxParallelWorkersPerGather: int32 | *2
maxParallelWorkers: int32 | *8
maxParallelMaintenanceWorkers: int32 | *2
}
}
```
</TabItem>
<TabItem value="toml" label="toml" default>
```toml
[postgres]
version = '14.6-20230925-1'
[postgres.resources.compute]
cpu = 1000
memory = 2048
[postgres.settings]
jit = "off"
maxConnections = 100
sharedBuffers = '256MB'
effectiveCacheSize = '768MB'
maintenanceWorkMem = '64MB'
checkpointCompletionTarget = 0.9
walBuffers = -1
defaultStatisticsTarget = 100
randomPageCost = 1.1
effectiveIOConcurrency = 200
workMem = '1310kB'
hugePages = 'off'
minWalSize = '80MB'
maxWalSize = '1GB'
maxWorkerProcesses = 8
maxParallelWorkersPerGather = 2
maxParallelWorkers = 8
maxParallelMaintenanceWorkers = 2
```
</TabItem>
</Tabs>
:::info
At the time of writing this document postgres settings are only supported via the [configuration file](https://nhost.io/blog/config).
:::

View File

@@ -144,6 +144,14 @@ One of the most common permission requirements is that authenticated users shoul
1. Select the **columns** you want the user to be able to read. In our case, we'll allow the user to read all columns.
1. Click **Save**.
## Known issues
### Permissions are slow
In certain situations, permission checks can cause significant delays. One way to identify this issue is by comparing the execution time of a GraphQL query when performed as an admin versus as a regular user. To resolve such cases, disabling the Just-in-Time (JIT) compilation in [Postgres](/database/settings) can be beneficial.
[Github issue](https://github.com/hasura/graphql-engine/issues/3672)
## Next Steps
Hasura has more in-depth documentation related to permissions that you can learn from:

View File

@@ -0,0 +1,174 @@
---
title: 'Settings'
sidebar_position: 3
---
import Tabs from '@theme/Tabs'
import TabItem from '@theme/TabItem'
Below you can find the official schema (cue) and an example to configure your graphql service:
<Tabs groupId="package-manager">
<TabItem value="schema" label="schema">
```cue
// Configuration for hasura service
#Hasura: {
// Version of hasura, you can see available versions in the URL below:
// https://hub.docker.com/r/hasura/graphql-engine/tags
version: string | *"v2.33.4-ce"
// JWT Secrets configuration
jwtSecrets: [#JWTSecret]
// Admin secret
adminSecret: string
// Webhook secret
webhookSecret: string
// Configuration for hasura services
// Reference: https://hasura.io/docs/latest/deployment/graphql-engine-flags/reference/
settings: {
// HASURA_GRAPHQL_CORS_DOMAIN
corsDomain: [...#Url] | *["*"]
// HASURA_GRAPHQL_DEV_MODE
devMode: bool | *true
// HASURA_GRAPHQL_ENABLE_ALLOWLIST
enableAllowList: bool | *false
// HASURA_GRAPHQL_ENABLE_CONSOLE
enableConsole: bool | *true
// HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS
enableRemoteSchemaPermissions: bool | *false
// HASURA_GRAPHQL_ENABLED_APIS
enabledAPIs: [...#HasuraAPIs] | *["metadata", "graphql", "pgdump", "config"]
// HASURA_GRAPHQL_LIVE_QUERIES_MULTIPLEXED_REFETCH_INTERVAL
liveQueriesMultiplexedRefetchInterval: uint32 | *1000
}
logs: {
// HASURA_GRAPHQL_LOG_LEVEL
level: "debug" | "info" | "error" | *"warn"
}
events: {
// HASURA_GRAPHQL_EVENTS_HTTP_POOL_SIZE
httpPoolSize: uint32 & >=1 & <=100 | *100
}
// Resources for the service
resources?: #Resources
}
```
</TabItem>
<TabItem value="toml" label="toml" default>
```toml
[hasura]
version = ''
adminSecret = 'adminsecret'
webhookSecret = 'webhooksecret'
[[hasura.jwtSecrets]]
type = 'HS256'
key = 'secret'
[hasura.settings]
corsDomain = ['*']
devMode = false
enableAllowList = true
enableConsole = true
enableRemoteSchemaPermissions = true
enabledAPIs = ['metadata']
liveQueriesMultiplexedRefetchInterval = 1000
[hasura.logs]
level = 'warn'
[hasura.events]
httpPoolSize = 10
[hasura.resources]
replicas = 1
[hasura.resources.compute]
cpu = 500
memory = 1024
```
</TabItem>
</Tabs>
### JWT Secret
All formats supported by [hasura](https://hasura.io/docs/latest/auth/authentication/jwt/) should be supported:
<Tabs groupId="package-manager">
<TabItem value="schema" label="schema" default>
```cue
#JWTSecret:
({
type: "HS384" | "HS512" | "RS256" | "RS384" | "RS512" | "Ed25519" | *"HS256"
key: string
} |
{
jwk_url: #Url | *null
}) &
{
claims_format?: "stringified_json" | *"json"
audience?: string
issuer?: string
allowed_skew?: uint32
header?: string
} & {
claims_map?: [...#ClaimMap]
} &
({
claims_namespace: string | *"https://hasura.io/jwt/claims"
} |
{
claims_namespace_path: string
} | *{})
#ClaimMap: {
claim: string
{
value: string
} | {
path: string
default?: string
}
} & {
}
```
</TabItem>
<TabItem value="toml" label="toml">
```toml
# example 1
[[hasura.jwtSecrets]]
type = 'HS256'
key = 'secret'
# example 2
[[hasura.jwtSecrets]]
jwk_url = 'https:/....'
# example 3
[[hasura.jwtSecrets]]
jwk_url = "https://......"
issuer = "https://my-auth-server.com"
[[hasura.jwtSecrets.claims_map]]
claim = "x-some-claim"
value = "some-value"
[[hasura.jwtSecrets.claims_map]]
claim = "x-other-claim"
path = "$.user.claim.id"
default = "default-value"
```
</TabItem>
</Tabs>

View File

@@ -0,0 +1,4 @@
{
"label": "Storage",
"position": 7
}

44
docs/docs/storage/av.mdx Normal file
View File

@@ -0,0 +1,44 @@
---
title: Antivirus
sidebar_label: Antivirus
sidebar_position: 2
---
import Tabs from '@theme/Tabs'
import TabItem from '@theme/TabItem'
Integration with [clamav](https://www.clamav.net) antivirus relies on an external [clamd](https://docs.clamav.net/manual/Usage/Scanning.html#clamd) service. When a file is uploaded `hasura-storage` will create the file metadata first and then check if the file is clean with `clamd` via its TCP socket. If the file is clean the rest of the process will continue as usual. If a virus is found details about the virus will be added to the `virus` table and the rest of the process will be aborted.
``` mermaid
sequenceDiagram
actor User
User ->> storage: upload file
storage ->>clamav: check for virus
alt virus found
storage-->s3: abort upload
storage->>graphql: insert row in virus table
else virus not found
storage->>s3: upload
storage->>graphql: update metadata
end
```
To enable the antivirus you need to follow the next steps:
1. Deploy using [Nhost Run](/run) a dedicated instance of `clamd` with this [one-click install link](https://app.nhost.io:/run-one-click-install?config=eyJuYW1lIjoiY2xhbWF2IiwiaW1hZ2UiOnsiaW1hZ2UiOiJkb2NrZXIuaW8vbmhvc3QvY2xhbWF2OjAuMS4xIn0sImNvbW1hbmQiOltdLCJyZXNvdXJjZXMiOnsiY29tcHV0ZSI6eyJjcHUiOjEwMDAsIm1lbW9yeSI6MjA0OH0sInN0b3JhZ2UiOltdLCJyZXBsaWNhcyI6MX0sImVudmlyb25tZW50IjpbXSwicG9ydHMiOlt7InBvcnQiOiIzMzEwIiwidHlwZSI6InRjcCIsInB1Ymxpc2giOmZhbHNlfV19).
2. Select the project:
![select project](/img/storage/av_01.png)
3. Click on "Create":
![click on create](/img/storage/av_02.png)
4. Make sure you are running **at least** storage version 0.4.0 and enable the antivirus:
![update settings](/img/storage/av_03.png)
5. Wait for the service to update and try to upload a sample virus file like [eicar](https://www.eicar.org/download-anti-malware-testfile/)
![upload virus](/img/storage/av_04.png)
6. If the setup is working the upload should fail
![upload fails](/img/storage/av_05.png)
7. You can also head to hasura and verify entries were added to the `virus` table:
![click on create](/img/storage/av_06.png)
That entry should have useful information about like the filename, the virus found and the user session. In addition, the information on that table can be used a source for events.

View File

@@ -0,0 +1,57 @@
---
title: "Example: CRM System"
sidebar_label: "Example: CRM System"
sidebar_position: 3
---
Let's say you want to build a CRM system and you want to store files for customers. This is one way how you could do that.
Start with, you would have two tables:
1. `customers` - Customer data.
2. `customer_files` - What file belongs to what customer
```text
- customers
- id
- name
- address
customer_files
- id
- customer_id (Foreign Key to `customers.id`)
- file_id (Foreign Key to `storage.files.id`)
```
You would also create a [Hasura Relationship](https://hasura.io/docs/latest/graphql/core/databases/postgres/schema/table-relationships/index/) (GraphQL relationship) between between `customers` and `customer_files` and between `customer_files` and `storage.files`.
With the two tables and GraphQL relationships in place, you can query customers and the customer's files like this:
```graphql
query {
customers {
# customers table
id
name
customer_files {
# customer_files table
id
file {
# storage.files table
id
name
size
mimeType
}
}
}
}
```
The file upload process would be as follows:
1. Upload a file.
2. Get the returned file id.
3. Insert (GraphQL Mutation) the file `id` and the customer's `id` into the `customer_files` table.
This would allow you to upload and download files belonging to specific customers in your CRM system.

View File

@@ -1,7 +1,7 @@
---
title: 'Storage'
sidebar_position: 7
title: 'Overview'
image: /img/og/storage.png
sidebar_position: 1
---
import Tabs from '@theme/Tabs'
@@ -195,57 +195,3 @@ Image Transformation works on both public and pre-signed URLs.
```text
https://[subdomain].storage.[region].nhost.run/v1/files/08e6fa32-0880-4d0e-a832-278198acb363?w=500
```
## Example: CRM System
Let's say you want to build a CRM system and you want to store files for customers. This is one way how you could do that.
Start with, you would have two tables:
1. `customers` - Customer data.
2. `customer_files` - What file belongs to what customer
```text
- customers
- id
- name
- address
customer_files
- id
- customer_id (Foreign Key to `customers.id`)
- file_id (Foreign Key to `storage.files.id`)
```
You would also create a [Hasura Relationship](https://hasura.io/docs/latest/graphql/core/databases/postgres/schema/table-relationships/index/) (GraphQL relationship) between between `customers` and `customer_files` and between `customer_files` and `storage.files`.
With the two tables and GraphQL relationships in place, you can query customers and the customer's files like this:
```graphql
query {
customers {
# customers table
id
name
customer_files {
# customer_files table
id
file {
# storage.files table
id
name
size
mimeType
}
}
}
}
```
The file upload process would be as follows:
1. Upload a file.
2. Get the returned file id.
3. Insert (GraphQL Mutation) the file `id` and the customer's `id` into the `customer_files` table.
This would allow you to upload and download files belonging to specific customers in your CRM system.

View File

@@ -26,6 +26,10 @@ const config = {
favicon: 'img/favicon.png',
organizationName: 'nhost',
projectName: 'docs',
markdown: {
mermaid: true
},
themes: ['@docusaurus/theme-mermaid'],
scripts: [
{ src: 'https://plausible.io/js/script.js', defer: true, 'data-domain': 'docs.nhost.io' }
],
@@ -44,7 +48,6 @@ const config = {
routeBasePath: '/',
breadcrumbs: false,
sidebarPath: require.resolve('./sidebars.js'),
remarkPlugins: [require('mdx-mermaid')],
editUrl: 'https://github.com/nhost/nhost/edit/main/docs/'
},
theme: {
@@ -177,6 +180,7 @@ const config = {
theme: lightCodeTheme,
darkTheme: darkCodeTheme,
defaultLanguage: 'javascript',
additionalLanguages: ['cue', 'toml'],
magicComments: [
{
className: 'code-block-error-line',

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/docs",
"version": "0.6.0",
"version": "0.6.2",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
@@ -19,12 +19,13 @@
"@docusaurus/core": "2.4.1",
"@docusaurus/plugin-sitemap": "2.4.1",
"@docusaurus/preset-classic": "2.4.1",
"@docusaurus/theme-mermaid": "2.4.1",
"@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1",
"docusaurus-plugin-image-zoom": "^0.1.1",
"mdx-mermaid": "^1.3.2",
"mermaid": "^9.0.0",
"prism-react-renderer": "^1.3.5",
"prismjs": "^1.29.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"unist-util-visit": "^2.0.0"

View File

@@ -44,7 +44,16 @@ const sidebars = {
}
]
},
'storage',
{
type: 'category',
label: 'Storage',
items: [
{
type: 'autogenerated',
dirName: 'storage'
}
]
},
'serverless-functions',
{
type: 'category',

View File

@@ -0,0 +1,2 @@
import type * as PrismNamespace from 'prismjs';
export default function prismIncludeLanguages(PrismObject: typeof PrismNamespace): void;

View File

@@ -0,0 +1,20 @@
/*global globalThis*/
import siteConfig from '@generated/docusaurus.config'
export default function prismIncludeLanguages(PrismObject) {
const {
themeConfig: { prism }
} = siteConfig
const { additionalLanguages } = prism
// Prism components work on the Prism instance on the window, while prism-
// react-renderer uses its own Prism instance. We temporarily mount the
// instance onto window, import components to enhance it, then remove it to
// avoid polluting global namespace.
// You can mutate PrismObject: registering plugins, deleting languages... As
// long as you don't re-assign it
globalThis.Prism = PrismObject
additionalLanguages.forEach((lang) => {
// eslint-disable-next-line global-require, import/no-dynamic-require
require(`prismjs/components/prism-${lang}`)
})
delete globalThis.Prism
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

BIN
docs/static/img/storage/av_01.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
docs/static/img/storage/av_02.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

BIN
docs/static/img/storage/av_03.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

BIN
docs/static/img/storage/av_04.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

BIN
docs/static/img/storage/av_05.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

BIN
docs/static/img/storage/av_06.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

View File

@@ -0,0 +1,2 @@
NEXT_PUBLIC_NHOST_SUBDOMAIN=local
NEXT_PUBLIC_NHOST_REGION=

View File

@@ -0,0 +1,6 @@
module.exports = {
extends: ['../../config/.eslintrc.js', 'plugin:@next/next/recommended'],
rules: {
'react/react-in-jsx-scope': 'off'
}
}

View File

@@ -0,0 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,11 @@
# @nhost-examples/nextjs-server-components
## 0.1.0
### Minor Changes
- 4fe4a1696: new quickstart project that demonstrates how to use the Nhost SDK with Next.js 13 server components
### Patch Changes
- @nhost/nhost-js@2.2.17

View File

@@ -0,0 +1,70 @@
# Nhost with Next.js Server Components
This quickstart showcases how to correctly add authentication to a Next.js 13 project using the new App Router and Server Components. The other parts of the SDK (Storage / GraphQL/ Functions) should work the same as before.
## Authentication
1. **Saving the auth session**
To enable authentication with Server Components we have to store the auth session in a cookie. This should be done right after any **signIn** or **signUp** operation. See example [here](https://github.com/nhost/nhost/blob/main/examples/quickstarts/nextjs-server-components/src/app/server-actions/auth/sign-in-email-password.ts).
2. **Oauth & refresh session middleware**
Create a middleware at the root of your project that calls the helper method `manageAuthSession`. Feel free to copy paste the the contents of the `/utils` folder to your project. The second argument for `manageAuthSession` is for handling the case where there's an error refreshing the current session with the `refreshToken` stored in the cookie.
```typescript
import { manageAuthSession } from '@utils/nhost'
import { NextRequest, NextResponse } from 'next/server'
export async function middleware(request: NextRequest) {
return manageAuthSession(request, () =>
NextResponse.redirect(new URL('/auth/sign-in', request.url))
)
}
```
3. **Protected routes**
To make sure only authenticated users access some Server Components, wrap them in the Higher Order Server Component `withAuthAsync`.
```typescript
import withAuthAsync from '@utils/auth-guard'
const MyProtectedServerComponent = async () => {
return <h2>Protected</h2>
}
export default withAuthAsync(MyProtectedServerComponent)
```
## 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. Terminal 1: Start the Nhost Backend
> Make sure you have the [Nhost CLI installed](https://docs.nhost.io/platform/cli).
```sh
cd examples/quickstarts/nhost-backend
nhost up
```
4. Terminal 2: Start the Next.js application
```sh
cd examples/quickstarts/nextjs-server-components
pnpm dev
```

View File

@@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: true
}
}
module.exports = nextConfig

View File

@@ -0,0 +1,35 @@
{
"name": "@nhost-examples/nextjs-server-components",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@apollo/client": "^3.8.2",
"@nhost/nhost-js": "workspace:^",
"autoprefixer": "10.4.15",
"cookies-next": "^3.0.0",
"eslint": "8.48.0",
"eslint-config-next": "13.4.19",
"form-data": "^4.0.0",
"js-cookie": "^3.0.5",
"next": "13.4.19",
"postcss": "8.4.29",
"react": "18.2.0",
"react-dom": "18.2.0",
"tailwind-merge": "^1.8.0",
"tailwindcss": "3.3.3",
"typescript": "5.2.2",
"xstate": "^4.38.2"
},
"devDependencies": {
"@types/js-cookie": "^3.0.2",
"@types/node": "20.5.6",
"@types/react": "18.2.21",
"@types/react-dom": "18.2.7"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -0,0 +1,36 @@
'use client'
import Input from '@components/input'
import SubmitButton from '@components/submit-button'
import { signIn } from '@server-actions/auth'
import { useState } from 'react'
export default function SignInWithEmailAndPassword() {
const [error, setError] = useState('')
async function handleSignIn(formData: FormData) {
const response = await signIn(formData)
if (response?.error) {
setError(response.error)
}
}
return (
<div className="flex flex-col items-center">
<h1 className="text-2xl font-semibold text-center">Sign in with email and password</h1>
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
<form className="w-full max-w-lg space-y-5" action={handleSignIn}>
<Input label="Email" id="email" name="email" type="email" required />
<Input label="Password" id="password" name="password" type="password" required />
<SubmitButton type="submit" className="w-full">
Sign in
</SubmitButton>
</form>
</div>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import Input from '@components/input'
import SubmitButton from '@components/submit-button'
import { NhostClient } from '@nhost/nhost-js'
import { useState, type FormEvent } from 'react'
const nhost = new NhostClient({
subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN,
region: process.env.NEXT_PUBLIC_NHOST_REGION
})
export default function SignInMagickLink() {
const [email, setEmail] = useState('')
const [error, setError] = useState('')
const [isSuccess, setIsSuccess] = useState(false)
const handleSignIn = async (e: FormEvent) => {
e.preventDefault()
const { error } = await nhost.auth.signIn({ email })
if (error) {
setError(error.message)
} else {
setIsSuccess(true)
}
}
return (
<div className="flex flex-col items-center">
<h1 className="text-2xl font-semibold text-center">Sign in with a magick link</h1>
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
{isSuccess && (
<p className="mt-3 font-semibold text-center text-green-500">
Click the link in the email to finish the sign in process
</p>
)}
<form className="w-full max-w-lg space-y-5" onSubmit={handleSignIn}>
<Input
label="Email"
id="email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
required
/>
<SubmitButton type="submit" className="w-full">
Sign In
</SubmitButton>
</form>
</div>
)
}

View File

@@ -0,0 +1,67 @@
'use client'
import { signInWithGoogle } from '@server-actions/auth'
import { useRouter } from 'next/navigation'
export default function SignIn() {
const router = useRouter()
return (
<div className="container flex justify-center">
<div className="w-full max-w-lg space-y-5">
<h1 className="text-2xl font-semibold text-center">Sign In</h1>
<button
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
onClick={() => router.push('/auth/sign-in/email-password')}
>
with email/password
</button>
<button
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
onClick={() => router.push('/auth/sign-in/webauthn')}
>
with a security key
</button>
<button
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
onClick={() => router.push('/auth/sign-in/magick-link')}
>
with a magick link
</button>
<button
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
onClick={() => router.push('/auth/sign-in/pat')}
>
with a Personal Access Token
</button>
<button
type="button"
className="text-white w-full bg-[#4285F4] hover:bg-[#4285F4]/90 focus:ring-4 focus:outline-none focus:ring-[#4285F4]/50 font-medium rounded-lg px-5 py-2.5 text-center inline-flex items-center justify-between dark:focus:ring-[#4285F4]/55 mr-2 mb-2"
onClick={() => signInWithGoogle()}
>
<svg
className="w-4 h-4 mr-2 -ml-1"
aria-hidden="true"
focusable="false"
data-prefix="fab"
data-icon="google"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 488 512"
>
<path
fill="currentColor"
d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z"
/>
</svg>
with Google <span />
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
'use client'
import Input from '@components/input'
import SubmitButton from '@components/submit-button'
import { signInWithPAT } from '@server-actions/auth'
import { useState } from 'react'
export default function SignInWithPAT() {
const [error, setError] = useState('')
async function handleSignIn(formData: FormData) {
const response = await signInWithPAT(formData)
if (response?.error) {
setError(response.error)
}
}
return (
<div className="flex flex-col items-center gap-4">
<h1 className="text-2xl font-semibold text-center">Sign In with Personal Access Token</h1>
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
<form className="w-full max-w-lg space-y-5" action={handleSignIn}>
<Input label="PAT" id="pat" name="pat" required />
<SubmitButton type="submit" className="w-full">
Sign In
</SubmitButton>
</form>
</div>
)
}

View File

@@ -0,0 +1,62 @@
'use client'
import Input from '@components/input'
import SubmitButton from '@components/submit-button'
import { NhostClient } from '@nhost/nhost-js'
import Cookies from 'js-cookie'
import { useRouter } from 'next/navigation'
import { useState, type FormEvent } from 'react'
const NHOST_SESSION_KEY = 'nhostSession'
const nhost = new NhostClient({
subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN,
region: process.env.NEXT_PUBLIC_NHOST_REGION
})
export default function SignInWithSecurityKey() {
const router = useRouter()
const [error, setError] = useState('')
const [email, setEmail] = useState('')
const handleSignIn = async (e: FormEvent) => {
e.preventDefault()
const { session, error } = await nhost.auth.signIn({
email,
securityKey: true
})
if (error) {
setError(error.message)
}
if (session) {
Cookies.set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { sameSite: 'strict' })
router.push('/protected/todos')
}
}
return (
<div className="flex flex-col items-center">
<h1 className="text-2xl font-semibold text-center">Sign In</h1>
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
<form className="w-full max-w-lg space-y-5" onSubmit={handleSignIn}>
<Input
label="Email"
id="email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
required
/>
<SubmitButton type="submit" className="w-full">
Sign In
</SubmitButton>
</form>
</div>
)
}

View File

@@ -0,0 +1,34 @@
'use client'
import Input from '@components/input'
import SubmitButton from '@components/submit-button'
import { signUp } from '@server-actions/auth'
import { useState } from 'react'
export default function SignUpWithEmailAndPassword() {
const [error, setError] = useState('')
async function handleSignUp(formData: FormData) {
const response = await signUp(formData)
if (response?.error) {
setError(response.error)
}
}
return (
<>
<h1 className="text-2xl font-semibold text-center">Sign Up</h1>
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
<form className="space-y-5" action={handleSignUp}>
<Input label="First Name" id="firstName" name="firstName" required />
<Input label="Last Name" id="lastName" name="lastName" required />
<Input label="Email" id="email" name="email" type="email" required />
<Input label="Password" id="password" name="password" type="password" required />
<SubmitButton type="submit">Sign Up</SubmitButton>
</form>
</>
)
}

View File

@@ -0,0 +1,53 @@
'use client'
import { signInWithGoogle } from '@server-actions/auth'
import { useRouter } from 'next/navigation'
export default function SignUp() {
const router = useRouter()
return (
<div className="container flex justify-center">
<div className="w-full max-w-lg space-y-5">
<h1 className="text-2xl font-semibold text-center">Sign Up</h1>
<button
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
onClick={() => router.push('/auth/sign-up/email-password')}
>
with email/password
</button>
<button
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
onClick={() => router.push('/auth/sign-up/webauthn')}
>
with a security key
</button>
<button
type="button"
className="text-white w-full bg-[#4285F4] hover:bg-[#4285F4]/90 focus:ring-4 focus:outline-none focus:ring-[#4285F4]/50 font-medium rounded-lg px-5 py-2.5 text-center inline-flex items-center justify-between dark:focus:ring-[#4285F4]/55 mr-2 mb-2"
onClick={() => signInWithGoogle()}
>
<svg
className="w-4 h-4 mr-2 -ml-1"
aria-hidden="true"
focusable="false"
data-prefix="fab"
data-icon="google"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 488 512"
>
<path
fill="currentColor"
d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z"
/>
</svg>
with Google <span />
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,67 @@
'use client'
import Input from '@components/input'
import SubmitButton from '@components/submit-button'
import { NhostClient } from '@nhost/nhost-js'
import Cookies from 'js-cookie'
import { useRouter } from 'next/navigation'
import { useState, type FormEvent } from 'react'
const NHOST_SESSION_KEY = 'nhostSession'
const nhost = new NhostClient({
subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN,
region: process.env.NEXT_PUBLIC_NHOST_REGION
})
export default function SignUpWebAuthn() {
const router = useRouter()
const [error, setError] = useState('')
const [email, setEmail] = useState('')
const handleSignUp = async (e: FormEvent) => {
e.preventDefault()
const { session, error } = await nhost.auth.signUp({
email,
securityKey: true
})
if (error) {
setError(error.message)
}
console.log({
handleSignUpSession: session
})
if (session) {
Cookies.set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { sameSite: 'strict' })
router.push('/protected/todos')
}
}
return (
<div className="flex flex-col items-center">
<h1 className="text-2xl font-semibold text-center">Sign Up with a security key</h1>
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
<form className="w-full max-w-lg space-y-5" onSubmit={handleSignUp}>
<Input
label="Email"
id="email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
required
/>
<SubmitButton type="submit" className="w-full">
Sign Up
</SubmitButton>
</form>
</div>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

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

View File

@@ -0,0 +1,24 @@
import Navigation from '@components/navigation'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app'
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={inter.className}>
<div className="app">
<Navigation />
<div className="container p-4 mx-auto mt-8 antialiased">{children}</div>
</div>
</body>
</html>
)
}

View File

@@ -0,0 +1,3 @@
export default function Home() {
return <h1 className="text-2xl text-center">Hi, login/register to get started</h1>
}

View File

@@ -0,0 +1,19 @@
import withAuth from '@utils/auth-guard'
import { getNhost } from '@utils/nhost'
type EchoResponse = {
headers: Record<string, string>
}
const Echo = async () => {
const nhost = await getNhost()
const { res } = await nhost.functions.call<EchoResponse>('echo')
return (
<div>
<pre className="overflow-auto">{JSON.stringify(res?.data.headers, null, 2)}</pre>
</div>
)
}
export default withAuth(Echo)

View File

@@ -0,0 +1,103 @@
import { gql } from '@apollo/client'
import PatItem, { type PAT } from '@components/pat-item'
import withAuthAsync from '@utils/auth-guard'
import { getNhost } from '@utils/nhost'
import Head from 'next/head'
import Link from 'next/link'
const PAT = async ({
params
}: {
params: {
[key: string]: string | string[] | undefined
}
}) => {
const page = parseInt(params.pagination?.at(0) || '0')
const nhost = await getNhost()
const {
data: {
authRefreshTokens,
authRefreshTokensAggregate: {
aggregate: { count }
}
}
} = await nhost.graphql.request(
gql`
query getPersonalAccessTokens($offset: Int, $limit: Int) {
authRefreshTokens(
offset: $offset
limit: $limit
order_by: { createdAt: desc }
where: { type: { _eq: pat } }
) {
id
metadata
type
expiresAt
}
authRefreshTokensAggregate(where: { type: { _eq: pat } }) {
aggregate {
count
}
}
}
`,
{
offset: page * 10,
limit: 10
}
)
return (
<div className="flex flex-col space-y-4">
<Head>
<title>Personal Access Tokens</title>
</Head>
<div className="flex items-center justify-between w-full">
<h2 className="text-xl">Personal Access Tokens ({count})</h2>
<Link
href={`/protected/pat/new`}
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
>
Add a PAT
</Link>
</div>
<ul className="space-y-1">
{authRefreshTokens.map((token: PAT) => (
<li key={token.id}>
<PatItem pat={token} />
</li>
))}
</ul>
{count > 10 && (
<div className="flex justify-center space-x-2">
{page > 0 && (
<Link
href={`/protected/pat/${page - 1}`}
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
>
Previous
</Link>
)}
{page + 1 < Math.ceil(count / 10) && (
<Link
href={`/protected/pat/${page + 1}`}
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
>
Next
</Link>
)}
</div>
)}
</div>
)
}
export default withAuthAsync(PAT)

View File

@@ -0,0 +1,13 @@
import PATForm from '@components/pat-form'
import withAuthAsync from '@utils/auth-guard'
const NewPat = async () => {
return (
<div className="flex flex-col max-w-3xl mx-auto space-y-4">
<h2 className="text-xl">New Personal Access Token</h2>
<PATForm />
</div>
)
}
export default withAuthAsync(NewPat)

View File

@@ -0,0 +1,96 @@
import { gql } from '@apollo/client'
import TodoItem, { type Todo } from '@components/todo-item'
import withAuthAsync from '@utils/auth-guard'
import { getNhost } from '@utils/nhost'
import Head from 'next/head'
import Link from 'next/link'
const Todos = async ({ params }: { params: { [key: string]: string | string[] | undefined } }) => {
const page = parseInt(params.pagination?.at(0) || '0')
const nhost = await getNhost()
const {
data: {
todos,
todos_aggregate: {
aggregate: { count }
}
}
} = await nhost.graphql.request(
gql`
query getTodos($limit: Int, $offset: Int) {
todos(limit: $limit, offset: $offset, order_by: { createdAt: desc }) {
id
title
done
attachment {
id
}
}
todos_aggregate {
aggregate {
count
}
}
}
`,
{
offset: page * 10,
limit: 10
}
)
return (
<div className="space-y-4">
<Head>
<title>Protected Page</title>
</Head>
<div className="flex items-center justify-between w-full">
<h2 className="text-xl">Todos ({count})</h2>
<Link
href={`/protected/todos/new`}
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
>
Add Todo
</Link>
</div>
<ul className="space-y-1">
{todos.map((todo: Todo) => (
<li key={todo.id}>
<TodoItem todo={todo} />
</li>
))}
</ul>
{count > 10 && (
<div className="flex justify-center space-x-2">
{page > 0 && (
<Link
href={`/protected/todos/${page - 1}`}
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
>
Previous
</Link>
)}
{page + 1 < Math.ceil(count / 10) && (
<Link
href={`/protected/todos/${page + 1}`}
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
>
Next
</Link>
)}
</div>
)}
</div>
)
}
export default withAuthAsync(Todos)

View File

@@ -0,0 +1,13 @@
import TodoForm from '@components/todo-form'
import withAuthAsync from '@utils/auth-guard'
const NewTodo = async () => {
return (
<div className="flex flex-col max-w-3xl mx-auto space-y-4">
<h2 className="text-xl">New Todo</h2>
<TodoForm />
</div>
)
}
export default withAuthAsync(NewTodo)

View File

@@ -0,0 +1,6 @@
export { signInWithApple } from './sign-in-apple'
export { signIn } from './sign-in-email-password'
export { signInWithGoogle } from './sign-in-google'
export { signInWithPAT } from './sign-in-pat'
export { signOut } from './sign-out'
export { signUp } from './sign-up-email-password'

View File

@@ -0,0 +1,19 @@
'use server'
import { getNhost } from '@utils/nhost'
import { redirect } from 'next/navigation'
export const signInWithApple = async () => {
const nhost = await getNhost()
const { providerUrl } = await nhost.auth.signIn({
provider: 'apple',
options: {
redirectTo: `/oauth`
}
})
if (providerUrl) {
redirect(providerUrl)
}
}

View File

@@ -0,0 +1,25 @@
'use server'
import { NHOST_SESSION_KEY, getNhost } from '@utils/nhost'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export const signIn = async (formData: FormData) => {
const nhost = await getNhost()
const email = formData.get('email') as string
const password = formData.get('password') as string
const { session, error } = await nhost.auth.signIn({ email, password })
if (session) {
cookies().set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { path: '/' })
redirect('/protected/todos')
}
if (error) {
return {
error: error?.message
}
}
}

View File

@@ -0,0 +1,19 @@
'use server'
import { getNhost } from '@utils/nhost'
import { redirect } from 'next/navigation'
export const signInWithGoogle = async () => {
const nhost = await getNhost()
const { providerUrl } = await nhost.auth.signIn({
provider: 'google',
options: {
redirectTo: `/oauth`
}
})
if (providerUrl) {
redirect(providerUrl)
}
}

View File

@@ -0,0 +1,24 @@
'use server'
import { NHOST_SESSION_KEY, getNhost } from '@utils/nhost'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export const signInWithPAT = async (formData: FormData) => {
const nhost = await getNhost()
const pat = formData.get('pat') as string
const { session, error } = await nhost.auth.signInPAT(pat)
if (session) {
cookies().set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { path: '/' })
redirect('/protected/todos')
}
if (error) {
return {
error: error?.message
}
}
}

View File

@@ -0,0 +1,15 @@
'use server'
import { NHOST_SESSION_KEY, getNhost } from '@utils/nhost'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export const signOut = async () => {
const nhost = await getNhost()
await nhost.auth.signOut()
cookies().delete(NHOST_SESSION_KEY)
redirect('/auth/sign-in')
}

View File

@@ -0,0 +1,33 @@
'use server'
import { NHOST_SESSION_KEY, getNhost } from '@utils/nhost'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export const signUp = async (formData: FormData) => {
const nhost = await getNhost()
const firstName = formData.get('firstName') as string
const lastName = formData.get('lastName') as string
const email = formData.get('email') as string
const password = formData.get('password') as string
const { session, error } = await nhost.auth.signUp({
email,
password,
options: {
displayName: `${firstName} ${lastName}`
}
})
if (session) {
cookies().set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { path: '/' })
redirect('/protected/todos')
}
if (error) {
return {
error: error?.message
}
}
}

View File

@@ -0,0 +1,16 @@
'use server'
import { getNhost } from '@utils/nhost'
import { redirect } from 'next/navigation'
export const createPAT = async (formData: FormData) => {
const nhost = await getNhost()
const name = formData.get('name') as string
const expiration = formData.get('expiration') as string
const expirationDate = new Date(expiration)
await nhost.auth.createPAT(expirationDate, { name })
redirect('/protected/pat')
}

View File

@@ -0,0 +1,24 @@
'use server'
import { gql } from '@apollo/client'
import { getNhost } from '@utils/nhost'
import { revalidatePath } from 'next/cache'
export const deletePAT = async (id: string) => {
const nhost = await getNhost()
await nhost.graphql.request(
gql`
mutation deletePersonalAccessToken($id: uuid!) {
deleteAuthRefreshToken(id: $id) {
id
}
}
`,
{
id
}
)
revalidatePath('/protected/pat')
}

View File

@@ -0,0 +1,2 @@
export { createPAT } from './create-pat'
export { deletePAT } from './delete-pat'

View File

@@ -0,0 +1,40 @@
'use server'
import { gql } from '@apollo/client'
import { getNhost } from '@utils/nhost'
import { redirect } from 'next/navigation'
export const createTodo = async (formData: FormData) => {
const nhost = await getNhost()
const title = formData.get('title') as string
const file = formData.get('file') as File
let payload: {
title: string
file_id?: string
} = {
title
}
if (file) {
const { fileMetadata } = await nhost.storage.upload({
formData
})
payload.file_id = fileMetadata?.processedFiles[0]?.id
}
await nhost.graphql.request(
gql`
mutation insertTodo($title: String!, $file_id: uuid) {
insert_todos_one(object: { title: $title, file_id: $file_id }) {
id
}
}
`,
payload
)
redirect('/protected/todos')
}

View File

@@ -0,0 +1,24 @@
'use server'
import { gql } from '@apollo/client'
import { getNhost } from '@utils/nhost'
import { revalidatePath } from 'next/cache'
export const deleteTodo = async (id: string) => {
const nhost = await getNhost()
await nhost.graphql.request(
gql`
mutation deleteTodo($id: uuid!) {
delete_todos_by_pk(id: $id) {
id
}
}
`,
{
id
}
)
revalidatePath('/protected/todos')
}

View File

@@ -0,0 +1,3 @@
export { createTodo } from './create-todo'
export { deleteTodo } from './delete-todo'
export { updateTodo } from './update-todo'

View File

@@ -0,0 +1,27 @@
'use server'
import { gql } from '@apollo/client'
import { getNhost } from '@utils/nhost'
import { revalidatePath } from 'next/cache'
export const updateTodo = async (id: string, done: boolean) => {
const nhost = await getNhost()
await nhost.graphql.request(
gql`
mutation updateTodo($id: uuid!, $done: Boolean!) {
update_todos_by_pk(pk_columns: { id: $id }, _set: { done: $done }) {
id
title
done
}
}
`,
{
id,
done
}
)
revalidatePath('/protected/todos')
}

View File

@@ -0,0 +1,36 @@
'use client'
import { DetailedHTMLProps, HTMLProps } from 'react'
// @ts-ignore
import { experimental_useFormStatus as useFormStatus } from 'react-dom'
export default function Input({
id,
type,
name,
label,
required,
className,
...rest
}: DetailedHTMLProps<HTMLProps<HTMLInputElement>, HTMLInputElement>) {
const { pending } = useFormStatus()
return (
<div className={className}>
{label && (
<label htmlFor={id} className="block mb-1 text-sm font-medium text-gray-700">
{label}
</label>
)}
<input
id={id}
type={type}
name={name}
required={required}
disabled={pending}
className="block w-full p-3 border rounded-md border-slate-300 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
{...rest}
/>
</div>
)
}

View File

@@ -0,0 +1,70 @@
import { signOut } from '@server-actions/auth'
import Link from 'next/link'
import { getNhost } from '../utils/nhost'
import SignOut from './sign-out'
export default async function Navigation() {
const nhost = await getNhost()
const user = nhost.auth.getUser()
const nav = [
{
href: '/',
name: 'Home'
},
{
href: '/protected/todos',
name: `${user ? '🔓' : '🔒'} Todos`
},
{
href: '/protected/echo',
name: `${user ? '🔓' : '🔒'} Echo`
},
{
href: '/protected/pat',
name: `${user ? '🔓' : '🔒'} PAT`
}
]
return (
<header className="bg-indigo-600">
<nav className="container mx-auto">
<div className="flex items-center justify-between w-full py-4">
<div className="flex items-center">
<div className="ml-10 space-x-8">
{nav.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-lg font-medium text-white hover:text-indigo-50"
>
{link.name}
</Link>
))}
</div>
</div>
<div className="ml-10 space-x-4">
{user ? (
<SignOut signOut={signOut} />
) : (
<>
<Link
href="/auth/sign-in"
className="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
</Link>
<Link
href="/auth/sign-up"
className="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
</Link>
</>
)}
</div>
</div>
</nav>
</header>
)
}

View File

@@ -0,0 +1,17 @@
'use client'
import Input from '@components/input'
import SubmitButton from '@components/submit-button'
import { createPAT } from '@server-actions/pat'
export default function PATForm() {
return (
<form className="space-y-4" action={createPAT}>
<Input name="name" label="Name" required />
<Input name="expiration" type="date" required />
<SubmitButton>Create PAT</SubmitButton>
</form>
)
}

View File

@@ -0,0 +1,44 @@
'use client'
import { deletePAT } from '@server-actions/pat'
export interface PAT {
id: string
type: string
metadata: Record<string, string>
expiresAt: string
}
export default function PatItem({ pat }: { pat: PAT }) {
const handleDeleteTodo = async () => {
await deletePAT(pat.id)
}
return (
<div className="flex flex-row items-center justify-between p-2 bg-slate-100">
<div>
<span className="justify-center block w-full space-x-2 rounded">{pat.metadata?.name}</span>
<span className="justify-center block w-full space-x-2 text-sm rounded">{pat.id}</span>
<span className="justify-center block w-full space-x-2 rounded text-slate-500">
expires on {new Date(pat.expiresAt).toLocaleDateString()}
</span>
</div>
<button onClick={handleDeleteTodo}>
<svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
</div>
)
}

View File

@@ -0,0 +1,12 @@
'use client'
export default function SignOut({ signOut }: { signOut: () => Promise<void> }) {
return (
<button
onClick={() => signOut()}
className="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>
)
}

View File

@@ -0,0 +1,37 @@
'use client'
import { ButtonHTMLAttributes, DetailedHTMLProps } from 'react'
// @ts-ignore
import { experimental_useFormStatus as useFormStatus } from 'react-dom'
import { twMerge } from 'tailwind-merge'
type ButtonProps = {
type?: 'button' | 'submit' | 'reset' | undefined
} & DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
export default function SubmitButton({
disabled,
type,
children,
className,
...rest
}: ButtonProps) {
const { pending } = useFormStatus()
return (
<button
type={type}
disabled={pending}
className={twMerge(
pending
? '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',
className,
'inline-flex items-center justify-center px-4 py-2 border border-transparent text-base font-medium rounded-md shadow-sm text-white focus:outline-none'
)}
{...rest}
>
{children}
</button>
)
}

View File

@@ -0,0 +1,23 @@
'use client'
import Input from '@components/input'
import { createTodo } from '@server-actions/todos'
import SubmitButton from './submit-button'
export default function TodoForm() {
return (
<form action={createTodo} className="flex flex-col space-y-2">
<Input
id="title"
name="title"
required
placeholder="What needs to be done"
className="w-full"
/>
<Input id="file" name="file" type="file" className="w-full" accept="image/*" />
<SubmitButton>Add</SubmitButton>
</form>
)
}

View File

@@ -0,0 +1,96 @@
'use client'
import { NhostClient } from '@nhost/nhost-js'
import { deleteTodo, updateTodo } from '@server-actions/todos'
import Link from 'next/link'
import { useState } from 'react'
import { twMerge } from 'tailwind-merge'
const nhost = new NhostClient({
subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN || 'local',
region: process.env.NEXT_PUBLIC_NHOST_REGION
})
export interface Todo {
id: string
title: string
done: boolean
attachment: {
id: string
}
}
const TodoItem = ({ todo }: { todo: Todo }) => {
const [completed, setCompleted] = useState(todo.done)
const handleCheckboxChange = async () => {
setCompleted(!completed)
await updateTodo(todo.id, !completed)
}
const handleDeleteTodo = async () => {
await deleteTodo(todo.id)
}
return (
<div
className={twMerge(
'flex flex-row items-center p-2 bg-slate-100',
completed && 'line-through bg-slate-200'
)}
>
<label
htmlFor={todo.id}
className={twMerge(
'block w-full space-x-2 rounded select-none justify-center',
completed && 'line-through bg-slate-200'
)}
>
<input type="checkbox" id={todo.id} checked={completed} onChange={handleCheckboxChange} />
<span>{todo.title}</span>
</label>
{todo.attachment && (
<Link
className="w-6 h-6"
target="_blank"
href={nhost.storage.getPublicUrl({ fileId: todo.attachment.id })}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13"
/>
</svg>
</Link>
)}
<button onClick={handleDeleteTodo} className="w-6 h-6">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
</div>
)
}
export default TodoItem

View File

@@ -0,0 +1,8 @@
import { manageAuthSession } from '@utils/nhost'
import { NextRequest, NextResponse } from 'next/server'
export async function middleware(request: NextRequest) {
return manageAuthSession(request, () =>
NextResponse.redirect(new URL('/auth/sign-in', request.url))
)
}

View File

@@ -0,0 +1,17 @@
import { getNhost } from '@utils/nhost'
import { redirect } from 'next/navigation'
const withAuthAsync =
<P extends {}>(Component: React.FunctionComponent<P>) =>
async (props: P) => {
const nhost = await getNhost()
const session = nhost.auth.getSession()
if (!session) {
redirect('/auth/sign-in')
}
return <Component {...props} />
}
export default withAuthAsync

View File

@@ -0,0 +1,58 @@
import { AuthErrorPayload, NhostClient, NhostSession } from '@nhost/nhost-js'
import { cookies } from 'next/headers'
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 getNhost = async (request?: NextRequest) => {
const $cookies = request?.cookies || cookies()
const nhost = new NhostClient({
subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN || 'local',
region: process.env.NEXT_PUBLIC_NHOST_REGION,
start: false
})
const sessionCookieValue = $cookies.get(NHOST_SESSION_KEY)?.value || ''
const initialSession: NhostSession = JSON.parse(atob(sessionCookieValue) || 'null')
nhost.auth.client.start({ initialSession })
await waitFor(nhost.auth.client.interpreter!, (state: StateFrom<any>) => !state.hasTag('loading'))
return nhost
}
export const manageAuthSession = async (
request: NextRequest,
onError?: (error: AuthErrorPayload) => NextResponse
) => {
const nhost = await getNhost(request)
const session = nhost.auth.getSession()
const url = new URL(request.url)
const refreshToken = url.searchParams.get('refreshToken') || undefined
const currentTime = Math.floor(Date.now() / 1000)
const tokenExpirationTime = nhost.auth.getDecodedAccessToken()?.exp
const accessTokenExpired = session && tokenExpirationTime && currentTime > tokenExpirationTime
if (accessTokenExpired || refreshToken) {
const { session: newSession, error } = await nhost.auth.refreshSession(refreshToken)
if (error) {
onError?.(error)
}
// remove the refreshToken from the url
url.searchParams.delete('refreshToken')
// overwrite the session cookie with the new session
return NextResponse.redirect(url, {
headers: {
'Set-Cookie': `${NHOST_SESSION_KEY}=${btoa(JSON.stringify(newSession))}; Path=/`
}
})
}
}

View File

@@ -0,0 +1,20 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic':
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
},
},
},
plugins: [],
}
export default config

View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@components/*": ["./src/components/*"],
"@utils/*": ["./src/utils/*"],
"@actions": ["./src/app/actions"],
"@server-actions/*": ["./src/app/server-actions/*"],
"@types": ["./src/types"],
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1 @@
.secrets.nhost

View File

@@ -0,0 +1,10 @@
import { Request, Response } from 'express'
import process from 'process'
export default (req: Request, res: Response) => {
return res.status(200).json({
headers: req.headers,
query: req.query,
node: process.version
})
}

Some files were not shown because too many files have changed in this diff Show More