Compare commits

..

82 Commits

Author SHA1 Message Date
Hassan Ben Jobrane
82212345c8 Merge pull request #2311 from nhost/changeset-release/main
chore: update versions
2023-10-11 13:54:25 +01:00
Hassan Ben Jobrane
32d3f167c5 Merge pull request #2313 from nhost/fix/quickstarts
fix: quickstarts: toml node version and tsconfig moduleResolution
2023-10-11 12:37:38 +01:00
github-actions[bot]
3d5f1ea922 chore: update versions 2023-10-11 10:24:24 +00:00
Hassan Ben Jobrane
97841ee5e8 Merge pull request #2312 from nhost/fix/dashboard/run-tab
fix: dashboard: disable run tab when developing locally
2023-10-11 11:21:38 +01:00
Hassan Ben Jobrane
4f3a615ebe fix: quickstarts: fix backend functions node version 2023-10-11 10:14:09 +01:00
Hassan Ben Jobrane
8e8197691c fix: quickstarts: change module resolution to node 2023-10-11 10:13:40 +01:00
Hassan Ben Jobrane
e10389ecf6 chore: add changeset 2023-10-11 10:08:11 +01:00
Hassan Ben Jobrane
cbdf6affec fix(dashboard): disable run services tab in local dev mode 2023-10-11 10:07:02 +01:00
Hassan Ben Jobrane
d19406e694 Merge pull request #2309 from nhost/fix/integration/apollo
fix: integrations: apollo: set accessToken to null after TOKEN_CHANGED event on sign-out
2023-10-10 16:24:39 +01:00
Hassan Ben Jobrane
cffc5dc65b Merge pull request #2310 from nhost/chore/ci/stop-dashboard-releases
chore: ci: stop @nhost/dashboard github releases
2023-10-10 16:24:01 +01:00
Hassan Ben Jobrane
2b5cb58553 chore: ci: stop @nhost/dashboard github releases 2023-10-10 16:03:37 +01:00
Hassan Ben Jobrane
7459a9413e chore: add changeset 2023-10-10 15:43:28 +01:00
Hassan Ben Jobrane
56871cc9f7 fix: integrations: apollo: set accessToken to null after TOKEN_CHANGED event on signout 2023-10-10 13:38:04 +01:00
Hassan Ben Jobrane
8f4d66e52d Merge pull request #2308 from nhost/changeset-release/main
chore: update versions
2023-10-10 13:31:53 +01:00
github-actions[bot]
315a820073 chore: update versions 2023-10-10 12:04:28 +00:00
Hassan Ben Jobrane
ca57ad2cbd Merge pull request #2301 from nhost/feat/quickstarts/sveltekit
feat(quickstarts): sveltekit <--> nhost
2023-10-10 13:00:14 +01:00
Hassan Ben Jobrane
40259344eb Merge pull request #2306 from nhost/chore/providers-updated-notice
chore(dashboard): show oauth providers update notice
2023-10-10 12:45:12 +01:00
Hassan Ben Jobrane
4749f60a08 chore: add changeset 2023-10-09 20:12:04 +01:00
Hassan Ben Jobrane
ac1888514d chore: update readme for sveltekit quickstart 2023-10-09 19:52:44 +01:00
Hassan Ben Jobrane
49b4af439b feat: signup/signin via webauthn 2023-10-09 19:23:50 +01:00
Hassan Ben Jobrane
61e03d6c70 chore: make sure deprication notice is under a project 2023-10-09 16:48:01 +01:00
Hassan Ben Jobrane
bec0fce497 chore: add deprication banner 2023-10-09 15:00:58 +01:00
Hassan Ben Jobrane
c01568a7dd chore: add changeset 2023-10-09 14:28:40 +01:00
Hassan Ben Jobrane
e934216a82 chore: bring back providers update alert 2023-10-09 14:26:41 +01:00
Hassan Ben Jobrane
701d6b8c84 feat: sveltekit: add delete pat 2023-10-07 19:47:05 +01:00
Hassan Ben Jobrane
e158e2440a wip: sveltekit: add echo and pat pages 2023-10-07 19:22:29 +01:00
Hassan Ben Jobrane
fbaa657001 wip: sveltekit: refresh access token + create/delete todos 2023-10-05 17:08:55 +01:00
Hassan Ben Jobrane
559db6d0ec wip: sveltekit: auth + todos(create) 2023-10-05 12:48:59 +01:00
Hassan Ben Jobrane
4c844930f1 wip: update: sveltekit 2023-10-04 16:58:59 +01:00
Hassan Ben Jobrane
3ef503ff81 Merge pull request #2298 from nhost/changeset-release/main
chore: update versions
2023-10-04 16:47:51 +02:00
github-actions[bot]
bfcfd236ea chore: update versions 2023-10-04 14:13:38 +00:00
Hassan Ben Jobrane
bfa7033506 Merge pull request #2296 from nhost/feat/query-announcements
feat: dashboard: query announcements
2023-10-04 16:09:48 +02:00
Hassan Ben Jobrane
78c29fcf0e feat: filter expired announcements 2023-10-04 14:40:22 +01:00
Hassan Ben Jobrane
f1b934ed22 chore: remove old announcement provider 2023-10-04 10:52:20 +01:00
Hassan Ben Jobrane
914369c53f feat: add announcements list component 2023-10-03 20:18:41 +01:00
Hassan Ben Jobrane
af379b967e chore: clean up commented code 2023-10-03 17:43:24 +01:00
Hassan Ben Jobrane
c3efb7ec84 chore: add changeset 2023-10-03 17:41:35 +01:00
Hassan Ben Jobrane
27cbd48c8c feat(dashboard): query latest announcement from platform 2023-10-03 17:40:50 +01:00
Hassan Ben Jobrane
236996a903 Merge pull request #2293 from nhost/changeset-release/main
chore: update versions
2023-10-02 13:01:48 +02:00
github-actions[bot]
5d0936bb93 chore: update versions 2023-10-02 10:38:35 +00:00
Hassan Ben Jobrane
733c212f2d Merge pull request #2291 from nhost/chore/announcement/node18
chore: node18 announcement
2023-10-02 12:35:53 +02:00
Hassan Ben Jobrane
8b47549189 Merge pull request #2286 from nhost/chore/ci/disable-github-releases
chore(ci): set createGithubReleases to false
2023-10-02 12:26:10 +02:00
Hassan Ben Jobrane
3c9c1025ce Merge pull request #2287 from nhost/fix/vue-sdk/nested-unref
fix(vue-sdk): correctly unref arrays
2023-10-02 12:25:14 +02:00
Hassan Ben Jobrane
3e46d3873c chore: add changeset 2023-10-02 10:50:46 +01:00
Hassan Ben Jobrane
4cf8820d72 chore: open announcement link in a new tab 2023-10-02 10:39:15 +01:00
Hassan Ben Jobrane
02a11184fb chore: change announcement link 2023-10-02 10:38:04 +01:00
Hassan Ben Jobrane
7214d47cc7 chore: add changeset 2023-09-30 17:54:42 +01:00
Hassan Ben Jobrane
238b77baad fix(vue): correctly unref arrays 2023-09-30 17:53:05 +01:00
Hassan Ben Jobrane
81b8e538b4 chore(ci): set createGithubReleases to false 2023-09-29 17:12:21 +01:00
Hassan Ben Jobrane
563a37e58d Merge pull request #2285 from nhost/changeset-release/main
chore: update versions
2023-09-29 18:05:40 +02:00
github-actions[bot]
bff23720ee chore: update versions 2023-09-29 15:43:43 +00:00
Hassan Ben Jobrane
02cbaeffd2 Merge pull request #2225 from nhost/feat/examples/nextjs-server-components
feat: quickstarts: draft for using server components
2023-09-29 17:40:52 +02:00
Hassan Ben Jobrane
9eb814c79a chore: update readme 2023-09-29 15:57:53 +01:00
Hassan Ben Jobrane
ebc5913bb3 chore: naming consistency 2023-09-29 15:57:53 +01:00
Hassan Ben Jobrane
4fe4a16964 chore: add changeset 2023-09-29 15:57:53 +01:00
Hassan Ben Jobrane
92c475b7a7 chore: add missing refreshToken 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
679b34b031 chore: cleanup 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
d3186aefbd refactor: extract session middleware into helper function 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
fdecac9d69 refactor: add high order component for protected pages 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
5077283028 chore: merge oauth handling in middleware 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
f5f662aad1 chore: refactor server actions 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
735b779af7 chore: clean up database setup 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
4418d6abcf chore: cleanup 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
049e315c30 fix: set correct path on cookie on oauth signin 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
764597538b fix: make sure that hasura-storage-js works on EdgeRuntime 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
c8aea785cc fix: tweak todo item layout 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
e0e44b2ff4 fix: set same path for session cookie 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
12280f7c87 feat: pat list pagination 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
732a4f40ca wip 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
d67fd599e4 feat: todos CRUD 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
a41231927a feat: add signin with pat 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
42ec665950 fix: return refreshToken in getAuthenticationResult 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
7225712a30 chore: update hasura auth 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
6593fdd9bb fix: make sure refreshToken is returned after signin/signup 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
40039fece5 Revert "refactor: make sure to return refresh token"
This reverts commit b31b358ca1898bb4173954b8b33059d92cc8c126.
2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
e5fcfb3cd5 refactor: make sure to return refresh token 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
218ec314fb feat(quickstarts): refactor and organize signup/signin 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
9367e91d45 feat: examples: add other sign in methods
Add sign in with google and webauthn
2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
06c640be2c chore: delete unnecessary files 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
ae45be9816 feat(quickstarts): draft for using server components 2023-09-29 15:57:52 +01:00
Hassan Ben Jobrane
ec4be590d8 Merge pull request #2284 from nhost/chore/ci/fix-release
chore: ci: fix release worflow
2023-09-29 16:56:12 +02:00
Hassan Ben Jobrane
5c51653aa0 chore: fix release worflow 2023-09-29 13:29:44 +01:00
288 changed files with 5933 additions and 2288 deletions

View File

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

View File

@@ -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

View File

@@ -2,5 +2,6 @@
"editor.codeActionsOnSave": {
"source.organizeImports": true
},
"eslint.workingDirectories": ["./dashboard"]
"eslint.workingDirectories": ["./dashboard"],
"typescript.tsdk": "node_modules/typescript/lib"
}

View File

@@ -1,5 +1,37 @@
# @nhost/dashboard
## 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>
)
);
}

View File

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

View File

@@ -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>

View 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&apos;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&apos;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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -142,6 +142,7 @@ export default function useProjectRoutes() {
exact: false,
label: 'Run',
icon: <ServicesIcon />,
disabled: !isPlatform,
},
...nhostRoutes,
];

View File

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

View File

@@ -0,0 +1,5 @@
mutation confirmProvidersUpdated($id: uuid!) {
updateApp(pk_columns: { id: $id }, _set: { providersUpdated: true }) {
id
}
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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 />

View File

@@ -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>
);

View File

@@ -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>
);

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,11 @@
# @nhost/docs
## 0.6.2
### Patch Changes
- 4fe4a1696: return `refreshToken` immediately after signIn and signUp
## 0.6.1
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/docs",
"version": "0.6.1",
"version": "0.6.2",
"private": true,
"scripts": {
"docusaurus": "docusaurus",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 629 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
'use client'
export default function SignOut({ signOut }: { signOut: () => Promise<void> }) {
return (
<button
onClick={() => signOut()}
className="inline-block px-4 py-2 text-base font-medium text-white bg-indigo-500 border border-transparent rounded-md hover:bg-opacity-75"
>
Sign out
</button>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "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"]
}

View File

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

View File

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

View 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"
}

View 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

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"allowJs": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"strictNullChecks": false
}
}

View File

@@ -0,0 +1 @@
version: 3

View File

@@ -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>

View File

@@ -0,0 +1 @@
Потвърждение за смяна на имейл

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