Compare commits
87 Commits
@nhost/das
...
@nhost/apo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
262828f9a1 | ||
|
|
12f9726ad7 | ||
|
|
845937b552 | ||
|
|
f777a3380a | ||
|
|
5081372cab | ||
|
|
82212345c8 | ||
|
|
32d3f167c5 | ||
|
|
3d5f1ea922 | ||
|
|
97841ee5e8 | ||
|
|
4f3a615ebe | ||
|
|
8e8197691c | ||
|
|
e10389ecf6 | ||
|
|
cbdf6affec | ||
|
|
d19406e694 | ||
|
|
cffc5dc65b | ||
|
|
2b5cb58553 | ||
|
|
7459a9413e | ||
|
|
56871cc9f7 | ||
|
|
8f4d66e52d | ||
|
|
315a820073 | ||
|
|
ca57ad2cbd | ||
|
|
40259344eb | ||
|
|
4749f60a08 | ||
|
|
ac1888514d | ||
|
|
49b4af439b | ||
|
|
61e03d6c70 | ||
|
|
bec0fce497 | ||
|
|
c01568a7dd | ||
|
|
e934216a82 | ||
|
|
701d6b8c84 | ||
|
|
e158e2440a | ||
|
|
fbaa657001 | ||
|
|
559db6d0ec | ||
|
|
4c844930f1 | ||
|
|
3ef503ff81 | ||
|
|
bfcfd236ea | ||
|
|
bfa7033506 | ||
|
|
78c29fcf0e | ||
|
|
f1b934ed22 | ||
|
|
914369c53f | ||
|
|
af379b967e | ||
|
|
c3efb7ec84 | ||
|
|
27cbd48c8c | ||
|
|
236996a903 | ||
|
|
5d0936bb93 | ||
|
|
733c212f2d | ||
|
|
8b47549189 | ||
|
|
3c9c1025ce | ||
|
|
3e46d3873c | ||
|
|
4cf8820d72 | ||
|
|
02a11184fb | ||
|
|
7214d47cc7 | ||
|
|
238b77baad | ||
|
|
81b8e538b4 | ||
|
|
563a37e58d | ||
|
|
bff23720ee | ||
|
|
02cbaeffd2 | ||
|
|
9eb814c79a | ||
|
|
ebc5913bb3 | ||
|
|
4fe4a16964 | ||
|
|
92c475b7a7 | ||
|
|
679b34b031 | ||
|
|
d3186aefbd | ||
|
|
fdecac9d69 | ||
|
|
5077283028 | ||
|
|
f5f662aad1 | ||
|
|
735b779af7 | ||
|
|
4418d6abcf | ||
|
|
049e315c30 | ||
|
|
764597538b | ||
|
|
c8aea785cc | ||
|
|
e0e44b2ff4 | ||
|
|
12280f7c87 | ||
|
|
732a4f40ca | ||
|
|
d67fd599e4 | ||
|
|
a41231927a | ||
|
|
42ec665950 | ||
|
|
7225712a30 | ||
|
|
6593fdd9bb | ||
|
|
40039fece5 | ||
|
|
e5fcfb3cd5 | ||
|
|
218ec314fb | ||
|
|
9367e91d45 | ||
|
|
06c640be2c | ||
|
|
ae45be9816 | ||
|
|
ec4be590d8 | ||
|
|
5c51653aa0 |
@@ -5,6 +5,5 @@
|
||||
"linked": [],
|
||||
"access": "restricted",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": ["@nhost-examples/sveltekit"]
|
||||
"updateInternalDependencies": "patch"
|
||||
}
|
||||
|
||||
15
.github/workflows/changesets.yaml
vendored
15
.github/workflows/changesets.yaml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
commit: 'chore: update versions'
|
||||
title: 'chore: update versions'
|
||||
publish: pnpm run release
|
||||
createGithubReleases: true
|
||||
createGithubReleases: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -66,6 +66,8 @@ jobs:
|
||||
publish-vercel:
|
||||
name: Publish to Vercel
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
@@ -93,6 +95,7 @@ jobs:
|
||||
needs:
|
||||
- test
|
||||
- version
|
||||
- publish-vercel
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
@@ -138,16 +141,6 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
push: true
|
||||
- name: Create GitHub Release
|
||||
uses: taiki-e/create-gh-release-action@v1
|
||||
with:
|
||||
changelog: dashboard/CHANGELOG.md
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
prefix: ${{ env.DASHBOARD_PACKAGE }}@
|
||||
ref: refs/tags/${{ env.DASHBOARD_PACKAGE }}@${{ needs.version.outputs.dashboardVersion }}
|
||||
- name: Remove tag on failure
|
||||
if: failure()
|
||||
run: git push --delete origin ${{ env.DASHBOARD_PACKAGE }}@${{ needs.version.outputs.dashboardVersion }}
|
||||
|
||||
bump-cli:
|
||||
name: Bump Dashboard version in the Nhost CLI
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -2,5 +2,6 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": true
|
||||
},
|
||||
"eslint.workingDirectories": ["./dashboard"]
|
||||
"eslint.workingDirectories": ["./dashboard"],
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 0.20.25
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.38
|
||||
|
||||
## 0.20.24
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e10389ecf: fix(dashboard): disable run tab when developing locally
|
||||
- @nhost/react-apollo@5.0.37
|
||||
|
||||
## 0.20.23
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c01568a7d: chore(dashboard): show alert to update oauth providers
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.20.19",
|
||||
"version": "0.20.25",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './Announcement';
|
||||
export * from './AnnouncementProvider';
|
||||
export { default as useAnnouncement } from './useAnnouncement';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
|
||||
export default function DepricationNotice() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
return (
|
||||
!currentProject?.providersUpdated && (
|
||||
<Alert severity="warning" className="grid place-content-center">
|
||||
<Text color="warning" className="max-w-3xl text-sm">
|
||||
On December 1st the old backend domain will cease to work. You need to
|
||||
make sure your client is instantiated using the subdomain and region
|
||||
and update your oauth2 settings. You can find more information{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
href="https://github.com/nhost/nhost/discussions/2303"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
</Alert>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ContactUs } from './DepricationNotice';
|
||||
@@ -1,3 +1,4 @@
|
||||
import DepricationNotice from '@/components/common/DepricationNotice/DepricationNotice';
|
||||
import type { ProjectLayoutProps } from '@/components/layout/ProjectLayout';
|
||||
import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import type { SettingsSidebarProps } from '@/components/layout/SettingsSidebar';
|
||||
@@ -45,44 +46,47 @@ export default function SettingsLayout({
|
||||
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex flex-col flex-auto w-full overflow-scroll overflow-x-hidden"
|
||||
className="flex w-full flex-auto flex-col overflow-scroll overflow-x-hidden"
|
||||
>
|
||||
<RetryableErrorBoundary>
|
||||
{hasGitRepo && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="grid grid-flow-row gap-2 place-content-center"
|
||||
>
|
||||
<Text color="warning" className="text-sm ">
|
||||
As you have a connected repository, make sure to synchronize
|
||||
your changes with{' '}
|
||||
<code
|
||||
className={twMerge(
|
||||
'rounded-md px-2 py-px',
|
||||
theme.palette.mode === 'dark'
|
||||
? 'bg-brown text-copper'
|
||||
: 'bg-slate-200 text-slate-700',
|
||||
)}
|
||||
>
|
||||
nhost config pull
|
||||
</code>{' '}
|
||||
or they may be reverted with the next push.
|
||||
<br />
|
||||
If there are multiple projects linked to the same repository and
|
||||
you only want these changes to apply to a subset of them, please
|
||||
check out{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
href="https://docs.nhost.io/cli/overlays"
|
||||
>
|
||||
docs.nhost.io/cli/overlays
|
||||
</a>{' '}
|
||||
for guidance.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex flex-col space-y-2">
|
||||
<DepricationNotice />
|
||||
{hasGitRepo && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="grid grid-flow-row place-content-center gap-2"
|
||||
>
|
||||
<Text color="warning" className="text-sm ">
|
||||
As you have a connected repository, make sure to synchronize
|
||||
your changes with{' '}
|
||||
<code
|
||||
className={twMerge(
|
||||
'rounded-md px-2 py-px',
|
||||
theme.palette.mode === 'dark'
|
||||
? 'bg-brown text-copper'
|
||||
: 'bg-slate-200 text-slate-700',
|
||||
)}
|
||||
>
|
||||
nhost config pull
|
||||
</code>{' '}
|
||||
or they may be reverted with the next push.
|
||||
<br />
|
||||
If there are multiple projects linked to the same repository
|
||||
and you only want these changes to apply to a subset of them,
|
||||
please check out{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
href="https://docs.nhost.io/cli/overlays"
|
||||
>
|
||||
docs.nhost.io/cli/overlays
|
||||
</a>{' '}
|
||||
for guidance.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</RetryableErrorBoundary>
|
||||
</Box>
|
||||
|
||||
100
dashboard/src/components/settings/ProvidersUpdatedAlert.tsx
Normal file
100
dashboard/src/components/settings/ProvidersUpdatedAlert.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useConfirmProvidersUpdatedMutation } from '@/utils/__generated__/graphql';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function ProvidersUpdatedAlert() {
|
||||
const theme = useTheme();
|
||||
const { openAlertDialog } = useDialog();
|
||||
const [confirmed, setConfirmed] = useState(true);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const [confirmProvidersUpdated] = useConfirmProvidersUpdatedMutation({
|
||||
variables: { id: currentProject?.id },
|
||||
});
|
||||
|
||||
async function handleSubmitConfirmation() {
|
||||
const confirmProvidersUpdatedPromise = confirmProvidersUpdated();
|
||||
|
||||
await toast.promise(
|
||||
confirmProvidersUpdatedPromise,
|
||||
{
|
||||
loading: 'Confirming...',
|
||||
success: 'Your settings have been updated successfully.',
|
||||
error: 'An error occurred while trying to confirm the message.',
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
setConfirmed(false);
|
||||
}
|
||||
|
||||
function handleOpenConfirmationDialog() {
|
||||
openAlertDialog({
|
||||
title: 'Confirm all providers updated?',
|
||||
payload: (
|
||||
<Text variant="subtitle1" component="span">
|
||||
Please make sure to update all providers before continuing. Your
|
||||
sign-in flows might break if you don't.
|
||||
</Text>
|
||||
),
|
||||
props: {
|
||||
onPrimaryAction: handleSubmitConfirmation,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!confirmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="grid items-center grid-flow-row gap-2 p-4 place-items-center lg:grid-flow-col lg:place-content-between"
|
||||
>
|
||||
<div className="grid grid-flow-row gap-1 text-left">
|
||||
<Text className="font-semibold">
|
||||
Please update the Redirect URL for all providers being used
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm+">
|
||||
We are deprecating your project's old DNS name in favor of
|
||||
individual DNS names for each service. Please make sure to update your
|
||||
providers to use the new auth specific URL under <b>Redirect URL</b>{' '}
|
||||
before the 1st of February 2023.{' '}
|
||||
<Link
|
||||
href="https://github.com/nhost/nhost/discussions/1319"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="font-medium"
|
||||
>
|
||||
Read the discussion here.
|
||||
<ArrowSquareOutIcon className="w-4 h-4 ml-1" />
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
className={
|
||||
theme.palette.mode === 'dark'
|
||||
? 'text-white hover:bg-brown'
|
||||
: 'text-black hover:bg-orange-300'
|
||||
}
|
||||
onClick={handleOpenConfirmationDialog}
|
||||
>
|
||||
I have updated all Redirect URLs
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
1
dashboard/src/components/settings/index.ts
Normal file
1
dashboard/src/components/settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ProvidersUpdatedAlert } from './ProvidersUpdatedAlert';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Announcements } from './Announcements';
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ export default function useProjectRoutes() {
|
||||
exact: false,
|
||||
label: 'Run',
|
||||
icon: <ServicesIcon />,
|
||||
disabled: !isPlatform,
|
||||
},
|
||||
...nhostRoutes,
|
||||
];
|
||||
|
||||
14
dashboard/src/gql/platform/getAnnouncements.gql
Normal file
14
dashboard/src/gql/platform/getAnnouncements.gql
Normal 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
|
||||
}
|
||||
}
|
||||
5
dashboard/src/gql/settings/confirmProvidersUpdated.gql
Normal file
5
dashboard/src/gql/settings/confirmProvidersUpdated.gql
Normal file
@@ -0,0 +1,5 @@
|
||||
mutation confirmProvidersUpdated($id: uuid!) {
|
||||
updateApp(pk_columns: { id: $id }, _set: { providersUpdated: true }) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import DepricationNotice from '@/components/common/DepricationNotice/DepricationNotice';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
@@ -67,5 +68,10 @@ export default function BackupsPage() {
|
||||
}
|
||||
|
||||
BackupsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <ProjectLayout>{page}</ProjectLayout>;
|
||||
return (
|
||||
<ProjectLayout>
|
||||
<DepricationNotice />
|
||||
{page}
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import DepricationNotice from '@/components/common/DepricationNotice/DepricationNotice';
|
||||
import { InlineCode } from '@/components/presentational/InlineCode';
|
||||
import { DataBrowserEmptyState } from '@/features/database/dataGrid/components/DataBrowserEmptyState';
|
||||
import { DataBrowserLayout } from '@/features/database/dataGrid/components/DataBrowserLayout';
|
||||
@@ -35,5 +36,10 @@ export default function DataBrowserDatabaseDetailsPage() {
|
||||
DataBrowserDatabaseDetailsPage.getLayout = function getLayout(
|
||||
page: ReactElement,
|
||||
) {
|
||||
return <DataBrowserLayout>{page}</DataBrowserLayout>;
|
||||
return (
|
||||
<DataBrowserLayout>
|
||||
<DepricationNotice />
|
||||
{page}
|
||||
</DataBrowserLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import DepricationNotice from '@/components/common/DepricationNotice/DepricationNotice';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import type { DeploymentStatus } from '@/components/presentational/StatusCircle';
|
||||
@@ -105,7 +106,7 @@ export default function DeploymentDetailsPage() {
|
||||
<Text color="secondary">{relativeDateOfDeployment}</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className=" flex items-center">
|
||||
<div className="flex items-center ">
|
||||
<Link
|
||||
className="self-center font-mono font-medium"
|
||||
target="_blank"
|
||||
@@ -139,7 +140,7 @@ export default function DeploymentDetailsPage() {
|
||||
|
||||
{deployment.deploymentLogs.map((log) => (
|
||||
<div key={log.id} className="flex font-mono">
|
||||
<div className=" mr-2 flex-shrink-0">
|
||||
<div className="mr-2 flex-shrink-0 ">
|
||||
{format(parseISO(log.createdAt), 'HH:mm:ss')}:
|
||||
</div>
|
||||
<div className="break-all">{log.message}</div>
|
||||
@@ -152,5 +153,10 @@ export default function DeploymentDetailsPage() {
|
||||
}
|
||||
|
||||
DeploymentDetailsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <ProjectLayout>{page}</ProjectLayout>;
|
||||
return (
|
||||
<ProjectLayout>
|
||||
<DepricationNotice />
|
||||
{page}
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import DepricationNotice from '@/components/common/DepricationNotice/DepricationNotice';
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
@@ -67,5 +68,10 @@ export default function DeploymentsPage() {
|
||||
}
|
||||
|
||||
DeploymentsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <ProjectLayout>{page}</ProjectLayout>;
|
||||
return (
|
||||
<ProjectLayout>
|
||||
<DepricationNotice />
|
||||
{page}
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import DepricationNotice from '@/components/common/DepricationNotice/DepricationNotice';
|
||||
import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import { LoadingScreen } from '@/components/presentational/LoadingScreen';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
@@ -351,6 +352,7 @@ GraphQLPage.getLayout = function getLayout(page: ReactElement) {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DepricationNotice />
|
||||
{page}
|
||||
</ProjectLayout>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import DepricationNotice from '@/components/common/DepricationNotice/DepricationNotice';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import { LoadingScreen } from '@/components/presentational/LoadingScreen';
|
||||
@@ -114,5 +115,10 @@ export default function HasuraPage() {
|
||||
}
|
||||
|
||||
HasuraPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <ProjectLayout>{page}</ProjectLayout>;
|
||||
return (
|
||||
<ProjectLayout>
|
||||
<DepricationNotice />
|
||||
{page}
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import DepricationNotice from '@/components/common/DepricationNotice/DepricationNotice';
|
||||
import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import { ApplicationErrored } from '@/features/projects/common/components/ApplicationErrored';
|
||||
import { ApplicationLive } from '@/features/projects/common/components/ApplicationLive';
|
||||
@@ -49,5 +50,10 @@ export default function AppIndexPage() {
|
||||
}
|
||||
|
||||
AppIndexPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <ProjectLayout>{page}</ProjectLayout>;
|
||||
return (
|
||||
<ProjectLayout>
|
||||
<DepricationNotice />
|
||||
{page}
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import DepricationNotice from '@/components/common/DepricationNotice/DepricationNotice';
|
||||
import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
@@ -134,5 +135,10 @@ export default function LogsPage() {
|
||||
}
|
||||
|
||||
LogsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <ProjectLayout>{page}</ProjectLayout>;
|
||||
return (
|
||||
<ProjectLayout>
|
||||
<DepricationNotice />
|
||||
{page}
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import DepricationNotice from '@/components/common/DepricationNotice/DepricationNotice';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
@@ -126,5 +127,10 @@ export default function MetricsPage() {
|
||||
}
|
||||
|
||||
MetricsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <ProjectLayout>{page}</ProjectLayout>;
|
||||
return (
|
||||
<ProjectLayout>
|
||||
<DepricationNotice />
|
||||
{page}
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/
|
||||
import type { GetRunServicesQuery } from '@/utils/__generated__/graphql';
|
||||
import { useGetRunServicesQuery } from '@/utils/__generated__/graphql';
|
||||
|
||||
import DepricationNotice from '@/components/common/DepricationNotice/DepricationNotice';
|
||||
import { UpgradeNotification } from '@/features/projects/common/components/UpgradeNotification';
|
||||
import {
|
||||
ServiceForm,
|
||||
@@ -43,7 +44,7 @@ export default function ServicesPage() {
|
||||
const router = useRouter();
|
||||
const { openDrawer, openAlertDialog } = useDialog();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const isPlanFree = currentProject.plan.isFree;
|
||||
const isPlanFree = currentProject?.plan?.isFree;
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(
|
||||
parseInt(router.query.page as string, 10) || 1,
|
||||
@@ -258,5 +259,10 @@ export default function ServicesPage() {
|
||||
}
|
||||
|
||||
ServicesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <ProjectLayout>{page}</ProjectLayout>;
|
||||
return (
|
||||
<ProjectLayout>
|
||||
<DepricationNotice />
|
||||
{page}
|
||||
</ProjectLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { SettingsLayout } from '@/components/layout/SettingsLayout';
|
||||
import { ProvidersUpdatedAlert } from '@/components/settings';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { AnonymousSignInSettings } from '@/features/authentication/settings/components/AnonymousSignInSettings';
|
||||
import { AppleProviderSettings } from '@/features/authentication/settings/components/AppleProviderSettings';
|
||||
@@ -54,6 +55,7 @@ export default function SettingsSignInMethodsPage() {
|
||||
<WebAuthnSettings />
|
||||
<AnonymousSignInSettings />
|
||||
<SMSSettings />
|
||||
{!currentProject.providersUpdated && <ProvidersUpdatedAlert />}
|
||||
<AppleProviderSettings />
|
||||
<AzureADProviderSettings />
|
||||
<DiscordProviderSettings />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import DepricationNotice from '@/components/common/DepricationNotice/DepricationNotice';
|
||||
import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import { LoadingScreen } from '@/components/presentational/LoadingScreen';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
@@ -44,6 +45,7 @@ StoragePage.getLayout = function getLayout(page: ReactElement) {
|
||||
<ProjectLayout
|
||||
mainContainerProps={{ sx: { backgroundColor: 'background.default' } }}
|
||||
>
|
||||
<DepricationNotice />
|
||||
{page}
|
||||
</ProjectLayout>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import DepricationNotice from '@/components/common/DepricationNotice/DepricationNotice';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Pagination } from '@/components/common/Pagination';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
@@ -390,6 +391,7 @@ export default function UsersPage() {
|
||||
UsersPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<ProjectLayout contentContainerProps={{ className: 'h-full' }}>
|
||||
<DepricationNotice />
|
||||
{page}
|
||||
</ProjectLayout>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
632
dashboard/src/utils/__generated__/graphql.ts
generated
632
dashboard/src/utils/__generated__/graphql.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,11 @@
|
||||
# @nhost/docs
|
||||
|
||||
## 0.6.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4fe4a1696: return `refreshToken` immediately after signIn and signUp
|
||||
|
||||
## 0.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/docs",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_NHOST_SUBDOMAIN=local
|
||||
NEXT_PUBLIC_NHOST_REGION=
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
extends: ['../../config/.eslintrc.js', 'plugin:@next/next/recommended'],
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off'
|
||||
}
|
||||
}
|
||||
35
examples/quickstarts/nextjs-server-components/.gitignore
vendored
Normal file
35
examples/quickstarts/nextjs-server-components/.gitignore
vendored
Normal 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
|
||||
11
examples/quickstarts/nextjs-server-components/CHANGELOG.md
Normal file
11
examples/quickstarts/nextjs-server-components/CHANGELOG.md
Normal 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
|
||||
70
examples/quickstarts/nextjs-server-components/README.md
Normal file
70
examples/quickstarts/nextjs-server-components/README.md
Normal 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
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
serverActions: true
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
35
examples/quickstarts/nextjs-server-components/package.json
Normal file
35
examples/quickstarts/nextjs-server-components/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Home() {
|
||||
return <h1 className="text-2xl text-center">Hi, login/register to get started</h1>
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createPAT } from './create-pat'
|
||||
export { deletePAT } from './delete-pat'
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { createTodo } from './create-todo'
|
||||
export { deleteTodo } from './delete-todo'
|
||||
export { updateTodo } from './update-todo'
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=/`
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
31
examples/quickstarts/nextjs-server-components/tsconfig.json
Normal file
31
examples/quickstarts/nextjs-server-components/tsconfig.json
Normal 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": "node",
|
||||
"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"]
|
||||
}
|
||||
1
examples/quickstarts/nhost-backend/.gitignore
vendored
Normal file
1
examples/quickstarts/nhost-backend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.secrets.nhost
|
||||
10
examples/quickstarts/nhost-backend/functions/echo.ts
Normal file
10
examples/quickstarts/nhost-backend/functions/echo.ts
Normal 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
|
||||
})
|
||||
}
|
||||
15
examples/quickstarts/nhost-backend/functions/package.json
Normal file
15
examples/quickstarts/nhost-backend/functions/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "functions",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.13"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
}
|
||||
78
examples/quickstarts/nhost-backend/functions/pnpm-lock.yaml
generated
Normal file
78
examples/quickstarts/nhost-backend/functions/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,78 @@
|
||||
lockfileVersion: '6.0'
|
||||
|
||||
devDependencies:
|
||||
'@types/express':
|
||||
specifier: ^4.17.13
|
||||
version: 4.17.13
|
||||
|
||||
packages:
|
||||
|
||||
/@types/body-parser@1.19.3:
|
||||
resolution: {integrity: sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ==}
|
||||
dependencies:
|
||||
'@types/connect': 3.4.36
|
||||
'@types/node': 20.6.3
|
||||
dev: true
|
||||
|
||||
/@types/connect@3.4.36:
|
||||
resolution: {integrity: sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==}
|
||||
dependencies:
|
||||
'@types/node': 20.6.3
|
||||
dev: true
|
||||
|
||||
/@types/express-serve-static-core@4.17.36:
|
||||
resolution: {integrity: sha512-zbivROJ0ZqLAtMzgzIUC4oNqDG9iF0lSsAqpOD9kbs5xcIM3dTiyuHvBc7R8MtWBp3AAWGaovJa+wzWPjLYW7Q==}
|
||||
dependencies:
|
||||
'@types/node': 20.6.3
|
||||
'@types/qs': 6.9.8
|
||||
'@types/range-parser': 1.2.4
|
||||
'@types/send': 0.17.1
|
||||
dev: true
|
||||
|
||||
/@types/express@4.17.13:
|
||||
resolution: {integrity: sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==}
|
||||
dependencies:
|
||||
'@types/body-parser': 1.19.3
|
||||
'@types/express-serve-static-core': 4.17.36
|
||||
'@types/qs': 6.9.8
|
||||
'@types/serve-static': 1.15.2
|
||||
dev: true
|
||||
|
||||
/@types/http-errors@2.0.2:
|
||||
resolution: {integrity: sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg==}
|
||||
dev: true
|
||||
|
||||
/@types/mime@1.3.2:
|
||||
resolution: {integrity: sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==}
|
||||
dev: true
|
||||
|
||||
/@types/mime@3.0.1:
|
||||
resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==}
|
||||
dev: true
|
||||
|
||||
/@types/node@20.6.3:
|
||||
resolution: {integrity: sha512-HksnYH4Ljr4VQgEy2lTStbCKv/P590tmPe5HqOnv9Gprffgv5WXAY+Y5Gqniu0GGqeTCUdBnzC3QSrzPkBkAMA==}
|
||||
dev: true
|
||||
|
||||
/@types/qs@6.9.8:
|
||||
resolution: {integrity: sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==}
|
||||
dev: true
|
||||
|
||||
/@types/range-parser@1.2.4:
|
||||
resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==}
|
||||
dev: true
|
||||
|
||||
/@types/send@0.17.1:
|
||||
resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==}
|
||||
dependencies:
|
||||
'@types/mime': 1.3.2
|
||||
'@types/node': 20.6.3
|
||||
dev: true
|
||||
|
||||
/@types/serve-static@1.15.2:
|
||||
resolution: {integrity: sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw==}
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.2
|
||||
'@types/mime': 3.0.1
|
||||
'@types/node': 20.6.3
|
||||
dev: true
|
||||
11
examples/quickstarts/nhost-backend/functions/tsconfig.json
Normal file
11
examples/quickstarts/nhost-backend/functions/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": false
|
||||
}
|
||||
}
|
||||
1
examples/quickstarts/nhost-backend/nhost/config.yaml
Normal file
1
examples/quickstarts/nhost-backend/nhost/config.yaml
Normal file
@@ -0,0 +1 @@
|
||||
version: 3
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Потвърдете смяната на вашия имейл</h2>
|
||||
<p>Използвайте посочения линк, за да повърдите смяната на имейл:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Смени имейл
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Потвърждение за смяна на имейл
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user