Compare commits

...

6 Commits

Author SHA1 Message Date
Nuno Pato
c130ab4058 Merge branch 'main' into chore/sdk-react-apollo-and-migration-guide 2025-11-16 16:50:43 -01:00
Nuno Pato
66cd621d29 asd 2025-10-02 23:07:30 +00:00
Nuno Pato
d8cd4e10ac Merge branch 'main' into chore/sdk-react-apollo-and-migration-guide 2025-10-02 18:22:57 +00:00
Nuno Pato
7f43e268ce asd 2025-10-02 15:48:31 +00:00
Nuno Pato
4f4b2ab6d1 asd 2025-10-02 00:16:18 +00:00
Nuno Pato
11e8178c72 chore: migrate react-apollo and migration guide for the new JS SDK 2025-10-01 23:15:50 +00:00
160 changed files with 12387 additions and 0 deletions

View File

@@ -438,6 +438,7 @@
{
"group": "Client Libraries",
"pages": [
"reference/migration-guide",
{
"group": "Javascript",
"icon": "js",

File diff suppressed because it is too large Load Diff

View 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'

View 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
```

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

View 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()
})

View File

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

View File

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

View File

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

View 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'

View 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()
})

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
Changez votre adresse courriel

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<h2>V&eacute;rifiez votre courriel</h2>
<p>Utilisez ce lien pour v&eacute;rifier votre courriel:</p>
<p>
<a href="${link}"> V&eacute;rifier courriel </a>
</p>
</body>
</html>

View File

@@ -0,0 +1 @@
Vérifier votre courriel

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<h2>R&eacute;initializer votre mot de passe</h2>
<p>Utilisez ce lien pour r&eacute;initializer votre mot de passe:</p>
<p>
<a href="${link}"> R&eacute;initializer mot de passe </a>
</p>
</body>
</html>

View File

@@ -0,0 +1 @@
Réinitialiser votre mot de passe

View File

@@ -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&ccedil;on s&eacute;curitaire:</p>
<p>
<a href="${link}"> Connexion </a>
</p>
</body>
</html>

View File

@@ -0,0 +1 @@
Lien de connexion sécurisé

View File

@@ -0,0 +1,6 @@
actions: []
custom_types:
enums: []
input_objects: []
objects: []
scalars: []

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
[]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
table:
name: books
schema: public
select_permissions:
- permission:
columns:
- id
- title
filter: {}
role: user

View File

@@ -0,0 +1,3 @@
table:
name: customers
schema: public

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
disabled_for_roles: []

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1 @@
[]

View File

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

View File

@@ -0,0 +1 @@
DROP TABLE "public"."books";

View File

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

View File

@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS "public"."user_id";
DROP TABLE "public"."todos_user_id";

View File

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

View File

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

View File

@@ -0,0 +1 @@
DROP table "public"."books";

View File

@@ -0,0 +1 @@
DROP TABLE "public"."notes";

View File

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

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS public.customers;

View File

@@ -0,0 +1 @@
CREATE TABLE public.customers (id uuid DEFAULT gen_random_uuid() NOT NULL, PRIMARY KEY (id));

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

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

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

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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

View 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

View 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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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