Compare commits
6 Commits
feat/datab
...
chore/sdk-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c130ab4058 | ||
|
|
66cd621d29 | ||
|
|
d8cd4e10ac | ||
|
|
7f43e268ce | ||
|
|
4f4b2ab6d1 | ||
|
|
11e8178c72 |
@@ -438,6 +438,7 @@
|
||||
{
|
||||
"group": "Client Libraries",
|
||||
"pages": [
|
||||
"reference/migration-guide",
|
||||
{
|
||||
"group": "Javascript",
|
||||
"icon": "js",
|
||||
|
||||
1160
docs/reference/migration-guide.mdx
Normal file
1160
docs/reference/migration-guide.mdx
Normal file
File diff suppressed because it is too large
Load Diff
39
examples/demos/react-apollo/.secrets.example
Normal file
39
examples/demos/react-apollo/.secrets.example
Normal file
@@ -0,0 +1,39 @@
|
||||
HASURA_GRAPHQL_ADMIN_SECRET='nhost-admin-secret'
|
||||
HASURA_GRAPHQL_JWT_SECRET='oqpdwyffgxncqamwlyebkaifyazvqgso'
|
||||
NHOST_WEBHOOK_SECRET='nhost-webhook-secret'
|
||||
GRAFANA_ADMIN_PASSWORD='FIXME'
|
||||
APPLE_SERVICE_IDENTIFIER = 'FIXME'
|
||||
APPLE_KEY_ID = 'FIXME'
|
||||
APPLE_TEAM_ID = 'FIXME'
|
||||
APPLE_PRIVATE_KEY = 'FIXME'
|
||||
GITHUB_CLIENT_ID = 'FIXME'
|
||||
GITHUB_CLIENT_SECRET = 'FIXME'
|
||||
GOOGLE_CLIENT_ID = 'FIXME'
|
||||
GOOGLE_CLIENT_SECRET = 'FIXME'
|
||||
LINKEDIN_CLIENT_ID = 'FIXME'
|
||||
LINKEDIN_CLIENT_SECRET = 'FIXME'
|
||||
DISCORD_CLIENT_ID = 'FIXME'
|
||||
DISCORD_CLIENT_SECRET = 'FIXME'
|
||||
SPOTIFY_CLIENT_ID = 'FIXME'
|
||||
SPOTIFY_CLIENT_SECRET = 'FIXME'
|
||||
TWITCH_CLIENT_ID = 'FIXME'
|
||||
TWITCH_CLIENT_SECRET = 'FIXME'
|
||||
GITLAB_CLIENT_ID = 'FIXME'
|
||||
GITLAB_CLIENT_SECRET = 'FIXME'
|
||||
BITBUCKET_CLIENT_ID = 'FIXME'
|
||||
BITBUCKET_CLIENT_SECRET = 'FIXME'
|
||||
WORKOS_CLIENT_ID = 'FIXME'
|
||||
WORKOS_CLIENT_SECRET = 'FIXME'
|
||||
WORKOS_DEFAULT_ORGANIZATION = 'FIXME'
|
||||
WORKOS_DEFAULT_CONNECTION = 'FIXME'
|
||||
AZUREAD_CLIENT_ID = 'FIXME'
|
||||
AZUREAD_CLIENT_SECRET = 'FIXME'
|
||||
AZUREAD_TENANT='FIXME'
|
||||
FACEBOOK_CLIENT_ID = 'FIXME'
|
||||
FACEBOOK_CLIENT_SECRET = 'FIXME'
|
||||
STRAVA_CLIENT_ID = 'FIXME'
|
||||
STRAVA_CLIENT_SECRET = 'FIXME'
|
||||
WINDOWSLIVE_CLIENT_ID = 'FIXME'
|
||||
WINDOWSLIVE_CLIENT_SECRET = 'FIXME'
|
||||
TWITTER_CONSUMER_KEY = 'FIXME'
|
||||
TWITTER_CONSUMER_SECRET = 'FIXME'
|
||||
41
examples/demos/react-apollo/README.md
Normal file
41
examples/demos/react-apollo/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Nhost with React and Apollo Client Example
|
||||
|
||||
## Live Demo
|
||||
|
||||
Visit our live demo application on [react-apollo.example.nhost.io](https://react-apollo.example.nhost.io)
|
||||
|
||||
## 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. Go to the React example folder
|
||||
|
||||
```sh
|
||||
cd examples/react-apollo
|
||||
```
|
||||
|
||||
4. Terminal 1: Start Nhost
|
||||
|
||||
> Make sure you have the [Nhost CLI installed](https://docs.nhost.io/platform/cli/local-development).
|
||||
|
||||
```sh
|
||||
nhost up
|
||||
```
|
||||
|
||||
5. Terminal 2: Start the React application
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
```
|
||||
17
examples/demos/react-apollo/components.json
Normal file
17
examples/demos/react-apollo/components.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
56
examples/demos/react-apollo/e2e/authenticated/apollo.test.ts
Normal file
56
examples/demos/react-apollo/e2e/authenticated/apollo.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { signInAnonymously, signUpWithEmailAndPassword, verifyEmail } from '../utils'
|
||||
|
||||
test('should add an item to the todo list when authenticated with email and password', async ({
|
||||
page
|
||||
}) => {
|
||||
const email = faker.internet.email()
|
||||
const password = faker.internet.password()
|
||||
const sentence = faker.lorem.sentence()
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
await signUpWithEmailAndPassword({ page, email, password })
|
||||
await expect(page.getByText(/verification email sent/i)).toBeVisible()
|
||||
|
||||
const newPage = await verifyEmail({ page, email, context: page.context() })
|
||||
|
||||
await newPage.getByRole('link', { name: /todos/i }).click()
|
||||
await expect(newPage.getByRole('heading', { name: /todos/i })).toBeVisible()
|
||||
|
||||
await newPage.getByRole('textbox').fill(sentence)
|
||||
await newPage.getByRole('button', { name: /add/i }).click()
|
||||
await expect(newPage.getByRole('main')).toContainText(sentence)
|
||||
})
|
||||
|
||||
test('should add an item to the todo list when authenticated anonymously', async ({ page }) => {
|
||||
const sentence = faker.lorem.sentence()
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
await signInAnonymously({ page })
|
||||
|
||||
await page.getByRole('link', { name: /todos/i }).click()
|
||||
await expect(page.getByRole('heading', { name: /todos/i })).toBeVisible()
|
||||
|
||||
await page.getByRole('textbox').fill(sentence)
|
||||
await page.getByRole('button', { name: /add/i }).click()
|
||||
await expect(page.getByRole('main')).toContainText(sentence)
|
||||
})
|
||||
|
||||
test('should fail when network is not available', async ({ page }) => {
|
||||
const sentence = faker.lorem.sentence()
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
await signInAnonymously({ page })
|
||||
await page.getByRole('link', { name: /todos/i }).click()
|
||||
await expect(page.getByRole('heading', { name: /todos/i })).toBeVisible()
|
||||
|
||||
await page.route('**', (route) => route.abort('internetdisconnected'))
|
||||
await page.getByRole('textbox').fill(sentence)
|
||||
await page.getByRole('button', { name: /add/i }).click()
|
||||
|
||||
await expect(page.getByText(/failed to fetch/i)).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { signUpWithEmailAndPassword, verifyEmail } from '../utils'
|
||||
|
||||
test('should be able to change email', async ({ page, browser }) => {
|
||||
const email = faker.internet.email()
|
||||
const password = faker.internet.password()
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
await signUpWithEmailAndPassword({ page, email, password })
|
||||
await expect(page.getByText(/verification email sent/i)).toBeVisible()
|
||||
|
||||
const newPage = await verifyEmail({ page, email, context: page.context() })
|
||||
await newPage.getByRole('link', { name: /profile/i }).click()
|
||||
|
||||
const newEmail = faker.internet.email()
|
||||
|
||||
await newPage.getByPlaceholder(/new email/i).fill(newEmail)
|
||||
|
||||
await newPage
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Change emailChange$/ })
|
||||
.getByRole('button')
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
newPage.getByText('Please check your inbox and follow the link to confirm the email change.')
|
||||
).toBeVisible()
|
||||
|
||||
await newPage.getByRole('link', { name: /sign out/i }).click()
|
||||
|
||||
const mailhogPage = await browser.newPage()
|
||||
|
||||
const updatedEmailPage = await verifyEmail({
|
||||
page: mailhogPage,
|
||||
email: newEmail,
|
||||
context: mailhogPage.context(),
|
||||
linkText: /change email/i,
|
||||
requestType: 'email-confirm-change'
|
||||
})
|
||||
|
||||
await expect(updatedEmailPage.getByRole('heading', { name: /profile/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should not accept an invalid email', async ({ page }) => {
|
||||
const email = faker.internet.email()
|
||||
const password = faker.internet.password()
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
await signUpWithEmailAndPassword({ page, email, password })
|
||||
await expect(page.getByText(/verification email sent/i)).toBeVisible()
|
||||
|
||||
const newPage = await verifyEmail({ page, email, context: page.context() })
|
||||
await newPage.getByRole('link', { name: /profile/i }).click()
|
||||
|
||||
const newEmail = faker.random.alphaNumeric()
|
||||
|
||||
await newPage.getByPlaceholder(/new email/i).fill(newEmail)
|
||||
|
||||
await newPage
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Change emailChange$/ })
|
||||
.getByRole('button')
|
||||
.click()
|
||||
|
||||
await expect(newPage.getByText(/email is incorrectly formatted/i)).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { signInWithEmailAndPassword, signUpWithEmailAndPassword, verifyEmail } from '../utils'
|
||||
|
||||
test('should be able to change password', async ({ page }) => {
|
||||
const email = faker.internet.email()
|
||||
const password = faker.internet.password()
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
await signUpWithEmailAndPassword({ page, email, password })
|
||||
await expect(page.getByText(/verification email sent/i)).toBeVisible()
|
||||
|
||||
const newPage = await verifyEmail({ page, email, context: page.context() })
|
||||
await newPage.getByRole('link', { name: /profile/i }).click()
|
||||
|
||||
const newPassword = faker.internet.password()
|
||||
|
||||
await newPage.getByPlaceholder(/new password/i).fill(newPassword)
|
||||
|
||||
// await newPage.locator('h1:has-text("Change password") + div button:has-text("Change")').click()
|
||||
await newPage
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Change passwordChange$/ })
|
||||
.getByRole('button')
|
||||
.click()
|
||||
|
||||
await expect(newPage.getByText(/password changed successfully./i)).toBeVisible()
|
||||
|
||||
await newPage.getByRole('link', { name: 'Sign out' }).click()
|
||||
|
||||
await signInWithEmailAndPassword({ page: newPage, email, password: newPassword })
|
||||
await expect(newPage.getByText(/you are authenticated/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should not accept an invalid email', async ({ page }) => {
|
||||
const email = faker.internet.email()
|
||||
const password = faker.internet.password()
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
await signUpWithEmailAndPassword({ page, email, password })
|
||||
await expect(page.getByText(/verification email sent/i)).toBeVisible()
|
||||
|
||||
const newPage = await verifyEmail({ page, email, context: page.context() })
|
||||
await newPage.getByRole('link', { name: /profile/i }).click()
|
||||
|
||||
const newPassword = faker.internet.password(2)
|
||||
|
||||
await newPage.getByPlaceholder(/new password/i).fill(newPassword)
|
||||
// await newPage.locator('h1:has-text("Change password") + div button:has-text("Change")').click()
|
||||
|
||||
await newPage
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Change passwordChange$/ })
|
||||
.getByRole('button')
|
||||
.click()
|
||||
|
||||
await expect(newPage.getByText(/password is incorrectly formatted/i)).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { signUpWithEmailAndPassword, verifyEmail } from '../utils'
|
||||
|
||||
test('should upload a single file', async ({ page }) => {
|
||||
const email = faker.internet.email()
|
||||
const password = faker.internet.password()
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
await signUpWithEmailAndPassword({ page, email, password })
|
||||
await expect(page.getByText(/verification email sent/i)).toBeVisible()
|
||||
|
||||
const newPage = await verifyEmail({ page, email, context: page.context() })
|
||||
await newPage.getByRole('link', { name: /storage/i }).click()
|
||||
|
||||
await newPage
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Drag a file here or click to select$/ })
|
||||
.nth(1)
|
||||
.locator('input[type=file]')
|
||||
.setInputFiles({
|
||||
buffer: Buffer.from('file contents', 'utf-8'),
|
||||
name: 'file.txt',
|
||||
mimeType: 'text/plain'
|
||||
})
|
||||
|
||||
await expect(newPage.getByText(/Uploaded successfully/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should upload two files using the same single file uploader', async ({ page }) => {
|
||||
const email = faker.internet.email()
|
||||
const password = faker.internet.password()
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
await signUpWithEmailAndPassword({ page, email, password })
|
||||
await expect(page.getByText(/verification email sent/i)).toBeVisible()
|
||||
|
||||
const newPage = await verifyEmail({ page, email, context: page.context() })
|
||||
await newPage.getByRole('link', { name: /storage/i }).click()
|
||||
|
||||
await newPage
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Drag a file here or click to select$/ })
|
||||
.nth(1)
|
||||
.locator('input[type=file]')
|
||||
.setInputFiles({
|
||||
buffer: Buffer.from('file contents 1', 'utf-8'),
|
||||
name: 'file1.txt',
|
||||
mimeType: 'text/plain'
|
||||
})
|
||||
|
||||
await expect(newPage.getByText(/Uploaded successfully/i)).toBeVisible()
|
||||
|
||||
await newPage
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Uploaded successfully$/ })
|
||||
.nth(1)
|
||||
.locator('input[type=file]')
|
||||
.setInputFiles({
|
||||
buffer: Buffer.from('file contents 2', 'utf-8'),
|
||||
name: 'file2.txt',
|
||||
mimeType: 'text/plain'
|
||||
})
|
||||
|
||||
await expect(newPage.getByText(/Uploaded successfully/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should upload multiple files at once', async ({ page }) => {
|
||||
const email = faker.internet.email()
|
||||
const password = faker.internet.password()
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
await signUpWithEmailAndPassword({ page, email, password })
|
||||
await expect(page.getByText(/verification email sent/i)).toBeVisible()
|
||||
|
||||
const newPage = await verifyEmail({ page, email, context: page.context() })
|
||||
await newPage.getByRole('link', { name: /storage/i }).click()
|
||||
|
||||
await newPage
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Drag a file here or click to select$/ })
|
||||
.nth(3)
|
||||
.locator('input[type=file]')
|
||||
.setInputFiles([
|
||||
{
|
||||
buffer: Buffer.from('file contents 1', 'utf-8'),
|
||||
name: 'file1.txt',
|
||||
mimeType: 'text/plain'
|
||||
},
|
||||
{
|
||||
buffer: Buffer.from('file contents 2', 'utf-8'),
|
||||
name: 'file2.txt',
|
||||
mimeType: 'text/plain'
|
||||
}
|
||||
])
|
||||
|
||||
await expect(newPage.getByText('file1.txt')).toBeVisible()
|
||||
await expect(newPage.getByText('file2.txt')).toBeVisible()
|
||||
await newPage.getByRole('button', { name: /upload/i }).click()
|
||||
|
||||
await expect(newPage.getByText(/Uploaded successfully/i)).toBeVisible()
|
||||
})
|
||||
3
examples/demos/react-apollo/e2e/config.ts
Normal file
3
examples/demos/react-apollo/e2e/config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const baseURL = 'http://localhost:3000'
|
||||
export const mailhogURL = 'https://local.mailhog.local.nhost.run'
|
||||
export const authBackendURL = 'https://local.auth.local.nhost.run'
|
||||
72
examples/demos/react-apollo/e2e/sign-in/auto-sign-in.test.ts
Normal file
72
examples/demos/react-apollo/e2e/sign-in/auto-sign-in.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { authBackendURL, baseURL } from '../config'
|
||||
import {
|
||||
clearStorage,
|
||||
getValueFromLocalStorage,
|
||||
signUpWithEmailAndPassword,
|
||||
verifyEmail
|
||||
} from '../utils'
|
||||
|
||||
test('should sign in automatically with a refresh token', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
const email = faker.internet.email()
|
||||
const password = faker.internet.password()
|
||||
|
||||
await signUpWithEmailAndPassword({ page, email, password })
|
||||
await expect(page.getByText(/verification email sent/i)).toBeVisible()
|
||||
|
||||
const newPage = await verifyEmail({ page, email, context: page.context() })
|
||||
|
||||
await expect(newPage.getByText(/you are authenticated/i)).toBeVisible()
|
||||
|
||||
const refreshToken = await getValueFromLocalStorage({
|
||||
page: newPage,
|
||||
origin: baseURL,
|
||||
key: 'nhostRefreshToken'
|
||||
})
|
||||
|
||||
// Clear storage and reload the page
|
||||
await clearStorage({ page: newPage })
|
||||
await newPage.reload()
|
||||
|
||||
await expect(newPage.getByText(/sign in/i).nth(1)).toBeVisible()
|
||||
|
||||
// User should be signed in automatically
|
||||
await newPage.goto(`${baseURL}/profile?refreshToken=${refreshToken}`)
|
||||
await expect(newPage.getByRole('heading', { name: 'Profile' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should fail automatic sign-in when network is not available', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
const email = faker.internet.email()
|
||||
const password = faker.internet.password()
|
||||
|
||||
await signUpWithEmailAndPassword({ page, email, password })
|
||||
await expect(page.getByText(/verification email sent/i)).toBeVisible()
|
||||
|
||||
const newPage = await verifyEmail({ page, email, context: page.context() })
|
||||
|
||||
await expect(newPage.getByText(/you are authenticated/i)).toBeVisible()
|
||||
|
||||
const refreshToken = await getValueFromLocalStorage({
|
||||
page: newPage,
|
||||
origin: baseURL,
|
||||
key: 'nhostRefreshToken'
|
||||
})
|
||||
|
||||
// Clear storage and reload the page
|
||||
await clearStorage({ page: newPage })
|
||||
await newPage.reload()
|
||||
|
||||
await expect(newPage.getByText(/sign in/i).nth(1)).toBeVisible()
|
||||
await newPage.route(`${authBackendURL}/**`, (route) => route.abort('internetdisconnected'))
|
||||
|
||||
// User should be signed in automatically
|
||||
await newPage.goto(`${baseURL}/profile?refreshToken=${refreshToken}`)
|
||||
await expect(
|
||||
newPage.getByText(/could not sign in automatically. retrying to get user information/i)
|
||||
).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect, test } from '@playwright/test'
|
||||
import totp from 'totp-generator'
|
||||
import { baseURL } from '../config'
|
||||
import {
|
||||
decodeQRCode,
|
||||
signInWithEmailAndPassword,
|
||||
signUpWithEmailAndPassword,
|
||||
verifyEmail
|
||||
} from '../utils'
|
||||
|
||||
const email = faker.internet.email()
|
||||
const password = faker.internet.password()
|
||||
|
||||
let page: Page
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage()
|
||||
|
||||
await page.goto('/')
|
||||
await signUpWithEmailAndPassword({ page, email, password })
|
||||
await expect(page.getByText(/verification email sent/i)).toBeVisible()
|
||||
|
||||
const newPage = await verifyEmail({ page, email, context: page.context() })
|
||||
await expect(newPage.getByText(/you are authenticated/i)).toBeVisible()
|
||||
await newPage.getByRole('link', { name: /sign out/i }).click()
|
||||
|
||||
page = newPage
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
await page.getByRole('link', { name: /sign out/i }).click()
|
||||
})
|
||||
|
||||
test.afterAll(() => {
|
||||
page.close()
|
||||
})
|
||||
|
||||
test('should sign in with email and password', async () => {
|
||||
await page.goto('/')
|
||||
|
||||
await signInWithEmailAndPassword({ page, email, password })
|
||||
await expect(page.getByText(/you are authenticated/i)).toBeVisible()
|
||||
})
|
||||
|
||||
// TODO: Create email verification test
|
||||
|
||||
test('should activate and sign in with MFA', async () => {
|
||||
await page.goto('/')
|
||||
|
||||
await signInWithEmailAndPassword({ page, email, password })
|
||||
await page.waitForURL(baseURL)
|
||||
await page.getByRole('link', { name: /profile/i }).click()
|
||||
await page.getByRole('button', { name: /generate/i }).click()
|
||||
|
||||
const image = page.getByAltText(/qrcode/i)
|
||||
const src = await image.getAttribute('src')
|
||||
|
||||
const { secret, algorithm, digits, period } = decodeQRCode(src)
|
||||
|
||||
const code = totp(secret, {
|
||||
algorithm: algorithm.replace('SHA1', 'SHA-1'),
|
||||
digits: parseInt(digits),
|
||||
period: parseInt(period)
|
||||
})
|
||||
|
||||
await page.getByPlaceholder(/enter activation code/i).fill(code)
|
||||
await page.getByRole('button', { name: /activate/i }).click()
|
||||
await expect(page.getByText(/mfa has been activated/i)).toBeVisible()
|
||||
await page.getByRole('link', { name: /sign out/i }).click()
|
||||
|
||||
await signInWithEmailAndPassword({ page, email, password })
|
||||
await expect(page.getByText(/send 2-step verification code/i)).toBeVisible()
|
||||
|
||||
const newCode = totp(secret, { timestamp: Date.now() })
|
||||
|
||||
await page.getByPlaceholder(/one-time password/i).fill(newCode)
|
||||
await page.getByRole('button', { name: /send 2-step verification code/i }).click()
|
||||
await expect(page.getByText(/you are authenticated/i)).toBeVisible()
|
||||
})
|
||||
53
examples/demos/react-apollo/e2e/sign-up/anonymous.test.ts
Normal file
53
examples/demos/react-apollo/e2e/sign-up/anonymous.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { expect, test } from '@playwright/test'
|
||||
import {
|
||||
getUserData,
|
||||
signInAnonymously,
|
||||
signUpWithEmailAndPassword,
|
||||
signUpWithEmailPasswordless,
|
||||
verifyEmail,
|
||||
verifyMagicLink
|
||||
} from '../utils'
|
||||
|
||||
test('should deanonymize with email and password', async ({ page, context }) => {
|
||||
const email = faker.internet.email()
|
||||
const password = faker.internet.password(8)
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
await signInAnonymously({ page })
|
||||
await page.getByRole('link', { name: /profile/i }).click()
|
||||
|
||||
const userData = await getUserData(page)
|
||||
|
||||
await signUpWithEmailAndPassword({ page, email, password })
|
||||
await expect(page.getByText(/verification email sent/i)).toBeVisible()
|
||||
|
||||
const authenticatedPage = await verifyEmail({ page, context, email })
|
||||
await authenticatedPage.getByRole('link', { name: /profile/i }).click()
|
||||
|
||||
const updatedUserData = await getUserData(authenticatedPage)
|
||||
expect(updatedUserData.id).toBe(userData.id)
|
||||
expect(updatedUserData.email).toBe(email)
|
||||
})
|
||||
|
||||
test('should deanonymize with a magic link', async ({ page, context }) => {
|
||||
const email = faker.internet.email()
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
await signInAnonymously({ page })
|
||||
await page.getByRole('link', { name: /profile/i }).click()
|
||||
|
||||
const userData = await getUserData(page)
|
||||
|
||||
await signUpWithEmailPasswordless({ page, email })
|
||||
await expect(page.getByText(/verification email sent/i)).toBeVisible()
|
||||
|
||||
const authenticatedPage = await verifyMagicLink({ page, context, email })
|
||||
await authenticatedPage.getByRole('link', { name: /profile/i }).click()
|
||||
|
||||
const updatedUserData = await getUserData(authenticatedPage)
|
||||
expect(updatedUserData.id).toBe(userData.id)
|
||||
expect(updatedUserData.email).toBe(email)
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { signUpWithEmailAndPassword, verifyEmail } from '../utils'
|
||||
|
||||
test('should sign up with email and password', async ({ page, context }) => {
|
||||
page.goto('/')
|
||||
|
||||
const email = faker.internet.email()
|
||||
const password = faker.internet.password()
|
||||
|
||||
await signUpWithEmailAndPassword({ page, email, password })
|
||||
await expect(page.getByText(/verification email sent/i)).toBeVisible()
|
||||
|
||||
const authenticatedPage = await verifyEmail({ page, context, email })
|
||||
await expect(authenticatedPage.getByText(/you are authenticated/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should raise an error when trying to sign up with an existing email', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
const email = faker.internet.email()
|
||||
const password = faker.internet.password()
|
||||
|
||||
await signUpWithEmailAndPassword({ page, email, password })
|
||||
await expect(page.getByText(/verification email sent/i)).toBeVisible()
|
||||
|
||||
// close modal
|
||||
await page.getByRole('dialog').getByRole('button').click()
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
await signUpWithEmailAndPassword({ page, email, password })
|
||||
await expect(page.getByText(/email already in use/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should fail when network is not available', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
const email = faker.internet.email()
|
||||
const password = faker.internet.password()
|
||||
|
||||
await page.route('**', (route) => route.abort('internetdisconnected'))
|
||||
await signUpWithEmailAndPassword({ page, email, password })
|
||||
|
||||
await expect(page.getByText(/network error/i)).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { signUpWithEmailPasswordless, verifyMagicLink } from '../utils'
|
||||
|
||||
test('should sign up with a magic link', async ({ page, context }) => {
|
||||
page.goto('/')
|
||||
|
||||
const email = faker.internet.email()
|
||||
|
||||
await signUpWithEmailPasswordless({ page, email })
|
||||
await expect(page.getByText(/verification email sent/i)).toBeVisible()
|
||||
|
||||
const authenticatedPage = await verifyMagicLink({ page, context, email })
|
||||
await authenticatedPage.getByRole('link', { name: /home/i }).click()
|
||||
await expect(
|
||||
authenticatedPage.getByText(
|
||||
/You are authenticated. You have now access to the authorised part of the application./i
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should fail when network is not available', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
const email = faker.internet.email()
|
||||
|
||||
await page.route('**', (route) => route.abort('internetdisconnected'))
|
||||
await signUpWithEmailPasswordless({ page, email })
|
||||
|
||||
await expect(page.getByText(/network error/i)).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { baseURL } from '../config'
|
||||
|
||||
test('should redirect to /sign-in when not authenticated', async ({ page }) => {
|
||||
await page.goto(`${baseURL}`)
|
||||
await page.waitForURL(`${baseURL}/sign-in`)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Sign In' })).toBeVisible()
|
||||
|
||||
await page.goto(`${baseURL}/todos`)
|
||||
await page.waitForURL(`${baseURL}/sign-in`)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Sign In' })).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { baseURL } from '../config'
|
||||
import { resetPassword, signUpWithEmailAndPassword } from '../utils'
|
||||
|
||||
test('should reset password', async ({ page, context }) => {
|
||||
await page.goto('/')
|
||||
|
||||
const email = faker.internet.email()
|
||||
const password = faker.internet.password(8)
|
||||
|
||||
await signUpWithEmailAndPassword({ page, email, password })
|
||||
await expect(page.getByText(/verification email sent/i)).toBeVisible()
|
||||
|
||||
await page.goto(`${baseURL}/sign-in`)
|
||||
await page.getByRole('link', { name: /continue with email \+ password/i }).click()
|
||||
await page.getByRole('link', { name: /forgot password?/i }).click()
|
||||
|
||||
await page.getByPlaceholder('email').type(email)
|
||||
await page.getByRole('button', { name: /reset your password/i }).click()
|
||||
|
||||
const authenticatedPage = await resetPassword({ page, context, email })
|
||||
|
||||
await authenticatedPage.waitForLoadState()
|
||||
await authenticatedPage.getByRole('link', { name: /profile/i }).click()
|
||||
|
||||
await expect(authenticatedPage.getByRole('heading', { name: 'Profile' })).toBeVisible()
|
||||
})
|
||||
267
examples/demos/react-apollo/e2e/utils.ts
Normal file
267
examples/demos/react-apollo/e2e/utils.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import type { User } from '@nhost/react'
|
||||
import type { BrowserContext, Page } from '@playwright/test'
|
||||
import jsQR from 'jsqr'
|
||||
import { PNG } from 'pngjs'
|
||||
import { baseURL, mailhogURL } from './config'
|
||||
|
||||
/**
|
||||
* Returns the user data from the profile page.
|
||||
*
|
||||
* @param page - The page to get the user data from.
|
||||
* @returns The user data.
|
||||
*/
|
||||
export async function getUserData(page: Page) {
|
||||
const userInformation = await page.locator('pre').nth(0).textContent()
|
||||
|
||||
const userData = userInformation ? JSON.parse(userInformation) : {}
|
||||
|
||||
return userData as User
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves when the sign up flow is completed.
|
||||
*
|
||||
* @param page - The page to sign up with.
|
||||
* @param email - The email address to sign up with.
|
||||
* @param password - The password to sign up with.
|
||||
*/
|
||||
export async function signUpWithEmailAndPassword({
|
||||
page,
|
||||
email,
|
||||
password
|
||||
}: {
|
||||
page: Page
|
||||
email: string
|
||||
password: string
|
||||
}) {
|
||||
await page.getByRole('link', { name: /sign up/i }).click()
|
||||
await page.getByRole('link', { name: /continue with email \+ password/i }).click()
|
||||
await page.getByPlaceholder(/first name/i).type(faker.name.firstName())
|
||||
await page.getByPlaceholder(/last name/i).type(faker.name.lastName())
|
||||
await page.getByPlaceholder(/email/i).type(email)
|
||||
await page.getByPlaceholder(/^password$/i).type(password)
|
||||
await page.getByRole('button', { name: /sign up/i }).click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves when the sign in flow is completed.
|
||||
*
|
||||
* @param page - The page to sign in with.
|
||||
* @param email - The email address to sign in with.
|
||||
* @param password - The password to sign in with.
|
||||
*/
|
||||
export async function signInWithEmailAndPassword({
|
||||
page,
|
||||
email,
|
||||
password
|
||||
}: {
|
||||
page: Page
|
||||
email: string
|
||||
password: string
|
||||
}) {
|
||||
await page.getByRole('link', { name: /continue with email \+ password/i }).click()
|
||||
await page.getByPlaceholder(/email/i).type(email)
|
||||
await page.getByPlaceholder(/password/i).type(password)
|
||||
await page.getByRole('button', { name: 'Sign In', exact: true }).click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves when the sign in flow is completed.
|
||||
*
|
||||
* @param page - The page to sign in with.
|
||||
*/
|
||||
export async function signInAnonymously({ page }: { page: Page }) {
|
||||
await page.getByRole('button', { name: /sign in anonymously/i }).click()
|
||||
await page.waitForURL(baseURL)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves when the sign up flow is completed.
|
||||
*
|
||||
* @param page - The page to sign up with.
|
||||
* @param email - The email address to sign up with.
|
||||
*/
|
||||
export async function signUpWithEmailPasswordless({ page, email }: { page: Page; email: string }) {
|
||||
await page.getByRole('link', { name: /sign up/i }).click()
|
||||
await page.getByRole('link', { name: /continue with a magic link/i }).click()
|
||||
await page.getByPlaceholder(/email/i).fill(email)
|
||||
await page.getByRole('button', { name: /sign up/i }).click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves to a new page that is opened after clicking
|
||||
* the magic link in the email.
|
||||
*
|
||||
* @param email - The email address to reset the password for.
|
||||
* @param page - The page to click the magic link in.
|
||||
* @param context - The browser context.
|
||||
* @returns A promise that resolves to a new page.
|
||||
*/
|
||||
export async function verifyMagicLink({
|
||||
email,
|
||||
page,
|
||||
context
|
||||
}: {
|
||||
email: string
|
||||
page: Page
|
||||
context: BrowserContext
|
||||
}) {
|
||||
await page.goto(mailhogURL)
|
||||
await page.locator('.messages > .msglist-message', { hasText: email }).nth(0).click()
|
||||
|
||||
// Based on: https://playwright.dev/docs/pages#handling-new-pages
|
||||
const authenticatedPagePromise = context.waitForEvent('page')
|
||||
|
||||
await page
|
||||
.frameLocator('#preview-html')
|
||||
.getByRole('link', { name: /verify email/i })
|
||||
.click()
|
||||
|
||||
const authenticatedPage = await authenticatedPagePromise
|
||||
await authenticatedPage.waitForLoadState()
|
||||
|
||||
return authenticatedPage
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves to a new page that is opened after clicking
|
||||
* the reset password link in the email.
|
||||
*
|
||||
* @param email - The email address to reset the password for.
|
||||
* @param page - The page to click the reset password link in.
|
||||
* @param context - The browser context.
|
||||
* @returns A promise that resolves to a new page.
|
||||
*/
|
||||
export async function resetPassword({
|
||||
email,
|
||||
page,
|
||||
context
|
||||
}: {
|
||||
email: string
|
||||
page: Page
|
||||
context: BrowserContext
|
||||
}) {
|
||||
await page.goto(mailhogURL)
|
||||
await page.locator('.messages > .msglist-message', { hasText: email }).nth(0).click()
|
||||
|
||||
// Based on: https://playwright.dev/docs/pages#handling-new-pages
|
||||
const authenticatedPagePromise = context.waitForEvent('page')
|
||||
|
||||
await page
|
||||
.frameLocator('#preview-html')
|
||||
.getByRole('link', { name: /verify email/i })
|
||||
.click()
|
||||
|
||||
const authenticatedPage = await authenticatedPagePromise
|
||||
await authenticatedPage.getByRole('link', { name: /Verify/i }).click()
|
||||
await authenticatedPage.waitForLoadState()
|
||||
return authenticatedPage
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves to a new page that is opened after clicking
|
||||
* the verify email link in the email.
|
||||
*
|
||||
* @param email - The email address to verify.
|
||||
* @param page - The page to click the verify email link in.
|
||||
* @param context - The browser context.
|
||||
* @param linkText - The text of the link to click.
|
||||
* @returns A promise that resolves to a new page.
|
||||
*/
|
||||
export async function verifyEmail({
|
||||
email,
|
||||
page,
|
||||
context,
|
||||
linkText = /verify email/i,
|
||||
requestType
|
||||
}: {
|
||||
email: string
|
||||
page: Page
|
||||
context: BrowserContext
|
||||
linkText?: string | RegExp
|
||||
requestType?: 'email-confirm-change' | 'email-verify' | 'password-reset' | 'signin-passwordless'
|
||||
}) {
|
||||
await page.goto(mailhogURL)
|
||||
await page.locator('.messages > .msglist-message', { hasText: email }).nth(0).click()
|
||||
|
||||
// Based on: https://playwright.dev/docs/pages#handling-new-pages
|
||||
const verifyEmailPagePromise = context.waitForEvent('page')
|
||||
await page.frameLocator('#preview-html').getByRole('link', { name: linkText }).click()
|
||||
const verifyEmailPage = await verifyEmailPagePromise
|
||||
await verifyEmailPage.waitForLoadState()
|
||||
|
||||
if (requestType === 'email-confirm-change') {
|
||||
return verifyEmailPage
|
||||
}
|
||||
|
||||
await verifyEmailPage.getByRole('link', { name: /verify/i }).click()
|
||||
await verifyEmailPage.waitForLoadState()
|
||||
return verifyEmailPage
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns decoded data from a QR code.
|
||||
*
|
||||
* @param base64String - The base64 encoded string of the QR code.
|
||||
* @returns The decoded data.
|
||||
*/
|
||||
export function decodeQRCode(base64String?: string | null) {
|
||||
if (!base64String) {
|
||||
return {
|
||||
secret: '',
|
||||
algorithm: '',
|
||||
digits: '',
|
||||
period: ''
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(base64String.replace('data:image/png;base64,', ''), 'base64')
|
||||
const pngData = PNG.sync.read(buffer)
|
||||
|
||||
const decoded = jsQR(Uint8ClampedArray.from(pngData.data), pngData.width, pngData.height)
|
||||
const params = decoded?.data?.split('?').at(-1)
|
||||
|
||||
// note: we are decoding MFA here
|
||||
const { secret, algorithm, digits, period } = Object.fromEntries(new URLSearchParams(params))
|
||||
|
||||
return { secret, algorithm, digits, period }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the local and session storage for a page.
|
||||
*
|
||||
* @param page - The page to clear the storage for.
|
||||
*/
|
||||
export async function clearStorage({ page }: { page: Page }) {
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves to the value of a key in local storage.
|
||||
*
|
||||
* @param page - The page to get the value from.
|
||||
* @param origin - The origin of the local storage.
|
||||
* @param key - The key to get the value for.
|
||||
* @returns The value of the key in local storage.
|
||||
*/
|
||||
export async function getValueFromLocalStorage({
|
||||
page,
|
||||
origin: externalOrigin,
|
||||
key
|
||||
}: {
|
||||
page: Page
|
||||
origin: string
|
||||
key: string
|
||||
}) {
|
||||
const storageState = await page.context().storageState()
|
||||
const localStorage = storageState.origins.find(
|
||||
({ origin }) => origin === externalOrigin
|
||||
)?.localStorage
|
||||
const value = localStorage?.find(({ name }) => name === key)?.value
|
||||
|
||||
return value || null
|
||||
}
|
||||
13
examples/demos/react-apollo/index.html
Normal file
13
examples/demos/react-apollo/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nhost <> React</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
examples/demos/react-apollo/nhost/config.yaml
Normal file
1
examples/demos/react-apollo/nhost/config.yaml
Normal file
@@ -0,0 +1 @@
|
||||
version: 3
|
||||
@@ -0,0 +1,14 @@
|
||||
<!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,14 @@
|
||||
<!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="${clientUrl}/verify?ticket=${ticket}&redirectTo=${redirectTo}&type=emailVerify"> Verify Email </a>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Verify your email
|
||||
@@ -0,0 +1,14 @@
|
||||
<!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="${clientUrl}/verify?ticket=${ticket}&redirectTo=${redirectTo}&type=emailVerify"> Reset password </a>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Reset your password
|
||||
@@ -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}">
|
||||
Verify Email
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Secure sign-in link
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Confirmer changement de courriel</h2>
|
||||
<p>Utilisez ce lien pour confirmer le changement de courriel:</p>
|
||||
<p>
|
||||
<a href="${link}"> Changer courriel </a>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Changez votre adresse courriel
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Vérifiez votre courriel</h2>
|
||||
<p>Utilisez ce lien pour vérifier votre courriel:</p>
|
||||
<p>
|
||||
<a href="${link}"> Vérifier courriel </a>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Vérifier votre courriel
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Réinitializer votre mot de passe</h2>
|
||||
<p>Utilisez ce lien pour réinitializer votre mot de passe:</p>
|
||||
<p>
|
||||
<a href="${link}"> Réinitializer mot de passe </a>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Réinitialiser votre mot de passe
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Lien magique</h2>
|
||||
<p>Utilisez ce lien pour vous connecter de façon sécuritaire:</p>
|
||||
<p>
|
||||
<a href="${link}"> Connexion </a>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Lien de connexion sécurisé
|
||||
6
examples/demos/react-apollo/nhost/metadata/actions.yaml
Normal file
6
examples/demos/react-apollo/nhost/metadata/actions.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
actions: []
|
||||
custom_types:
|
||||
enums: []
|
||||
input_objects: []
|
||||
objects: []
|
||||
scalars: []
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,14 @@
|
||||
- name: default
|
||||
kind: postgres
|
||||
configuration:
|
||||
connection_info:
|
||||
database_url:
|
||||
from_env: HASURA_GRAPHQL_DATABASE_URL
|
||||
isolation_level: read-committed
|
||||
pool_settings:
|
||||
connection_lifetime: 600
|
||||
idle_timeout: 180
|
||||
max_connections: 50
|
||||
retries: 20
|
||||
use_prepared_statements: true
|
||||
tables: "!include default/tables/tables.yaml"
|
||||
@@ -0,0 +1,23 @@
|
||||
table:
|
||||
name: provider_requests
|
||||
schema: auth
|
||||
configuration:
|
||||
column_config:
|
||||
id:
|
||||
custom_name: id
|
||||
options:
|
||||
custom_name: options
|
||||
custom_column_names:
|
||||
id: id
|
||||
options: options
|
||||
custom_name: authProviderRequests
|
||||
custom_root_fields:
|
||||
delete: deleteAuthProviderRequests
|
||||
delete_by_pk: deleteAuthProviderRequest
|
||||
insert: insertAuthProviderRequests
|
||||
insert_one: insertAuthProviderRequest
|
||||
select: authProviderRequests
|
||||
select_aggregate: authProviderRequestsAggregate
|
||||
select_by_pk: authProviderRequest
|
||||
update: updateAuthProviderRequests
|
||||
update_by_pk: updateAuthProviderRequest
|
||||
@@ -0,0 +1,28 @@
|
||||
table:
|
||||
name: providers
|
||||
schema: auth
|
||||
configuration:
|
||||
column_config:
|
||||
id:
|
||||
custom_name: id
|
||||
custom_column_names:
|
||||
id: id
|
||||
custom_name: authProviders
|
||||
custom_root_fields:
|
||||
delete: deleteAuthProviders
|
||||
delete_by_pk: deleteAuthProvider
|
||||
insert: insertAuthProviders
|
||||
insert_one: insertAuthProvider
|
||||
select: authProviders
|
||||
select_aggregate: authProvidersAggregate
|
||||
select_by_pk: authProvider
|
||||
update: updateAuthProviders
|
||||
update_by_pk: updateAuthProvider
|
||||
array_relationships:
|
||||
- name: userProviders
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: provider_id
|
||||
table:
|
||||
name: user_providers
|
||||
schema: auth
|
||||
@@ -0,0 +1,26 @@
|
||||
table:
|
||||
name: refresh_token_types
|
||||
schema: auth
|
||||
is_enum: true
|
||||
configuration:
|
||||
column_config: {}
|
||||
custom_column_names: {}
|
||||
custom_name: authRefreshTokenTypes
|
||||
custom_root_fields:
|
||||
delete: deleteAuthRefreshTokenTypes
|
||||
delete_by_pk: deleteAuthRefreshTokenType
|
||||
insert: insertAuthRefreshTokenTypes
|
||||
insert_one: insertAuthRefreshTokenType
|
||||
select: authRefreshTokenTypes
|
||||
select_aggregate: authRefreshTokenTypesAggregate
|
||||
select_by_pk: authRefreshTokenType
|
||||
update: updateAuthRefreshTokenTypes
|
||||
update_by_pk: updateAuthRefreshTokenType
|
||||
array_relationships:
|
||||
- name: refreshTokens
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: type
|
||||
table:
|
||||
name: refresh_tokens
|
||||
schema: auth
|
||||
@@ -0,0 +1,61 @@
|
||||
table:
|
||||
name: refresh_tokens
|
||||
schema: auth
|
||||
configuration:
|
||||
column_config:
|
||||
created_at:
|
||||
custom_name: createdAt
|
||||
expires_at:
|
||||
custom_name: expiresAt
|
||||
refresh_token_hash:
|
||||
custom_name: refreshTokenHash
|
||||
type:
|
||||
custom_name: type
|
||||
user_id:
|
||||
custom_name: userId
|
||||
custom_column_names:
|
||||
created_at: createdAt
|
||||
expires_at: expiresAt
|
||||
refresh_token_hash: refreshTokenHash
|
||||
type: type
|
||||
user_id: userId
|
||||
custom_name: authRefreshTokens
|
||||
custom_root_fields:
|
||||
delete: deleteAuthRefreshTokens
|
||||
delete_by_pk: deleteAuthRefreshToken
|
||||
insert: insertAuthRefreshTokens
|
||||
insert_one: insertAuthRefreshToken
|
||||
select: authRefreshTokens
|
||||
select_aggregate: authRefreshTokensAggregate
|
||||
select_by_pk: authRefreshToken
|
||||
update: updateAuthRefreshTokens
|
||||
update_by_pk: updateAuthRefreshToken
|
||||
object_relationships:
|
||||
- name: refreshTokenType
|
||||
using:
|
||||
foreign_key_constraint_on: type
|
||||
- name: user
|
||||
using:
|
||||
foreign_key_constraint_on: user_id
|
||||
select_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- id
|
||||
- created_at
|
||||
- expires_at
|
||||
- metadata
|
||||
- type
|
||||
- user_id
|
||||
filter:
|
||||
user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
delete_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
filter:
|
||||
_and:
|
||||
- user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
- type:
|
||||
_eq: pat
|
||||
@@ -0,0 +1,35 @@
|
||||
table:
|
||||
name: roles
|
||||
schema: auth
|
||||
configuration:
|
||||
column_config:
|
||||
role:
|
||||
custom_name: role
|
||||
custom_column_names:
|
||||
role: role
|
||||
custom_name: authRoles
|
||||
custom_root_fields:
|
||||
delete: deleteAuthRoles
|
||||
delete_by_pk: deleteAuthRole
|
||||
insert: insertAuthRoles
|
||||
insert_one: insertAuthRole
|
||||
select: authRoles
|
||||
select_aggregate: authRolesAggregate
|
||||
select_by_pk: authRole
|
||||
update: updateAuthRoles
|
||||
update_by_pk: updateAuthRole
|
||||
array_relationships:
|
||||
- name: userRoles
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: role
|
||||
table:
|
||||
name: user_roles
|
||||
schema: auth
|
||||
- name: usersByDefaultRole
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: default_role
|
||||
table:
|
||||
name: users
|
||||
schema: auth
|
||||
@@ -0,0 +1,58 @@
|
||||
table:
|
||||
name: user_providers
|
||||
schema: auth
|
||||
configuration:
|
||||
column_config:
|
||||
access_token:
|
||||
custom_name: accessToken
|
||||
created_at:
|
||||
custom_name: createdAt
|
||||
id:
|
||||
custom_name: id
|
||||
provider_id:
|
||||
custom_name: providerId
|
||||
provider_user_id:
|
||||
custom_name: providerUserId
|
||||
refresh_token:
|
||||
custom_name: refreshToken
|
||||
updated_at:
|
||||
custom_name: updatedAt
|
||||
user_id:
|
||||
custom_name: userId
|
||||
custom_column_names:
|
||||
access_token: accessToken
|
||||
created_at: createdAt
|
||||
id: id
|
||||
provider_id: providerId
|
||||
provider_user_id: providerUserId
|
||||
refresh_token: refreshToken
|
||||
updated_at: updatedAt
|
||||
user_id: userId
|
||||
custom_name: authUserProviders
|
||||
custom_root_fields:
|
||||
delete: deleteAuthUserProviders
|
||||
delete_by_pk: deleteAuthUserProvider
|
||||
insert: insertAuthUserProviders
|
||||
insert_one: insertAuthUserProvider
|
||||
select: authUserProviders
|
||||
select_aggregate: authUserProvidersAggregate
|
||||
select_by_pk: authUserProvider
|
||||
update: updateAuthUserProviders
|
||||
update_by_pk: updateAuthUserProvider
|
||||
object_relationships:
|
||||
- name: provider
|
||||
using:
|
||||
foreign_key_constraint_on: provider_id
|
||||
- name: user
|
||||
using:
|
||||
foreign_key_constraint_on: user_id
|
||||
select_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- id
|
||||
- provider_id
|
||||
filter:
|
||||
user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
comment: ""
|
||||
@@ -0,0 +1,36 @@
|
||||
table:
|
||||
name: user_roles
|
||||
schema: auth
|
||||
configuration:
|
||||
column_config:
|
||||
created_at:
|
||||
custom_name: createdAt
|
||||
id:
|
||||
custom_name: id
|
||||
role:
|
||||
custom_name: role
|
||||
user_id:
|
||||
custom_name: userId
|
||||
custom_column_names:
|
||||
created_at: createdAt
|
||||
id: id
|
||||
role: role
|
||||
user_id: userId
|
||||
custom_name: authUserRoles
|
||||
custom_root_fields:
|
||||
delete: deleteAuthUserRoles
|
||||
delete_by_pk: deleteAuthUserRole
|
||||
insert: insertAuthUserRoles
|
||||
insert_one: insertAuthUserRole
|
||||
select: authUserRoles
|
||||
select_aggregate: authUserRolesAggregate
|
||||
select_by_pk: authUserRole
|
||||
update: updateAuthUserRoles
|
||||
update_by_pk: updateAuthUserRole
|
||||
object_relationships:
|
||||
- name: roleByRole
|
||||
using:
|
||||
foreign_key_constraint_on: role
|
||||
- name: user
|
||||
using:
|
||||
foreign_key_constraint_on: user_id
|
||||
@@ -0,0 +1,49 @@
|
||||
table:
|
||||
name: user_security_keys
|
||||
schema: auth
|
||||
configuration:
|
||||
column_config:
|
||||
credential_id:
|
||||
custom_name: credentialId
|
||||
credential_public_key:
|
||||
custom_name: credentialPublicKey
|
||||
id:
|
||||
custom_name: id
|
||||
user_id:
|
||||
custom_name: userId
|
||||
custom_column_names:
|
||||
credential_id: credentialId
|
||||
credential_public_key: credentialPublicKey
|
||||
id: id
|
||||
user_id: userId
|
||||
custom_name: authUserSecurityKeys
|
||||
custom_root_fields:
|
||||
delete: deleteAuthUserSecurityKeys
|
||||
delete_by_pk: deleteAuthUserSecurityKey
|
||||
insert: insertAuthUserSecurityKeys
|
||||
insert_one: insertAuthUserSecurityKey
|
||||
select: authUserSecurityKeys
|
||||
select_aggregate: authUserSecurityKeysAggregate
|
||||
select_by_pk: authUserSecurityKey
|
||||
update: updateAuthUserSecurityKeys
|
||||
update_by_pk: updateAuthUserSecurityKey
|
||||
object_relationships:
|
||||
- name: user
|
||||
using:
|
||||
foreign_key_constraint_on: user_id
|
||||
select_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- id
|
||||
- nickname
|
||||
- user_id
|
||||
filter:
|
||||
user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
delete_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
filter:
|
||||
user_id:
|
||||
_eq: x-hasura-auth-elevated
|
||||
@@ -0,0 +1,148 @@
|
||||
table:
|
||||
name: users
|
||||
schema: auth
|
||||
configuration:
|
||||
column_config:
|
||||
active_mfa_type:
|
||||
custom_name: activeMfaType
|
||||
avatar_url:
|
||||
custom_name: avatarUrl
|
||||
created_at:
|
||||
custom_name: createdAt
|
||||
default_role:
|
||||
custom_name: defaultRole
|
||||
disabled:
|
||||
custom_name: disabled
|
||||
display_name:
|
||||
custom_name: displayName
|
||||
email:
|
||||
custom_name: email
|
||||
email_verified:
|
||||
custom_name: emailVerified
|
||||
id:
|
||||
custom_name: id
|
||||
is_anonymous:
|
||||
custom_name: isAnonymous
|
||||
last_seen:
|
||||
custom_name: lastSeen
|
||||
locale:
|
||||
custom_name: locale
|
||||
new_email:
|
||||
custom_name: newEmail
|
||||
otp_hash:
|
||||
custom_name: otpHash
|
||||
otp_hash_expires_at:
|
||||
custom_name: otpHashExpiresAt
|
||||
otp_method_last_used:
|
||||
custom_name: otpMethodLastUsed
|
||||
password_hash:
|
||||
custom_name: passwordHash
|
||||
phone_number:
|
||||
custom_name: phoneNumber
|
||||
phone_number_verified:
|
||||
custom_name: phoneNumberVerified
|
||||
ticket:
|
||||
custom_name: ticket
|
||||
ticket_expires_at:
|
||||
custom_name: ticketExpiresAt
|
||||
totp_secret:
|
||||
custom_name: totpSecret
|
||||
updated_at:
|
||||
custom_name: updatedAt
|
||||
webauthn_current_challenge:
|
||||
custom_name: currentChallenge
|
||||
custom_column_names:
|
||||
active_mfa_type: activeMfaType
|
||||
avatar_url: avatarUrl
|
||||
created_at: createdAt
|
||||
default_role: defaultRole
|
||||
disabled: disabled
|
||||
display_name: displayName
|
||||
email: email
|
||||
email_verified: emailVerified
|
||||
id: id
|
||||
is_anonymous: isAnonymous
|
||||
last_seen: lastSeen
|
||||
locale: locale
|
||||
new_email: newEmail
|
||||
otp_hash: otpHash
|
||||
otp_hash_expires_at: otpHashExpiresAt
|
||||
otp_method_last_used: otpMethodLastUsed
|
||||
password_hash: passwordHash
|
||||
phone_number: phoneNumber
|
||||
phone_number_verified: phoneNumberVerified
|
||||
ticket: ticket
|
||||
ticket_expires_at: ticketExpiresAt
|
||||
totp_secret: totpSecret
|
||||
updated_at: updatedAt
|
||||
webauthn_current_challenge: currentChallenge
|
||||
custom_name: users
|
||||
custom_root_fields:
|
||||
delete: deleteUsers
|
||||
delete_by_pk: deleteUser
|
||||
insert: insertUsers
|
||||
insert_one: insertUser
|
||||
select: users
|
||||
select_aggregate: usersAggregate
|
||||
select_by_pk: user
|
||||
update: updateUsers
|
||||
update_by_pk: updateUser
|
||||
object_relationships:
|
||||
- name: defaultRoleByRole
|
||||
using:
|
||||
foreign_key_constraint_on: default_role
|
||||
array_relationships:
|
||||
- name: refreshTokens
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: user_id
|
||||
table:
|
||||
name: refresh_tokens
|
||||
schema: auth
|
||||
- name: roles
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: user_id
|
||||
table:
|
||||
name: user_roles
|
||||
schema: auth
|
||||
- name: securityKeys
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: user_id
|
||||
table:
|
||||
name: user_security_keys
|
||||
schema: auth
|
||||
- name: userProviders
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: user_id
|
||||
table:
|
||||
name: user_providers
|
||||
schema: auth
|
||||
select_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- active_mfa_type
|
||||
- avatar_url
|
||||
- created_at
|
||||
- default_role
|
||||
- disabled
|
||||
- display_name
|
||||
- email
|
||||
- email_verified
|
||||
- id
|
||||
- is_anonymous
|
||||
- last_seen
|
||||
- locale
|
||||
- metadata
|
||||
- otp_hash
|
||||
- otp_method_last_used
|
||||
- phone_number
|
||||
- phone_number_verified
|
||||
- updated_at
|
||||
- webauthn_current_challenge
|
||||
filter:
|
||||
id:
|
||||
_eq: X-Hasura-User-Id
|
||||
@@ -0,0 +1,10 @@
|
||||
table:
|
||||
name: books
|
||||
schema: public
|
||||
select_permissions:
|
||||
- permission:
|
||||
columns:
|
||||
- id
|
||||
- title
|
||||
filter: {}
|
||||
role: user
|
||||
@@ -0,0 +1,3 @@
|
||||
table:
|
||||
name: customers
|
||||
schema: public
|
||||
@@ -0,0 +1,62 @@
|
||||
table:
|
||||
name: notes
|
||||
schema: public
|
||||
configuration:
|
||||
column_config:
|
||||
created_at:
|
||||
custom_name: createdAt
|
||||
updated_at:
|
||||
custom_name: updatedAt
|
||||
custom_column_names:
|
||||
created_at: createdAt
|
||||
updated_at: updatedAt
|
||||
custom_root_fields:
|
||||
delete: deleteNotes
|
||||
delete_by_pk: deleteNote
|
||||
insert: inserNotes
|
||||
insert_one: insertNote
|
||||
select_aggregate: notesAggregate
|
||||
select_by_pk: note
|
||||
update: updateNotes
|
||||
update_by_pk: updateNote
|
||||
object_relationships:
|
||||
- name: user
|
||||
using:
|
||||
foreign_key_constraint_on: user_id
|
||||
insert_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
check:
|
||||
user_id:
|
||||
_eq: x-hasura-auth-elevated
|
||||
set:
|
||||
user_id: x-hasura-User-Id
|
||||
columns:
|
||||
- content
|
||||
select_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- content
|
||||
- created_at
|
||||
- id
|
||||
- updated_at
|
||||
filter:
|
||||
user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
allow_aggregations: true
|
||||
update_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- content
|
||||
filter:
|
||||
user_id:
|
||||
_eq: x-hasura-auth-elevated
|
||||
check: null
|
||||
delete_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
filter:
|
||||
user_id:
|
||||
_eq: x-hasura-auth-elevated
|
||||
@@ -0,0 +1,102 @@
|
||||
table:
|
||||
name: todos
|
||||
schema: public
|
||||
configuration:
|
||||
column_config:
|
||||
created_at:
|
||||
custom_name: createdAt
|
||||
updated_at:
|
||||
custom_name: updatedAt
|
||||
user_id:
|
||||
custom_name: userId
|
||||
custom_column_names:
|
||||
created_at: createdAt
|
||||
updated_at: updatedAt
|
||||
user_id: userId
|
||||
custom_root_fields:
|
||||
delete: deleteTodos
|
||||
delete_by_pk: deleteTodo
|
||||
insert: insertTodos
|
||||
insert_one: insertTodo
|
||||
select_aggregate: todosAggregate
|
||||
select_by_pk: todo
|
||||
update: updateTodos
|
||||
update_by_pk: updateTodo
|
||||
object_relationships:
|
||||
- name: user
|
||||
using:
|
||||
foreign_key_constraint_on: user_id
|
||||
insert_permissions:
|
||||
- role: anonymous
|
||||
permission:
|
||||
check: {}
|
||||
set:
|
||||
user_id: x-hasura-user-id
|
||||
columns:
|
||||
- contents
|
||||
- id
|
||||
- role: user
|
||||
permission:
|
||||
check: {}
|
||||
set:
|
||||
user_id: x-hasura-user-id
|
||||
columns:
|
||||
- contents
|
||||
- id
|
||||
select_permissions:
|
||||
- role: anonymous
|
||||
permission:
|
||||
columns:
|
||||
- contents
|
||||
- created_at
|
||||
- updated_at
|
||||
- id
|
||||
- user_id
|
||||
filter:
|
||||
user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
allow_aggregations: true
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- contents
|
||||
- created_at
|
||||
- updated_at
|
||||
- id
|
||||
- user_id
|
||||
filter:
|
||||
user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
allow_aggregations: true
|
||||
update_permissions:
|
||||
- role: anonymous
|
||||
permission:
|
||||
columns:
|
||||
- contents
|
||||
filter:
|
||||
user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
check:
|
||||
user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- contents
|
||||
filter:
|
||||
user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
check:
|
||||
user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
delete_permissions:
|
||||
- role: anonymous
|
||||
permission:
|
||||
filter:
|
||||
user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
- role: user
|
||||
permission:
|
||||
filter:
|
||||
user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
@@ -0,0 +1,49 @@
|
||||
table:
|
||||
name: buckets
|
||||
schema: storage
|
||||
configuration:
|
||||
column_config:
|
||||
cache_control:
|
||||
custom_name: cacheControl
|
||||
created_at:
|
||||
custom_name: createdAt
|
||||
download_expiration:
|
||||
custom_name: downloadExpiration
|
||||
id:
|
||||
custom_name: id
|
||||
max_upload_file_size:
|
||||
custom_name: maxUploadFileSize
|
||||
min_upload_file_size:
|
||||
custom_name: minUploadFileSize
|
||||
presigned_urls_enabled:
|
||||
custom_name: presignedUrlsEnabled
|
||||
updated_at:
|
||||
custom_name: updatedAt
|
||||
custom_column_names:
|
||||
cache_control: cacheControl
|
||||
created_at: createdAt
|
||||
download_expiration: downloadExpiration
|
||||
id: id
|
||||
max_upload_file_size: maxUploadFileSize
|
||||
min_upload_file_size: minUploadFileSize
|
||||
presigned_urls_enabled: presignedUrlsEnabled
|
||||
updated_at: updatedAt
|
||||
custom_name: buckets
|
||||
custom_root_fields:
|
||||
delete: deleteBuckets
|
||||
delete_by_pk: deleteBucket
|
||||
insert: insertBuckets
|
||||
insert_one: insertBucket
|
||||
select: buckets
|
||||
select_aggregate: bucketsAggregate
|
||||
select_by_pk: bucket
|
||||
update: updateBuckets
|
||||
update_by_pk: updateBucket
|
||||
array_relationships:
|
||||
- name: files
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: bucket_id
|
||||
table:
|
||||
name: files
|
||||
schema: storage
|
||||
@@ -0,0 +1,168 @@
|
||||
table:
|
||||
name: files
|
||||
schema: storage
|
||||
configuration:
|
||||
column_config:
|
||||
bucket_id:
|
||||
custom_name: bucketId
|
||||
created_at:
|
||||
custom_name: createdAt
|
||||
etag:
|
||||
custom_name: etag
|
||||
id:
|
||||
custom_name: id
|
||||
is_uploaded:
|
||||
custom_name: isUploaded
|
||||
mime_type:
|
||||
custom_name: mimeType
|
||||
name:
|
||||
custom_name: name
|
||||
size:
|
||||
custom_name: size
|
||||
updated_at:
|
||||
custom_name: updatedAt
|
||||
uploaded_by_user_id:
|
||||
custom_name: uploadedByUserId
|
||||
custom_column_names:
|
||||
bucket_id: bucketId
|
||||
created_at: createdAt
|
||||
etag: etag
|
||||
id: id
|
||||
is_uploaded: isUploaded
|
||||
mime_type: mimeType
|
||||
name: name
|
||||
size: size
|
||||
updated_at: updatedAt
|
||||
uploaded_by_user_id: uploadedByUserId
|
||||
custom_name: files
|
||||
custom_root_fields:
|
||||
delete: deleteFiles
|
||||
delete_by_pk: deleteFile
|
||||
insert: insertFiles
|
||||
insert_one: insertFile
|
||||
select: files
|
||||
select_aggregate: filesAggregate
|
||||
select_by_pk: file
|
||||
update: updateFiles
|
||||
update_by_pk: updateFile
|
||||
object_relationships:
|
||||
- name: bucket
|
||||
using:
|
||||
foreign_key_constraint_on: bucket_id
|
||||
insert_permissions:
|
||||
- role: anonymous
|
||||
permission:
|
||||
check:
|
||||
size:
|
||||
_lt: 1024000
|
||||
set:
|
||||
uploaded_by_user_id: x-hasura-User-Id
|
||||
columns:
|
||||
- is_uploaded
|
||||
- size
|
||||
- bucket_id
|
||||
- etag
|
||||
- mime_type
|
||||
- name
|
||||
- created_at
|
||||
- updated_at
|
||||
- id
|
||||
- uploaded_by_user_id
|
||||
- role: user
|
||||
permission:
|
||||
check: {}
|
||||
set:
|
||||
uploaded_by_user_id: x-hasura-User-Id
|
||||
columns:
|
||||
- is_uploaded
|
||||
- size
|
||||
- bucket_id
|
||||
- etag
|
||||
- mime_type
|
||||
- name
|
||||
- created_at
|
||||
- updated_at
|
||||
- id
|
||||
- uploaded_by_user_id
|
||||
select_permissions:
|
||||
- role: anonymous
|
||||
permission:
|
||||
columns:
|
||||
- is_uploaded
|
||||
- size
|
||||
- bucket_id
|
||||
- etag
|
||||
- mime_type
|
||||
- name
|
||||
- created_at
|
||||
- updated_at
|
||||
- id
|
||||
- uploaded_by_user_id
|
||||
filter:
|
||||
uploaded_by_user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- bucket_id
|
||||
- created_at
|
||||
- etag
|
||||
- id
|
||||
- is_uploaded
|
||||
- metadata
|
||||
- mime_type
|
||||
- name
|
||||
- size
|
||||
- updated_at
|
||||
- uploaded_by_user_id
|
||||
filter:
|
||||
uploaded_by_user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
update_permissions:
|
||||
- role: anonymous
|
||||
permission:
|
||||
columns:
|
||||
- is_uploaded
|
||||
- size
|
||||
- bucket_id
|
||||
- etag
|
||||
- mime_type
|
||||
- name
|
||||
- created_at
|
||||
- updated_at
|
||||
- id
|
||||
- uploaded_by_user_id
|
||||
filter:
|
||||
uploaded_by_user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
check:
|
||||
uploaded_by_user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- is_uploaded
|
||||
- size
|
||||
- bucket_id
|
||||
- etag
|
||||
- mime_type
|
||||
- name
|
||||
- created_at
|
||||
- updated_at
|
||||
- id
|
||||
- uploaded_by_user_id
|
||||
filter:
|
||||
uploaded_by_user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
check: {}
|
||||
delete_permissions:
|
||||
- role: anonymous
|
||||
permission:
|
||||
filter:
|
||||
uploaded_by_user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
- role: user
|
||||
permission:
|
||||
filter:
|
||||
uploaded_by_user_id:
|
||||
_eq: X-Hasura-User-Id
|
||||
@@ -0,0 +1,42 @@
|
||||
table:
|
||||
name: virus
|
||||
schema: storage
|
||||
configuration:
|
||||
column_config:
|
||||
created_at:
|
||||
custom_name: createdAt
|
||||
file_id:
|
||||
custom_name: fileId
|
||||
filename:
|
||||
custom_name: filename
|
||||
id:
|
||||
custom_name: id
|
||||
updated_at:
|
||||
custom_name: updatedAt
|
||||
user_session:
|
||||
custom_name: userSession
|
||||
virus:
|
||||
custom_name: virus
|
||||
custom_column_names:
|
||||
created_at: createdAt
|
||||
file_id: fileId
|
||||
filename: filename
|
||||
id: id
|
||||
updated_at: updatedAt
|
||||
user_session: userSession
|
||||
virus: virus
|
||||
custom_name: virus
|
||||
custom_root_fields:
|
||||
delete: deleteViruses
|
||||
delete_by_pk: deleteVirus
|
||||
insert: insertViruses
|
||||
insert_one: insertVirus
|
||||
select: viruses
|
||||
select_aggregate: virusesAggregate
|
||||
select_by_pk: virus
|
||||
update: updateViruses
|
||||
update_by_pk: updateVirus
|
||||
object_relationships:
|
||||
- name: file
|
||||
using:
|
||||
foreign_key_constraint_on: file_id
|
||||
@@ -0,0 +1,15 @@
|
||||
- "!include auth_provider_requests.yaml"
|
||||
- "!include auth_providers.yaml"
|
||||
- "!include auth_refresh_token_types.yaml"
|
||||
- "!include auth_refresh_tokens.yaml"
|
||||
- "!include auth_roles.yaml"
|
||||
- "!include auth_user_providers.yaml"
|
||||
- "!include auth_user_roles.yaml"
|
||||
- "!include auth_user_security_keys.yaml"
|
||||
- "!include auth_users.yaml"
|
||||
- "!include public_customers.yaml"
|
||||
- "!include public_notes.yaml"
|
||||
- "!include public_todos.yaml"
|
||||
- "!include storage_buckets.yaml"
|
||||
- "!include storage_files.yaml"
|
||||
- "!include storage_virus.yaml"
|
||||
@@ -0,0 +1 @@
|
||||
disabled_for_roles: []
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
examples/demos/react-apollo/nhost/metadata/network.yaml
Normal file
1
examples/demos/react-apollo/nhost/metadata/network.yaml
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
1
examples/demos/react-apollo/nhost/metadata/version.yaml
Normal file
1
examples/demos/react-apollo/nhost/metadata/version.yaml
Normal file
@@ -0,0 +1 @@
|
||||
version: 3
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE "public"."books";
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE TABLE "public"."books" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "title" text NOT NULL, PRIMARY KEY ("id") );
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS "public"."user_id";
|
||||
DROP TABLE "public"."todos_user_id";
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE "public"."todos" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now(), "user_id" uuid NOT NULL, "contents" text NOT NULL, PRIMARY KEY ("id") , FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON UPDATE cascade ON DELETE cascade);
|
||||
CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
_new record;
|
||||
BEGIN
|
||||
_new := NEW;
|
||||
_new."updated_at" = NOW();
|
||||
RETURN _new;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
CREATE TRIGGER "set_public_todos_updated_at"
|
||||
BEFORE UPDATE ON "public"."todos"
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"();
|
||||
COMMENT ON TRIGGER "set_public_todos_updated_at" ON "public"."todos"
|
||||
IS 'trigger to set value of column "updated_at" to current timestamp on row update';
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE INDEX "todos_user_id" on
|
||||
"public"."todos" using btree ("user_id");
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE TABLE "public"."books" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "title" text NOT NULL, PRIMARY KEY ("id") );
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
@@ -0,0 +1 @@
|
||||
DROP table "public"."books";
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE "public"."notes";
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE TABLE "public"."notes" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "content" text NOT NULL, "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now(), "user_id" uuid NOT NULL, PRIMARY KEY ("id") , FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON UPDATE restrict ON DELETE restrict, UNIQUE ("id"));
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS public.customers;
|
||||
@@ -0,0 +1 @@
|
||||
CREATE TABLE public.customers (id uuid DEFAULT gen_random_uuid() NOT NULL, PRIMARY KEY (id));
|
||||
204
examples/demos/react-apollo/nhost/nhost.toml
Normal file
204
examples/demos/react-apollo/nhost/nhost.toml
Normal file
@@ -0,0 +1,204 @@
|
||||
[global]
|
||||
[[global.environment]]
|
||||
name = 'AUTH_API_PREFIX'
|
||||
value = '/v1'
|
||||
|
||||
[hasura]
|
||||
version = 'v2.46.0-ce'
|
||||
adminSecret = '{{ secrets.HASURA_GRAPHQL_ADMIN_SECRET }}'
|
||||
webhookSecret = '{{ secrets.NHOST_WEBHOOK_SECRET }}'
|
||||
|
||||
[[hasura.jwtSecrets]]
|
||||
type = 'HS256'
|
||||
key = '{{ secrets.HASURA_GRAPHQL_JWT_SECRET }}'
|
||||
|
||||
[hasura.settings]
|
||||
corsDomain = ['*']
|
||||
devMode = true
|
||||
enableAllowList = false
|
||||
enableConsole = true
|
||||
enableRemoteSchemaPermissions = false
|
||||
enabledAPIs = ['metadata', 'graphql', 'pgdump', 'config']
|
||||
liveQueriesMultiplexedRefetchInterval = 1000
|
||||
|
||||
[hasura.logs]
|
||||
level = 'warn'
|
||||
|
||||
[hasura.events]
|
||||
httpPoolSize = 100
|
||||
|
||||
[functions]
|
||||
[functions.node]
|
||||
version = 22
|
||||
|
||||
[auth]
|
||||
version = '0.41.2-beta'
|
||||
|
||||
[auth.elevatedPrivileges]
|
||||
mode = 'required'
|
||||
|
||||
[auth.redirections]
|
||||
clientUrl = 'https://react-apollo.example.nhost.io/'
|
||||
allowedUrls = ['https://react-apollo.example.nhost.io', 'https://react-apollo.example.nhost.io/profile', 'https://vue-apollo.example.nhost.io', 'https://vue-apollo.example.nhost.io/profile', 'https://*.vercel.app', 'http://localhost:5174', 'http://localhost:5174/profile', 'http://localhost:5174/', 'http://localhost:5174/profile', 'http://localhost:3000/', 'http://localhost:3000/profile']
|
||||
|
||||
[auth.signUp]
|
||||
enabled = true
|
||||
disableNewUsers = false
|
||||
|
||||
[auth.user]
|
||||
[auth.user.roles]
|
||||
default = 'user'
|
||||
allowed = ['user', 'me']
|
||||
|
||||
[auth.user.locale]
|
||||
default = 'en'
|
||||
allowed = ['en']
|
||||
|
||||
[auth.user.gravatar]
|
||||
enabled = true
|
||||
default = 'blank'
|
||||
rating = 'g'
|
||||
|
||||
[auth.user.email]
|
||||
|
||||
[auth.user.emailDomains]
|
||||
|
||||
[auth.session]
|
||||
[auth.session.accessToken]
|
||||
expiresIn = 900
|
||||
|
||||
[auth.session.refreshToken]
|
||||
expiresIn = 43200
|
||||
|
||||
[auth.method]
|
||||
[auth.method.anonymous]
|
||||
enabled = true
|
||||
|
||||
[auth.method.emailPasswordless]
|
||||
enabled = true
|
||||
|
||||
[auth.method.emailPassword]
|
||||
hibpEnabled = false
|
||||
emailVerificationRequired = true
|
||||
passwordMinLength = 8
|
||||
|
||||
[auth.method.smsPasswordless]
|
||||
enabled = false
|
||||
|
||||
[auth.method.oauth]
|
||||
[auth.method.oauth.apple]
|
||||
enabled = true
|
||||
clientId = '{{ secrets.APPLE_SERVICE_IDENTIFIER }}'
|
||||
keyId = '{{ secrets.APPLE_KEY_ID }}'
|
||||
teamId = '{{ secrets.APPLE_TEAM_ID }}'
|
||||
privateKey = '{{ secrets.APPLE_PRIVATE_KEY }}'
|
||||
|
||||
[auth.method.oauth.azuread]
|
||||
tenant = 'common'
|
||||
enabled = true
|
||||
clientId = '{{ secrets.AZUREAD_CLIENT_ID }}'
|
||||
clientSecret = '{{ secrets.AZUREAD_CLIENT_SECRET }}'
|
||||
|
||||
[auth.method.oauth.bitbucket]
|
||||
enabled = true
|
||||
clientId = '{{ secrets.BITBUCKET_CLIENT_ID }}'
|
||||
clientSecret = '{{ secrets.BITBUCKET_CLIENT_SECRET }}'
|
||||
|
||||
[auth.method.oauth.discord]
|
||||
enabled = true
|
||||
clientId = '{{ secrets.DISCORD_CLIENT_ID }}'
|
||||
clientSecret = '{{ secrets.DISCORD_CLIENT_SECRET }}'
|
||||
|
||||
[auth.method.oauth.entraid]
|
||||
tenant = 'common'
|
||||
enabled = true
|
||||
clientId = '{{ secrets.AZUREAD_CLIENT_ID }}'
|
||||
clientSecret = '{{ secrets.AZUREAD_CLIENT_SECRET }}'
|
||||
|
||||
[auth.method.oauth.facebook]
|
||||
enabled = true
|
||||
clientId = '{{ secrets.FACEBOOK_CLIENT_ID }}'
|
||||
clientSecret = '{{ secrets.FACEBOOK_CLIENT_SECRET }}'
|
||||
|
||||
[auth.method.oauth.github]
|
||||
enabled = true
|
||||
clientId = '{{ secrets.GITHUB_CLIENT_ID }}'
|
||||
clientSecret = '{{ secrets.GITHUB_CLIENT_SECRET }}'
|
||||
|
||||
[auth.method.oauth.gitlab]
|
||||
enabled = true
|
||||
clientId = '{{ secrets.GITLAB_CLIENT_ID }}'
|
||||
clientSecret = '{{ secrets.GITLAB_CLIENT_SECRET }}'
|
||||
|
||||
[auth.method.oauth.google]
|
||||
enabled = true
|
||||
clientId = '{{ secrets.GOOGLE_CLIENT_ID }}'
|
||||
clientSecret = '{{ secrets.GOOGLE_CLIENT_SECRET }}'
|
||||
|
||||
[auth.method.oauth.linkedin]
|
||||
enabled = true
|
||||
clientId = '{{ secrets.LINKEDIN_CLIENT_ID }}'
|
||||
clientSecret = '{{ secrets.LINKEDIN_CLIENT_SECRET }}'
|
||||
|
||||
[auth.method.oauth.spotify]
|
||||
enabled = true
|
||||
clientId = '{{ secrets.SPOTIFY_CLIENT_ID }}'
|
||||
clientSecret = '{{ secrets.SPOTIFY_CLIENT_SECRET }}'
|
||||
|
||||
[auth.method.oauth.strava]
|
||||
enabled = true
|
||||
clientId = '{{ secrets.STRAVA_CLIENT_ID }}'
|
||||
clientSecret = '{{ secrets.STRAVA_CLIENT_SECRET }}'
|
||||
|
||||
[auth.method.oauth.twitch]
|
||||
enabled = true
|
||||
clientId = '{{ secrets.TWITCH_CLIENT_ID }}'
|
||||
clientSecret = '{{ secrets.TWITCH_CLIENT_SECRET }}'
|
||||
|
||||
[auth.method.oauth.twitter]
|
||||
enabled = true
|
||||
consumerKey = '{{ secrets.TWITTER_CONSUMER_KEY }}'
|
||||
consumerSecret = '{{ secrets.TWITTER_CONSUMER_SECRET }}'
|
||||
|
||||
[auth.method.oauth.windowslive]
|
||||
enabled = true
|
||||
clientId = '{{ secrets.WINDOWSLIVE_CLIENT_ID }}'
|
||||
clientSecret = '{{ secrets.WINDOWSLIVE_CLIENT_SECRET }}'
|
||||
|
||||
[auth.method.oauth.workos]
|
||||
connection = '{{ secrets.WORKOS_DEFAULT_CONNECTION }}'
|
||||
enabled = true
|
||||
clientId = '{{ secrets.WORKOS_CLIENT_ID }}'
|
||||
organization = '{{ secrets.WORKOS_DEFAULT_ORGANIZATION }}'
|
||||
clientSecret = '{{ secrets.WORKOS_CLIENT_SECRET }}'
|
||||
|
||||
[auth.method.webauthn]
|
||||
enabled = true
|
||||
|
||||
[auth.method.webauthn.relyingParty]
|
||||
id = 'nhost.io'
|
||||
name = 'apollo-example'
|
||||
origins = ['https://react-apollo.example.nhost.io']
|
||||
|
||||
[auth.method.webauthn.attestation]
|
||||
timeout = 60000
|
||||
|
||||
[auth.totp]
|
||||
enabled = true
|
||||
issuer = 'nhost'
|
||||
|
||||
[postgres]
|
||||
version = '16.6-20250311-1'
|
||||
|
||||
[postgres.resources]
|
||||
[postgres.resources.storage]
|
||||
capacity = 1
|
||||
|
||||
[provider]
|
||||
|
||||
[storage]
|
||||
version = '0.7.1'
|
||||
|
||||
[observability]
|
||||
[observability.grafana]
|
||||
adminPassword = '{{ secrets.GRAFANA_ADMIN_PASSWORD }}'
|
||||
120
examples/demos/react-apollo/nhost/overlays/local.json
Normal file
120
examples/demos/react-apollo/nhost/overlays/local.json
Normal file
@@ -0,0 +1,120 @@
|
||||
[
|
||||
{
|
||||
"value": "disabled",
|
||||
"op": "replace",
|
||||
"path": "/auth/elevatedPrivileges/mode"
|
||||
},
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "/auth/method/oauth/apple/clientId"
|
||||
},
|
||||
{
|
||||
"value": false,
|
||||
"op": "replace",
|
||||
"path": "/auth/method/oauth/apple/enabled"
|
||||
},
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "/auth/method/oauth/apple/keyId"
|
||||
},
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "/auth/method/oauth/apple/privateKey"
|
||||
},
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "/auth/method/oauth/apple/teamId"
|
||||
},
|
||||
{
|
||||
"value": "localhost",
|
||||
"op": "replace",
|
||||
"path": "/auth/method/webauthn/relyingParty/id"
|
||||
},
|
||||
{
|
||||
"value": "http://localhost:3000",
|
||||
"op": "replace",
|
||||
"path": "/auth/method/webauthn/relyingParty/origins/0"
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"bruteForce": {
|
||||
"interval": "5m",
|
||||
"limit": 100
|
||||
},
|
||||
"emails": {
|
||||
"interval": "1h",
|
||||
"limit": 100
|
||||
},
|
||||
"global": {
|
||||
"interval": "1m",
|
||||
"limit": 1000
|
||||
},
|
||||
"signups": {
|
||||
"interval": "5m",
|
||||
"limit": 100
|
||||
},
|
||||
"sms": {
|
||||
"interval": "1h",
|
||||
"limit": 100
|
||||
}
|
||||
},
|
||||
"op": "add",
|
||||
"path": "/auth/rateLimit"
|
||||
},
|
||||
{
|
||||
"value": "http://localhost:3000",
|
||||
"op": "replace",
|
||||
"path": "/auth/redirections/allowedUrls/0"
|
||||
},
|
||||
{
|
||||
"value": "http://localhost:3000/profile",
|
||||
"op": "replace",
|
||||
"path": "/auth/redirections/allowedUrls/1"
|
||||
},
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "/auth/redirections/allowedUrls/2"
|
||||
},
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "/auth/redirections/allowedUrls/2"
|
||||
},
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "/auth/redirections/allowedUrls/2"
|
||||
},
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "/auth/redirections/allowedUrls/2"
|
||||
},
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "/auth/redirections/allowedUrls/2"
|
||||
},
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "/auth/redirections/allowedUrls/2"
|
||||
},
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "/auth/redirections/allowedUrls/2"
|
||||
},
|
||||
{
|
||||
"value": "http://localhost:3000",
|
||||
"op": "replace",
|
||||
"path": "/auth/redirections/clientUrl"
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"host": "smtp.test.com",
|
||||
"method": "LOGIN",
|
||||
"password": "test123123",
|
||||
"port": 587,
|
||||
"secure": false,
|
||||
"sender": "test@nhost.io",
|
||||
"user": "test"
|
||||
},
|
||||
"op": "add",
|
||||
"path": "/provider/smtp"
|
||||
}
|
||||
]
|
||||
66
examples/demos/react-apollo/package.json
Normal file
66
examples/demos/react-apollo/package.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "@nhost-examples/react-apollo",
|
||||
"version": "1.6.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"test:typecheck": "tsc --noEmit",
|
||||
"test:lint": "biome check .",
|
||||
"format": "biome format --write",
|
||||
"preview": "vite preview",
|
||||
"build:preview": "pnpm build && pnpm preview",
|
||||
"install-browsers": "pnpm playwright install && pnpm playwright install-deps",
|
||||
"e2e": "pnpm e2e:start-backend && pnpm e2e:test",
|
||||
"e2e:test": "pnpm install-browsers && pnpm playwright test",
|
||||
"e2e:start-backend": "cp .secrets.example .secrets && nhost up --down-on-error"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.11.1",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@icons-pack/react-simple-icons": "^13.8.0",
|
||||
"@nhost/nhost-js": "workspace:*",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^1.2.1",
|
||||
"graphql": "^16.11.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"lucide-react": "^0.416.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"react": "^19.1.0",
|
||||
"react-code-block": "^1.0.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.52.2",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"@playwright/test": "^1.41.0",
|
||||
"@types/node": "^24.6.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"dotenv": "^16.4.5",
|
||||
"globals": "^15.9.0",
|
||||
"jsqr": "^1.4.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"totp-generator": "^0.0.13",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
37
examples/demos/react-apollo/playwright.config.ts
Normal file
37
examples/demos/react-apollo/playwright.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
import dotenv from 'dotenv'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env.test') })
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30 * 1000,
|
||||
expect: {
|
||||
timeout: 5000
|
||||
},
|
||||
webServer: {
|
||||
command: 'pnpm build:preview',
|
||||
port: 3000
|
||||
},
|
||||
use: {
|
||||
trace: 'retain-on-failure',
|
||||
baseURL: 'http://localhost:3000'
|
||||
},
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: 'html',
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] }
|
||||
}
|
||||
]
|
||||
})
|
||||
3262
examples/demos/react-apollo/pnpm-lock.yaml
generated
Normal file
3262
examples/demos/react-apollo/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
examples/demos/react-apollo/postcss.config.js
Normal file
6
examples/demos/react-apollo/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
10
examples/demos/react-apollo/public/favicon.svg
Normal file
10
examples/demos/react-apollo/public/favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0)">
|
||||
<path d="M88.0355 21.4793L53.1776 1.35118C50.0502 -0.450393 46.168 -0.450393 43.0343 1.35118C39.9069 3.1591 37.9657 6.52119 37.9657 10.1307V12.7569L35.6948 11.4438C32.5674 9.64222 28.6851 9.64222 25.5514 11.4438C22.424 13.2517 20.4829 16.6138 20.4829 20.2296V22.8559L18.2119 21.5428C15.0845 19.7412 11.2022 19.7412 8.06851 21.5428C4.94113 23.3507 3 26.7128 3 30.3286V93.3963C3 95.2106 4.05303 96.898 5.68967 97.6846C7.31996 98.4775 9.29916 98.2619 10.7201 97.1391L28.0063 83.5067L54.662 98.8962C55.3979 99.3212 56.2225 99.5306 57.0472 99.5306C57.8719 99.5306 58.6965 99.3149 59.4324 98.8962C60.9041 98.0462 61.8176 96.4666 61.8176 94.7666V56.813C61.8176 50.5836 58.4682 44.7856 53.0761 41.6709L44.3347 36.6214V10.137C44.3347 8.79218 45.0579 7.53616 46.2251 6.86374C47.3923 6.19132 48.8386 6.19132 50.0058 6.86374L84.8638 26.9855C88.2956 28.9647 90.4271 32.663 90.4271 36.6214V83.881C90.4271 85.2258 89.7039 86.4819 88.5367 87.1543L79.3004 92.4892V46.714C79.3004 40.4846 75.951 34.6866 70.559 31.5719L49.0987 19.1829V26.5225L67.3809 37.0782C70.8127 39.0573 72.9442 42.7493 72.9442 46.714V95.236C72.9442 96.9297 73.8577 98.5156 75.3294 99.3656C76.0652 99.7907 76.8899 100 77.7145 100C78.5392 100 79.3639 99.7843 80.0997 99.3656L91.7212 92.6541C94.8485 90.8462 96.7897 87.4841 96.7897 83.8683V36.6087C96.777 30.3984 93.4276 24.594 88.0355 21.4793ZM49.8853 47.1771C53.3172 49.1563 55.4486 52.8483 55.4486 56.813V92.0198L33.373 79.2756L40.4588 73.6932C42.9137 71.7584 44.322 68.8594 44.322 65.732V43.9736L49.8853 47.1771ZM37.9657 40.2943V65.7194C37.9657 66.8866 37.4392 67.9713 36.5258 68.6881L9.35626 90.1104V30.3223C9.35626 28.9774 10.0794 27.7214 11.2466 27.049C12.4139 26.3766 13.8602 26.3766 15.0274 27.049L20.4829 30.1954V75.2664L26.8391 70.255V20.2296C26.8391 18.8848 27.5623 17.6288 28.7295 16.9564C29.8967 16.2839 31.3431 16.2839 32.5103 16.9564L37.9657 20.1028V32.9485L31.6095 29.2756V36.6214L37.9657 40.2943Z" fill="#0052CD"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="93.7897" height="100" fill="white" transform="translate(3)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
60
examples/demos/react-apollo/src/App.tsx
Normal file
60
examples/demos/react-apollo/src/App.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { AuthGate } from '@/components/auth/auth-gate'
|
||||
import Home from '@/components/routes/app/home'
|
||||
import Layout from '@/components/routes/app/layout'
|
||||
import Profile from '@/components/routes/app/profile'
|
||||
import ProtectedNotes from '@/components/routes/app/protected-notes'
|
||||
import Storage from '@/components/routes/app/storage'
|
||||
import Todos from '@/components/routes/app/todos'
|
||||
import ForgotPassword from '@/components/routes/auth/forgot-password'
|
||||
import SignIn from '@/components/routes/auth/sign-in/sign-in'
|
||||
import SignInEmailPassword from '@/components/routes/auth/sign-in/sign-in-email-password'
|
||||
import SignInMagicLink from '@/components/routes/auth/sign-in/sign-in-magic-link'
|
||||
import SignInSecurityKey from '@/components/routes/auth/sign-in/sign-in-security-key'
|
||||
import SignUp from '@/components/routes/auth/sign-up/sign-up'
|
||||
import SignUpEmailPassword from '@/components/routes/auth/sign-up/sign-up-email-password'
|
||||
import SignUpMagicLink from '@/components/routes/auth/sign-up/sign-up-magic-link'
|
||||
import SignUpSecurityKey from '@/components/routes/auth/sign-up/sign-up-security-key'
|
||||
import VerifyEmail from './components/routes/auth/verify-email'
|
||||
import SignInEmailOTP from './components/routes/auth/sign-in/sign-in-email-otp'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<AuthGate>
|
||||
<Layout />
|
||||
</AuthGate>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/protected-notes" element={<ProtectedNotes />} />
|
||||
<Route path="/storage" element={<Storage />} />
|
||||
<Route path="/todos" element={<Todos />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/sign-in">
|
||||
<Route path="/sign-in/" element={<SignIn />} />
|
||||
<Route path="/sign-in/email-password" element={<SignInEmailPassword />} />
|
||||
<Route path="/sign-in/security-key" element={<SignInSecurityKey />} />
|
||||
<Route path="/sign-in/magic-link" element={<SignInMagicLink />} />
|
||||
<Route path="/sign-in/email-otp" element={<SignInEmailOTP />} />
|
||||
<Route path="/sign-in/forgot-password" element={<ForgotPassword />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/sign-up">
|
||||
<Route path="/sign-up/" element={<SignUp />} />
|
||||
<Route path="/sign-up/email-password" element={<SignUpEmailPassword />} />
|
||||
<Route path="/sign-up/security-key" element={<SignUpSecurityKey />} />
|
||||
<Route path="/sign-up/magic-link" element={<SignUpMagicLink />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/verify" element={<VerifyEmail />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
5
examples/demos/react-apollo/src/assets/nhost.svg
Normal file
5
examples/demos/react-apollo/src/assets/nhost.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="34" viewBox="0 0 32 34" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M29.0132 7.30478L17.1202 0.459517C16.5932 0.158403 15.9966 0 15.3895 0C14.7824 0 14.1858 0.158403 13.6588 0.459517C13.1342 0.76236 12.6985 1.19746 12.3951 1.7213C12.0918 2.24514 11.9315 2.83935 11.9303 3.44447V4.33793L11.1551 3.89067C10.6281 3.58956 10.0315 3.43115 9.42437 3.43115C8.81725 3.43115 8.22065 3.58956 7.69369 3.89067C7.16838 4.19405 6.73217 4.63001 6.42879 5.15484C6.1254 5.67968 5.96551 6.27494 5.96514 6.88095V7.77335L5.18991 7.32715C4.66308 7.02623 4.06668 6.86793 3.45976 6.86793C2.85284 6.86793 2.25645 7.02623 1.72961 7.32715C1.20473 7.63003 0.768706 8.06526 0.465167 8.58929C0.161628 9.11332 0.00122225 9.70778 0 10.3132L0 31.7563C0.0007938 32.0609 0.0873376 32.3593 0.249755 32.6172C0.412172 32.8751 0.643921 33.0822 0.918553 33.2149C1.19318 33.3476 1.49964 33.4005 1.80294 33.3676C2.10624 33.3347 2.39417 33.2173 2.63388 33.0288L8.53077 28.3922L17.6267 33.6252C17.8748 33.7656 18.1552 33.8394 18.4403 33.8394C18.7255 33.8394 19.0058 33.7656 19.254 33.6252C19.7551 33.3355 20.0676 32.7988 20.0676 32.2206V19.3191C20.0657 18.2752 19.7892 17.2501 19.2657 16.3464C18.7423 15.4428 17.9903 14.6924 17.085 14.1703L14.1024 12.4536V3.44767C14.103 3.22174 14.163 2.99993 14.2765 2.80447C14.3899 2.60902 14.5529 2.44679 14.7489 2.33406C14.945 2.22133 15.1673 2.16206 15.3935 2.1622C15.6197 2.16233 15.8419 2.22187 16.0379 2.33483L27.9308 9.1769C28.5067 9.50917 28.9851 9.9866 29.3182 10.5615C29.6513 11.1363 29.8274 11.7884 29.8289 12.4526V28.5211C29.8289 28.979 29.5815 29.4049 29.1838 29.6339L26.0327 31.4474V15.8837C26.0306 14.84 25.754 13.8151 25.2306 12.9116C24.7072 12.0082 23.9552 11.2579 23.0502 10.7359L15.7286 6.5242V9.0193L21.9657 12.6081C22.5417 12.9401 23.0203 13.4175 23.3534 13.9924C23.6866 14.5673 23.8626 15.2195 23.8638 15.8837V32.3814C23.8638 32.9564 24.1751 33.4963 24.6774 33.786C24.9256 33.9263 25.2059 34 25.491 34C25.7762 34 26.0565 33.9263 26.3046 33.786L30.2704 31.5039C31.3367 30.8894 32 29.7468 32 28.5168V12.4483C31.9952 11.4052 31.717 10.3814 31.193 9.47903C30.669 8.57663 29.9174 7.82701 29.0132 7.30478V7.30478ZM15.9952 16.0413C16.5714 16.3734 17.0501 16.851 17.3832 17.4261C17.7164 18.0012 17.8923 18.6537 17.8933 19.3181V31.2877L10.3628 26.9546L12.7802 25.0569C13.1919 24.7358 13.5247 24.325 13.7531 23.8559C13.9816 23.3867 14.0996 22.8716 14.0982 22.3499V14.953L15.9963 16.0424L15.9952 16.0413ZM11.9292 13.7017V22.3456C11.9292 22.7428 11.749 23.1124 11.4376 23.3552L2.16788 30.6392V10.31C2.16821 10.0841 2.22804 9.86224 2.34138 9.66675C2.45471 9.47125 2.61755 9.30897 2.81356 9.19621C3.00956 9.08345 3.23182 9.02418 3.45802 9.02434C3.68422 9.0245 3.9064 9.0841 4.10224 9.19714L5.96514 10.2674V25.5915L8.13303 23.8876V6.87882C8.13324 6.65277 8.19305 6.43076 8.30644 6.23511C8.41983 6.03947 8.5828 5.87708 8.77897 5.76429C8.97513 5.6515 9.19758 5.59227 9.42393 5.59257C9.65029 5.59287 9.87258 5.65268 10.0684 5.76598L11.9292 6.83516V11.2034L9.7624 9.95536V12.4526L11.9314 13.7017H11.9292Z" fill="white"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,26 @@
|
||||
import { useAuth } from "@/providers/auth";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { FC, PropsWithChildren } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
export const AuthGate: FC<PropsWithChildren<unknown>> = ({ children }) => {
|
||||
const location = useLocation();
|
||||
const { isLoading, isAuthenticated } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen gap-4">
|
||||
<LoaderCircle className="w-10 h-10 animate-spin-fast text-slate-500" />
|
||||
<span className="max-w-md text-center">
|
||||
Could not sign in automatically. Retrying to get user information
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/sign-in" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
183
examples/demos/react-apollo/src/components/auth/oauth-links.tsx
Normal file
183
examples/demos/react-apollo/src/components/auth/oauth-links.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
// import { SiApple, SiGithub, SiGoogle, SiLinkedin, SiDiscord, SiSpotify, SiTwitch, SiGitlab, SiBitbucket, SiMicrosoftazure, SiFacebook, SiStrava, SiWindows, SiX } from '@icons-pack/react-simple-icons'
|
||||
import {
|
||||
SiApple,
|
||||
SiGithub,
|
||||
SiGoogle,
|
||||
// SiLinkedin,
|
||||
// SiLinkerd,
|
||||
SiDiscord,
|
||||
SiSpotify,
|
||||
SiTwitch,
|
||||
SiGitlab,
|
||||
SiBitbucket,
|
||||
SiFacebook,
|
||||
SiStrava,
|
||||
SiX,
|
||||
} from "@icons-pack/react-simple-icons";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { WorkOSIcon } from "../ui/workosicon";
|
||||
import { useProviderLink } from "@/hooks";
|
||||
|
||||
export default function OAuthLinks() {
|
||||
const github = useProviderLink("github");
|
||||
const apple = useProviderLink("apple");
|
||||
const google = useProviderLink("google");
|
||||
const linkedin = useProviderLink("linkedin");
|
||||
const discord = useProviderLink("discord");
|
||||
const spotify = useProviderLink("spotify");
|
||||
const twitch = useProviderLink("twitch");
|
||||
const gitlab = useProviderLink("gitlab");
|
||||
const bitbucket = useProviderLink("bitbucket");
|
||||
const workos = useProviderLink("workos");
|
||||
const facebook = useProviderLink("facebook");
|
||||
const strava = useProviderLink("strava");
|
||||
const twitter = useProviderLink("twitter");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full max-w-md space-y-2">
|
||||
<Link
|
||||
to={github}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link" }),
|
||||
"bg-[#131111] text-white hover:opacity-90 hover:no-underline",
|
||||
)}
|
||||
>
|
||||
<SiGithub className="w-4 h-4" />
|
||||
<span className="flex-1 text-center">Continue with Github</span>
|
||||
</Link>
|
||||
<Link
|
||||
to={google}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link" }),
|
||||
"bg-[#DE5246] text-white hover:opacity-90 hover:no-underline",
|
||||
)}
|
||||
>
|
||||
<SiGoogle className="w-4 h-4" />
|
||||
<span className="flex-1 text-center">Continue with Google</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to={apple}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link" }),
|
||||
"bg-[#131111] text-white hover:opacity-90 hover:no-underline",
|
||||
)}
|
||||
>
|
||||
<SiApple className="w-4 h-4" />
|
||||
<span className="flex-1 text-center">Continue with Apple</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to={linkedin}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link" }),
|
||||
"bg-[#0073B1] text-white hover:opacity-90 hover:no-underline",
|
||||
)}
|
||||
>
|
||||
{/* <SiLinkedin className="w-4 h-4" /> */}
|
||||
<span className="flex-1 text-center">Continue with LinkedIn</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to={discord}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link" }),
|
||||
"bg-[#7289da] text-white hover:opacity-90 hover:no-underline",
|
||||
)}
|
||||
>
|
||||
<SiDiscord className="w-4 h-4" />
|
||||
<span className="flex-1 text-center">Continue with Discord</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to={spotify}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link" }),
|
||||
"bg-[#1DB954] text-white hover:opacity-90 hover:no-underline",
|
||||
)}
|
||||
>
|
||||
<SiSpotify className="w-4 h-4" />
|
||||
<span className="flex-1 text-center">Continue with Spotify</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to={twitch}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link" }),
|
||||
"bg-[#9146ff] text-white hover:opacity-90 hover:no-underline",
|
||||
)}
|
||||
>
|
||||
<SiTwitch className="w-4 h-4" />
|
||||
<span className="flex-1 text-center">Continue with Twitch</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to={gitlab}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link" }),
|
||||
"bg-[#FCA326] text-white hover:opacity-90 hover:no-underline",
|
||||
)}
|
||||
>
|
||||
<SiGitlab className="w-4 h-4" />
|
||||
<span className="flex-1 text-center">Continue with Gitlab</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to={bitbucket}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link" }),
|
||||
"bg-[#253858] text-white hover:opacity-90 hover:no-underline",
|
||||
)}
|
||||
>
|
||||
<SiBitbucket className="w-4 h-4" />
|
||||
<span className="flex-1 text-center">Continue with Bitbucket</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to={workos}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link" }),
|
||||
"bg-[#4F46E5] text-white hover:opacity-90 hover:no-underline",
|
||||
)}
|
||||
>
|
||||
<WorkOSIcon />
|
||||
<span className="flex-1 text-center">Continue with WorkOS</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to={facebook}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link" }),
|
||||
"bg-[#3b5998] text-white hover:opacity-90 hover:no-underline",
|
||||
)}
|
||||
>
|
||||
<SiFacebook className="w-4 h-4" />
|
||||
<span className="flex-1 text-center">Continue with Facebook</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to={strava}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link" }),
|
||||
"bg-[#FC5200] text-white hover:opacity-90 hover:no-underline",
|
||||
)}
|
||||
>
|
||||
<SiStrava className="w-4 h-4" />
|
||||
<span className="flex-1 text-center">Continue with Strava</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to={twitter}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link" }),
|
||||
"bg-[#000000] text-white hover:opacity-90 hover:no-underline",
|
||||
)}
|
||||
>
|
||||
<SiX className="w-4 h-4" />
|
||||
<span className="flex-1 text-center">Continue with Twitter</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { type ErrorResponse } from "@nhost/nhost-js/auth";
|
||||
import { type FetchError } from "@nhost/nhost-js/fetch";
|
||||
import { useNhostClient } from "@/providers/nhost";
|
||||
|
||||
export default function SignInFooter() {
|
||||
const navigate = useNavigate();
|
||||
const nhost = useNhostClient();
|
||||
|
||||
const anonymousHandler = async () => {
|
||||
try {
|
||||
const response = await nhost.auth.signInAnonymous();
|
||||
|
||||
if (response.body.session) {
|
||||
navigate("/");
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
// Handle network errors or other exceptions
|
||||
console.error("Failed to sign in anonymously:", error);
|
||||
// toast.error('Failed to sign in. Please try again.')
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<p className="text-sm text-center">
|
||||
Don‘t have an account?{" "}
|
||||
<Link
|
||||
to="/sign-up"
|
||||
className={cn(buttonVariants({ variant: "link" }), "m-0, p-0")}
|
||||
>
|
||||
Sign up
|
||||
</Link>{" "}
|
||||
or{" "}
|
||||
<Button variant="link" className="p-0 m-0" onClick={anonymousHandler}>
|
||||
sign in anonymously
|
||||
</Button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { buttonVariants } from '../ui/button'
|
||||
|
||||
export default function SignUpFooter() {
|
||||
return (
|
||||
<p className="text-sm text-center">
|
||||
Already have an account{' '}
|
||||
<Link to="/sign-in" className={cn(buttonVariants({ variant: 'link' }), 'p-0')}>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAuth } from "@/providers/auth";
|
||||
import { useNhostClient } from "@/providers/nhost";
|
||||
import { useSecurity } from "@/hooks";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Input } from "../ui/input";
|
||||
|
||||
export const ChangeEmail: React.FC = () => {
|
||||
const nhost = useNhostClient();
|
||||
const { user } = useAuth();
|
||||
const { requiresElevation, checkElevation } = useSecurity();
|
||||
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.email) {
|
||||
setNewEmail(user.email);
|
||||
}
|
||||
}, [user?.email]);
|
||||
|
||||
const change = async () => {
|
||||
if (!newEmail.trim()) {
|
||||
toast.error("Please enter a new email address");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newEmail && user?.email === newEmail) {
|
||||
toast.error("You need to set a different email as the current one");
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresElevation) {
|
||||
try {
|
||||
await checkElevation();
|
||||
} catch {
|
||||
toast.error("Could not elevate permissions");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await nhost.auth.changeUserEmail({
|
||||
newEmail,
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/profile`,
|
||||
},
|
||||
});
|
||||
|
||||
toast.info(
|
||||
"Please check your inbox and follow the link to confirm the email change.",
|
||||
);
|
||||
setNewEmail(user?.email || "");
|
||||
} catch (error) {
|
||||
console.error("Failed to change email:", error);
|
||||
toast.error("Failed to change email. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between p-6">
|
||||
<CardTitle>Change email</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Input
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
placeholder="New email"
|
||||
/>
|
||||
<Button onClick={change} disabled={isLoading}>
|
||||
{isLoading ? "Changing..." : "Change"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useNhostClient } from "@/providers/nhost";
|
||||
import { useSecurity } from "@/hooks";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function ChangePassword() {
|
||||
const nhost = useNhostClient();
|
||||
const { requiresElevation, checkElevation } = useSecurity();
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const change = async () => {
|
||||
if (requiresElevation) {
|
||||
try {
|
||||
await checkElevation();
|
||||
} catch {
|
||||
toast.error("Could not elevate permissions");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await nhost.auth.changeUserPassword({
|
||||
newPassword: password,
|
||||
});
|
||||
|
||||
if (result.body === "OK") {
|
||||
toast.success(`Password changed successfully.`);
|
||||
}
|
||||
if (result.body !== "OK") {
|
||||
toast.error(result.body);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between p-6">
|
||||
<CardTitle>Change password</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Input
|
||||
value={password}
|
||||
type="password"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="New password"
|
||||
/>
|
||||
<Button onClick={change}>Change</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SiGithub } from "@icons-pack/react-simple-icons";
|
||||
import { useAuth } from "@/providers/auth";
|
||||
import { useProviderLink } from "@/hooks";
|
||||
import { useNhostClient } from "@/providers/nhost";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function ConnectGithub() {
|
||||
const { session } = useAuth();
|
||||
const nhost = useNhostClient();
|
||||
|
||||
const github = useProviderLink("github", {
|
||||
connect: session?.accessToken,
|
||||
redirectTo: `${window.location.origin}/profile`,
|
||||
});
|
||||
|
||||
const [isGithubConnected, setIsGithubConnected] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAuthUserProviders = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await nhost.graphql.request<{
|
||||
authUserProviders: {
|
||||
id: string;
|
||||
providerId: string;
|
||||
}[];
|
||||
}>({
|
||||
query: `
|
||||
query getAuthUserProviders {
|
||||
authUserProviders {
|
||||
id
|
||||
providerId
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
if (response.body.data?.authUserProviders) {
|
||||
setIsGithubConnected(
|
||||
response.body.data.authUserProviders.some(
|
||||
(item: { providerId: string }) => item.providerId === "github",
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch auth user providers:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAuthUserProviders();
|
||||
}, [nhost]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between p-6">
|
||||
<CardTitle>Connect with Github</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!isLoading && isGithubConnected && (
|
||||
<div className="flex flex-row items-center gap-2 w-fit">
|
||||
<SiGithub className="w-4 h-4" />
|
||||
<span className="flex-1 text-center">Github connected</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isGithubConnected && (
|
||||
<Link
|
||||
to={github}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link" }),
|
||||
"bg-[#131111] text-white hover:opacity-90 hover:no-underline gap-2",
|
||||
)}
|
||||
>
|
||||
<SiGithub className="w-4 h-4" />
|
||||
<span className="flex-1 text-center">Continue with Github</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{isLoading && <LoaderCircle className="w-5 h-5 animate-spin-fast" />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import Code from "@/components/ui/code";
|
||||
import { useNhostClient } from "@/providers/nhost";
|
||||
|
||||
export default function JwtClaims() {
|
||||
const nhost = useNhostClient();
|
||||
const claims = nhost.getUserSession()?.decodedToken;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between p-6">
|
||||
<CardTitle>Jwt Claims</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Code code={JSON.stringify(claims, null, 2)} language="js" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
365
examples/demos/react-apollo/src/components/profile/mfa.tsx
Normal file
365
examples/demos/react-apollo/src/components/profile/mfa.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNhostClient } from "@/providers/nhost";
|
||||
import { useAuth } from "@/providers/auth";
|
||||
import { type ErrorResponse } from "@nhost/nhost-js/auth";
|
||||
import { type FetchError } from "@nhost/nhost-js/fetch";
|
||||
import { useSecurity } from "@/hooks";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Shield, ShieldCheck, QrCode, Copy } from "lucide-react";
|
||||
|
||||
interface MfaStatusQuery {
|
||||
users: {
|
||||
id: string;
|
||||
activeMfaType: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
export default function MFASettings() {
|
||||
const { user } = useAuth();
|
||||
const [isMfaEnabled, setIsMfaEnabled] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isLoadingStatus, setIsLoadingStatus] = useState<boolean>(true);
|
||||
const nhost = useNhostClient();
|
||||
const { requiresElevation, checkElevation } = useSecurity();
|
||||
|
||||
// MFA setup states
|
||||
const [isSettingUpMfa, setIsSettingUpMfa] = useState<boolean>(false);
|
||||
const [totpSecret, setTotpSecret] = useState<string>("");
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
|
||||
const [verificationCode, setVerificationCode] = useState<string>("");
|
||||
|
||||
// Disabling MFA states
|
||||
const [isDisablingMfa, setIsDisablingMfa] = useState<boolean>(false);
|
||||
const [disableVerificationCode, setDisableVerificationCode] =
|
||||
useState<string>("");
|
||||
|
||||
// Fetch MFA status from database
|
||||
useEffect(() => {
|
||||
const fetchMfaStatus = async () => {
|
||||
if (!user?.id) return;
|
||||
|
||||
setIsLoadingStatus(true);
|
||||
try {
|
||||
const response = await nhost.graphql.request<MfaStatusQuery>({
|
||||
query: `
|
||||
query GetMfaStatus($userId: uuid!) {
|
||||
users(where: { id: { _eq: $userId } }) {
|
||||
id
|
||||
activeMfaType
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { userId: user.id },
|
||||
});
|
||||
console.log(response.body.data);
|
||||
|
||||
if (response.body.data?.users?.[0]) {
|
||||
const mfaType = response.body.data?.users[0].activeMfaType;
|
||||
setIsMfaEnabled(mfaType === "totp");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch MFA status:", error);
|
||||
toast.error("Failed to load MFA status");
|
||||
} finally {
|
||||
setIsLoadingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMfaStatus();
|
||||
}, [user?.id, nhost]);
|
||||
|
||||
// Begin MFA setup process
|
||||
const handleEnableMfa = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Check if elevation is required before enabling MFA
|
||||
if (requiresElevation) {
|
||||
await checkElevation();
|
||||
}
|
||||
|
||||
// Generate TOTP secret
|
||||
const response = await nhost.auth.changeUserMfa();
|
||||
setTotpSecret(response.body.totpSecret);
|
||||
setQrCodeUrl(response.body.imageUrl);
|
||||
setIsSettingUpMfa(true);
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
toast.error(`Failed to enable MFA: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Verify TOTP and enable MFA
|
||||
const handleVerifyTotp = async (): Promise<void> => {
|
||||
if (!verificationCode.trim()) {
|
||||
toast.error("Please enter the verification code");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Verify and activate MFA
|
||||
await nhost.auth.verifyChangeUserMfa({
|
||||
activeMfaType: "totp",
|
||||
code: verificationCode,
|
||||
});
|
||||
|
||||
setIsMfaEnabled(true);
|
||||
setIsSettingUpMfa(false);
|
||||
setVerificationCode("");
|
||||
toast.success("MFA has been successfully enabled");
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
toast.error(`Failed to verify code: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Show disable MFA confirmation
|
||||
const handleShowDisableMfa = (): void => {
|
||||
setIsDisablingMfa(true);
|
||||
setDisableVerificationCode("");
|
||||
};
|
||||
|
||||
// Disable MFA
|
||||
const handleDisableMfa = async (): Promise<void> => {
|
||||
if (!disableVerificationCode.trim()) {
|
||||
toast.error("Please enter your verification code to confirm");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Check if elevation is required before disabling MFA
|
||||
if (requiresElevation) {
|
||||
await checkElevation();
|
||||
}
|
||||
|
||||
// Disable MFA by setting activeMfaType to empty string
|
||||
await nhost.auth.verifyChangeUserMfa({
|
||||
activeMfaType: "",
|
||||
code: disableVerificationCode,
|
||||
});
|
||||
|
||||
setIsMfaEnabled(false);
|
||||
setIsDisablingMfa(false);
|
||||
setDisableVerificationCode("");
|
||||
toast.success("MFA has been successfully disabled");
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
toast.error(`Failed to disable MFA: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel MFA setup
|
||||
const handleCancelMfaSetup = (): void => {
|
||||
setIsSettingUpMfa(false);
|
||||
setTotpSecret("");
|
||||
setQrCodeUrl("");
|
||||
setVerificationCode("");
|
||||
};
|
||||
|
||||
// Cancel MFA disable
|
||||
const handleCancelMfaDisable = (): void => {
|
||||
setIsDisablingMfa(false);
|
||||
setDisableVerificationCode("");
|
||||
};
|
||||
|
||||
// Copy secret to clipboard
|
||||
const handleCopySecret = async (): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(totpSecret);
|
||||
toast.success("Secret copied to clipboard");
|
||||
} catch {
|
||||
toast.error("Failed to copy secret");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
<CardTitle>Multi-Factor Authentication</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Status:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{isLoadingStatus ? (
|
||||
<>
|
||||
<Shield className="w-4 h-4 text-muted-foreground animate-pulse" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Loading...
|
||||
</span>
|
||||
</>
|
||||
) : isMfaEnabled ? (
|
||||
<>
|
||||
<ShieldCheck className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-green-500">
|
||||
Enabled
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="w-4 h-4 text-yellow-500" />
|
||||
<span className="text-sm font-medium text-yellow-500">
|
||||
Disabled
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingStatus ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading MFA status...
|
||||
</div>
|
||||
</div>
|
||||
) : isSettingUpMfa ? (
|
||||
<div className="space-y-6">
|
||||
<Alert>
|
||||
<QrCode className="w-4 h-4" />
|
||||
<AlertDescription>
|
||||
Scan the QR code below with your authenticator app (Google
|
||||
Authenticator, Authy, etc.) or manually enter the secret key.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{qrCodeUrl && (
|
||||
<div className="flex justify-center">
|
||||
<div className="p-4 bg-white rounded-lg border">
|
||||
<img
|
||||
src={qrCodeUrl}
|
||||
alt="TOTP QR Code"
|
||||
className="w-48 h-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Or manually enter this secret key:</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 p-2 bg-muted rounded-md font-mono text-sm">
|
||||
{totpSecret}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleCopySecret}>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="verification-code">Verification Code</Label>
|
||||
<Input
|
||||
id="verification-code"
|
||||
type="text"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
placeholder="Enter 6-digit code"
|
||||
maxLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleVerifyTotp}
|
||||
disabled={isLoading || !verificationCode.trim()}
|
||||
>
|
||||
{isLoading ? "Verifying..." : "Verify and Enable"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancelMfaSetup}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : isDisablingMfa ? (
|
||||
<div className="space-y-6">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
To disable Multi-Factor Authentication, please enter the current
|
||||
verification code from your authenticator app.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disable-verification-code">
|
||||
Current Verification Code
|
||||
</Label>
|
||||
<Input
|
||||
id="disable-verification-code"
|
||||
type="text"
|
||||
value={disableVerificationCode}
|
||||
onChange={(e) => setDisableVerificationCode(e.target.value)}
|
||||
placeholder="Enter 6-digit code"
|
||||
maxLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleDisableMfa}
|
||||
disabled={isLoading || !disableVerificationCode.trim()}
|
||||
variant="destructive"
|
||||
>
|
||||
{isLoading ? "Disabling..." : "Confirm Disable"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancelMfaDisable}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Multi-Factor Authentication adds an extra layer of security to
|
||||
your account by requiring a verification code from your
|
||||
authenticator app when signing in.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Separator />
|
||||
|
||||
{isMfaEnabled ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleShowDisableMfa}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Processing..." : "Disable MFA"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleEnableMfa} disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : "Enable MFA"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { startRegistration } from "@simplewebauthn/browser";
|
||||
import { Fingerprint, Info, Plus, Trash } from "lucide-react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useAuth } from "@/providers/auth";
|
||||
import { useNhostClient } from "@/providers/nhost";
|
||||
import { useSecurity } from "@/hooks";
|
||||
|
||||
type SecurityKey = {
|
||||
id: string;
|
||||
nickname?: string | undefined;
|
||||
};
|
||||
|
||||
type SecurityKeysQuery = {
|
||||
authUserSecurityKeys: SecurityKey[];
|
||||
};
|
||||
|
||||
const addSecurityKeySchema = z.object({
|
||||
nickname: z.string().min(1),
|
||||
});
|
||||
|
||||
export default function SecurityKeys() {
|
||||
const { user } = useAuth();
|
||||
const nhost = useNhostClient();
|
||||
|
||||
const [keys, setKeys] = useState<SecurityKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddSecurityKeyDialog, setShowAddSecurityDialog] = useState(false);
|
||||
const { isElevated, checkElevation } = useSecurity();
|
||||
|
||||
const fetchSecurityKeys = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await nhost.graphql.request<SecurityKeysQuery>({
|
||||
query: `
|
||||
query securityKeys($userId: uuid!) {
|
||||
authUserSecurityKeys(where: { userId: { _eq: $userId } }) {
|
||||
id
|
||||
nickname
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { userId: user?.id },
|
||||
});
|
||||
|
||||
if (response.body.data?.authUserSecurityKeys) {
|
||||
setKeys(response.body.data.authUserSecurityKeys || []);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to load security keys: ${error}", error || {});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [nhost.graphql, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id) {
|
||||
fetchSecurityKeys();
|
||||
}
|
||||
}, [user?.id, fetchSecurityKeys]);
|
||||
|
||||
const form = useForm<z.infer<typeof addSecurityKeySchema>>({
|
||||
resolver: zodResolver(addSecurityKeySchema),
|
||||
defaultValues: {
|
||||
nickname: "",
|
||||
},
|
||||
});
|
||||
|
||||
const elevatePermissionIfNeeded = async () => {
|
||||
if (!isElevated && keys.length > 0) {
|
||||
try {
|
||||
await checkElevation();
|
||||
} catch {
|
||||
toast.error("Could not elevate permissions");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, elevatedToken: null }; // return success if already elevated or no keys
|
||||
};
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof addSecurityKeySchema>) => {
|
||||
const { nickname } = values;
|
||||
|
||||
const elevationResult = await elevatePermissionIfNeeded();
|
||||
|
||||
if (!elevationResult || !elevationResult.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const webAuthnOptions = await nhost.auth.addSecurityKey();
|
||||
const credential = await startRegistration(webAuthnOptions.body);
|
||||
const result = await nhost.auth.verifyAddSecurityKey({
|
||||
credential,
|
||||
nickname,
|
||||
});
|
||||
|
||||
if (!result.body) {
|
||||
// toast.error(error?.message)
|
||||
toast.error("Failed to add security key");
|
||||
} else if (result.body) {
|
||||
setKeys((previousKeys) => [...previousKeys, result.body]);
|
||||
setShowAddSecurityDialog(false);
|
||||
form.reset();
|
||||
|
||||
await fetchSecurityKeys();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSecurityKey = async (id: string) => {
|
||||
const elevationResult = await elevatePermissionIfNeeded();
|
||||
|
||||
if (!elevationResult || !elevationResult.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await nhost.graphql.request({
|
||||
query: `
|
||||
mutation removeSecurityKey($id: uuid!) {
|
||||
deleteAuthUserSecurityKey(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id },
|
||||
});
|
||||
|
||||
if (response.body?.errors) {
|
||||
throw new Error("Failed to delete security key");
|
||||
}
|
||||
|
||||
setKeys((prevKeys) => prevKeys.filter((key) => key.id !== id));
|
||||
toast.success("Security key removed successfully");
|
||||
} catch (error) {
|
||||
console.error("Error deleting security key:", error);
|
||||
toast.error("Failed to delete security key");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between p-6">
|
||||
<CardTitle>Security keys</CardTitle>
|
||||
<Button
|
||||
className="m-0"
|
||||
onClick={() => setShowAddSecurityDialog(true)}
|
||||
>
|
||||
<Plus />
|
||||
Add
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading security keys...
|
||||
</div>
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<Alert className="w-full">
|
||||
<Info className="w-4 h-4" />
|
||||
<AlertTitle>No security keys</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
You can add a security key by clicking <b>Add</b>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{!loading && (
|
||||
<div className="space-y-4">
|
||||
{keys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="flex flex-row items-center justify-between w-full px-4 py-2 border rounded-md"
|
||||
>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Fingerprint />
|
||||
<span className="">{key.nickname || key.id}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteSecurityKey(key.id)}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={showAddSecurityKeyDialog}
|
||||
onOpenChange={(open) => setShowAddSecurityDialog(open)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New security key</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col w-full space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nickname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input placeholder="nickname" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit">Create</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import Code from "@/components/ui/code";
|
||||
import { useAuth } from "@/providers/auth";
|
||||
|
||||
export default function UserInfo() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between p-6">
|
||||
<CardTitle>User information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Code code={JSON.stringify(user, null, 2)} language="js" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Home page</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-slate-500">
|
||||
<p>You are authenticated. You have now access to the authorised part of the application.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user