Compare commits
44 Commits
@nhost/das
...
@nhost/vue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
236996a903 | ||
|
|
5d0936bb93 | ||
|
|
733c212f2d | ||
|
|
8b47549189 | ||
|
|
3c9c1025ce | ||
|
|
3e46d3873c | ||
|
|
4cf8820d72 | ||
|
|
02a11184fb | ||
|
|
7214d47cc7 | ||
|
|
238b77baad | ||
|
|
81b8e538b4 | ||
|
|
563a37e58d | ||
|
|
bff23720ee | ||
|
|
02cbaeffd2 | ||
|
|
9eb814c79a | ||
|
|
ebc5913bb3 | ||
|
|
4fe4a16964 | ||
|
|
92c475b7a7 | ||
|
|
679b34b031 | ||
|
|
d3186aefbd | ||
|
|
fdecac9d69 | ||
|
|
5077283028 | ||
|
|
f5f662aad1 | ||
|
|
735b779af7 | ||
|
|
4418d6abcf | ||
|
|
049e315c30 | ||
|
|
764597538b | ||
|
|
c8aea785cc | ||
|
|
e0e44b2ff4 | ||
|
|
12280f7c87 | ||
|
|
732a4f40ca | ||
|
|
d67fd599e4 | ||
|
|
a41231927a | ||
|
|
42ec665950 | ||
|
|
7225712a30 | ||
|
|
6593fdd9bb | ||
|
|
40039fece5 | ||
|
|
e5fcfb3cd5 | ||
|
|
218ec314fb | ||
|
|
9367e91d45 | ||
|
|
06c640be2c | ||
|
|
ae45be9816 | ||
|
|
ec4be590d8 | ||
|
|
5c51653aa0 |
5
.github/workflows/changesets.yaml
vendored
5
.github/workflows/changesets.yaml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
NEXT_PUBLIC_NHOST_SUBDOMAIN=local
|
||||||
|
NEXT_PUBLIC_NHOST_REGION=
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: ['../../config/.eslintrc.js', 'plugin:@next/next/recommended'],
|
||||||
|
rules: {
|
||||||
|
'react/react-in-jsx-scope': 'off'
|
||||||
|
}
|
||||||
|
}
|
||||||
35
examples/quickstarts/nextjs-server-components/.gitignore
vendored
Normal file
35
examples/quickstarts/nextjs-server-components/.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
11
examples/quickstarts/nextjs-server-components/CHANGELOG.md
Normal file
11
examples/quickstarts/nextjs-server-components/CHANGELOG.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# @nhost-examples/nextjs-server-components
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 4fe4a1696: new quickstart project that demonstrates how to use the Nhost SDK with Next.js 13 server components
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @nhost/nhost-js@2.2.17
|
||||||
70
examples/quickstarts/nextjs-server-components/README.md
Normal file
70
examples/quickstarts/nextjs-server-components/README.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Nhost with Next.js Server Components
|
||||||
|
|
||||||
|
This quickstart showcases how to correctly add authentication to a Next.js 13 project using the new App Router and Server Components. The other parts of the SDK (Storage / GraphQL/ Functions) should work the same as before.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
1. **Saving the auth session**
|
||||||
|
|
||||||
|
To enable authentication with Server Components we have to store the auth session in a cookie. This should be done right after any **signIn** or **signUp** operation. See example [here](https://github.com/nhost/nhost/blob/main/examples/quickstarts/nextjs-server-components/src/app/server-actions/auth/sign-in-email-password.ts).
|
||||||
|
|
||||||
|
2. **Oauth & refresh session middleware**
|
||||||
|
|
||||||
|
Create a middleware at the root of your project that calls the helper method `manageAuthSession`. Feel free to copy paste the the contents of the `/utils` folder to your project. The second argument for `manageAuthSession` is for handling the case where there's an error refreshing the current session with the `refreshToken` stored in the cookie.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { manageAuthSession } from '@utils/nhost'
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
return manageAuthSession(request, () =>
|
||||||
|
NextResponse.redirect(new URL('/auth/sign-in', request.url))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Protected routes**
|
||||||
|
|
||||||
|
To make sure only authenticated users access some Server Components, wrap them in the Higher Order Server Component `withAuthAsync`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import withAuthAsync from '@utils/auth-guard'
|
||||||
|
|
||||||
|
const MyProtectedServerComponent = async () => {
|
||||||
|
return <h2>Protected</h2>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withAuthAsync(MyProtectedServerComponent)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Get Started
|
||||||
|
|
||||||
|
1. Clone the repository
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/nhost/nhost
|
||||||
|
cd nhost
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install and build dependencies
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Terminal 1: Start the Nhost Backend
|
||||||
|
|
||||||
|
> Make sure you have the [Nhost CLI installed](https://docs.nhost.io/platform/cli).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd examples/quickstarts/nhost-backend
|
||||||
|
nhost up
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Terminal 2: Start the Next.js application
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd examples/quickstarts/nextjs-server-components
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
experimental: {
|
||||||
|
serverActions: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
35
examples/quickstarts/nextjs-server-components/package.json
Normal file
35
examples/quickstarts/nextjs-server-components/package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@nhost-examples/nextjs-server-components",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@apollo/client": "^3.8.2",
|
||||||
|
"@nhost/nhost-js": "workspace:^",
|
||||||
|
"autoprefixer": "10.4.15",
|
||||||
|
"cookies-next": "^3.0.0",
|
||||||
|
"eslint": "8.48.0",
|
||||||
|
"eslint-config-next": "13.4.19",
|
||||||
|
"form-data": "^4.0.0",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
|
"next": "13.4.19",
|
||||||
|
"postcss": "8.4.29",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"tailwind-merge": "^1.8.0",
|
||||||
|
"tailwindcss": "3.3.3",
|
||||||
|
"typescript": "5.2.2",
|
||||||
|
"xstate": "^4.38.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/js-cookie": "^3.0.2",
|
||||||
|
"@types/node": "20.5.6",
|
||||||
|
"@types/react": "18.2.21",
|
||||||
|
"@types/react-dom": "18.2.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
||||||
|
After Width: | Height: | Size: 629 B |
@@ -0,0 +1,36 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Input from '@components/input'
|
||||||
|
import SubmitButton from '@components/submit-button'
|
||||||
|
import { signIn } from '@server-actions/auth'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export default function SignInWithEmailAndPassword() {
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function handleSignIn(formData: FormData) {
|
||||||
|
const response = await signIn(formData)
|
||||||
|
|
||||||
|
if (response?.error) {
|
||||||
|
setError(response.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<h1 className="text-2xl font-semibold text-center">Sign in with email and password</h1>
|
||||||
|
|
||||||
|
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<form className="w-full max-w-lg space-y-5" action={handleSignIn}>
|
||||||
|
<Input label="Email" id="email" name="email" type="email" required />
|
||||||
|
|
||||||
|
<Input label="Password" id="password" name="password" type="password" required />
|
||||||
|
|
||||||
|
<SubmitButton type="submit" className="w-full">
|
||||||
|
Sign in
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Input from '@components/input'
|
||||||
|
import SubmitButton from '@components/submit-button'
|
||||||
|
import { NhostClient } from '@nhost/nhost-js'
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
|
||||||
|
const nhost = new NhostClient({
|
||||||
|
subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN,
|
||||||
|
region: process.env.NEXT_PUBLIC_NHOST_REGION
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function SignInMagickLink() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isSuccess, setIsSuccess] = useState(false)
|
||||||
|
|
||||||
|
const handleSignIn = async (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const { error } = await nhost.auth.signIn({ email })
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setError(error.message)
|
||||||
|
} else {
|
||||||
|
setIsSuccess(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<h1 className="text-2xl font-semibold text-center">Sign in with a magick link</h1>
|
||||||
|
|
||||||
|
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
|
||||||
|
{isSuccess && (
|
||||||
|
<p className="mt-3 font-semibold text-center text-green-500">
|
||||||
|
Click the link in the email to finish the sign in process
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="w-full max-w-lg space-y-5" onSubmit={handleSignIn}>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<SubmitButton type="submit" className="w-full">
|
||||||
|
Sign In
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { signInWithGoogle } from '@server-actions/auth'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function SignIn() {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container flex justify-center">
|
||||||
|
<div className="w-full max-w-lg space-y-5">
|
||||||
|
<h1 className="text-2xl font-semibold text-center">Sign In</h1>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
|
||||||
|
onClick={() => router.push('/auth/sign-in/email-password')}
|
||||||
|
>
|
||||||
|
with email/password
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
|
||||||
|
onClick={() => router.push('/auth/sign-in/webauthn')}
|
||||||
|
>
|
||||||
|
with a security key
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
|
||||||
|
onClick={() => router.push('/auth/sign-in/magick-link')}
|
||||||
|
>
|
||||||
|
with a magick link
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
|
||||||
|
onClick={() => router.push('/auth/sign-in/pat')}
|
||||||
|
>
|
||||||
|
with a Personal Access Token
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-white w-full bg-[#4285F4] hover:bg-[#4285F4]/90 focus:ring-4 focus:outline-none focus:ring-[#4285F4]/50 font-medium rounded-lg px-5 py-2.5 text-center inline-flex items-center justify-between dark:focus:ring-[#4285F4]/55 mr-2 mb-2"
|
||||||
|
onClick={() => signInWithGoogle()}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2 -ml-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
data-prefix="fab"
|
||||||
|
data-icon="google"
|
||||||
|
role="img"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 488 512"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
with Google <span />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Input from '@components/input'
|
||||||
|
import SubmitButton from '@components/submit-button'
|
||||||
|
import { signInWithPAT } from '@server-actions/auth'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export default function SignInWithPAT() {
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function handleSignIn(formData: FormData) {
|
||||||
|
const response = await signInWithPAT(formData)
|
||||||
|
|
||||||
|
if (response?.error) {
|
||||||
|
setError(response.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<h1 className="text-2xl font-semibold text-center">Sign In with Personal Access Token</h1>
|
||||||
|
|
||||||
|
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<form className="w-full max-w-lg space-y-5" action={handleSignIn}>
|
||||||
|
<Input label="PAT" id="pat" name="pat" required />
|
||||||
|
<SubmitButton type="submit" className="w-full">
|
||||||
|
Sign In
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Input from '@components/input'
|
||||||
|
import SubmitButton from '@components/submit-button'
|
||||||
|
import { NhostClient } from '@nhost/nhost-js'
|
||||||
|
import Cookies from 'js-cookie'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
|
||||||
|
const NHOST_SESSION_KEY = 'nhostSession'
|
||||||
|
|
||||||
|
const nhost = new NhostClient({
|
||||||
|
subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN,
|
||||||
|
region: process.env.NEXT_PUBLIC_NHOST_REGION
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function SignInWithSecurityKey() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
|
||||||
|
const handleSignIn = async (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const { session, error } = await nhost.auth.signIn({
|
||||||
|
email,
|
||||||
|
securityKey: true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setError(error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
Cookies.set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { sameSite: 'strict' })
|
||||||
|
router.push('/protected/todos')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<h1 className="text-2xl font-semibold text-center">Sign In</h1>
|
||||||
|
|
||||||
|
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<form className="w-full max-w-lg space-y-5" onSubmit={handleSignIn}>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<SubmitButton type="submit" className="w-full">
|
||||||
|
Sign In
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Input from '@components/input'
|
||||||
|
import SubmitButton from '@components/submit-button'
|
||||||
|
import { signUp } from '@server-actions/auth'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export default function SignUpWithEmailAndPassword() {
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function handleSignUp(formData: FormData) {
|
||||||
|
const response = await signUp(formData)
|
||||||
|
|
||||||
|
if (response?.error) {
|
||||||
|
setError(response.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl font-semibold text-center">Sign Up</h1>
|
||||||
|
|
||||||
|
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<form className="space-y-5" action={handleSignUp}>
|
||||||
|
<Input label="First Name" id="firstName" name="firstName" required />
|
||||||
|
<Input label="Last Name" id="lastName" name="lastName" required />
|
||||||
|
<Input label="Email" id="email" name="email" type="email" required />
|
||||||
|
<Input label="Password" id="password" name="password" type="password" required />
|
||||||
|
<SubmitButton type="submit">Sign Up</SubmitButton>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { signInWithGoogle } from '@server-actions/auth'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function SignUp() {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container flex justify-center">
|
||||||
|
<div className="w-full max-w-lg space-y-5">
|
||||||
|
<h1 className="text-2xl font-semibold text-center">Sign Up</h1>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
|
||||||
|
onClick={() => router.push('/auth/sign-up/email-password')}
|
||||||
|
>
|
||||||
|
with email/password
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
|
||||||
|
onClick={() => router.push('/auth/sign-up/webauthn')}
|
||||||
|
>
|
||||||
|
with a security key
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-white w-full bg-[#4285F4] hover:bg-[#4285F4]/90 focus:ring-4 focus:outline-none focus:ring-[#4285F4]/50 font-medium rounded-lg px-5 py-2.5 text-center inline-flex items-center justify-between dark:focus:ring-[#4285F4]/55 mr-2 mb-2"
|
||||||
|
onClick={() => signInWithGoogle()}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2 -ml-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
data-prefix="fab"
|
||||||
|
data-icon="google"
|
||||||
|
role="img"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 488 512"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
with Google <span />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Input from '@components/input'
|
||||||
|
import SubmitButton from '@components/submit-button'
|
||||||
|
import { NhostClient } from '@nhost/nhost-js'
|
||||||
|
import Cookies from 'js-cookie'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
|
||||||
|
const NHOST_SESSION_KEY = 'nhostSession'
|
||||||
|
|
||||||
|
const nhost = new NhostClient({
|
||||||
|
subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN,
|
||||||
|
region: process.env.NEXT_PUBLIC_NHOST_REGION
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function SignUpWebAuthn() {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
|
||||||
|
const handleSignUp = async (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const { session, error } = await nhost.auth.signUp({
|
||||||
|
email,
|
||||||
|
securityKey: true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setError(error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
handleSignUpSession: session
|
||||||
|
})
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
Cookies.set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { sameSite: 'strict' })
|
||||||
|
router.push('/protected/todos')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<h1 className="text-2xl font-semibold text-center">Sign Up with a security key</h1>
|
||||||
|
|
||||||
|
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
<form className="w-full max-w-lg space-y-5" onSubmit={handleSignUp}>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<SubmitButton type="submit" className="w-full">
|
||||||
|
Sign Up
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Navigation from '@components/navigation'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { Inter } from 'next/font/google'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Create Next App',
|
||||||
|
description: 'Generated by create next app'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className={inter.className}>
|
||||||
|
<div className="app">
|
||||||
|
<Navigation />
|
||||||
|
<div className="container p-4 mx-auto mt-8 antialiased">{children}</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Home() {
|
||||||
|
return <h1 className="text-2xl text-center">Hi, login/register to get started</h1>
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import withAuth from '@utils/auth-guard'
|
||||||
|
import { getNhost } from '@utils/nhost'
|
||||||
|
|
||||||
|
type EchoResponse = {
|
||||||
|
headers: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const Echo = async () => {
|
||||||
|
const nhost = await getNhost()
|
||||||
|
const { res } = await nhost.functions.call<EchoResponse>('echo')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<pre className="overflow-auto">{JSON.stringify(res?.data.headers, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withAuth(Echo)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { gql } from '@apollo/client'
|
||||||
|
import PatItem, { type PAT } from '@components/pat-item'
|
||||||
|
import withAuthAsync from '@utils/auth-guard'
|
||||||
|
import { getNhost } from '@utils/nhost'
|
||||||
|
import Head from 'next/head'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
const PAT = async ({
|
||||||
|
params
|
||||||
|
}: {
|
||||||
|
params: {
|
||||||
|
[key: string]: string | string[] | undefined
|
||||||
|
}
|
||||||
|
}) => {
|
||||||
|
const page = parseInt(params.pagination?.at(0) || '0')
|
||||||
|
const nhost = await getNhost()
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: {
|
||||||
|
authRefreshTokens,
|
||||||
|
authRefreshTokensAggregate: {
|
||||||
|
aggregate: { count }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} = await nhost.graphql.request(
|
||||||
|
gql`
|
||||||
|
query getPersonalAccessTokens($offset: Int, $limit: Int) {
|
||||||
|
authRefreshTokens(
|
||||||
|
offset: $offset
|
||||||
|
limit: $limit
|
||||||
|
order_by: { createdAt: desc }
|
||||||
|
where: { type: { _eq: pat } }
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
metadata
|
||||||
|
type
|
||||||
|
expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
authRefreshTokensAggregate(where: { type: { _eq: pat } }) {
|
||||||
|
aggregate {
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
offset: page * 10,
|
||||||
|
limit: 10
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<Head>
|
||||||
|
<title>Personal Access Tokens</title>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<h2 className="text-xl">Personal Access Tokens ({count})</h2>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/protected/pat/new`}
|
||||||
|
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Add a PAT
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{authRefreshTokens.map((token: PAT) => (
|
||||||
|
<li key={token.id}>
|
||||||
|
<PatItem pat={token} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{count > 10 && (
|
||||||
|
<div className="flex justify-center space-x-2">
|
||||||
|
{page > 0 && (
|
||||||
|
<Link
|
||||||
|
href={`/protected/pat/${page - 1}`}
|
||||||
|
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{page + 1 < Math.ceil(count / 10) && (
|
||||||
|
<Link
|
||||||
|
href={`/protected/pat/${page + 1}`}
|
||||||
|
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withAuthAsync(PAT)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import PATForm from '@components/pat-form'
|
||||||
|
import withAuthAsync from '@utils/auth-guard'
|
||||||
|
|
||||||
|
const NewPat = async () => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col max-w-3xl mx-auto space-y-4">
|
||||||
|
<h2 className="text-xl">New Personal Access Token</h2>
|
||||||
|
<PATForm />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withAuthAsync(NewPat)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { gql } from '@apollo/client'
|
||||||
|
import TodoItem, { type Todo } from '@components/todo-item'
|
||||||
|
import withAuthAsync from '@utils/auth-guard'
|
||||||
|
import { getNhost } from '@utils/nhost'
|
||||||
|
|
||||||
|
import Head from 'next/head'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
const Todos = async ({ params }: { params: { [key: string]: string | string[] | undefined } }) => {
|
||||||
|
const page = parseInt(params.pagination?.at(0) || '0')
|
||||||
|
|
||||||
|
const nhost = await getNhost()
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: {
|
||||||
|
todos,
|
||||||
|
todos_aggregate: {
|
||||||
|
aggregate: { count }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} = await nhost.graphql.request(
|
||||||
|
gql`
|
||||||
|
query getTodos($limit: Int, $offset: Int) {
|
||||||
|
todos(limit: $limit, offset: $offset, order_by: { createdAt: desc }) {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
done
|
||||||
|
attachment {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
todos_aggregate {
|
||||||
|
aggregate {
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
offset: page * 10,
|
||||||
|
limit: 10
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Head>
|
||||||
|
<title>Protected Page</title>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<h2 className="text-xl">Todos ({count})</h2>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/protected/todos/new`}
|
||||||
|
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Add Todo
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{todos.map((todo: Todo) => (
|
||||||
|
<li key={todo.id}>
|
||||||
|
<TodoItem todo={todo} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{count > 10 && (
|
||||||
|
<div className="flex justify-center space-x-2">
|
||||||
|
{page > 0 && (
|
||||||
|
<Link
|
||||||
|
href={`/protected/todos/${page - 1}`}
|
||||||
|
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{page + 1 < Math.ceil(count / 10) && (
|
||||||
|
<Link
|
||||||
|
href={`/protected/todos/${page + 1}`}
|
||||||
|
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withAuthAsync(Todos)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import TodoForm from '@components/todo-form'
|
||||||
|
import withAuthAsync from '@utils/auth-guard'
|
||||||
|
|
||||||
|
const NewTodo = async () => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col max-w-3xl mx-auto space-y-4">
|
||||||
|
<h2 className="text-xl">New Todo</h2>
|
||||||
|
<TodoForm />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withAuthAsync(NewTodo)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { signInWithApple } from './sign-in-apple'
|
||||||
|
export { signIn } from './sign-in-email-password'
|
||||||
|
export { signInWithGoogle } from './sign-in-google'
|
||||||
|
export { signInWithPAT } from './sign-in-pat'
|
||||||
|
export { signOut } from './sign-out'
|
||||||
|
export { signUp } from './sign-up-email-password'
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { getNhost } from '@utils/nhost'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export const signInWithApple = async () => {
|
||||||
|
const nhost = await getNhost()
|
||||||
|
|
||||||
|
const { providerUrl } = await nhost.auth.signIn({
|
||||||
|
provider: 'apple',
|
||||||
|
options: {
|
||||||
|
redirectTo: `/oauth`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (providerUrl) {
|
||||||
|
redirect(providerUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { NHOST_SESSION_KEY, getNhost } from '@utils/nhost'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export const signIn = async (formData: FormData) => {
|
||||||
|
const nhost = await getNhost()
|
||||||
|
|
||||||
|
const email = formData.get('email') as string
|
||||||
|
const password = formData.get('password') as string
|
||||||
|
|
||||||
|
const { session, error } = await nhost.auth.signIn({ email, password })
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
cookies().set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { path: '/' })
|
||||||
|
redirect('/protected/todos')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return {
|
||||||
|
error: error?.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { getNhost } from '@utils/nhost'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export const signInWithGoogle = async () => {
|
||||||
|
const nhost = await getNhost()
|
||||||
|
|
||||||
|
const { providerUrl } = await nhost.auth.signIn({
|
||||||
|
provider: 'google',
|
||||||
|
options: {
|
||||||
|
redirectTo: `/oauth`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (providerUrl) {
|
||||||
|
redirect(providerUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { NHOST_SESSION_KEY, getNhost } from '@utils/nhost'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export const signInWithPAT = async (formData: FormData) => {
|
||||||
|
const nhost = await getNhost()
|
||||||
|
|
||||||
|
const pat = formData.get('pat') as string
|
||||||
|
|
||||||
|
const { session, error } = await nhost.auth.signInPAT(pat)
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
cookies().set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { path: '/' })
|
||||||
|
redirect('/protected/todos')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return {
|
||||||
|
error: error?.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { NHOST_SESSION_KEY, getNhost } from '@utils/nhost'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export const signOut = async () => {
|
||||||
|
const nhost = await getNhost()
|
||||||
|
|
||||||
|
await nhost.auth.signOut()
|
||||||
|
|
||||||
|
cookies().delete(NHOST_SESSION_KEY)
|
||||||
|
|
||||||
|
redirect('/auth/sign-in')
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { NHOST_SESSION_KEY, getNhost } from '@utils/nhost'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export const signUp = async (formData: FormData) => {
|
||||||
|
const nhost = await getNhost()
|
||||||
|
|
||||||
|
const firstName = formData.get('firstName') as string
|
||||||
|
const lastName = formData.get('lastName') as string
|
||||||
|
const email = formData.get('email') as string
|
||||||
|
const password = formData.get('password') as string
|
||||||
|
|
||||||
|
const { session, error } = await nhost.auth.signUp({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
options: {
|
||||||
|
displayName: `${firstName} ${lastName}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
cookies().set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { path: '/' })
|
||||||
|
redirect('/protected/todos')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return {
|
||||||
|
error: error?.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { getNhost } from '@utils/nhost'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export const createPAT = async (formData: FormData) => {
|
||||||
|
const nhost = await getNhost()
|
||||||
|
|
||||||
|
const name = formData.get('name') as string
|
||||||
|
const expiration = formData.get('expiration') as string
|
||||||
|
const expirationDate = new Date(expiration)
|
||||||
|
|
||||||
|
await nhost.auth.createPAT(expirationDate, { name })
|
||||||
|
|
||||||
|
redirect('/protected/pat')
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { gql } from '@apollo/client'
|
||||||
|
import { getNhost } from '@utils/nhost'
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
|
||||||
|
export const deletePAT = async (id: string) => {
|
||||||
|
const nhost = await getNhost()
|
||||||
|
|
||||||
|
await nhost.graphql.request(
|
||||||
|
gql`
|
||||||
|
mutation deletePersonalAccessToken($id: uuid!) {
|
||||||
|
deleteAuthRefreshToken(id: $id) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
revalidatePath('/protected/pat')
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { createPAT } from './create-pat'
|
||||||
|
export { deletePAT } from './delete-pat'
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { gql } from '@apollo/client'
|
||||||
|
import { getNhost } from '@utils/nhost'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export const createTodo = async (formData: FormData) => {
|
||||||
|
const nhost = await getNhost()
|
||||||
|
|
||||||
|
const title = formData.get('title') as string
|
||||||
|
const file = formData.get('file') as File
|
||||||
|
|
||||||
|
let payload: {
|
||||||
|
title: string
|
||||||
|
file_id?: string
|
||||||
|
} = {
|
||||||
|
title
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
const { fileMetadata } = await nhost.storage.upload({
|
||||||
|
formData
|
||||||
|
})
|
||||||
|
|
||||||
|
payload.file_id = fileMetadata?.processedFiles[0]?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
await nhost.graphql.request(
|
||||||
|
gql`
|
||||||
|
mutation insertTodo($title: String!, $file_id: uuid) {
|
||||||
|
insert_todos_one(object: { title: $title, file_id: $file_id }) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
|
||||||
|
redirect('/protected/todos')
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { gql } from '@apollo/client'
|
||||||
|
import { getNhost } from '@utils/nhost'
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
|
||||||
|
export const deleteTodo = async (id: string) => {
|
||||||
|
const nhost = await getNhost()
|
||||||
|
|
||||||
|
await nhost.graphql.request(
|
||||||
|
gql`
|
||||||
|
mutation deleteTodo($id: uuid!) {
|
||||||
|
delete_todos_by_pk(id: $id) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
revalidatePath('/protected/todos')
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { createTodo } from './create-todo'
|
||||||
|
export { deleteTodo } from './delete-todo'
|
||||||
|
export { updateTodo } from './update-todo'
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { gql } from '@apollo/client'
|
||||||
|
import { getNhost } from '@utils/nhost'
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
|
||||||
|
export const updateTodo = async (id: string, done: boolean) => {
|
||||||
|
const nhost = await getNhost()
|
||||||
|
|
||||||
|
await nhost.graphql.request(
|
||||||
|
gql`
|
||||||
|
mutation updateTodo($id: uuid!, $done: Boolean!) {
|
||||||
|
update_todos_by_pk(pk_columns: { id: $id }, _set: { done: $done }) {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
done
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
revalidatePath('/protected/todos')
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { DetailedHTMLProps, HTMLProps } from 'react'
|
||||||
|
// @ts-ignore
|
||||||
|
import { experimental_useFormStatus as useFormStatus } from 'react-dom'
|
||||||
|
|
||||||
|
export default function Input({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: DetailedHTMLProps<HTMLProps<HTMLInputElement>, HTMLInputElement>) {
|
||||||
|
const { pending } = useFormStatus()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={id} className="block mb-1 text-sm font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
name={name}
|
||||||
|
required={required}
|
||||||
|
disabled={pending}
|
||||||
|
className="block w-full p-3 border rounded-md border-slate-300 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { signOut } from '@server-actions/auth'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { getNhost } from '../utils/nhost'
|
||||||
|
import SignOut from './sign-out'
|
||||||
|
|
||||||
|
export default async function Navigation() {
|
||||||
|
const nhost = await getNhost()
|
||||||
|
const user = nhost.auth.getUser()
|
||||||
|
|
||||||
|
const nav = [
|
||||||
|
{
|
||||||
|
href: '/',
|
||||||
|
name: 'Home'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/protected/todos',
|
||||||
|
name: `${user ? '🔓' : '🔒'} Todos`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/protected/echo',
|
||||||
|
name: `${user ? '🔓' : '🔒'} Echo`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/protected/pat',
|
||||||
|
name: `${user ? '🔓' : '🔒'} PAT`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="bg-indigo-600">
|
||||||
|
<nav className="container mx-auto">
|
||||||
|
<div className="flex items-center justify-between w-full py-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="ml-10 space-x-8">
|
||||||
|
{nav.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className="text-lg font-medium text-white hover:text-indigo-50"
|
||||||
|
>
|
||||||
|
{link.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-10 space-x-4">
|
||||||
|
{user ? (
|
||||||
|
<SignOut signOut={signOut} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/auth/sign-in"
|
||||||
|
className="inline-block px-4 py-2 text-base font-medium text-white bg-indigo-500 border border-transparent rounded-md hover:bg-opacity-75"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/auth/sign-up"
|
||||||
|
className="inline-block px-4 py-2 text-base font-medium text-indigo-600 bg-white border border-transparent rounded-md hover:bg-indigo-50"
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Input from '@components/input'
|
||||||
|
import SubmitButton from '@components/submit-button'
|
||||||
|
import { createPAT } from '@server-actions/pat'
|
||||||
|
|
||||||
|
export default function PATForm() {
|
||||||
|
return (
|
||||||
|
<form className="space-y-4" action={createPAT}>
|
||||||
|
<Input name="name" label="Name" required />
|
||||||
|
|
||||||
|
<Input name="expiration" type="date" required />
|
||||||
|
|
||||||
|
<SubmitButton>Create PAT</SubmitButton>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { deletePAT } from '@server-actions/pat'
|
||||||
|
|
||||||
|
export interface PAT {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
metadata: Record<string, string>
|
||||||
|
expiresAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PatItem({ pat }: { pat: PAT }) {
|
||||||
|
const handleDeleteTodo = async () => {
|
||||||
|
await deletePAT(pat.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row items-center justify-between p-2 bg-slate-100">
|
||||||
|
<div>
|
||||||
|
<span className="justify-center block w-full space-x-2 rounded">{pat.metadata?.name}</span>
|
||||||
|
<span className="justify-center block w-full space-x-2 text-sm rounded">{pat.id}</span>
|
||||||
|
<span className="justify-center block w-full space-x-2 rounded text-slate-500">
|
||||||
|
expires on {new Date(pat.expiresAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={handleDeleteTodo}>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-6 h-6"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
export default function SignOut({ signOut }: { signOut: () => Promise<void> }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => signOut()}
|
||||||
|
className="inline-block px-4 py-2 text-base font-medium text-white bg-indigo-500 border border-transparent rounded-md hover:bg-opacity-75"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ButtonHTMLAttributes, DetailedHTMLProps } from 'react'
|
||||||
|
// @ts-ignore
|
||||||
|
import { experimental_useFormStatus as useFormStatus } from 'react-dom'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
type ButtonProps = {
|
||||||
|
type?: 'button' | 'submit' | 'reset' | undefined
|
||||||
|
} & DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
|
||||||
|
|
||||||
|
export default function SubmitButton({
|
||||||
|
disabled,
|
||||||
|
type,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: ButtonProps) {
|
||||||
|
const { pending } = useFormStatus()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
disabled={pending}
|
||||||
|
className={twMerge(
|
||||||
|
pending
|
||||||
|
? 'bg-indigo-200 hover:bg-grey-700'
|
||||||
|
: 'bg-indigo-600 hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500',
|
||||||
|
className,
|
||||||
|
'inline-flex items-center justify-center px-4 py-2 border border-transparent text-base font-medium rounded-md shadow-sm text-white focus:outline-none'
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Input from '@components/input'
|
||||||
|
import { createTodo } from '@server-actions/todos'
|
||||||
|
import SubmitButton from './submit-button'
|
||||||
|
|
||||||
|
export default function TodoForm() {
|
||||||
|
return (
|
||||||
|
<form action={createTodo} className="flex flex-col space-y-2">
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
placeholder="What needs to be done"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input id="file" name="file" type="file" className="w-full" accept="image/*" />
|
||||||
|
|
||||||
|
<SubmitButton>Add</SubmitButton>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { NhostClient } from '@nhost/nhost-js'
|
||||||
|
import { deleteTodo, updateTodo } from '@server-actions/todos'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
const nhost = new NhostClient({
|
||||||
|
subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN || 'local',
|
||||||
|
region: process.env.NEXT_PUBLIC_NHOST_REGION
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface Todo {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
done: boolean
|
||||||
|
attachment: {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TodoItem = ({ todo }: { todo: Todo }) => {
|
||||||
|
const [completed, setCompleted] = useState(todo.done)
|
||||||
|
|
||||||
|
const handleCheckboxChange = async () => {
|
||||||
|
setCompleted(!completed)
|
||||||
|
await updateTodo(todo.id, !completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteTodo = async () => {
|
||||||
|
await deleteTodo(todo.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={twMerge(
|
||||||
|
'flex flex-row items-center p-2 bg-slate-100',
|
||||||
|
completed && 'line-through bg-slate-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor={todo.id}
|
||||||
|
className={twMerge(
|
||||||
|
'block w-full space-x-2 rounded select-none justify-center',
|
||||||
|
completed && 'line-through bg-slate-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input type="checkbox" id={todo.id} checked={completed} onChange={handleCheckboxChange} />
|
||||||
|
<span>{todo.title}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{todo.attachment && (
|
||||||
|
<Link
|
||||||
|
className="w-6 h-6"
|
||||||
|
target="_blank"
|
||||||
|
href={nhost.storage.getPublicUrl({ fileId: todo.attachment.id })}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-6 h-6"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button onClick={handleDeleteTodo} className="w-6 h-6">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-6 h-6"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TodoItem
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { manageAuthSession } from '@utils/nhost'
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
return manageAuthSession(request, () =>
|
||||||
|
NextResponse.redirect(new URL('/auth/sign-in', request.url))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { getNhost } from '@utils/nhost'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
const withAuthAsync =
|
||||||
|
<P extends {}>(Component: React.FunctionComponent<P>) =>
|
||||||
|
async (props: P) => {
|
||||||
|
const nhost = await getNhost()
|
||||||
|
const session = nhost.auth.getSession()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect('/auth/sign-in')
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Component {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withAuthAsync
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { AuthErrorPayload, NhostClient, NhostSession } from '@nhost/nhost-js'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { type StateFrom } from 'xstate/lib/types'
|
||||||
|
import { waitFor } from 'xstate/lib/waitFor'
|
||||||
|
|
||||||
|
export const NHOST_SESSION_KEY = 'nhost-session'
|
||||||
|
|
||||||
|
export const getNhost = async (request?: NextRequest) => {
|
||||||
|
const $cookies = request?.cookies || cookies()
|
||||||
|
|
||||||
|
const nhost = new NhostClient({
|
||||||
|
subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN || 'local',
|
||||||
|
region: process.env.NEXT_PUBLIC_NHOST_REGION,
|
||||||
|
start: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const sessionCookieValue = $cookies.get(NHOST_SESSION_KEY)?.value || ''
|
||||||
|
const initialSession: NhostSession = JSON.parse(atob(sessionCookieValue) || 'null')
|
||||||
|
|
||||||
|
nhost.auth.client.start({ initialSession })
|
||||||
|
await waitFor(nhost.auth.client.interpreter!, (state: StateFrom<any>) => !state.hasTag('loading'))
|
||||||
|
|
||||||
|
return nhost
|
||||||
|
}
|
||||||
|
|
||||||
|
export const manageAuthSession = async (
|
||||||
|
request: NextRequest,
|
||||||
|
onError?: (error: AuthErrorPayload) => NextResponse
|
||||||
|
) => {
|
||||||
|
const nhost = await getNhost(request)
|
||||||
|
const session = nhost.auth.getSession()
|
||||||
|
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const refreshToken = url.searchParams.get('refreshToken') || undefined
|
||||||
|
|
||||||
|
const currentTime = Math.floor(Date.now() / 1000)
|
||||||
|
const tokenExpirationTime = nhost.auth.getDecodedAccessToken()?.exp
|
||||||
|
const accessTokenExpired = session && tokenExpirationTime && currentTime > tokenExpirationTime
|
||||||
|
|
||||||
|
if (accessTokenExpired || refreshToken) {
|
||||||
|
const { session: newSession, error } = await nhost.auth.refreshSession(refreshToken)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
onError?.(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove the refreshToken from the url
|
||||||
|
url.searchParams.delete('refreshToken')
|
||||||
|
|
||||||
|
// overwrite the session cookie with the new session
|
||||||
|
return NextResponse.redirect(url, {
|
||||||
|
headers: {
|
||||||
|
'Set-Cookie': `${NHOST_SESSION_KEY}=${btoa(JSON.stringify(newSession))}; Path=/`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
backgroundImage: {
|
||||||
|
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||||
|
'gradient-conic':
|
||||||
|
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
export default config
|
||||||
31
examples/quickstarts/nextjs-server-components/tsconfig.json
Normal file
31
examples/quickstarts/nextjs-server-components/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "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"]
|
||||||
|
}
|
||||||
1
examples/quickstarts/nhost-backend/.gitignore
vendored
Normal file
1
examples/quickstarts/nhost-backend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.secrets.nhost
|
||||||
10
examples/quickstarts/nhost-backend/functions/echo.ts
Normal file
10
examples/quickstarts/nhost-backend/functions/echo.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Request, Response } from 'express'
|
||||||
|
import process from 'process'
|
||||||
|
|
||||||
|
export default (req: Request, res: Response) => {
|
||||||
|
return res.status(200).json({
|
||||||
|
headers: req.headers,
|
||||||
|
query: req.query,
|
||||||
|
node: process.version
|
||||||
|
})
|
||||||
|
}
|
||||||
15
examples/quickstarts/nhost-backend/functions/package.json
Normal file
15
examples/quickstarts/nhost-backend/functions/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "functions",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.13"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC"
|
||||||
|
}
|
||||||
78
examples/quickstarts/nhost-backend/functions/pnpm-lock.yaml
generated
Normal file
78
examples/quickstarts/nhost-backend/functions/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
lockfileVersion: '6.0'
|
||||||
|
|
||||||
|
devDependencies:
|
||||||
|
'@types/express':
|
||||||
|
specifier: ^4.17.13
|
||||||
|
version: 4.17.13
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
/@types/body-parser@1.19.3:
|
||||||
|
resolution: {integrity: sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ==}
|
||||||
|
dependencies:
|
||||||
|
'@types/connect': 3.4.36
|
||||||
|
'@types/node': 20.6.3
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@types/connect@3.4.36:
|
||||||
|
resolution: {integrity: sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==}
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 20.6.3
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@types/express-serve-static-core@4.17.36:
|
||||||
|
resolution: {integrity: sha512-zbivROJ0ZqLAtMzgzIUC4oNqDG9iF0lSsAqpOD9kbs5xcIM3dTiyuHvBc7R8MtWBp3AAWGaovJa+wzWPjLYW7Q==}
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 20.6.3
|
||||||
|
'@types/qs': 6.9.8
|
||||||
|
'@types/range-parser': 1.2.4
|
||||||
|
'@types/send': 0.17.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@types/express@4.17.13:
|
||||||
|
resolution: {integrity: sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==}
|
||||||
|
dependencies:
|
||||||
|
'@types/body-parser': 1.19.3
|
||||||
|
'@types/express-serve-static-core': 4.17.36
|
||||||
|
'@types/qs': 6.9.8
|
||||||
|
'@types/serve-static': 1.15.2
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@types/http-errors@2.0.2:
|
||||||
|
resolution: {integrity: sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@types/mime@1.3.2:
|
||||||
|
resolution: {integrity: sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@types/mime@3.0.1:
|
||||||
|
resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@types/node@20.6.3:
|
||||||
|
resolution: {integrity: sha512-HksnYH4Ljr4VQgEy2lTStbCKv/P590tmPe5HqOnv9Gprffgv5WXAY+Y5Gqniu0GGqeTCUdBnzC3QSrzPkBkAMA==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@types/qs@6.9.8:
|
||||||
|
resolution: {integrity: sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@types/range-parser@1.2.4:
|
||||||
|
resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@types/send@0.17.1:
|
||||||
|
resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==}
|
||||||
|
dependencies:
|
||||||
|
'@types/mime': 1.3.2
|
||||||
|
'@types/node': 20.6.3
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@types/serve-static@1.15.2:
|
||||||
|
resolution: {integrity: sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw==}
|
||||||
|
dependencies:
|
||||||
|
'@types/http-errors': 2.0.2
|
||||||
|
'@types/mime': 3.0.1
|
||||||
|
'@types/node': 20.6.3
|
||||||
|
dev: true
|
||||||
11
examples/quickstarts/nhost-backend/functions/tsconfig.json
Normal file
11
examples/quickstarts/nhost-backend/functions/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"strictNullChecks": false
|
||||||
|
}
|
||||||
|
}
|
||||||
1
examples/quickstarts/nhost-backend/nhost/config.yaml
Normal file
1
examples/quickstarts/nhost-backend/nhost/config.yaml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
version: 3
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h2>Потвърдете смяната на вашия имейл</h2>
|
||||||
|
<p>Използвайте посочения линк, за да повърдите смяната на имейл:</p>
|
||||||
|
<p>
|
||||||
|
<a href="${link}">
|
||||||
|
Смени имейл
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Потвърждение за смяна на имейл
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h2>Потвърдете вашия имейл</h2>
|
||||||
|
<p>Използвайте посочения линк, за да потвърдите вашия имейл:</p>
|
||||||
|
<p>
|
||||||
|
<a href="${link}">
|
||||||
|
Потвърдете имейл
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Потвърждаване на имейл
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h2>Смяна на парола</h2>
|
||||||
|
<p>Използвайте посочения линк, за да смените вашата парола:</p>
|
||||||
|
<p>
|
||||||
|
<a href="${link}">
|
||||||
|
Смяна на парола
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Смяна на парола
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Вашият код е ${code}.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h2>Магически линк за вход</h2>
|
||||||
|
<p>Използвайте посочения линк за защитен и бърз вход:</p>
|
||||||
|
<p>
|
||||||
|
<a href="${link}">
|
||||||
|
Вход
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Магически линк за вход
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Změna vaší emailové adresy
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Ověření vaší emailové adresy
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Obnova hesla
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Váš kód je ${code}.
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Bezpečný odkaz k přihlášení
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Change your email address
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Verify your email
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Reset your password
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Your code is ${code}.
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Secure sign-in link
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Cambiar dirección de correo electrónico
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Verifica tu correo electrónico
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Recuperar contraseña
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user