Compare commits

...

44 Commits

Author SHA1 Message Date
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
190 changed files with 3681 additions and 505 deletions

View File

@@ -42,7 +42,7 @@ jobs:
commit: 'chore: update versions' commit: 'chore: update versions'
title: 'chore: update versions' title: 'chore: update versions'
publish: pnpm run release publish: pnpm run release
createGithubReleases: true createGithubReleases: false
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -66,6 +66,8 @@ jobs:
publish-vercel: publish-vercel:
name: Publish to Vercel name: Publish to Vercel
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs:
- test
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
@@ -93,6 +95,7 @@ jobs:
needs: needs:
- test - test
- version - version
- publish-vercel
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3

View File

@@ -1,5 +1,18 @@
# @nhost/dashboard # @nhost/dashboard
## 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 ## 0.20.19
### Patch Changes ### Patch Changes

View File

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

View File

@@ -38,7 +38,7 @@ function Announcement(
<span /> <span />
<div className="flex items-center self-center truncate"> <div className="flex items-center self-center truncate">
<a href={href}> <a href={href} target="_blank" rel="noopener noreferrer">
<Text className="cursor-pointer truncate hover:underline"> <Text className="cursor-pointer truncate hover:underline">
{children} {children}
</Text> </Text>

View File

@@ -38,7 +38,7 @@ export interface AnnouncementContextProps {
// Note: You can define the active announcement here. // Note: You can define the active announcement here.
const announcement: AnnouncementType = { const announcement: AnnouncementType = {
id: 'node-18', id: 'node-18',
href: 'https://github.com/nhost/nhost/discussions/2239', href: 'https://github.com/nhost/nhost/discussions/2288',
content: content:
"Starting October 1st, we're upgrading to Node.js 18 for improved performance, security, and stability. Learn more.", "Starting October 1st, we're upgrading to Node.js 18 for improved performance, security, and stability. Learn more.",
}; };

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@nhost/docs", "name": "@nhost/docs",
"version": "0.6.1", "version": "0.6.2",
"private": true, "private": true,
"scripts": { "scripts": {
"docusaurus": "docusaurus", "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": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@components/*": ["./src/components/*"],
"@utils/*": ["./src/utils/*"],
"@actions": ["./src/app/actions"],
"@server-actions/*": ["./src/app/server-actions/*"],
"@types": ["./src/types"],
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

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

View File

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

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 @@
Потвърждение за смяна на имейл

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 @@
Потвърждаване на имейл

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 @@
Смяна на парола

View File

@@ -0,0 +1 @@
Вашият код е ${code}.

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 @@
Магически линк за вход

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<h2>Potvrzení změny emailové adresy</h2>
<p>Použijte tento odkaz k potvrzení změny emailové adresy:</p>
<p>
<a href="${link}">
Změnit email
</a>
</p>
</body>
</html>

View File

@@ -0,0 +1 @@
Změna vaší emailové adresy

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<h2>Ověření emailové adresy</h2>
<p>Použijte tento odkaz k ověření vaší emailové adresy:</p>
<p>
<a href="${link}">
Ověřit emailovou adresu
</a>
</p>
</body>
</html>

View File

@@ -0,0 +1 @@
Ověření vaší emailové adresy

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<h2>Obnova hesla</h2>
<p>Použijte tento odkaz k obnovení vašeho hesla:</p>
<p>
<a href="${link}">
Obnova hesla
</a>
</p>
</body>
</html>

View File

@@ -0,0 +1 @@
Obnova hesla

View File

@@ -0,0 +1 @@
Váš kód je ${code}.

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<h2>Magický odkaz</h2>
<p>Použijte tento odkaz k bezpečnému přihlášení:</p>
<p>
<a href="${link}">
Přihlášení
</a>
</p>
</body>
</html>

View File

@@ -0,0 +1 @@
Bezpečný odkaz k přihlášení

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<h2>Confirm Email Change</h2>
<p>Use this link to confirm changing email:</p>
<p>
<a href="${link}">
Change email
</a>
</p>
</body>
</html>

View File

@@ -0,0 +1 @@
Change your email address

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<h2>Verify Email</h2>
<p>Use this link to verify your email:</p>
<p>
<a href="${link}">
Verify Email
</a>
</p>
</body>
</html>

View File

@@ -0,0 +1 @@
Verify your email

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<h2>Reset Password</h2>
<p>Use this link to reset your password:</p>
<p>
<a href="${link}">
Reset password
</a>
</p>
</body>
</html>

View File

@@ -0,0 +1 @@
Reset your password

View File

@@ -0,0 +1 @@
Your code is ${code}.

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<h2>Magic Link</h2>
<p>Use this link to securely sign in:</p>
<p>
<a href="${link}">
Sign In
</a>
</p>
</body>
</html>

View File

@@ -0,0 +1 @@
Secure sign-in link

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<h2>Confirmar cambio de correo electrónico</h2>
<p>Utiliza el siguiente enlace para confirmar el cambio de correo:</p>
<p>
<a href="${link}">
Cambiar correo electrónico
</a>
</p>
</body>
</html>

View File

@@ -0,0 +1 @@
Cambiar dirección de correo electrónico

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<h2>Verificar correo electrónico</h2>
<p>Utilza el siguiente enlace para verificar tu correo:</p>
<p>
<a href="${link}">
Verificar correo electrónico
</a>
</p>
</body>
</html>

View File

@@ -0,0 +1 @@
Verifica tu correo electrónico

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<h2>Recuperar contraseña</h2>
<p>Utiliza el siguiente enlace para recuperar tu contraseña:</p>
<p>
<a href="${link}">
Recuperar contraseña
</a>
</p>
</body>
</html>

View File

@@ -0,0 +1 @@
Recuperar contraseña

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