Compare commits

..

33 Commits

Author SHA1 Message Date
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
187 changed files with 3659 additions and 501 deletions

View File

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

View File

@@ -1,5 +1,12 @@
# @nhost/dashboard
## 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.20",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",

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": "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

View File

@@ -0,0 +1 @@
Tu código es ${code}.

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<h2>Enlace mágico</h2>
<p>Utiliza este enlace para iniciar sesión de forma segura:</p>
<p>
<a href="${link}">
Iniciar sesión
</a>
</p>
</body>
</html>

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