Compare commits
57 Commits
@nhost/das
...
@nhost/nex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
563a37e58d | ||
|
|
bff23720ee | ||
|
|
02cbaeffd2 | ||
|
|
9eb814c79a | ||
|
|
ebc5913bb3 | ||
|
|
4fe4a16964 | ||
|
|
92c475b7a7 | ||
|
|
679b34b031 | ||
|
|
d3186aefbd | ||
|
|
fdecac9d69 | ||
|
|
5077283028 | ||
|
|
f5f662aad1 | ||
|
|
735b779af7 | ||
|
|
4418d6abcf | ||
|
|
049e315c30 | ||
|
|
764597538b | ||
|
|
c8aea785cc | ||
|
|
e0e44b2ff4 | ||
|
|
12280f7c87 | ||
|
|
732a4f40ca | ||
|
|
d67fd599e4 | ||
|
|
a41231927a | ||
|
|
42ec665950 | ||
|
|
7225712a30 | ||
|
|
6593fdd9bb | ||
|
|
40039fece5 | ||
|
|
e5fcfb3cd5 | ||
|
|
218ec314fb | ||
|
|
9367e91d45 | ||
|
|
06c640be2c | ||
|
|
ae45be9816 | ||
|
|
ec4be590d8 | ||
|
|
5c51653aa0 | ||
|
|
7348c15ad1 | ||
|
|
44831e32a7 | ||
|
|
ee0f837762 | ||
|
|
e040979e91 | ||
|
|
68100d63b9 | ||
|
|
9b800046d7 | ||
|
|
807d8574b6 | ||
|
|
77028e4eef | ||
|
|
e0d32aab33 | ||
|
|
75c4c8ae36 | ||
|
|
1d90639e46 | ||
|
|
765b398b21 | ||
|
|
30aae1557c | ||
|
|
a3efc1d131 | ||
|
|
612d754965 | ||
|
|
b2e5f30379 | ||
|
|
3b3e83a218 | ||
|
|
0d5231f1a1 | ||
|
|
1a8332a3ca | ||
|
|
7418105de2 | ||
|
|
425d485f85 | ||
|
|
d8d25b3ea0 | ||
|
|
320513f6f5 | ||
|
|
e622ca0d83 |
54
.github/workflows/changesets.yaml
vendored
@@ -42,6 +42,7 @@ jobs:
|
||||
commit: 'chore: update versions'
|
||||
title: 'chore: update versions'
|
||||
publish: pnpm run release
|
||||
createGithubReleases: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -62,12 +63,39 @@ jobs:
|
||||
uses: ./.github/workflows/dashboard.yaml
|
||||
secrets: inherit
|
||||
|
||||
publish-vercel:
|
||||
name: Publish to Vercel
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Node and dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
with:
|
||||
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ env.TURBO_TEAM }}
|
||||
- name: Setup Vercel CLI
|
||||
run: pnpm add -g vercel
|
||||
- name: Trigger a Vercel deployment
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_VERCEL_PROJECT_ID }}
|
||||
run: |
|
||||
vercel pull --environment=production --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
vercel build --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
vercel deploy --prebuilt --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
|
||||
publish-docker:
|
||||
name: Publish to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test
|
||||
- version
|
||||
- publish-vercel
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
@@ -124,32 +152,6 @@ jobs:
|
||||
if: failure()
|
||||
run: git push --delete origin ${{ env.DASHBOARD_PACKAGE }}@${{ needs.version.outputs.dashboardVersion }}
|
||||
|
||||
publish-vercel:
|
||||
name: Publish to Vercel
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- publish-docker
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Node and dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
with:
|
||||
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ env.TURBO_TEAM }}
|
||||
- name: Setup Vercel CLI
|
||||
run: pnpm add -g vercel
|
||||
- name: Trigger a Vercel deployment
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_VERCEL_PROJECT_ID }}
|
||||
run: |
|
||||
vercel pull --environment=production --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
vercel build --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
vercel deploy --prebuilt --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
|
||||
bump-cli:
|
||||
name: Bump Dashboard version in the Nhost CLI
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 0.20.20
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.36
|
||||
- @nhost/nextjs@1.13.38
|
||||
|
||||
## 0.20.19
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 75c4c8ae3: feat(dashboard): make env value input multiline
|
||||
|
||||
## 0.20.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 425d485f8: fix(dashboard): make sure dedicated resources pricing follows total resources
|
||||
|
||||
## 0.20.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.20.17",
|
||||
"version": "0.20.20",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
|
||||
@@ -128,6 +128,8 @@ export default function BaseEnvironmentVariableForm({
|
||||
error={!!errors.value}
|
||||
helperText={errors?.value?.message}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={5}
|
||||
autoComplete="off"
|
||||
autoFocus={mode === 'edit'}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ArrowRightIcon } from '@/components/ui/v2/icons/ArrowRightIcon';
|
||||
import { Slider, sliderClasses } from '@/components/ui/v2/Slider';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useProPlan } from '@/features/projects/common/hooks/useProPlan';
|
||||
import { calculateBillableResources } from '@/features/projects/resources/settings/utils/calculateBillableResources';
|
||||
import { getAllocatedResources } from '@/features/projects/resources/settings/utils/getAllocatedResources';
|
||||
import { prettifyMemory } from '@/features/projects/resources/settings/utils/prettifyMemory';
|
||||
import { prettifyVCPU } from '@/features/projects/resources/settings/utils/prettifyVCPU';
|
||||
@@ -63,34 +62,7 @@ export default function TotalResourcesFormFragment({
|
||||
(formValues.totalAvailableVCPU / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_PRICE;
|
||||
|
||||
const billableResources = calculateBillableResources(
|
||||
{
|
||||
replicas: formValues.database?.replicas,
|
||||
vcpu: formValues.database?.vcpu,
|
||||
memory: formValues.database?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.hasura?.replicas,
|
||||
vcpu: formValues.hasura?.vcpu,
|
||||
memory: formValues.hasura?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.auth?.replicas,
|
||||
vcpu: formValues.auth?.vcpu,
|
||||
memory: formValues.auth?.memory,
|
||||
},
|
||||
{
|
||||
replicas: formValues.storage?.replicas,
|
||||
vcpu: formValues.storage?.vcpu,
|
||||
memory: formValues.storage?.memory,
|
||||
},
|
||||
);
|
||||
|
||||
const updatedPrice =
|
||||
Math.max(
|
||||
priceForTotalAvailableVCPU,
|
||||
(billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE,
|
||||
) + proPlan.price;
|
||||
const updatedPrice = priceForTotalAvailableVCPU + proPlan.price;
|
||||
|
||||
const { vcpu: allocatedVCPU, memory: allocatedMemory } =
|
||||
getAllocatedResources(formValues);
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# @nhost/docs
|
||||
|
||||
## 0.6.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4fe4a1696: return `refreshToken` immediately after signIn and signUp
|
||||
|
||||
## 0.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 612d75496: updated postgres and graphql documentation
|
||||
- 3b3e83a21: fix(docs): correct rendering of mermaid diagrams in dark mode
|
||||
- 765b398b2: added jit settings documentation
|
||||
- 30aae1557: minor fix to performance documentation
|
||||
- a3efc1d13: docs: added storage/antivirus documentation
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
159
docs/docs/database/extensions.mdx
Normal file
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: 'Extensions'
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
## postgis
|
||||
|
||||
PostGIS extends the capabilities of the PostgreSQL relational database by adding support storing, indexing and querying geographic data.
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION postgis;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION postgis;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [Official website](https://postgis.net)
|
||||
|
||||
## pgvector
|
||||
|
||||
Open-source vector similarity search for Postgres. Store your vectors with the rest of your data. Supports:
|
||||
|
||||
* exact and approximate nearest neighbor search
|
||||
* L2 distance, inner product, and cosine distance
|
||||
* any language with a Postgres client
|
||||
|
||||
Plus ACID compliance, point-in-time recovery, JOINs, and all of the other great features of Postgres
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION vector;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION vector;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [GitHub](https://github.com/pgvector/pgvector)
|
||||
|
||||
## pg_cron
|
||||
|
||||
pg_cron is a simple cron-based job scheduler for PostgreSQL (10 or higher) that runs inside the database as an extension. It uses the same syntax as regular cron, but it allows you to schedule PostgreSQL commands directly from the database. You can also use '[1-59] seconds' to schedule a job based on an interval.
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION pg_cron;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION pg_cron;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [GitHub](https://github.com/citusdata/pg_cron)
|
||||
|
||||
## hypopg
|
||||
|
||||
HypoPG is a PostgreSQL extension adding support for hypothetical indexes.
|
||||
|
||||
An hypothetical -- or virtual -- index is an index that doesn't really exists, and thus doesn't cost CPU, disk or any resource to create. They're useful to know if specific indexes can increase performance for problematic queries, since you can know if PostgreSQL will use these indexes or not without having to spend resources to create them.
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION hypopg;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION hypopg;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [GitHub](https://github.com/HypoPG/hypopg)
|
||||
* [Documentation](https://hypopg.readthedocs.io)
|
||||
|
||||
## timescaledb
|
||||
|
||||
TimescaleDB is an open-source database designed to make SQL scalable for time-series data. It is engineered up from PostgreSQL and packaged as a PostgreSQL extension, providing automatic partitioning across time and space (partitioning key), as well as full SQL support.
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION timescaledb;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION timescaledb;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [GitHub](https://github.com/timescale/timescaledb)
|
||||
* [Documentation](https://docs.timescale.com)
|
||||
* [Website](https://www.timescale.com)
|
||||
|
||||
## pg_stat_statements
|
||||
|
||||
The pg_stat_statements module provides a means for tracking planning and execution statistics of all SQL statements executed by a server.
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION pg_stat_statements;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION pg_stat_statements;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
* [Documentation](https://www.postgresql.org/docs/14/pgstatstatements.html)
|
||||
89
docs/docs/database/performance.mdx
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: 'Performance'
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
Ensuring a healthy and performant PostgreSQL service is crucial as it directly impacts the overall response time and stability of your backend. Since Postgres serves as the centerpiece of your backend, prioritize the optimization and maintenance of your Postgres service to achieve the desired performance and reliability.
|
||||
|
||||
In case your Postgres service is not meeting your performance expectations, you can explore the following options:
|
||||
|
||||
1. Consider upgrading your [dedicated compute](/platform/compute) resources to provide more processing power and memory to the Postgres server.
|
||||
|
||||
2. Fine-tune the configuration parameters of Postgres to optimize its performance. Adjust settings such as `shared_buffers`, `work_mem`, and `effective_cache_size` to better align with your workload and server resources.
|
||||
|
||||
3. Identify and analyze slow-performing queries using tools like query logs or query monitoring extensions. Optimize or rewrite these queries to improve their efficiency.
|
||||
|
||||
4. Evaluate the usage of indexes in your database. Identify queries that could benefit from additional indexes and strategically add them to improve query performance.
|
||||
|
||||
By implementing these steps, you can effectively address performance concerns and enhance the overall performance of your Postgres service.
|
||||
|
||||
## Upgrade to our latest postgres image
|
||||
|
||||
Before trying anything else, always upgrade to our latest postgres image first. You can find our availables images in the dashbhoard, under your database settings.
|
||||
|
||||
## Upgrading dedicated compute
|
||||
|
||||
Increasing CPU and memory is the simplest way to address performance issues. You can read more about compute resources [here](/platform/compute).
|
||||
|
||||
## Fine-tune configuration parameters
|
||||
|
||||
When optimizing your Postgres setup, you can consider adjusting various Postgres settings. You can find a list of these parameters [here](/database/settings). Keep in mind that the optimal values for these parameters will depend on factors such as available resources, workload, and data distribution.
|
||||
|
||||
To help you get started, you can use [pgtune](https://pgtune.leopard.in.ua) as a reference tool. Pgtune can generate recommended configuration settings based on your system specifications. By providing information about your system, it can suggest parameter values that may be a good starting point for optimization.
|
||||
|
||||
However, it's important to note that the generated settings from pgtune are not guaranteed to be the best for your specific environment. It's always recommended to review and customize the suggested settings based on your particular requirements, performance testing, and ongoing monitoring of your Postgres database.
|
||||
|
||||
## Identifying slow queries
|
||||
|
||||
Monitoring slow queries is a highly effective method for tackling performance issues. Several tools leverage [pg_stat_statements](https://www.postgresql.org/docs/14/pgstatstatements.html), a PostgreSQL extension, to provide constant monitoring. You can employ these tools to identify and address slow queries in real-time.
|
||||
|
||||
### pghero
|
||||
|
||||
[PgHero](https://github.com/ankane/pghero) is one of such tools you can use to idenfity and address slow queries. You can easily run pghero alongside your postgres with [Nhost Run](/run):
|
||||
|
||||
1. First, make sure the extension [pg_stat_statements](/database/extensions#pg_stat_statements) is enabled.
|
||||
|
||||
2. Click on this [one-click install link](https://app.nhost.io:/run-one-click-install?config=eyJuYW1lIjoicGdoZXJvIiwiaW1hZ2UiOnsiaW1hZ2UiOiJkb2NrZXIuaW8vYW5rYW5lL3BnaGVybzpsYXRlc3QifSwiY29tbWFuZCI6W10sInJlc291cmNlcyI6eyJjb21wdXRlIjp7ImNwdSI6MTI1LCJtZW1vcnkiOjI1Nn0sInN0b3JhZ2UiOltdLCJyZXBsaWNhcyI6MX0sImVudmlyb25tZW50IjpbeyJuYW1lIjoiREFUQUJBU0VfVVJMIiwidmFsdWUiOiJwb3N0Z3JlczovL3Bvc3RncmVzOltQQVNTV09SRF1AcG9zdGdyZXMtc2VydmljZTo1NDMyL1tTVUJET01BSU5dP3NzbG1vZGU9ZGlzYWJsZSJ9LHsibmFtZSI6IlBHSEVST19VU0VSTkFNRSIsInZhbHVlIjoiW1VTRVJdIn0seyJuYW1lIjoiUEdIRVJPX1BBU1NXT1JEIiwidmFsdWUiOiJbUEFTU1dPUkRdIn1dLCJwb3J0cyI6W3sicG9ydCI6ODA4MCwidHlwZSI6Imh0dHAiLCJwdWJsaXNoIjp0cnVlfV19)
|
||||
|
||||
3. Select your project:
|
||||

|
||||
|
||||
4. Replace the placeholders with your postgres password, subdomain and a user and password to protect your pghero service. Finally, click on create.
|
||||

|
||||
|
||||
5. After confirming the service, copy the URL:
|
||||

|
||||
|
||||
6. Finally, you can open the link you just copied to access pghero:
|
||||
|
||||

|
||||
|
||||
|
||||
:::info
|
||||
When you create a new service, it can take a few minutes for the DNS (Domain Name System) to propagate. If your browser displays an error stating that it couldn't find the server or website, simply wait for a couple of minutes and then try again.
|
||||
:::
|
||||
|
||||
After successfully setting up pghero, it will begin displaying slow queries, suggesting index proposals, and offering other valuable information. Utilize this data to enhance your service's performance.
|
||||
|
||||
## Adding indexes
|
||||
|
||||
Indexes can significantly enhance the speed of data retrieval. However, it's essential to be aware that they introduce additional overhead during mutations. Therefore, understanding your workload is crucial before opting to add an index.
|
||||
|
||||
There are tools you can use to help analyze your workload and detect missing indexes.
|
||||
|
||||
### pghero
|
||||
|
||||
[PgHero](https://github.com/ankane/pghero), in addition to help with slow queries, can also help finding missing and duplicate indexes. See previous section on how to deploy pghero with [Nhost Run](/run).
|
||||
|
||||
### dexter
|
||||
|
||||
[Dexter](https://github.com/ankane/dexter) can leverage both [pg_stat_statements](https://www.postgresql.org/docs/14/pgstatstatements.html) and [hypopg](https://hypopg.readthedocs.io/en/rel1_stable/) to find and evaluate indexes. You can run dexter directly from your machine:
|
||||
|
||||
1. Enable [hypopg](/database/extensions#hypopg)
|
||||
2. Execute the command `docker run --rm -it ankane/dexter [POSTGRES_CONN_STRING] --pg-stat-statements`
|
||||
|
||||
```
|
||||
$ docker run --rm -it ankane/dexter [POSTGRES_CONN_STRING] --pg-stat-statements
|
||||
Processing 1631 new query fingerprints
|
||||
No new indexes found
|
||||
```
|
||||
84
docs/docs/database/settings.mdx
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: 'Settings'
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
Below you can find the official schema (cue) and an example to configure your postgres database:
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="schema" label="schema">
|
||||
|
||||
```cue
|
||||
#Postgres: {
|
||||
version: string | *"14.6-20230705-1"
|
||||
|
||||
// Resources for the service, optional
|
||||
resources?: #Resources & {
|
||||
replicas: 1
|
||||
}
|
||||
|
||||
// postgres settings of the same name in camelCase, optional
|
||||
settings?: {
|
||||
jit: "off" | "on" | *"on"
|
||||
maxConnections: int32 | *100
|
||||
sharedBuffers: string | *"128MB"
|
||||
effectiveCacheSize: string | *"4GB"
|
||||
maintenanceWorkMem: string | *"64MB"
|
||||
checkpointCompletionTarget: number | *0.9
|
||||
walBuffers: int32 | *-1
|
||||
defaultStatisticsTarget: int32 | *100
|
||||
randomPageCost: number | *4.0
|
||||
effectiveIOConcurrency: int32 | *1
|
||||
workMem: string | *"4MB"
|
||||
hugePages: string | *"try"
|
||||
minWalSize: string | *"80MB"
|
||||
maxWalSize: string | *"1GB"
|
||||
maxWorkerProcesses: int32 | *8
|
||||
maxParallelWorkersPerGather: int32 | *2
|
||||
maxParallelWorkers: int32 | *8
|
||||
maxParallelMaintenanceWorkers: int32 | *2
|
||||
}
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="toml" label="toml" default>
|
||||
|
||||
```toml
|
||||
[postgres]
|
||||
version = '14.6-20230925-1'
|
||||
|
||||
[postgres.resources.compute]
|
||||
cpu = 1000
|
||||
memory = 2048
|
||||
|
||||
[postgres.settings]
|
||||
jit = "off"
|
||||
maxConnections = 100
|
||||
sharedBuffers = '256MB'
|
||||
effectiveCacheSize = '768MB'
|
||||
maintenanceWorkMem = '64MB'
|
||||
checkpointCompletionTarget = 0.9
|
||||
walBuffers = -1
|
||||
defaultStatisticsTarget = 100
|
||||
randomPageCost = 1.1
|
||||
effectiveIOConcurrency = 200
|
||||
workMem = '1310kB'
|
||||
hugePages = 'off'
|
||||
minWalSize = '80MB'
|
||||
maxWalSize = '1GB'
|
||||
maxWorkerProcesses = 8
|
||||
maxParallelWorkersPerGather = 2
|
||||
maxParallelWorkers = 8
|
||||
maxParallelMaintenanceWorkers = 2
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
:::info
|
||||
At the time of writing this document postgres settings are only supported via the [configuration file](https://nhost.io/blog/config).
|
||||
:::
|
||||
@@ -144,6 +144,14 @@ One of the most common permission requirements is that authenticated users shoul
|
||||
1. Select the **columns** you want the user to be able to read. In our case, we'll allow the user to read all columns.
|
||||
1. Click **Save**.
|
||||
|
||||
## Known issues
|
||||
|
||||
### Permissions are slow
|
||||
|
||||
In certain situations, permission checks can cause significant delays. One way to identify this issue is by comparing the execution time of a GraphQL query when performed as an admin versus as a regular user. To resolve such cases, disabling the Just-in-Time (JIT) compilation in [Postgres](/database/settings) can be beneficial.
|
||||
|
||||
[Github issue](https://github.com/hasura/graphql-engine/issues/3672)
|
||||
|
||||
## Next Steps
|
||||
|
||||
Hasura has more in-depth documentation related to permissions that you can learn from:
|
||||
|
||||
174
docs/docs/graphql/settings.mdx
Normal file
@@ -0,0 +1,174 @@
|
||||
---
|
||||
title: 'Settings'
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
Below you can find the official schema (cue) and an example to configure your graphql service:
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="schema" label="schema">
|
||||
|
||||
```cue
|
||||
// Configuration for hasura service
|
||||
#Hasura: {
|
||||
// Version of hasura, you can see available versions in the URL below:
|
||||
// https://hub.docker.com/r/hasura/graphql-engine/tags
|
||||
version: string | *"v2.33.4-ce"
|
||||
|
||||
// JWT Secrets configuration
|
||||
jwtSecrets: [#JWTSecret]
|
||||
|
||||
// Admin secret
|
||||
adminSecret: string
|
||||
|
||||
// Webhook secret
|
||||
webhookSecret: string
|
||||
|
||||
// Configuration for hasura services
|
||||
// Reference: https://hasura.io/docs/latest/deployment/graphql-engine-flags/reference/
|
||||
settings: {
|
||||
// HASURA_GRAPHQL_CORS_DOMAIN
|
||||
corsDomain: [...#Url] | *["*"]
|
||||
// HASURA_GRAPHQL_DEV_MODE
|
||||
devMode: bool | *true
|
||||
// HASURA_GRAPHQL_ENABLE_ALLOWLIST
|
||||
enableAllowList: bool | *false
|
||||
// HASURA_GRAPHQL_ENABLE_CONSOLE
|
||||
enableConsole: bool | *true
|
||||
// HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS
|
||||
enableRemoteSchemaPermissions: bool | *false
|
||||
// HASURA_GRAPHQL_ENABLED_APIS
|
||||
enabledAPIs: [...#HasuraAPIs] | *["metadata", "graphql", "pgdump", "config"]
|
||||
|
||||
// HASURA_GRAPHQL_LIVE_QUERIES_MULTIPLEXED_REFETCH_INTERVAL
|
||||
liveQueriesMultiplexedRefetchInterval: uint32 | *1000
|
||||
}
|
||||
|
||||
logs: {
|
||||
// HASURA_GRAPHQL_LOG_LEVEL
|
||||
level: "debug" | "info" | "error" | *"warn"
|
||||
}
|
||||
|
||||
events: {
|
||||
// HASURA_GRAPHQL_EVENTS_HTTP_POOL_SIZE
|
||||
httpPoolSize: uint32 & >=1 & <=100 | *100
|
||||
}
|
||||
|
||||
// Resources for the service
|
||||
resources?: #Resources
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="toml" label="toml" default>
|
||||
|
||||
```toml
|
||||
[hasura]
|
||||
version = ''
|
||||
adminSecret = 'adminsecret'
|
||||
webhookSecret = 'webhooksecret'
|
||||
|
||||
[[hasura.jwtSecrets]]
|
||||
type = 'HS256'
|
||||
key = 'secret'
|
||||
|
||||
[hasura.settings]
|
||||
corsDomain = ['*']
|
||||
devMode = false
|
||||
enableAllowList = true
|
||||
enableConsole = true
|
||||
enableRemoteSchemaPermissions = true
|
||||
enabledAPIs = ['metadata']
|
||||
liveQueriesMultiplexedRefetchInterval = 1000
|
||||
|
||||
[hasura.logs]
|
||||
level = 'warn'
|
||||
|
||||
[hasura.events]
|
||||
httpPoolSize = 10
|
||||
|
||||
[hasura.resources]
|
||||
replicas = 1
|
||||
|
||||
[hasura.resources.compute]
|
||||
cpu = 500
|
||||
memory = 1024
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### JWT Secret
|
||||
|
||||
All formats supported by [hasura](https://hasura.io/docs/latest/auth/authentication/jwt/) should be supported:
|
||||
|
||||
<Tabs groupId="package-manager">
|
||||
<TabItem value="schema" label="schema" default>
|
||||
|
||||
```cue
|
||||
#JWTSecret:
|
||||
({
|
||||
type: "HS384" | "HS512" | "RS256" | "RS384" | "RS512" | "Ed25519" | *"HS256"
|
||||
key: string
|
||||
} |
|
||||
{
|
||||
jwk_url: #Url | *null
|
||||
}) &
|
||||
{
|
||||
claims_format?: "stringified_json" | *"json"
|
||||
audience?: string
|
||||
issuer?: string
|
||||
allowed_skew?: uint32
|
||||
header?: string
|
||||
} & {
|
||||
claims_map?: [...#ClaimMap]
|
||||
|
||||
} &
|
||||
({
|
||||
claims_namespace: string | *"https://hasura.io/jwt/claims"
|
||||
} |
|
||||
{
|
||||
claims_namespace_path: string
|
||||
} | *{})
|
||||
|
||||
#ClaimMap: {
|
||||
claim: string
|
||||
{
|
||||
value: string
|
||||
} | {
|
||||
path: string
|
||||
default?: string
|
||||
}
|
||||
} & {
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="toml" label="toml">
|
||||
|
||||
```toml
|
||||
# example 1
|
||||
[[hasura.jwtSecrets]]
|
||||
type = 'HS256'
|
||||
key = 'secret'
|
||||
|
||||
# example 2
|
||||
[[hasura.jwtSecrets]]
|
||||
jwk_url = 'https:/....'
|
||||
|
||||
# example 3
|
||||
[[hasura.jwtSecrets]]
|
||||
jwk_url = "https://......"
|
||||
issuer = "https://my-auth-server.com"
|
||||
|
||||
[[hasura.jwtSecrets.claims_map]]
|
||||
claim = "x-some-claim"
|
||||
value = "some-value"
|
||||
|
||||
[[hasura.jwtSecrets.claims_map]]
|
||||
claim = "x-other-claim"
|
||||
path = "$.user.claim.id"
|
||||
default = "default-value"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
4
docs/docs/storage/_category_.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"label": "Storage",
|
||||
"position": 7
|
||||
}
|
||||
44
docs/docs/storage/av.mdx
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: Antivirus
|
||||
sidebar_label: Antivirus
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
Integration with [clamav](https://www.clamav.net) antivirus relies on an external [clamd](https://docs.clamav.net/manual/Usage/Scanning.html#clamd) service. When a file is uploaded `hasura-storage` will create the file metadata first and then check if the file is clean with `clamd` via its TCP socket. If the file is clean the rest of the process will continue as usual. If a virus is found details about the virus will be added to the `virus` table and the rest of the process will be aborted.
|
||||
|
||||
``` mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
User ->> storage: upload file
|
||||
storage ->>clamav: check for virus
|
||||
alt virus found
|
||||
storage-->s3: abort upload
|
||||
storage->>graphql: insert row in virus table
|
||||
else virus not found
|
||||
storage->>s3: upload
|
||||
storage->>graphql: update metadata
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
To enable the antivirus you need to follow the next steps:
|
||||
|
||||
|
||||
1. Deploy using [Nhost Run](/run) a dedicated instance of `clamd` with this [one-click install link](https://app.nhost.io:/run-one-click-install?config=eyJuYW1lIjoiY2xhbWF2IiwiaW1hZ2UiOnsiaW1hZ2UiOiJkb2NrZXIuaW8vbmhvc3QvY2xhbWF2OjAuMS4xIn0sImNvbW1hbmQiOltdLCJyZXNvdXJjZXMiOnsiY29tcHV0ZSI6eyJjcHUiOjEwMDAsIm1lbW9yeSI6MjA0OH0sInN0b3JhZ2UiOltdLCJyZXBsaWNhcyI6MX0sImVudmlyb25tZW50IjpbXSwicG9ydHMiOlt7InBvcnQiOiIzMzEwIiwidHlwZSI6InRjcCIsInB1Ymxpc2giOmZhbHNlfV19).
|
||||
2. Select the project:
|
||||

|
||||
3. Click on "Create":
|
||||

|
||||
4. Make sure you are running **at least** storage version 0.4.0 and enable the antivirus:
|
||||

|
||||
5. Wait for the service to update and try to upload a sample virus file like [eicar](https://www.eicar.org/download-anti-malware-testfile/)
|
||||

|
||||
6. If the setup is working the upload should fail
|
||||

|
||||
7. You can also head to hasura and verify entries were added to the `virus` table:
|
||||

|
||||
|
||||
That entry should have useful information about like the filename, the virus found and the user session. In addition, the information on that table can be used a source for events.
|
||||
57
docs/docs/storage/example_crm.mdx
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: "Example: CRM System"
|
||||
sidebar_label: "Example: CRM System"
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
Let's say you want to build a CRM system and you want to store files for customers. This is one way how you could do that.
|
||||
|
||||
Start with, you would have two tables:
|
||||
|
||||
1. `customers` - Customer data.
|
||||
2. `customer_files` - What file belongs to what customer
|
||||
|
||||
```text
|
||||
- customers
|
||||
- id
|
||||
- name
|
||||
- address
|
||||
|
||||
customer_files
|
||||
- id
|
||||
- customer_id (Foreign Key to `customers.id`)
|
||||
- file_id (Foreign Key to `storage.files.id`)
|
||||
```
|
||||
|
||||
You would also create a [Hasura Relationship](https://hasura.io/docs/latest/graphql/core/databases/postgres/schema/table-relationships/index/) (GraphQL relationship) between between `customers` and `customer_files` and between `customer_files` and `storage.files`.
|
||||
|
||||
With the two tables and GraphQL relationships in place, you can query customers and the customer's files like this:
|
||||
|
||||
```graphql
|
||||
query {
|
||||
customers {
|
||||
# customers table
|
||||
id
|
||||
name
|
||||
customer_files {
|
||||
# customer_files table
|
||||
id
|
||||
file {
|
||||
# storage.files table
|
||||
id
|
||||
name
|
||||
size
|
||||
mimeType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The file upload process would be as follows:
|
||||
|
||||
1. Upload a file.
|
||||
2. Get the returned file id.
|
||||
3. Insert (GraphQL Mutation) the file `id` and the customer's `id` into the `customer_files` table.
|
||||
|
||||
This would allow you to upload and download files belonging to specific customers in your CRM system.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 'Storage'
|
||||
sidebar_position: 7
|
||||
title: 'Overview'
|
||||
image: /img/og/storage.png
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
@@ -195,57 +195,3 @@ Image Transformation works on both public and pre-signed URLs.
|
||||
```text
|
||||
https://[subdomain].storage.[region].nhost.run/v1/files/08e6fa32-0880-4d0e-a832-278198acb363?w=500
|
||||
```
|
||||
|
||||
## Example: CRM System
|
||||
|
||||
Let's say you want to build a CRM system and you want to store files for customers. This is one way how you could do that.
|
||||
|
||||
Start with, you would have two tables:
|
||||
|
||||
1. `customers` - Customer data.
|
||||
2. `customer_files` - What file belongs to what customer
|
||||
|
||||
```text
|
||||
- customers
|
||||
- id
|
||||
- name
|
||||
- address
|
||||
|
||||
customer_files
|
||||
- id
|
||||
- customer_id (Foreign Key to `customers.id`)
|
||||
- file_id (Foreign Key to `storage.files.id`)
|
||||
```
|
||||
|
||||
You would also create a [Hasura Relationship](https://hasura.io/docs/latest/graphql/core/databases/postgres/schema/table-relationships/index/) (GraphQL relationship) between between `customers` and `customer_files` and between `customer_files` and `storage.files`.
|
||||
|
||||
With the two tables and GraphQL relationships in place, you can query customers and the customer's files like this:
|
||||
|
||||
```graphql
|
||||
query {
|
||||
customers {
|
||||
# customers table
|
||||
id
|
||||
name
|
||||
customer_files {
|
||||
# customer_files table
|
||||
id
|
||||
file {
|
||||
# storage.files table
|
||||
id
|
||||
name
|
||||
size
|
||||
mimeType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The file upload process would be as follows:
|
||||
|
||||
1. Upload a file.
|
||||
2. Get the returned file id.
|
||||
3. Insert (GraphQL Mutation) the file `id` and the customer's `id` into the `customer_files` table.
|
||||
|
||||
This would allow you to upload and download files belonging to specific customers in your CRM system.
|
||||
@@ -26,6 +26,10 @@ const config = {
|
||||
favicon: 'img/favicon.png',
|
||||
organizationName: 'nhost',
|
||||
projectName: 'docs',
|
||||
markdown: {
|
||||
mermaid: true
|
||||
},
|
||||
themes: ['@docusaurus/theme-mermaid'],
|
||||
scripts: [
|
||||
{ src: 'https://plausible.io/js/script.js', defer: true, 'data-domain': 'docs.nhost.io' }
|
||||
],
|
||||
@@ -44,7 +48,6 @@ const config = {
|
||||
routeBasePath: '/',
|
||||
breadcrumbs: false,
|
||||
sidebarPath: require.resolve('./sidebars.js'),
|
||||
remarkPlugins: [require('mdx-mermaid')],
|
||||
editUrl: 'https://github.com/nhost/nhost/edit/main/docs/'
|
||||
},
|
||||
theme: {
|
||||
@@ -177,6 +180,7 @@ const config = {
|
||||
theme: lightCodeTheme,
|
||||
darkTheme: darkCodeTheme,
|
||||
defaultLanguage: 'javascript',
|
||||
additionalLanguages: ['cue', 'toml'],
|
||||
magicComments: [
|
||||
{
|
||||
className: 'code-block-error-line',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/docs",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
@@ -19,12 +19,13 @@
|
||||
"@docusaurus/core": "2.4.1",
|
||||
"@docusaurus/plugin-sitemap": "2.4.1",
|
||||
"@docusaurus/preset-classic": "2.4.1",
|
||||
"@docusaurus/theme-mermaid": "2.4.1",
|
||||
"@mdx-js/react": "^1.6.22",
|
||||
"clsx": "^1.2.1",
|
||||
"docusaurus-plugin-image-zoom": "^0.1.1",
|
||||
"mdx-mermaid": "^1.3.2",
|
||||
"mermaid": "^9.0.0",
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"unist-util-visit": "^2.0.0"
|
||||
|
||||
@@ -44,7 +44,16 @@ const sidebars = {
|
||||
}
|
||||
]
|
||||
},
|
||||
'storage',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Storage',
|
||||
items: [
|
||||
{
|
||||
type: 'autogenerated',
|
||||
dirName: 'storage'
|
||||
}
|
||||
]
|
||||
},
|
||||
'serverless-functions',
|
||||
{
|
||||
type: 'category',
|
||||
|
||||
2
docs/src/theme/prism-include-languages.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import type * as PrismNamespace from 'prismjs';
|
||||
export default function prismIncludeLanguages(PrismObject: typeof PrismNamespace): void;
|
||||
20
docs/src/theme/prism-include-languages.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/*global globalThis*/
|
||||
import siteConfig from '@generated/docusaurus.config'
|
||||
export default function prismIncludeLanguages(PrismObject) {
|
||||
const {
|
||||
themeConfig: { prism }
|
||||
} = siteConfig
|
||||
const { additionalLanguages } = prism
|
||||
// Prism components work on the Prism instance on the window, while prism-
|
||||
// react-renderer uses its own Prism instance. We temporarily mount the
|
||||
// instance onto window, import components to enhance it, then remove it to
|
||||
// avoid polluting global namespace.
|
||||
// You can mutate PrismObject: registering plugins, deleting languages... As
|
||||
// long as you don't re-assign it
|
||||
globalThis.Prism = PrismObject
|
||||
additionalLanguages.forEach((lang) => {
|
||||
// eslint-disable-next-line global-require, import/no-dynamic-require
|
||||
require(`prismjs/components/prism-${lang}`)
|
||||
})
|
||||
delete globalThis.Prism
|
||||
}
|
||||
BIN
docs/static/img/database/performance/pghero_01.png
vendored
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
docs/static/img/database/performance/pghero_02.png
vendored
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
docs/static/img/database/performance/pghero_03.png
vendored
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
docs/static/img/database/performance/pghero_04.png
vendored
Normal file
|
After Width: | Height: | Size: 389 KiB |
BIN
docs/static/img/storage/av_01.png
vendored
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
docs/static/img/storage/av_02.png
vendored
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
docs/static/img/storage/av_03.png
vendored
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
docs/static/img/storage/av_04.png
vendored
Normal file
|
After Width: | Height: | Size: 539 KiB |
BIN
docs/static/img/storage/av_05.png
vendored
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
docs/static/img/storage/av_06.png
vendored
Normal file
|
After Width: | Height: | Size: 266 KiB |
@@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_NHOST_SUBDOMAIN=local
|
||||
NEXT_PUBLIC_NHOST_REGION=
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
extends: ['../../config/.eslintrc.js', 'plugin:@next/next/recommended'],
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off'
|
||||
}
|
||||
}
|
||||
35
examples/quickstarts/nextjs-server-components/.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
11
examples/quickstarts/nextjs-server-components/CHANGELOG.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# @nhost-examples/nextjs-server-components
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 4fe4a1696: new quickstart project that demonstrates how to use the Nhost SDK with Next.js 13 server components
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/nhost-js@2.2.17
|
||||
70
examples/quickstarts/nextjs-server-components/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Nhost with Next.js Server Components
|
||||
|
||||
This quickstart showcases how to correctly add authentication to a Next.js 13 project using the new App Router and Server Components. The other parts of the SDK (Storage / GraphQL/ Functions) should work the same as before.
|
||||
|
||||
## Authentication
|
||||
|
||||
1. **Saving the auth session**
|
||||
|
||||
To enable authentication with Server Components we have to store the auth session in a cookie. This should be done right after any **signIn** or **signUp** operation. See example [here](https://github.com/nhost/nhost/blob/main/examples/quickstarts/nextjs-server-components/src/app/server-actions/auth/sign-in-email-password.ts).
|
||||
|
||||
2. **Oauth & refresh session middleware**
|
||||
|
||||
Create a middleware at the root of your project that calls the helper method `manageAuthSession`. Feel free to copy paste the the contents of the `/utils` folder to your project. The second argument for `manageAuthSession` is for handling the case where there's an error refreshing the current session with the `refreshToken` stored in the cookie.
|
||||
|
||||
```typescript
|
||||
import { manageAuthSession } from '@utils/nhost'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
return manageAuthSession(request, () =>
|
||||
NextResponse.redirect(new URL('/auth/sign-in', request.url))
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
3. **Protected routes**
|
||||
|
||||
To make sure only authenticated users access some Server Components, wrap them in the Higher Order Server Component `withAuthAsync`.
|
||||
|
||||
```typescript
|
||||
import withAuthAsync from '@utils/auth-guard'
|
||||
|
||||
const MyProtectedServerComponent = async () => {
|
||||
return <h2>Protected</h2>
|
||||
}
|
||||
|
||||
export default withAuthAsync(MyProtectedServerComponent)
|
||||
```
|
||||
|
||||
## Get Started
|
||||
|
||||
1. Clone the repository
|
||||
|
||||
```sh
|
||||
git clone https://github.com/nhost/nhost
|
||||
cd nhost
|
||||
```
|
||||
|
||||
2. Install and build dependencies
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
3. Terminal 1: Start the Nhost Backend
|
||||
|
||||
> Make sure you have the [Nhost CLI installed](https://docs.nhost.io/platform/cli).
|
||||
|
||||
```sh
|
||||
cd examples/quickstarts/nhost-backend
|
||||
nhost up
|
||||
```
|
||||
|
||||
4. Terminal 2: Start the Next.js application
|
||||
|
||||
```sh
|
||||
cd examples/quickstarts/nextjs-server-components
|
||||
pnpm dev
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
serverActions: true
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
35
examples/quickstarts/nextjs-server-components/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@nhost-examples/nextjs-server-components",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.8.2",
|
||||
"@nhost/nhost-js": "workspace:^",
|
||||
"autoprefixer": "10.4.15",
|
||||
"cookies-next": "^3.0.0",
|
||||
"eslint": "8.48.0",
|
||||
"eslint-config-next": "13.4.19",
|
||||
"form-data": "^4.0.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"next": "13.4.19",
|
||||
"postcss": "8.4.29",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"tailwind-merge": "^1.8.0",
|
||||
"tailwindcss": "3.3.3",
|
||||
"typescript": "5.2.2",
|
||||
"xstate": "^4.38.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-cookie": "^3.0.2",
|
||||
"@types/node": "20.5.6",
|
||||
"@types/react": "18.2.21",
|
||||
"@types/react-dom": "18.2.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
||||
|
After Width: | Height: | Size: 629 B |
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import Input from '@components/input'
|
||||
import SubmitButton from '@components/submit-button'
|
||||
import { signIn } from '@server-actions/auth'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function SignInWithEmailAndPassword() {
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function handleSignIn(formData: FormData) {
|
||||
const response = await signIn(formData)
|
||||
|
||||
if (response?.error) {
|
||||
setError(response.error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<h1 className="text-2xl font-semibold text-center">Sign in with email and password</h1>
|
||||
|
||||
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
|
||||
|
||||
<form className="w-full max-w-lg space-y-5" action={handleSignIn}>
|
||||
<Input label="Email" id="email" name="email" type="email" required />
|
||||
|
||||
<Input label="Password" id="password" name="password" type="password" required />
|
||||
|
||||
<SubmitButton type="submit" className="w-full">
|
||||
Sign in
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import Input from '@components/input'
|
||||
import SubmitButton from '@components/submit-button'
|
||||
import { NhostClient } from '@nhost/nhost-js'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
|
||||
const nhost = new NhostClient({
|
||||
subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN,
|
||||
region: process.env.NEXT_PUBLIC_NHOST_REGION
|
||||
})
|
||||
|
||||
export default function SignInMagickLink() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
|
||||
const handleSignIn = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const { error } = await nhost.auth.signIn({ email })
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
} else {
|
||||
setIsSuccess(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<h1 className="text-2xl font-semibold text-center">Sign in with a magick link</h1>
|
||||
|
||||
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
|
||||
{isSuccess && (
|
||||
<p className="mt-3 font-semibold text-center text-green-500">
|
||||
Click the link in the email to finish the sign in process
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form className="w-full max-w-lg space-y-5" onSubmit={handleSignIn}>
|
||||
<Input
|
||||
label="Email"
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<SubmitButton type="submit" className="w-full">
|
||||
Sign In
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
import { signInWithGoogle } from '@server-actions/auth'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function SignIn() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="container flex justify-center">
|
||||
<div className="w-full max-w-lg space-y-5">
|
||||
<h1 className="text-2xl font-semibold text-center">Sign In</h1>
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
|
||||
onClick={() => router.push('/auth/sign-in/email-password')}
|
||||
>
|
||||
with email/password
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
|
||||
onClick={() => router.push('/auth/sign-in/webauthn')}
|
||||
>
|
||||
with a security key
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
|
||||
onClick={() => router.push('/auth/sign-in/magick-link')}
|
||||
>
|
||||
with a magick link
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
|
||||
onClick={() => router.push('/auth/sign-in/pat')}
|
||||
>
|
||||
with a Personal Access Token
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="text-white w-full bg-[#4285F4] hover:bg-[#4285F4]/90 focus:ring-4 focus:outline-none focus:ring-[#4285F4]/50 font-medium rounded-lg px-5 py-2.5 text-center inline-flex items-center justify-between dark:focus:ring-[#4285F4]/55 mr-2 mb-2"
|
||||
onClick={() => signInWithGoogle()}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2 -ml-1"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
data-prefix="fab"
|
||||
data-icon="google"
|
||||
role="img"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 488 512"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z"
|
||||
/>
|
||||
</svg>
|
||||
with Google <span />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import Input from '@components/input'
|
||||
import SubmitButton from '@components/submit-button'
|
||||
import { signInWithPAT } from '@server-actions/auth'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function SignInWithPAT() {
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function handleSignIn(formData: FormData) {
|
||||
const response = await signInWithPAT(formData)
|
||||
|
||||
if (response?.error) {
|
||||
setError(response.error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<h1 className="text-2xl font-semibold text-center">Sign In with Personal Access Token</h1>
|
||||
|
||||
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
|
||||
|
||||
<form className="w-full max-w-lg space-y-5" action={handleSignIn}>
|
||||
<Input label="PAT" id="pat" name="pat" required />
|
||||
<SubmitButton type="submit" className="w-full">
|
||||
Sign In
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import Input from '@components/input'
|
||||
import SubmitButton from '@components/submit-button'
|
||||
import { NhostClient } from '@nhost/nhost-js'
|
||||
import Cookies from 'js-cookie'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
|
||||
const NHOST_SESSION_KEY = 'nhostSession'
|
||||
|
||||
const nhost = new NhostClient({
|
||||
subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN,
|
||||
region: process.env.NEXT_PUBLIC_NHOST_REGION
|
||||
})
|
||||
|
||||
export default function SignInWithSecurityKey() {
|
||||
const router = useRouter()
|
||||
const [error, setError] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
const handleSignIn = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const { session, error } = await nhost.auth.signIn({
|
||||
email,
|
||||
securityKey: true
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
}
|
||||
|
||||
if (session) {
|
||||
Cookies.set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { sameSite: 'strict' })
|
||||
router.push('/protected/todos')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<h1 className="text-2xl font-semibold text-center">Sign In</h1>
|
||||
|
||||
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
|
||||
|
||||
<form className="w-full max-w-lg space-y-5" onSubmit={handleSignIn}>
|
||||
<Input
|
||||
label="Email"
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<SubmitButton type="submit" className="w-full">
|
||||
Sign In
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import Input from '@components/input'
|
||||
import SubmitButton from '@components/submit-button'
|
||||
import { signUp } from '@server-actions/auth'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function SignUpWithEmailAndPassword() {
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function handleSignUp(formData: FormData) {
|
||||
const response = await signUp(formData)
|
||||
|
||||
if (response?.error) {
|
||||
setError(response.error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-semibold text-center">Sign Up</h1>
|
||||
|
||||
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
|
||||
|
||||
<form className="space-y-5" action={handleSignUp}>
|
||||
<Input label="First Name" id="firstName" name="firstName" required />
|
||||
<Input label="Last Name" id="lastName" name="lastName" required />
|
||||
<Input label="Email" id="email" name="email" type="email" required />
|
||||
<Input label="Password" id="password" name="password" type="password" required />
|
||||
<SubmitButton type="submit">Sign Up</SubmitButton>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { signInWithGoogle } from '@server-actions/auth'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function SignUp() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="container flex justify-center">
|
||||
<div className="w-full max-w-lg space-y-5">
|
||||
<h1 className="text-2xl font-semibold text-center">Sign Up</h1>
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
|
||||
onClick={() => router.push('/auth/sign-up/email-password')}
|
||||
>
|
||||
with email/password
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center w-full px-4 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent rounded-lg shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 focus:outline-none"
|
||||
onClick={() => router.push('/auth/sign-up/webauthn')}
|
||||
>
|
||||
with a security key
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="text-white w-full bg-[#4285F4] hover:bg-[#4285F4]/90 focus:ring-4 focus:outline-none focus:ring-[#4285F4]/50 font-medium rounded-lg px-5 py-2.5 text-center inline-flex items-center justify-between dark:focus:ring-[#4285F4]/55 mr-2 mb-2"
|
||||
onClick={() => signInWithGoogle()}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2 -ml-1"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
data-prefix="fab"
|
||||
data-icon="google"
|
||||
role="img"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 488 512"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z"
|
||||
/>
|
||||
</svg>
|
||||
with Google <span />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
import Input from '@components/input'
|
||||
import SubmitButton from '@components/submit-button'
|
||||
import { NhostClient } from '@nhost/nhost-js'
|
||||
import Cookies from 'js-cookie'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
|
||||
const NHOST_SESSION_KEY = 'nhostSession'
|
||||
|
||||
const nhost = new NhostClient({
|
||||
subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN,
|
||||
region: process.env.NEXT_PUBLIC_NHOST_REGION
|
||||
})
|
||||
|
||||
export default function SignUpWebAuthn() {
|
||||
const router = useRouter()
|
||||
|
||||
const [error, setError] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
const handleSignUp = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const { session, error } = await nhost.auth.signUp({
|
||||
email,
|
||||
securityKey: true
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
}
|
||||
|
||||
console.log({
|
||||
handleSignUpSession: session
|
||||
})
|
||||
|
||||
if (session) {
|
||||
Cookies.set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { sameSite: 'strict' })
|
||||
router.push('/protected/todos')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<h1 className="text-2xl font-semibold text-center">Sign Up with a security key</h1>
|
||||
|
||||
{error && <p className="mt-3 font-semibold text-center text-red-500">{error}</p>}
|
||||
|
||||
<form className="w-full max-w-lg space-y-5" onSubmit={handleSignUp}>
|
||||
<Input
|
||||
label="Email"
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<SubmitButton type="submit" className="w-full">
|
||||
Sign Up
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,24 @@
|
||||
import Navigation from '@components/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Create Next App',
|
||||
description: 'Generated by create next app'
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<div className="app">
|
||||
<Navigation />
|
||||
<div className="container p-4 mx-auto mt-8 antialiased">{children}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Home() {
|
||||
return <h1 className="text-2xl text-center">Hi, login/register to get started</h1>
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import withAuth from '@utils/auth-guard'
|
||||
import { getNhost } from '@utils/nhost'
|
||||
|
||||
type EchoResponse = {
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
const Echo = async () => {
|
||||
const nhost = await getNhost()
|
||||
const { res } = await nhost.functions.call<EchoResponse>('echo')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<pre className="overflow-auto">{JSON.stringify(res?.data.headers, null, 2)}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(Echo)
|
||||
@@ -0,0 +1,103 @@
|
||||
import { gql } from '@apollo/client'
|
||||
import PatItem, { type PAT } from '@components/pat-item'
|
||||
import withAuthAsync from '@utils/auth-guard'
|
||||
import { getNhost } from '@utils/nhost'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
|
||||
const PAT = async ({
|
||||
params
|
||||
}: {
|
||||
params: {
|
||||
[key: string]: string | string[] | undefined
|
||||
}
|
||||
}) => {
|
||||
const page = parseInt(params.pagination?.at(0) || '0')
|
||||
const nhost = await getNhost()
|
||||
|
||||
const {
|
||||
data: {
|
||||
authRefreshTokens,
|
||||
authRefreshTokensAggregate: {
|
||||
aggregate: { count }
|
||||
}
|
||||
}
|
||||
} = await nhost.graphql.request(
|
||||
gql`
|
||||
query getPersonalAccessTokens($offset: Int, $limit: Int) {
|
||||
authRefreshTokens(
|
||||
offset: $offset
|
||||
limit: $limit
|
||||
order_by: { createdAt: desc }
|
||||
where: { type: { _eq: pat } }
|
||||
) {
|
||||
id
|
||||
metadata
|
||||
type
|
||||
expiresAt
|
||||
}
|
||||
|
||||
authRefreshTokensAggregate(where: { type: { _eq: pat } }) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
offset: page * 10,
|
||||
limit: 10
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Head>
|
||||
<title>Personal Access Tokens</title>
|
||||
</Head>
|
||||
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h2 className="text-xl">Personal Access Tokens ({count})</h2>
|
||||
|
||||
<Link
|
||||
href={`/protected/pat/new`}
|
||||
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
Add a PAT
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1">
|
||||
{authRefreshTokens.map((token: PAT) => (
|
||||
<li key={token.id}>
|
||||
<PatItem pat={token} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{count > 10 && (
|
||||
<div className="flex justify-center space-x-2">
|
||||
{page > 0 && (
|
||||
<Link
|
||||
href={`/protected/pat/${page - 1}`}
|
||||
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{page + 1 < Math.ceil(count / 10) && (
|
||||
<Link
|
||||
href={`/protected/pat/${page + 1}`}
|
||||
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuthAsync(PAT)
|
||||
@@ -0,0 +1,13 @@
|
||||
import PATForm from '@components/pat-form'
|
||||
import withAuthAsync from '@utils/auth-guard'
|
||||
|
||||
const NewPat = async () => {
|
||||
return (
|
||||
<div className="flex flex-col max-w-3xl mx-auto space-y-4">
|
||||
<h2 className="text-xl">New Personal Access Token</h2>
|
||||
<PATForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuthAsync(NewPat)
|
||||
@@ -0,0 +1,96 @@
|
||||
import { gql } from '@apollo/client'
|
||||
import TodoItem, { type Todo } from '@components/todo-item'
|
||||
import withAuthAsync from '@utils/auth-guard'
|
||||
import { getNhost } from '@utils/nhost'
|
||||
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
|
||||
const Todos = async ({ params }: { params: { [key: string]: string | string[] | undefined } }) => {
|
||||
const page = parseInt(params.pagination?.at(0) || '0')
|
||||
|
||||
const nhost = await getNhost()
|
||||
|
||||
const {
|
||||
data: {
|
||||
todos,
|
||||
todos_aggregate: {
|
||||
aggregate: { count }
|
||||
}
|
||||
}
|
||||
} = await nhost.graphql.request(
|
||||
gql`
|
||||
query getTodos($limit: Int, $offset: Int) {
|
||||
todos(limit: $limit, offset: $offset, order_by: { createdAt: desc }) {
|
||||
id
|
||||
title
|
||||
done
|
||||
attachment {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
todos_aggregate {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
offset: page * 10,
|
||||
limit: 10
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Head>
|
||||
<title>Protected Page</title>
|
||||
</Head>
|
||||
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h2 className="text-xl">Todos ({count})</h2>
|
||||
|
||||
<Link
|
||||
href={`/protected/todos/new`}
|
||||
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
Add Todo
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1">
|
||||
{todos.map((todo: Todo) => (
|
||||
<li key={todo.id}>
|
||||
<TodoItem todo={todo} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{count > 10 && (
|
||||
<div className="flex justify-center space-x-2">
|
||||
{page > 0 && (
|
||||
<Link
|
||||
href={`/protected/todos/${page - 1}`}
|
||||
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{page + 1 < Math.ceil(count / 10) && (
|
||||
<Link
|
||||
href={`/protected/todos/${page + 1}`}
|
||||
className="px-4 py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuthAsync(Todos)
|
||||
@@ -0,0 +1,13 @@
|
||||
import TodoForm from '@components/todo-form'
|
||||
import withAuthAsync from '@utils/auth-guard'
|
||||
|
||||
const NewTodo = async () => {
|
||||
return (
|
||||
<div className="flex flex-col max-w-3xl mx-auto space-y-4">
|
||||
<h2 className="text-xl">New Todo</h2>
|
||||
<TodoForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuthAsync(NewTodo)
|
||||
@@ -0,0 +1,6 @@
|
||||
export { signInWithApple } from './sign-in-apple'
|
||||
export { signIn } from './sign-in-email-password'
|
||||
export { signInWithGoogle } from './sign-in-google'
|
||||
export { signInWithPAT } from './sign-in-pat'
|
||||
export { signOut } from './sign-out'
|
||||
export { signUp } from './sign-up-email-password'
|
||||
@@ -0,0 +1,19 @@
|
||||
'use server'
|
||||
|
||||
import { getNhost } from '@utils/nhost'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const signInWithApple = async () => {
|
||||
const nhost = await getNhost()
|
||||
|
||||
const { providerUrl } = await nhost.auth.signIn({
|
||||
provider: 'apple',
|
||||
options: {
|
||||
redirectTo: `/oauth`
|
||||
}
|
||||
})
|
||||
|
||||
if (providerUrl) {
|
||||
redirect(providerUrl)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'use server'
|
||||
|
||||
import { NHOST_SESSION_KEY, getNhost } from '@utils/nhost'
|
||||
import { cookies } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const signIn = async (formData: FormData) => {
|
||||
const nhost = await getNhost()
|
||||
|
||||
const email = formData.get('email') as string
|
||||
const password = formData.get('password') as string
|
||||
|
||||
const { session, error } = await nhost.auth.signIn({ email, password })
|
||||
|
||||
if (session) {
|
||||
cookies().set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { path: '/' })
|
||||
redirect('/protected/todos')
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
error: error?.message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use server'
|
||||
|
||||
import { getNhost } from '@utils/nhost'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const signInWithGoogle = async () => {
|
||||
const nhost = await getNhost()
|
||||
|
||||
const { providerUrl } = await nhost.auth.signIn({
|
||||
provider: 'google',
|
||||
options: {
|
||||
redirectTo: `/oauth`
|
||||
}
|
||||
})
|
||||
|
||||
if (providerUrl) {
|
||||
redirect(providerUrl)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use server'
|
||||
|
||||
import { NHOST_SESSION_KEY, getNhost } from '@utils/nhost'
|
||||
import { cookies } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const signInWithPAT = async (formData: FormData) => {
|
||||
const nhost = await getNhost()
|
||||
|
||||
const pat = formData.get('pat') as string
|
||||
|
||||
const { session, error } = await nhost.auth.signInPAT(pat)
|
||||
|
||||
if (session) {
|
||||
cookies().set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { path: '/' })
|
||||
redirect('/protected/todos')
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
error: error?.message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use server'
|
||||
|
||||
import { NHOST_SESSION_KEY, getNhost } from '@utils/nhost'
|
||||
import { cookies } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const signOut = async () => {
|
||||
const nhost = await getNhost()
|
||||
|
||||
await nhost.auth.signOut()
|
||||
|
||||
cookies().delete(NHOST_SESSION_KEY)
|
||||
|
||||
redirect('/auth/sign-in')
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use server'
|
||||
|
||||
import { NHOST_SESSION_KEY, getNhost } from '@utils/nhost'
|
||||
import { cookies } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const signUp = async (formData: FormData) => {
|
||||
const nhost = await getNhost()
|
||||
|
||||
const firstName = formData.get('firstName') as string
|
||||
const lastName = formData.get('lastName') as string
|
||||
const email = formData.get('email') as string
|
||||
const password = formData.get('password') as string
|
||||
|
||||
const { session, error } = await nhost.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
displayName: `${firstName} ${lastName}`
|
||||
}
|
||||
})
|
||||
|
||||
if (session) {
|
||||
cookies().set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), { path: '/' })
|
||||
redirect('/protected/todos')
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
error: error?.message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
'use server'
|
||||
|
||||
import { getNhost } from '@utils/nhost'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const createPAT = async (formData: FormData) => {
|
||||
const nhost = await getNhost()
|
||||
|
||||
const name = formData.get('name') as string
|
||||
const expiration = formData.get('expiration') as string
|
||||
const expirationDate = new Date(expiration)
|
||||
|
||||
await nhost.auth.createPAT(expirationDate, { name })
|
||||
|
||||
redirect('/protected/pat')
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use server'
|
||||
|
||||
import { gql } from '@apollo/client'
|
||||
import { getNhost } from '@utils/nhost'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export const deletePAT = async (id: string) => {
|
||||
const nhost = await getNhost()
|
||||
|
||||
await nhost.graphql.request(
|
||||
gql`
|
||||
mutation deletePersonalAccessToken($id: uuid!) {
|
||||
deleteAuthRefreshToken(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
id
|
||||
}
|
||||
)
|
||||
|
||||
revalidatePath('/protected/pat')
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createPAT } from './create-pat'
|
||||
export { deletePAT } from './delete-pat'
|
||||
@@ -0,0 +1,40 @@
|
||||
'use server'
|
||||
|
||||
import { gql } from '@apollo/client'
|
||||
import { getNhost } from '@utils/nhost'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const createTodo = async (formData: FormData) => {
|
||||
const nhost = await getNhost()
|
||||
|
||||
const title = formData.get('title') as string
|
||||
const file = formData.get('file') as File
|
||||
|
||||
let payload: {
|
||||
title: string
|
||||
file_id?: string
|
||||
} = {
|
||||
title
|
||||
}
|
||||
|
||||
if (file) {
|
||||
const { fileMetadata } = await nhost.storage.upload({
|
||||
formData
|
||||
})
|
||||
|
||||
payload.file_id = fileMetadata?.processedFiles[0]?.id
|
||||
}
|
||||
|
||||
await nhost.graphql.request(
|
||||
gql`
|
||||
mutation insertTodo($title: String!, $file_id: uuid) {
|
||||
insert_todos_one(object: { title: $title, file_id: $file_id }) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
payload
|
||||
)
|
||||
|
||||
redirect('/protected/todos')
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use server'
|
||||
|
||||
import { gql } from '@apollo/client'
|
||||
import { getNhost } from '@utils/nhost'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export const deleteTodo = async (id: string) => {
|
||||
const nhost = await getNhost()
|
||||
|
||||
await nhost.graphql.request(
|
||||
gql`
|
||||
mutation deleteTodo($id: uuid!) {
|
||||
delete_todos_by_pk(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
id
|
||||
}
|
||||
)
|
||||
|
||||
revalidatePath('/protected/todos')
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { createTodo } from './create-todo'
|
||||
export { deleteTodo } from './delete-todo'
|
||||
export { updateTodo } from './update-todo'
|
||||
@@ -0,0 +1,27 @@
|
||||
'use server'
|
||||
|
||||
import { gql } from '@apollo/client'
|
||||
import { getNhost } from '@utils/nhost'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export const updateTodo = async (id: string, done: boolean) => {
|
||||
const nhost = await getNhost()
|
||||
|
||||
await nhost.graphql.request(
|
||||
gql`
|
||||
mutation updateTodo($id: uuid!, $done: Boolean!) {
|
||||
update_todos_by_pk(pk_columns: { id: $id }, _set: { done: $done }) {
|
||||
id
|
||||
title
|
||||
done
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
id,
|
||||
done
|
||||
}
|
||||
)
|
||||
|
||||
revalidatePath('/protected/todos')
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import { DetailedHTMLProps, HTMLProps } from 'react'
|
||||
// @ts-ignore
|
||||
import { experimental_useFormStatus as useFormStatus } from 'react-dom'
|
||||
|
||||
export default function Input({
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
label,
|
||||
required,
|
||||
className,
|
||||
...rest
|
||||
}: DetailedHTMLProps<HTMLProps<HTMLInputElement>, HTMLInputElement>) {
|
||||
const { pending } = useFormStatus()
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<label htmlFor={id} className="block mb-1 text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
name={name}
|
||||
required={required}
|
||||
disabled={pending}
|
||||
className="block w-full p-3 border rounded-md border-slate-300 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { signOut } from '@server-actions/auth'
|
||||
import Link from 'next/link'
|
||||
import { getNhost } from '../utils/nhost'
|
||||
import SignOut from './sign-out'
|
||||
|
||||
export default async function Navigation() {
|
||||
const nhost = await getNhost()
|
||||
const user = nhost.auth.getUser()
|
||||
|
||||
const nav = [
|
||||
{
|
||||
href: '/',
|
||||
name: 'Home'
|
||||
},
|
||||
{
|
||||
href: '/protected/todos',
|
||||
name: `${user ? '🔓' : '🔒'} Todos`
|
||||
},
|
||||
{
|
||||
href: '/protected/echo',
|
||||
name: `${user ? '🔓' : '🔒'} Echo`
|
||||
},
|
||||
{
|
||||
href: '/protected/pat',
|
||||
name: `${user ? '🔓' : '🔒'} PAT`
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<header className="bg-indigo-600">
|
||||
<nav className="container mx-auto">
|
||||
<div className="flex items-center justify-between w-full py-4">
|
||||
<div className="flex items-center">
|
||||
<div className="ml-10 space-x-8">
|
||||
{nav.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-lg font-medium text-white hover:text-indigo-50"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-10 space-x-4">
|
||||
{user ? (
|
||||
<SignOut signOut={signOut} />
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/auth/sign-in"
|
||||
className="inline-block px-4 py-2 text-base font-medium text-white bg-indigo-500 border border-transparent rounded-md hover:bg-opacity-75"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/auth/sign-up"
|
||||
className="inline-block px-4 py-2 text-base font-medium text-indigo-600 bg-white border border-transparent rounded-md hover:bg-indigo-50"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import Input from '@components/input'
|
||||
import SubmitButton from '@components/submit-button'
|
||||
import { createPAT } from '@server-actions/pat'
|
||||
|
||||
export default function PATForm() {
|
||||
return (
|
||||
<form className="space-y-4" action={createPAT}>
|
||||
<Input name="name" label="Name" required />
|
||||
|
||||
<Input name="expiration" type="date" required />
|
||||
|
||||
<SubmitButton>Create PAT</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { deletePAT } from '@server-actions/pat'
|
||||
|
||||
export interface PAT {
|
||||
id: string
|
||||
type: string
|
||||
metadata: Record<string, string>
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export default function PatItem({ pat }: { pat: PAT }) {
|
||||
const handleDeleteTodo = async () => {
|
||||
await deletePAT(pat.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-between p-2 bg-slate-100">
|
||||
<div>
|
||||
<span className="justify-center block w-full space-x-2 rounded">{pat.metadata?.name}</span>
|
||||
<span className="justify-center block w-full space-x-2 text-sm rounded">{pat.id}</span>
|
||||
<span className="justify-center block w-full space-x-2 rounded text-slate-500">
|
||||
expires on {new Date(pat.expiresAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button onClick={handleDeleteTodo}>
|
||||
<svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
export default function SignOut({ signOut }: { signOut: () => Promise<void> }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => signOut()}
|
||||
className="inline-block px-4 py-2 text-base font-medium text-white bg-indigo-500 border border-transparent rounded-md hover:bg-opacity-75"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { ButtonHTMLAttributes, DetailedHTMLProps } from 'react'
|
||||
// @ts-ignore
|
||||
import { experimental_useFormStatus as useFormStatus } from 'react-dom'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
type ButtonProps = {
|
||||
type?: 'button' | 'submit' | 'reset' | undefined
|
||||
} & DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
|
||||
|
||||
export default function SubmitButton({
|
||||
disabled,
|
||||
type,
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
const { pending } = useFormStatus()
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
disabled={pending}
|
||||
className={twMerge(
|
||||
pending
|
||||
? 'bg-indigo-200 hover:bg-grey-700'
|
||||
: 'bg-indigo-600 hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500',
|
||||
className,
|
||||
'inline-flex items-center justify-center px-4 py-2 border border-transparent text-base font-medium rounded-md shadow-sm text-white focus:outline-none'
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import Input from '@components/input'
|
||||
import { createTodo } from '@server-actions/todos'
|
||||
import SubmitButton from './submit-button'
|
||||
|
||||
export default function TodoForm() {
|
||||
return (
|
||||
<form action={createTodo} className="flex flex-col space-y-2">
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
required
|
||||
placeholder="What needs to be done"
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<Input id="file" name="file" type="file" className="w-full" accept="image/*" />
|
||||
|
||||
<SubmitButton>Add</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import { NhostClient } from '@nhost/nhost-js'
|
||||
import { deleteTodo, updateTodo } from '@server-actions/todos'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
const nhost = new NhostClient({
|
||||
subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN || 'local',
|
||||
region: process.env.NEXT_PUBLIC_NHOST_REGION
|
||||
})
|
||||
|
||||
export interface Todo {
|
||||
id: string
|
||||
title: string
|
||||
done: boolean
|
||||
attachment: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
const TodoItem = ({ todo }: { todo: Todo }) => {
|
||||
const [completed, setCompleted] = useState(todo.done)
|
||||
|
||||
const handleCheckboxChange = async () => {
|
||||
setCompleted(!completed)
|
||||
await updateTodo(todo.id, !completed)
|
||||
}
|
||||
|
||||
const handleDeleteTodo = async () => {
|
||||
await deleteTodo(todo.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'flex flex-row items-center p-2 bg-slate-100',
|
||||
completed && 'line-through bg-slate-200'
|
||||
)}
|
||||
>
|
||||
<label
|
||||
htmlFor={todo.id}
|
||||
className={twMerge(
|
||||
'block w-full space-x-2 rounded select-none justify-center',
|
||||
completed && 'line-through bg-slate-200'
|
||||
)}
|
||||
>
|
||||
<input type="checkbox" id={todo.id} checked={completed} onChange={handleCheckboxChange} />
|
||||
<span>{todo.title}</span>
|
||||
</label>
|
||||
|
||||
{todo.attachment && (
|
||||
<Link
|
||||
className="w-6 h-6"
|
||||
target="_blank"
|
||||
href={nhost.storage.getPublicUrl({ fileId: todo.attachment.id })}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<button onClick={handleDeleteTodo} className="w-6 h-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TodoItem
|
||||
@@ -0,0 +1,8 @@
|
||||
import { manageAuthSession } from '@utils/nhost'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
return manageAuthSession(request, () =>
|
||||
NextResponse.redirect(new URL('/auth/sign-in', request.url))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getNhost } from '@utils/nhost'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
const withAuthAsync =
|
||||
<P extends {}>(Component: React.FunctionComponent<P>) =>
|
||||
async (props: P) => {
|
||||
const nhost = await getNhost()
|
||||
const session = nhost.auth.getSession()
|
||||
|
||||
if (!session) {
|
||||
redirect('/auth/sign-in')
|
||||
}
|
||||
|
||||
return <Component {...props} />
|
||||
}
|
||||
|
||||
export default withAuthAsync
|
||||
@@ -0,0 +1,58 @@
|
||||
import { AuthErrorPayload, NhostClient, NhostSession } from '@nhost/nhost-js'
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { type StateFrom } from 'xstate/lib/types'
|
||||
import { waitFor } from 'xstate/lib/waitFor'
|
||||
|
||||
export const NHOST_SESSION_KEY = 'nhost-session'
|
||||
|
||||
export const getNhost = async (request?: NextRequest) => {
|
||||
const $cookies = request?.cookies || cookies()
|
||||
|
||||
const nhost = new NhostClient({
|
||||
subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN || 'local',
|
||||
region: process.env.NEXT_PUBLIC_NHOST_REGION,
|
||||
start: false
|
||||
})
|
||||
|
||||
const sessionCookieValue = $cookies.get(NHOST_SESSION_KEY)?.value || ''
|
||||
const initialSession: NhostSession = JSON.parse(atob(sessionCookieValue) || 'null')
|
||||
|
||||
nhost.auth.client.start({ initialSession })
|
||||
await waitFor(nhost.auth.client.interpreter!, (state: StateFrom<any>) => !state.hasTag('loading'))
|
||||
|
||||
return nhost
|
||||
}
|
||||
|
||||
export const manageAuthSession = async (
|
||||
request: NextRequest,
|
||||
onError?: (error: AuthErrorPayload) => NextResponse
|
||||
) => {
|
||||
const nhost = await getNhost(request)
|
||||
const session = nhost.auth.getSession()
|
||||
|
||||
const url = new URL(request.url)
|
||||
const refreshToken = url.searchParams.get('refreshToken') || undefined
|
||||
|
||||
const currentTime = Math.floor(Date.now() / 1000)
|
||||
const tokenExpirationTime = nhost.auth.getDecodedAccessToken()?.exp
|
||||
const accessTokenExpired = session && tokenExpirationTime && currentTime > tokenExpirationTime
|
||||
|
||||
if (accessTokenExpired || refreshToken) {
|
||||
const { session: newSession, error } = await nhost.auth.refreshSession(refreshToken)
|
||||
|
||||
if (error) {
|
||||
onError?.(error)
|
||||
}
|
||||
|
||||
// remove the refreshToken from the url
|
||||
url.searchParams.delete('refreshToken')
|
||||
|
||||
// overwrite the session cookie with the new session
|
||||
return NextResponse.redirect(url, {
|
||||
headers: {
|
||||
'Set-Cookie': `${NHOST_SESSION_KEY}=${btoa(JSON.stringify(newSession))}; Path=/`
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
backgroundImage: {
|
||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||
'gradient-conic':
|
||||
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
export default config
|
||||
31
examples/quickstarts/nextjs-server-components/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@components/*": ["./src/components/*"],
|
||||
"@utils/*": ["./src/utils/*"],
|
||||
"@actions": ["./src/app/actions"],
|
||||
"@server-actions/*": ["./src/app/server-actions/*"],
|
||||
"@types": ["./src/types"],
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
1
examples/quickstarts/nhost-backend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.secrets.nhost
|
||||
10
examples/quickstarts/nhost-backend/functions/echo.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Request, Response } from 'express'
|
||||
import process from 'process'
|
||||
|
||||
export default (req: Request, res: Response) => {
|
||||
return res.status(200).json({
|
||||
headers: req.headers,
|
||||
query: req.query,
|
||||
node: process.version
|
||||
})
|
||||
}
|
||||
15
examples/quickstarts/nhost-backend/functions/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "functions",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.13"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
}
|
||||
78
examples/quickstarts/nhost-backend/functions/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,78 @@
|
||||
lockfileVersion: '6.0'
|
||||
|
||||
devDependencies:
|
||||
'@types/express':
|
||||
specifier: ^4.17.13
|
||||
version: 4.17.13
|
||||
|
||||
packages:
|
||||
|
||||
/@types/body-parser@1.19.3:
|
||||
resolution: {integrity: sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ==}
|
||||
dependencies:
|
||||
'@types/connect': 3.4.36
|
||||
'@types/node': 20.6.3
|
||||
dev: true
|
||||
|
||||
/@types/connect@3.4.36:
|
||||
resolution: {integrity: sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==}
|
||||
dependencies:
|
||||
'@types/node': 20.6.3
|
||||
dev: true
|
||||
|
||||
/@types/express-serve-static-core@4.17.36:
|
||||
resolution: {integrity: sha512-zbivROJ0ZqLAtMzgzIUC4oNqDG9iF0lSsAqpOD9kbs5xcIM3dTiyuHvBc7R8MtWBp3AAWGaovJa+wzWPjLYW7Q==}
|
||||
dependencies:
|
||||
'@types/node': 20.6.3
|
||||
'@types/qs': 6.9.8
|
||||
'@types/range-parser': 1.2.4
|
||||
'@types/send': 0.17.1
|
||||
dev: true
|
||||
|
||||
/@types/express@4.17.13:
|
||||
resolution: {integrity: sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==}
|
||||
dependencies:
|
||||
'@types/body-parser': 1.19.3
|
||||
'@types/express-serve-static-core': 4.17.36
|
||||
'@types/qs': 6.9.8
|
||||
'@types/serve-static': 1.15.2
|
||||
dev: true
|
||||
|
||||
/@types/http-errors@2.0.2:
|
||||
resolution: {integrity: sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg==}
|
||||
dev: true
|
||||
|
||||
/@types/mime@1.3.2:
|
||||
resolution: {integrity: sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==}
|
||||
dev: true
|
||||
|
||||
/@types/mime@3.0.1:
|
||||
resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==}
|
||||
dev: true
|
||||
|
||||
/@types/node@20.6.3:
|
||||
resolution: {integrity: sha512-HksnYH4Ljr4VQgEy2lTStbCKv/P590tmPe5HqOnv9Gprffgv5WXAY+Y5Gqniu0GGqeTCUdBnzC3QSrzPkBkAMA==}
|
||||
dev: true
|
||||
|
||||
/@types/qs@6.9.8:
|
||||
resolution: {integrity: sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==}
|
||||
dev: true
|
||||
|
||||
/@types/range-parser@1.2.4:
|
||||
resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==}
|
||||
dev: true
|
||||
|
||||
/@types/send@0.17.1:
|
||||
resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==}
|
||||
dependencies:
|
||||
'@types/mime': 1.3.2
|
||||
'@types/node': 20.6.3
|
||||
dev: true
|
||||
|
||||
/@types/serve-static@1.15.2:
|
||||
resolution: {integrity: sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw==}
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.2
|
||||
'@types/mime': 3.0.1
|
||||
'@types/node': 20.6.3
|
||||
dev: true
|
||||
11
examples/quickstarts/nhost-backend/functions/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": false
|
||||
}
|
||||
}
|
||||
1
examples/quickstarts/nhost-backend/nhost/config.yaml
Normal file
@@ -0,0 +1 @@
|
||||
version: 3
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Потвърдете смяната на вашия имейл</h2>
|
||||
<p>Използвайте посочения линк, за да повърдите смяната на имейл:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Смени имейл
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Потвърждение за смяна на имейл
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Потвърдете вашия имейл</h2>
|
||||
<p>Използвайте посочения линк, за да потвърдите вашия имейл:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Потвърдете имейл
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Потвърждаване на имейл
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Смяна на парола</h2>
|
||||
<p>Използвайте посочения линк, за да смените вашата парола:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Смяна на парола
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Смяна на парола
|
||||
@@ -0,0 +1 @@
|
||||
Вашият код е ${code}.
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Магически линк за вход</h2>
|
||||
<p>Използвайте посочения линк за защитен и бърз вход:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Вход
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
Магически линк за вход
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Potvrzení změny emailové adresy</h2>
|
||||
<p>Použijte tento odkaz k potvrzení změny emailové adresy:</p>
|
||||
<p>
|
||||
<a href="${link}">
|
||||
Změnit email
|
||||
</a>
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||