Compare commits
103 Commits
@nhost/nex
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe3c462099 | ||
|
|
f8b082cb02 | ||
|
|
e2c4ca85b3 | ||
|
|
0165b998c2 | ||
|
|
5d970cc229 | ||
|
|
69db1594cc | ||
|
|
158cf0da49 | ||
|
|
7992fc3baa | ||
|
|
16d383516e | ||
|
|
29cdf6b125 | ||
|
|
6b67c9996a | ||
|
|
23274dee41 | ||
|
|
a5b55c2667 | ||
|
|
1263676eb3 | ||
|
|
b1b647ad96 | ||
|
|
21bbaf5e95 | ||
|
|
eef9c91403 | ||
|
|
1742cb444d | ||
|
|
c4f374d7f3 | ||
|
|
369ec13070 | ||
|
|
101129eef2 | ||
|
|
228fda0364 | ||
|
|
74085c67a2 | ||
|
|
a273725419 | ||
|
|
c5240f8d74 | ||
|
|
4490068257 | ||
|
|
3601de3f85 | ||
|
|
ac9404610b | ||
|
|
63570db57c | ||
|
|
538ed78f5a | ||
|
|
b1a31ecb00 | ||
|
|
3d151c448c | ||
|
|
bac8ace434 | ||
|
|
fdd417ed25 | ||
|
|
a402fc17de | ||
|
|
4416ceb9cf | ||
|
|
4762ebf61e | ||
|
|
73e28b5831 | ||
|
|
2a7dc5060f | ||
|
|
9b8ede40a9 | ||
|
|
f005c20d99 | ||
|
|
4adfd613b6 | ||
|
|
b6da82c8e3 | ||
|
|
816456edc4 | ||
|
|
deaf0e86d4 | ||
|
|
23f8206f18 | ||
|
|
9dde4d7988 | ||
|
|
26385b9cf9 | ||
|
|
6d318206ef | ||
|
|
4d727b78a1 | ||
|
|
de0a125e98 | ||
|
|
ea1ad29031 | ||
|
|
3da40e5712 | ||
|
|
b9087a4add | ||
|
|
1b7a6d0252 | ||
|
|
1417d3e794 | ||
|
|
e187923858 | ||
|
|
8a60ed4074 | ||
|
|
d7d11a44a7 | ||
|
|
062e4691cd | ||
|
|
a95d49fa2c | ||
|
|
d14fc96899 | ||
|
|
93db718254 | ||
|
|
c367bd58b9 | ||
|
|
0bfed4d9e1 | ||
|
|
1f3aecd379 | ||
|
|
42306ea3bb | ||
|
|
1b12a175f6 | ||
|
|
32060aaea0 | ||
|
|
f94cace5f2 | ||
|
|
5de965d9a5 | ||
|
|
e10b3adc11 | ||
|
|
457db76b06 | ||
|
|
1e952a026e | ||
|
|
2f4c040789 | ||
|
|
74648752b4 | ||
|
|
09d218a3fe | ||
|
|
2e8938dbb0 | ||
|
|
ec60d03536 | ||
|
|
2f3767552f | ||
|
|
bc401c0dd2 | ||
|
|
2145243b19 | ||
|
|
ca012d790c | ||
|
|
aeda14ef53 | ||
|
|
3fa5e2005a | ||
|
|
beadd84adb | ||
|
|
f8f55d2b99 | ||
|
|
03a98d4f3a | ||
|
|
8ed8e04ab6 | ||
|
|
587efd4551 | ||
|
|
a48dd5bf74 | ||
|
|
ef53df5cb3 | ||
|
|
7055ffc37a | ||
|
|
c68ce6d480 | ||
|
|
98a149c8bf | ||
|
|
ceb558975e | ||
|
|
7a87321a7e | ||
|
|
9349766c0a | ||
|
|
31655191a3 | ||
|
|
e3b91efa84 | ||
|
|
cfe736776a | ||
|
|
481bf237cc | ||
|
|
33ce9bf1b9 |
30
.github/workflows/changesets.yaml
vendored
30
.github/workflows/changesets.yaml
vendored
@@ -18,6 +18,7 @@ env:
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: Version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
hasChangesets: ${{ steps.changesets.outputs.hasChangesets }}
|
||||
@@ -60,7 +61,8 @@ jobs:
|
||||
uses: ./.github/workflows/dashboard.yaml
|
||||
secrets: inherit
|
||||
|
||||
publish:
|
||||
publish-docker:
|
||||
name: Publish to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test
|
||||
@@ -120,3 +122,29 @@ jobs:
|
||||
- name: Remove tag on failure
|
||||
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: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.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
|
||||
vercel build --prod
|
||||
vercel deploy --prebuilt --prod
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -48,6 +48,10 @@ todo.md
|
||||
.netlify
|
||||
.monorepo-example
|
||||
|
||||
# Local Vercel folder
|
||||
.vercel
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# TypeDoc output
|
||||
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"eslint.workingDirectories": ["./dashboard"]
|
||||
}
|
||||
|
||||
@@ -99,11 +99,6 @@ You can take a look at the changeset documentation: [How to add a changeset](htt
|
||||
|
||||
You'll notice that `git commit` takes a few seconds to run. We set a commit hook that scans the changes in the code, automatically generates documentation from the inline [TSDoc](https://tsdoc.org/) annotations, and adds these generated documentation files to the commit. They automatically update the [reference documentation](https://docs.nhost.io/reference).
|
||||
|
||||
The document generation script that is run in the pre-commit hook requires to be built first. You may need to run the following command before the commit:
|
||||
|
||||
```sh
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
<!-- ## Good practices
|
||||
- lint
|
||||
|
||||
37
README.md
37
README.md
@@ -179,14 +179,21 @@ Here are some ways of contributing to making Nhost better:
|
||||
<sub><b>Grégory D'Angelo</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/ejkkan">
|
||||
<img src="https://avatars.githubusercontent.com/u/32518962?v=4" width="100;" alt="ejkkan"/>
|
||||
<br />
|
||||
<sub><b>Erik Magnusson</b></sub>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/guicurcio">
|
||||
<img src="https://avatars.githubusercontent.com/u/20285232?v=4" width="100;" alt="guicurcio"/>
|
||||
<br />
|
||||
<sub><b>Guido Curcio</b></sub>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/subatuba21">
|
||||
<img src="https://avatars.githubusercontent.com/u/34824571?v=4" width="100;" alt="subatuba21"/>
|
||||
@@ -221,15 +228,15 @@ Here are some ways of contributing to making Nhost better:
|
||||
<br />
|
||||
<sub><b>Christopher Möller</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</td></tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/GavanWilhite">
|
||||
<img src="https://avatars.githubusercontent.com/u/2085119?v=4" width="100;" alt="GavanWilhite"/>
|
||||
<br />
|
||||
<sub><b>Gavan Wilhite</b></sub>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/FuzzyReason">
|
||||
<img src="https://avatars.githubusercontent.com/u/62517920?v=4" width="100;" alt="FuzzyReason"/>
|
||||
@@ -237,13 +244,6 @@ Here are some ways of contributing to making Nhost better:
|
||||
<sub><b>Vadim Smirnov</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/ejkkan">
|
||||
<img src="https://avatars.githubusercontent.com/u/32518962?v=4" width="100;" alt="ejkkan"/>
|
||||
<br />
|
||||
<sub><b>Erik Magnusson</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/macmac49">
|
||||
<img src="https://avatars.githubusercontent.com/u/831190?v=4" width="100;" alt="macmac49"/>
|
||||
@@ -495,6 +495,13 @@ Here are some ways of contributing to making Nhost better:
|
||||
<sub><b>Quentin Decré</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/elephant3">
|
||||
<img src="https://avatars.githubusercontent.com/u/48279149?v=4" width="100;" alt="elephant3"/>
|
||||
<br />
|
||||
<sub><b>Siarhei Lipchyk</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/altschuler">
|
||||
<img src="https://avatars.githubusercontent.com/u/956928?v=4" width="100;" alt="altschuler"/>
|
||||
@@ -522,15 +529,15 @@ Here are some ways of contributing to making Nhost better:
|
||||
<br />
|
||||
<sub><b>Vadim</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</td></tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/TheRedLancer">
|
||||
<img src="https://avatars.githubusercontent.com/u/58493767?v=4" width="100;" alt="TheRedLancer"/>
|
||||
<br />
|
||||
<sub><b>Zach Burnaby</b></sub>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/komninoschat">
|
||||
<img src="https://avatars.githubusercontent.com/u/29049104?v=4" width="100;" alt="komninoschat"/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['next', 'airbnb', 'airbnb-typescript', 'airbnb/hooks', 'prettier'],
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- eef9c914: feat(dashboard): add Roles and Permissions page
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- a48dd5bf: feat(dashboard): make backend port configurable
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5de965d9: fix(dashboard): alphabetic ordering of providers
|
||||
- b9087a4a: fix(dashboard): console -> dashboard terminology
|
||||
- ca012d79: docs(workos): WorkOS Docs
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
FROM node:16-alpine AS pruner
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
@@ -17,10 +16,13 @@ RUN apk update
|
||||
WORKDIR /app
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NEXT_PUBLIC_NHOST_PLATFORM false
|
||||
ENV NEXT_PUBLIC_NHOST_MIGRATIONS_URL http://localhost:9693
|
||||
ENV NEXT_PUBLIC_NHOST_HASURA_URL http://localhost:9695
|
||||
ENV NEXT_PUBLIC_ENV dev
|
||||
ENV NEXT_PUBLIC_NHOST_PLATFORM false
|
||||
|
||||
# placeholders for ports, will be replaced on runtime by entrypoint script
|
||||
ENV NEXT_PUBLIC_NHOST_MIGRATIONS_PORT __NEXT_PUBLIC_NHOST_MIGRATIONS_PORT__
|
||||
ENV NEXT_PUBLIC_NHOST_HASURA_PORT __NEXT_PUBLIC_NHOST_HASURA_PORT__
|
||||
ENV NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT __NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT__
|
||||
|
||||
RUN yarn global add pnpm@7.17.0
|
||||
COPY .gitignore .gitignore
|
||||
@@ -40,11 +42,14 @@ RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
USER nextjs
|
||||
|
||||
COPY --from=builder /app/dashboard/next.config.js .
|
||||
COPY --from=builder /app/dashboard/package.json .
|
||||
COPY --from=builder /app/dashboard/public ./dashboard/public
|
||||
COPY --chown=nextjs:nodejs dashboard/docker-entrypoint.sh .
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/next.config.js .
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/package.json .
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/public ./dashboard/public
|
||||
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/.next/standalone/app ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/.next/static ./dashboard/.next/static
|
||||
|
||||
CMD node dashboard/server.js
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
CMD ["node", "dashboard/server.js"]
|
||||
|
||||
@@ -30,31 +30,27 @@ First, you need to run the following command to start your backend locally:
|
||||
cd <your_nhost_project> && nhost dev
|
||||
```
|
||||
|
||||
Two environment variables are required to connect the Nhost Dashboard to your local backend:
|
||||
You can connect the Nhost Dashboard to your locally running backend by setting the following environment variables in `.env.development.local`:
|
||||
|
||||
- `NEXT_PUBLIC_NHOST_PLATFORM` should be set to `false`, because otherwise the Nhost Dashboard will try to connect to the Nhost platform.
|
||||
- `NEXT_PUBLIC_NHOST_MIGRATIONS_URL` should be set to `http://localhost:9693` unless Hasura is configured to run on a different port. This is the URL of Hasura's migrations endpoint.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
```bash
|
||||
NEXT_PUBLIC_ENV=dev
|
||||
NEXT_PUBLIC_NHOST_PLATFORM=false
|
||||
NEXT_PUBLIC_NHOST_MIGRATIONS_URL=http://localhost:9693
|
||||
```
|
||||
|
||||
### Full list of environment variables
|
||||
|
||||
| Name | Description |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| `NEXT_PUBLIC_NHOST_PLATFORM` | This should be set to `false` to connect the Nhost Dashboard to a locally running Nhost backend. |
|
||||
| `NEXT_PUBLIC_NHOST_MIGRATIONS_URL` | URL of Hasura's migrations endpoint. Used only if local development is enabled. |
|
||||
| `NEXT_PUBLIC_NHOST_HASURA_URL` | URL of the Hasura Console. Used only when `NEXT_PUBLIC_ENV` is `dev`. |
|
||||
| `NEXT_PUBLIC_ENV` | `dev`, `staging` or `prod`. Should be set to `dev` in most cases. |
|
||||
| `NEXT_PUBLIC_NHOST_BACKEND_URL` | Backend URL. Not necessary for local development. |
|
||||
| `NEXT_PUBLIC_STRIPE_PK` | Stripe public key. Not necessary for local development. |
|
||||
| `NEXT_PUBLIC_GITHUB_APP_INSTALL_URL` | URL of the GitHub application. Not necessary for local development. |
|
||||
| `NEXT_PUBLIC_ANALYTICS_WRITE_KEY` | Analytics key. Not necessary for local development. |
|
||||
| `NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET` | URL of the Bragi websocket. Not necessary for local development. |
|
||||
| Name | Description |
|
||||
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_ENV` | `dev`, `staging` or `prod`. Should be set to `dev` in most cases. |
|
||||
| `NEXT_PUBLIC_NHOST_PLATFORM` | This should be set to `false` to connect the Nhost Dashboard to a locally running Nhost backend. Setting this to `true` turns off local development. |
|
||||
| `NEXT_PUBLIC_NHOST_LOCAL_MIGRATIONS_PORT` | Custom port that was passed to the CLI. Used only if local development is enabled. Default: `9693` |
|
||||
| `NEXT_PUBLIC_NHOST_LOCAL_HASURA_PORT` | Custom port that was passed to the CLI. Used only if local development is enabled and `NEXT_PUBLIC_ENV` is `dev`. Default: `9695` |
|
||||
| `NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT` | Custom port that was passed to the CLI. Used only if local development is enabled. Default: `1337` |
|
||||
| `NEXT_PUBLIC_NHOST_BACKEND_URL` | Backend URL. Not necessary for local development. |
|
||||
| `NEXT_PUBLIC_STRIPE_PK` | Stripe public key. Not necessary for local development. |
|
||||
| `NEXT_PUBLIC_GITHUB_APP_INSTALL_URL` | URL of the GitHub application. Not necessary for local development. |
|
||||
| `NEXT_PUBLIC_ANALYTICS_WRITE_KEY` | Analytics key. Not necessary for local development. |
|
||||
| `NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET` | URL of the Bragi websocket. Not necessary for local development. |
|
||||
|
||||
## ESLint Rules
|
||||
|
||||
@@ -67,6 +63,7 @@ NEXT_PUBLIC_NHOST_MIGRATIONS_URL=http://localhost:9693
|
||||
| `import/extensions` | JS / TS files should be imported without file extensions. |
|
||||
| `react/jsx-filename-extension` | JSX should only appear in `.jsx` and `.tsx` files. |
|
||||
| `react/jsx-no-bind` | Further investigation must be made on the performance impact of functions directly passed as props to components. |
|
||||
| `import/order` | Until we have a better auto-formatter, we disable this rule. |
|
||||
| `import/no-extraneous-dependencies` | `devDependencies` should be excluded from the list of disallowed imports. |
|
||||
| `curly` | By default it only enforces curly braces for multi-line blocks, but it should be enforced for single-line blocks as well. |
|
||||
| `no-restricted-exports` | `export { default } from './module'` is used heavily in `@/ui/v2` which is a restricted export by default. |
|
||||
|
||||
15
dashboard/docker-entrypoint.sh
Executable file
15
dashboard/docker-entrypoint.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
# read ports from env variables or use defaults
|
||||
NEXT_PUBLIC_NHOST_MIGRATIONS_PORT="${NEXT_PUBLIC_NHOST_MIGRATIONS_PORT:=9693}"
|
||||
NEXT_PUBLIC_NHOST_HASURA_PORT="${NEXT_PUBLIC_NHOST_HASURA_PORT:=9695}"
|
||||
NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT="${NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT:=1337}"
|
||||
|
||||
# replace placeholders
|
||||
find dashboard -type f -exec sed -i "s/__NEXT_PUBLIC_NHOST_MIGRATIONS_PORT__/${NEXT_PUBLIC_NHOST_MIGRATIONS_PORT}/g" {} +
|
||||
find dashboard -type f -exec sed -i "s/__NEXT_PUBLIC_NHOST_HASURA_PORT__/${NEXT_PUBLIC_NHOST_HASURA_PORT}/g" {} +
|
||||
find dashboard -type f -exec sed -i "s/__NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT__/${NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT}/g" {} +
|
||||
|
||||
exec "$@"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.4.2",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -8,7 +8,7 @@
|
||||
"build": "next build --no-lint",
|
||||
"analyze": "ANALYZE=true pnpm build --no-lint",
|
||||
"start": "next start",
|
||||
"lint": "next lint --max-warnings 6",
|
||||
"lint": "next lint --max-warnings 3",
|
||||
"test": "vitest",
|
||||
"codegen": "graphql-codegen --config graphql.config.yaml --errors-only",
|
||||
"nhost:dev": "nhost dev -d",
|
||||
|
||||
1
dashboard/public/assets/twilio.svg
Normal file
1
dashboard/public/assets/twilio.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="21" height="21" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.08 0c5.578 0 10.08 4.507 10.08 10.09 0 5.584-4.502 10.09-10.08 10.09A10.072 10.072 0 0 1 0 10.09C0 4.507 4.503 0 10.08 0Zm0 2.69a7.375 7.375 0 0 0-7.392 7.4c0 4.104 3.293 7.4 7.392 7.4 4.1 0 7.392-3.296 7.392-7.4 0-4.103-3.293-7.4-7.392-7.4Zm-2.486 7.804c1.142 0 2.083.942 2.083 2.085 0 1.144-.94 2.086-2.083 2.086a2.095 2.095 0 0 1-2.083-2.086c0-1.143.94-2.085 2.083-2.085Zm4.973 0c1.142 0 2.083.942 2.083 2.085 0 1.144-.94 2.086-2.083 2.086a2.095 2.095 0 0 1-2.084-2.086c0-1.143.941-2.085 2.084-2.085Zm0-4.978c1.142 0 2.083.942 2.083 2.085 0 1.144-.94 2.086-2.083 2.086A2.095 2.095 0 0 1 10.483 7.6c0-1.143.941-2.085 2.084-2.085Zm-4.973 0c1.142 0 2.083.942 2.083 2.085 0 1.144-.94 2.086-2.083 2.086A2.095 2.095 0 0 1 5.51 7.6c0-1.143.94-2.085 2.083-2.085Z" fill="#F22F46"/></svg>
|
||||
|
After Width: | Height: | Size: 869 B |
@@ -5,6 +5,7 @@ import Button from '@/ui/v2/Button';
|
||||
import ArrowSquareOutIcon from '@/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import Link from '@/ui/v2/Link';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { LOCAL_HASURA_URL } from '@/utils/env';
|
||||
import { generateRemoteAppUrl } from '@/utils/helpers';
|
||||
import Image from 'next/image';
|
||||
|
||||
@@ -24,7 +25,7 @@ export function HasuraData({ close }: HasuraDataProps) {
|
||||
|
||||
const hasuraUrl =
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? process.env.NEXT_PUBLIC_NHOST_HASURA_URL || 'http://localhost:9695'
|
||||
? LOCAL_HASURA_URL
|
||||
: generateRemoteAppUrl(currentApplication.subdomain);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { SelectorOption } from '@/ui/Selector';
|
||||
import Selector from '@/ui/Selector';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Toggle } from '@/ui/Toggle';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface PermissionSettingsProps {
|
||||
text: string;
|
||||
desc?: string;
|
||||
toggle?: boolean;
|
||||
onChange?: any;
|
||||
checked?: boolean;
|
||||
options?: any;
|
||||
value?: SelectorOption;
|
||||
} // @TODO: Fix alt attribute on images.
|
||||
// @FIX: Double border
|
||||
|
||||
export function PermissionSetting({
|
||||
text,
|
||||
desc,
|
||||
toggle,
|
||||
checked = false,
|
||||
onChange,
|
||||
options,
|
||||
value,
|
||||
}: PermissionSettingsProps) {
|
||||
return (
|
||||
<div className="flex flex-row place-content-between py-2">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col space-y-1 self-center px-0.5',
|
||||
!desc && 'py-3.5',
|
||||
desc && 'py-2',
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
size="normal"
|
||||
className="font-medium"
|
||||
color="greyscaleDark"
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
{desc && (
|
||||
<Text
|
||||
variant="body"
|
||||
size="tiny"
|
||||
className="font-normal"
|
||||
color="greyscaleDark"
|
||||
>
|
||||
{desc}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{toggle ? (
|
||||
<div className="flex flex-row">
|
||||
<Toggle checked={checked} onChange={onChange} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-row self-center">
|
||||
<Selector
|
||||
width="w-28"
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { Provider as ProviderType } from '@/types/providers';
|
||||
import Status, { StatusEnum } from '@/ui/Status';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ChevronRightIcon } from '@heroicons/react/solid';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
interface ProviderProps {
|
||||
provider: ProviderType;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function Provider({ provider, enabled }: ProviderProps) {
|
||||
const { name, logo } = provider;
|
||||
|
||||
const {
|
||||
query: { workspaceSlug, appSlug },
|
||||
} = useRouter();
|
||||
|
||||
const nameLowerCase = name.toLowerCase();
|
||||
return (
|
||||
<Link
|
||||
href={`/${workspaceSlug}/${appSlug}/settings/sign-in-methods/${nameLowerCase}`}
|
||||
passHref
|
||||
>
|
||||
<a
|
||||
href={`${workspaceSlug}/${appSlug}/settings/sign-in-methods/${nameLowerCase}`}
|
||||
className="flex cursor-pointer flex-row place-content-between border-t py-2.5"
|
||||
>
|
||||
<div className="grid grid-flow-col items-center gap-2">
|
||||
<div className="h-6 w-6">
|
||||
<Image
|
||||
src={logo}
|
||||
alt={`Logo of ${name}`}
|
||||
width={24}
|
||||
height={24}
|
||||
layout="responsive"
|
||||
/>
|
||||
</div>
|
||||
<Text className="font-medium" color="greyscaleDark" size="normal">
|
||||
{name}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
{enabled ? (
|
||||
<Status status={StatusEnum.Live}>Enabled</Status>
|
||||
) : (
|
||||
<Status status={StatusEnum.Closed}>Disabled</Status>
|
||||
)}
|
||||
<ChevronRightIcon className="ml-2 h-4 w-4 cursor-pointer self-center" />
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default Provider;
|
||||
@@ -1,183 +0,0 @@
|
||||
import { CreateUserRoleModal } from '@/components/applications/users/roles/CreateRoleModal';
|
||||
import { EditUserRoleModal } from '@/components/applications/users/roles/EditUserRoleModal';
|
||||
import Lock from '@/components/icons/Lock';
|
||||
import type { GetRolesQuery } from '@/generated/graphql';
|
||||
import { Modal } from '@/ui';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ChevronRightIcon } from '@heroicons/react/solid';
|
||||
import clsx from 'clsx';
|
||||
import type { Dispatch, MouseEvent, MouseEventHandler } from 'react';
|
||||
import { useReducer } from 'react';
|
||||
|
||||
function RolesTableHead() {
|
||||
return (
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-64 py-3 text-left font-medium text-base">
|
||||
<Text className="text-xs font-bold text-greyscaleDark">Role</Text>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
interface UserRoleProps {
|
||||
role: string;
|
||||
isSystemRole: boolean;
|
||||
onClick?: MouseEventHandler<HTMLTableRowElement>;
|
||||
}
|
||||
|
||||
function UserRole({ role, isSystemRole, onClick }: UserRoleProps) {
|
||||
return (
|
||||
<tr
|
||||
className={clsx(isSystemRole ? 'cursor-not-allowed' : 'cursor-pointer')}
|
||||
onClick={onClick}
|
||||
>
|
||||
<td className="py-2">
|
||||
<Text
|
||||
size="normal"
|
||||
className={clsx(
|
||||
isSystemRole ? 'text-greyscaleGrey' : 'text-greyscaleDark',
|
||||
'pl-1 font-medium',
|
||||
)}
|
||||
>
|
||||
{role}
|
||||
</Text>
|
||||
</td>
|
||||
<td className="text-right">
|
||||
{isSystemRole ? (
|
||||
<div className="inline-flex pr-1">
|
||||
<Text
|
||||
size="tiny"
|
||||
className=" font-mono text-xs font-medium uppercase tracking-wide text-greyscaleGrey"
|
||||
>
|
||||
System Role
|
||||
</Text>
|
||||
<Lock className="ml-1 h-5 w-5 text-greyscaleGrey" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex self-center py-2 pr-1.5">
|
||||
<ChevronRightIcon className="h-4.5 w-4.5 text-greyscaleDark" />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export type UserRoleDetails = {
|
||||
name: string;
|
||||
isSystemRole: boolean;
|
||||
};
|
||||
|
||||
export const getUserRoles = (data): UserRoleDetails[] => {
|
||||
const authUserDefaultAllowedRoles =
|
||||
data.app.authUserDefaultAllowedRoles.split(',');
|
||||
|
||||
return authUserDefaultAllowedRoles.map((role: string) => ({
|
||||
name: role,
|
||||
isSystemRole: ['user', 'me'].includes(role),
|
||||
}));
|
||||
};
|
||||
|
||||
type ModalState = {
|
||||
visible: boolean;
|
||||
type: 'create' | 'edit';
|
||||
payload: UserRoleDetails;
|
||||
};
|
||||
|
||||
type ModalAction = {
|
||||
type: 'OPEN_CREATE_MODAL' | 'OPEN_EDIT_MODAL' | 'CLOSE_MODAL';
|
||||
payload?: UserRoleDetails;
|
||||
};
|
||||
|
||||
function modalStateReducer(state: ModalState, action: ModalAction): ModalState {
|
||||
switch (action.type) {
|
||||
case 'OPEN_CREATE_MODAL':
|
||||
return { ...state, visible: true, type: 'create', payload: null };
|
||||
case 'OPEN_EDIT_MODAL':
|
||||
return { ...state, visible: true, type: 'edit', payload: action.payload };
|
||||
case 'CLOSE_MODAL':
|
||||
return { ...state, visible: false };
|
||||
default:
|
||||
throw new Error(`Action type ${action.type} is not supported.`);
|
||||
}
|
||||
}
|
||||
|
||||
function AddNewUserRole({ dispatch }: { dispatch: Dispatch<ModalAction> }) {
|
||||
return (
|
||||
<tr className="cursor-pointer border-y-1 border-solid border-gray-300">
|
||||
<td className="p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: 'OPEN_CREATE_MODAL' })}
|
||||
>
|
||||
<Text className="text-sm+ font-medium text-blue">
|
||||
Create New Role
|
||||
</Text>
|
||||
</button>
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function RolesTableBody({ data }: { data: GetRolesQuery }) {
|
||||
const userRoles = getUserRoles(data);
|
||||
const [
|
||||
{ visible: modalVisible, type: modalType, payload: modalPayload },
|
||||
dispatch,
|
||||
] = useReducer(modalStateReducer, {
|
||||
visible: false,
|
||||
type: null,
|
||||
payload: null,
|
||||
});
|
||||
|
||||
function handleRoleEdit(event: MouseEvent<HTMLTableRowElement>, role: any) {
|
||||
dispatch({ type: 'OPEN_EDIT_MODAL', payload: role });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
showModal={modalVisible}
|
||||
close={() => dispatch({ type: 'CLOSE_MODAL' })}
|
||||
>
|
||||
{modalType === 'create' ? (
|
||||
<CreateUserRoleModal
|
||||
onClose={() => dispatch({ type: 'CLOSE_MODAL' })}
|
||||
/>
|
||||
) : (
|
||||
<EditUserRoleModal
|
||||
onClose={() => dispatch({ type: 'CLOSE_MODAL' })}
|
||||
payload={modalPayload}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
<tbody className="divide-y-1 border-t-1 border-b-1 border-solid border-gray-300 ">
|
||||
{userRoles.map((role) => (
|
||||
<UserRole
|
||||
key={role.name}
|
||||
role={role.name}
|
||||
isSystemRole={role.isSystemRole}
|
||||
onClick={
|
||||
role.isSystemRole
|
||||
? undefined
|
||||
: (event) => handleRoleEdit(event, role)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<AddNewUserRole dispatch={dispatch} />
|
||||
</tbody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RolesTable({ data }: { data: GetRolesQuery }) {
|
||||
return (
|
||||
<table className="w-full table-fixed overflow-x-auto">
|
||||
<RolesTableHead />
|
||||
<RolesTableBody data={data} />
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { TextProps } from '@/ui/Text';
|
||||
import { Text } from '@/ui/Text';
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface SettingsSectionProps {
|
||||
/**
|
||||
* Title of this section.
|
||||
*/
|
||||
title: ReactNode;
|
||||
/**
|
||||
* Props to be passed to the title component.
|
||||
*/
|
||||
titleProps?: TextProps;
|
||||
/**
|
||||
* Props to be passed to the wrapper component.
|
||||
*/
|
||||
wrapperProps?: TextProps;
|
||||
/**
|
||||
* Description of this section.
|
||||
*/
|
||||
desc?: ReactNode;
|
||||
/**
|
||||
* Props to be passed to the description component.
|
||||
*/
|
||||
descriptionProps?: TextProps;
|
||||
}
|
||||
|
||||
export function SettingsSection({
|
||||
children,
|
||||
title,
|
||||
titleProps,
|
||||
descriptionProps,
|
||||
desc,
|
||||
wrapperProps,
|
||||
}: PropsWithChildren<SettingsSectionProps>) {
|
||||
const { className: titleClassName, ...restTitleProps } = titleProps || {};
|
||||
const { className: wrapperClassName } = wrapperProps || {};
|
||||
const { className: descriptionClassName, ...restDescriptionProps } =
|
||||
descriptionProps || {};
|
||||
|
||||
return (
|
||||
<div className={twMerge('mt-10', wrapperClassName)}>
|
||||
<div className="mx-auto font-display">
|
||||
<div className="flex flex-col place-content-between">
|
||||
<div>
|
||||
<Text
|
||||
size="large"
|
||||
variant="heading"
|
||||
className={twMerge('mb-1.5 font-medium', titleClassName)}
|
||||
color="greyscaleDark"
|
||||
{...restTitleProps}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{desc && (
|
||||
<Text
|
||||
variant="body"
|
||||
size="normal"
|
||||
color="greyscaleDark"
|
||||
className={twMerge('mb-3 font-normal', descriptionClassName)}
|
||||
{...restDescriptionProps}
|
||||
>
|
||||
{desc}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useWorkspaceContext } from '@/context/workspace-context';
|
||||
import useCustomClaims from '@/hooks/useCustomClaims';
|
||||
import type { CustomClaim } from '@/types/application';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import {
|
||||
refetchGetAppCustomClaimsQuery,
|
||||
useUpdateAppMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import type {
|
||||
CreatePermissionVariableBaseFormData,
|
||||
CreatePermissionVariableModalBaseProps,
|
||||
} from './CreatePermissionVariableModalBase';
|
||||
import CreatePermissionVariableModalBase from './CreatePermissionVariableModalBase';
|
||||
|
||||
export type CreatePermissionVariableFormData =
|
||||
CreatePermissionVariableBaseFormData;
|
||||
|
||||
export type CreatePermissionVariableModalProps = Pick<
|
||||
CreatePermissionVariableModalBaseProps,
|
||||
'onClose'
|
||||
>;
|
||||
|
||||
export default function CreatePermissionVariableModal({
|
||||
onClose,
|
||||
}: CreatePermissionVariableModalProps) {
|
||||
const [error, setError] = useState<Error>();
|
||||
|
||||
const form = useForm<CreatePermissionVariableFormData>({
|
||||
reValidateMode: 'onSubmit',
|
||||
});
|
||||
|
||||
const {
|
||||
workspaceContext: { appId },
|
||||
} = useWorkspaceContext();
|
||||
|
||||
const { data: customClaims } = useCustomClaims({ appId });
|
||||
|
||||
const [updateApp] = useUpdateAppMutation({
|
||||
refetchQueries: [refetchGetAppCustomClaimsQuery({ id: appId })],
|
||||
});
|
||||
|
||||
async function handleSubmit(permissionVariable: CustomClaim) {
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
if (
|
||||
customClaims.some(
|
||||
(claim) =>
|
||||
claim.key.toLowerCase() === permissionVariable.key.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Permission variable with this field name already exists.',
|
||||
);
|
||||
}
|
||||
|
||||
await updateApp({
|
||||
variables: {
|
||||
id: appId,
|
||||
app: {
|
||||
authJwtCustomClaims: [...customClaims, permissionVariable]
|
||||
.filter((claim) => !claim.system)
|
||||
.reduce(
|
||||
(authJwtCustomClaims, claim) => ({
|
||||
...authJwtCustomClaims,
|
||||
[claim.key]: claim.value,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
triggerToast('Permission variable created');
|
||||
|
||||
if (!onClose) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (updateError) {
|
||||
if (updateError instanceof Error) {
|
||||
setError(updateError);
|
||||
} else {
|
||||
setError(new Error(updateError));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<CreatePermissionVariableModalBase
|
||||
title="Create Permission Variable"
|
||||
type="create"
|
||||
onSubmit={handleSubmit}
|
||||
onClose={onClose}
|
||||
errorComponent={
|
||||
error && (
|
||||
<Alert className="mt-4" severity="error">
|
||||
{error.message}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import type { ChangeEvent, MouseEventHandler, ReactNode } from 'react';
|
||||
import type { SubmitHandler } from 'react-hook-form';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
export interface CreatePermissionVariableBaseFormData {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface CreateModalBaseProps<T> {
|
||||
/**
|
||||
* Title of this modal.
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Type of this modal.
|
||||
*/
|
||||
type?: 'create' | 'edit';
|
||||
/**
|
||||
* Callback to be called when the modal is closed.
|
||||
*/
|
||||
onClose?: VoidFunction;
|
||||
/**
|
||||
* Callback to be called when remove button is clicked.
|
||||
*/
|
||||
onRemove?: MouseEventHandler<HTMLButtonElement>;
|
||||
/**
|
||||
* Callback to be called when the form is submitted.
|
||||
*/
|
||||
onSubmit: SubmitHandler<T>;
|
||||
/**
|
||||
* Error to be displayed.
|
||||
*/
|
||||
errorComponent?: ReactNode;
|
||||
}
|
||||
|
||||
export type CreatePermissionVariableModalBaseProps =
|
||||
CreateModalBaseProps<CreatePermissionVariableBaseFormData>;
|
||||
|
||||
export default function CreatePermissionVariableModalBase({
|
||||
title,
|
||||
type,
|
||||
onClose,
|
||||
onRemove,
|
||||
onSubmit,
|
||||
errorComponent,
|
||||
}: CreatePermissionVariableModalBaseProps) {
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
register,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useFormContext<CreatePermissionVariableBaseFormData>();
|
||||
|
||||
const keyHandlers = register('key', {
|
||||
required: true,
|
||||
pattern: {
|
||||
value: /^[a-zA-Z-]+$/i,
|
||||
message: 'Must contain only letters and hyphens',
|
||||
},
|
||||
});
|
||||
|
||||
const valueHandlers = register('value', {
|
||||
required: true,
|
||||
pattern: {
|
||||
value: /^[a-zA-Z0-9._[\]]+$/i,
|
||||
message: 'Must contain only letters, dots, brackets, and underscores',
|
||||
},
|
||||
});
|
||||
|
||||
const isComplete = !!watch('key') && !!watch('value');
|
||||
|
||||
return (
|
||||
<div className="w-modal p-6 text-left">
|
||||
<div className="grid w-full grid-flow-col items-center justify-between">
|
||||
<Text variant="h3" component="h2">
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{type === 'edit' && onRemove && (
|
||||
<Button variant="borderless" color="error" onClick={onRemove}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Text className="mt-2 text-sm+ text-greyscaleDark">
|
||||
Enter the field name and the path you want to use in this permission
|
||||
variable.
|
||||
</Text>
|
||||
|
||||
{errorComponent}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} autoComplete="off">
|
||||
<div className="my-4 grid grid-flow-row divide-y-1 divide-solid divide-gray-200 border-y border-gray-200">
|
||||
<Input
|
||||
{...keyHandlers}
|
||||
value={watch('key')}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (
|
||||
event.target.value &&
|
||||
!/^[a-zA-Z-]+$/gi.test(event.target.value)
|
||||
) {
|
||||
// prevent the user from entering invalid characters
|
||||
return;
|
||||
}
|
||||
|
||||
keyHandlers.onChange(event);
|
||||
}}
|
||||
id="key"
|
||||
variant="inline"
|
||||
inlineInputProportion="66%"
|
||||
label="Field name"
|
||||
fullWidth
|
||||
startAdornment={
|
||||
<Text className="min-w-[73px] text-sm+ text-greyscaleGrey">
|
||||
X-Hasura-
|
||||
</Text>
|
||||
}
|
||||
componentsProps={{
|
||||
inputWrapper: { className: 'my-1' },
|
||||
input: {
|
||||
className: 'border-transparent focus-within:border-solid pl-2',
|
||||
},
|
||||
inputRoot: { className: '!pl-[1px]' },
|
||||
}}
|
||||
autoFocus
|
||||
error={!!errors?.key?.message}
|
||||
helperText={errors?.key?.message}
|
||||
hideEmptyHelperText
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...valueHandlers}
|
||||
value={watch('value')}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (
|
||||
event.target.value &&
|
||||
!/^[a-zA-Z-]+$/gi.test(event.target.value)
|
||||
) {
|
||||
// prevent the user from entering invalid characters
|
||||
return;
|
||||
}
|
||||
|
||||
valueHandlers.onChange(event);
|
||||
}}
|
||||
id="value"
|
||||
variant="inline"
|
||||
inlineInputProportion="66%"
|
||||
label="Path"
|
||||
fullWidth
|
||||
startAdornment={
|
||||
<Text className="text-sm+ text-greyscaleGrey">user.</Text>
|
||||
}
|
||||
componentsProps={{
|
||||
inputWrapper: { className: 'my-1' },
|
||||
input: {
|
||||
className: 'border-transparent focus-within:border-solid pl-2',
|
||||
},
|
||||
inputRoot: { className: '!pl-[1px]' },
|
||||
}}
|
||||
error={!!errors?.value?.message}
|
||||
helperText={errors?.value?.message}
|
||||
hideEmptyHelperText
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isComplete}
|
||||
>
|
||||
{type === 'create' ? 'Create Permission Variable' : 'Save Changes'}
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { useWorkspaceContext } from '@/context/workspace-context';
|
||||
import useCustomClaims from '@/hooks/useCustomClaims';
|
||||
import type { CustomClaim } from '@/types/application';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import {
|
||||
refetchGetAppCustomClaimsQuery,
|
||||
useUpdateAppMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import type {
|
||||
CreatePermissionVariableBaseFormData,
|
||||
CreatePermissionVariableModalBaseProps,
|
||||
} from './CreatePermissionVariableModalBase';
|
||||
import CreatePermissionVariableModalBase from './CreatePermissionVariableModalBase';
|
||||
|
||||
export type EditPermissionVariableFormData =
|
||||
CreatePermissionVariableBaseFormData;
|
||||
|
||||
export type EditPermissionVariableModalProps = Pick<
|
||||
CreatePermissionVariableModalBaseProps,
|
||||
'onClose'
|
||||
> & {
|
||||
/**
|
||||
* The permission variable to edit.
|
||||
*/
|
||||
payload: CustomClaim;
|
||||
};
|
||||
|
||||
export default function EditPermissionVariableModal({
|
||||
payload: originalCustomClaim,
|
||||
...props
|
||||
}: EditPermissionVariableModalProps) {
|
||||
const [error, setError] = useState<Error>();
|
||||
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
||||
|
||||
const form = useForm<EditPermissionVariableFormData>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
key: originalCustomClaim.key || '',
|
||||
value: originalCustomClaim.value || '',
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
workspaceContext: { appId },
|
||||
} = useWorkspaceContext();
|
||||
|
||||
const { data: customClaims } = useCustomClaims({ appId });
|
||||
|
||||
const [updateApp] = useUpdateAppMutation({
|
||||
refetchQueries: [refetchGetAppCustomClaimsQuery({ id: appId })],
|
||||
});
|
||||
|
||||
async function handleSubmit(permissionVariable: CustomClaim) {
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
if (
|
||||
originalCustomClaim.key.toLowerCase() !==
|
||||
permissionVariable.key.toLowerCase() &&
|
||||
customClaims.some(
|
||||
(claim) =>
|
||||
claim.key.toLowerCase() === permissionVariable.key.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Permission variable with this field name already exists.',
|
||||
);
|
||||
}
|
||||
|
||||
// we need to preserve the original position of the permission variable
|
||||
const currentIndex = customClaims.findIndex(
|
||||
(claim) =>
|
||||
claim.key.toLowerCase() === originalCustomClaim.key.toLowerCase(),
|
||||
);
|
||||
|
||||
await updateApp({
|
||||
variables: {
|
||||
id: appId,
|
||||
app: {
|
||||
authJwtCustomClaims: customClaims
|
||||
.slice(0, currentIndex)
|
||||
.concat(permissionVariable)
|
||||
.concat(customClaims.slice(currentIndex + 1))
|
||||
.filter((claim) => !claim.system)
|
||||
.reduce(
|
||||
(authJwtCustomClaims, claim) => ({
|
||||
...authJwtCustomClaims,
|
||||
[claim.key]: claim.value,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
triggerToast(`Permission variable updated`);
|
||||
|
||||
if (props.onClose) {
|
||||
props.onClose();
|
||||
}
|
||||
} catch (updateError) {
|
||||
if (updateError instanceof Error) {
|
||||
setError(updateError);
|
||||
} else {
|
||||
setError(new Error(updateError));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
await updateApp({
|
||||
variables: {
|
||||
id: appId,
|
||||
app: {
|
||||
authJwtCustomClaims: customClaims
|
||||
.filter(
|
||||
(claim) =>
|
||||
claim.key !== originalCustomClaim.key && !claim.system,
|
||||
)
|
||||
.reduce(
|
||||
(authJwtCustomClaims, claim) => ({
|
||||
...authJwtCustomClaims,
|
||||
[claim.key]: claim.value,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setShowRemoveModal(false);
|
||||
|
||||
triggerToast('Permission variable removed');
|
||||
|
||||
if (props.onClose) {
|
||||
props.onClose();
|
||||
}
|
||||
} catch (updateError) {
|
||||
if (updateError instanceof Error) {
|
||||
setError(updateError);
|
||||
} else {
|
||||
setError(new Error(updateError));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
showModal={showRemoveModal}
|
||||
close={() => setShowRemoveModal(false)}
|
||||
>
|
||||
<div className="grid w-96 grid-flow-row gap-2 p-6 text-left text-greyscaleDark">
|
||||
<Text variant="h3" component="h2">
|
||||
Remove {originalCustomClaim.key}?
|
||||
</Text>
|
||||
|
||||
<Text>You will not be able to use it in permissions anymore.</Text>
|
||||
|
||||
<Text>
|
||||
If you have permission checks currently using this property, they
|
||||
will never resolve to true.
|
||||
</Text>
|
||||
|
||||
<div className="mt-2 grid grid-flow-row gap-2">
|
||||
<Button color="error" onClick={handleRemove} className="w-full">
|
||||
Remove Permission Variable
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => setShowRemoveModal(false)}
|
||||
className="w-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<FormProvider {...form}>
|
||||
<CreatePermissionVariableModalBase
|
||||
title="Edit Permission Variable"
|
||||
type="edit"
|
||||
onSubmit={handleSubmit}
|
||||
onRemove={() => setShowRemoveModal(true)}
|
||||
errorComponent={
|
||||
error && (
|
||||
<Alert className="mt-4" severity="error">
|
||||
{error.message}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
</FormProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Loading from '@/ui/Loading';
|
||||
import {
|
||||
refetchGetRolesQuery,
|
||||
useGetRolesQuery,
|
||||
useUpdateAppMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import type {
|
||||
CreateUserRoleBaseFormData,
|
||||
CreateUserRoleModalBaseProps,
|
||||
} from './CreateUserRoleModalBase';
|
||||
import { CreateUserRoleModalBase } from './CreateUserRoleModalBase';
|
||||
|
||||
export type CreateUserRoleFormData = CreateUserRoleBaseFormData;
|
||||
|
||||
export type CreateUserRoleModalProps = Pick<
|
||||
CreateUserRoleModalBaseProps,
|
||||
'onClose'
|
||||
>;
|
||||
|
||||
export function CreateUserRoleModal({ onClose }: CreateUserRoleModalProps) {
|
||||
const [error, setError] = useState<Error>();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
|
||||
const form = useForm<CreateUserRoleBaseFormData>({
|
||||
reValidateMode: 'onSubmit',
|
||||
});
|
||||
|
||||
const [updateApp] = useUpdateAppMutation({
|
||||
refetchQueries: [refetchGetRolesQuery({ id: currentApplication.id })],
|
||||
});
|
||||
|
||||
const {
|
||||
data: currentRolesData,
|
||||
loading,
|
||||
error: getRolesError,
|
||||
} = useGetRolesQuery({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (getRolesError) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2.5xl">
|
||||
<Alert severity="error">{error.message}</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSubmit(data) {
|
||||
setError(undefined);
|
||||
|
||||
const newAuthUserDefaultAllowedRoles = `${currentRolesData.app.authUserDefaultAllowedRoles},${data.roleName}`;
|
||||
|
||||
try {
|
||||
await updateApp({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
authUserDefaultAllowedRoles: newAuthUserDefaultAllowedRoles,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!onClose) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (updateError) {
|
||||
setError(updateError);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<CreateUserRoleModalBase
|
||||
title="Create New Role"
|
||||
type="create"
|
||||
onSubmit={handleSubmit}
|
||||
onClose={onClose}
|
||||
errorComponent={
|
||||
error && (
|
||||
<Alert className="mt-4" severity="error">
|
||||
{error.message}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { CreateModalBaseProps } from '@/components/applications/users/permissions/modal/CreatePermissionVariableModalBase';
|
||||
import { Input } from '@/ui';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
export interface CreateUserRoleBaseFormData {
|
||||
roleName: string;
|
||||
}
|
||||
|
||||
export type CreateUserRoleModalBaseProps =
|
||||
CreateModalBaseProps<CreateUserRoleBaseFormData>;
|
||||
export type CreateUserRoleModal = Pick<CreateUserRoleModalBaseProps, 'onClose'>;
|
||||
|
||||
export function CreateUserRoleModalBase({
|
||||
title,
|
||||
type,
|
||||
onRemove,
|
||||
onSubmit,
|
||||
errorComponent,
|
||||
}: CreateUserRoleModalBaseProps) {
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useFormContext<CreateUserRoleBaseFormData>();
|
||||
|
||||
return (
|
||||
<div className="w-modal- p-6 text-left">
|
||||
<div className="mx-auto items-center justify-between">
|
||||
<Text
|
||||
variant="heading"
|
||||
className="text-center text-lg font-medium text-greyscaleDark"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{errorComponent}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} autoComplete="off">
|
||||
<div className="mt-3 mb-3 divide-y border-t border-b py-1">
|
||||
<div className="flex flex-row place-content-between py-2">
|
||||
<div className="flex w-full flex-row">
|
||||
<Text
|
||||
color="greyscaleDark"
|
||||
className="self-center font-medium"
|
||||
size="normal"
|
||||
>
|
||||
New Role Name
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex w-full">
|
||||
<Controller
|
||||
name="roleName"
|
||||
control={control}
|
||||
rules={{
|
||||
required: true,
|
||||
pattern: {
|
||||
value: /^[a-zA-Z0-9-_]+$/,
|
||||
message: 'Must contain only letters, hyphens, and numbers.',
|
||||
},
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
id="roleName"
|
||||
required
|
||||
value={field.value || ''}
|
||||
onChange={(value: string) => {
|
||||
if (value && !/^[a-zA-Z0-9-_]+$/gi.test(value)) {
|
||||
// prevent the user from entering invalid characters
|
||||
return;
|
||||
}
|
||||
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{type === 'create' ? 'Create New User Role' : 'Save Changes'}
|
||||
</Button>
|
||||
{type === 'edit' && onRemove && (
|
||||
<Button variant="menu" border onClick={onRemove}>
|
||||
<Text className="text-sm+ font-medium text-red">Remove Role</Text>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import type { GetRolesQuery } from '@/generated/graphql';
|
||||
import {
|
||||
refetchGetRolesQuery,
|
||||
useGetRolesQuery,
|
||||
useUpdateAppMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import { Button } from '@/ui/Button';
|
||||
import Loading from '@/ui/Loading';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import type {
|
||||
CreateUserRoleBaseFormData,
|
||||
CreateUserRoleModalBaseProps,
|
||||
} from './CreateUserRoleModalBase';
|
||||
import { CreateUserRoleModalBase } from './CreateUserRoleModalBase';
|
||||
|
||||
export type EditUserRoleFormData = CreateUserRoleBaseFormData;
|
||||
|
||||
export type EditUserRoleModalProps = Pick<
|
||||
CreateUserRoleModalBaseProps,
|
||||
'onClose'
|
||||
> & {
|
||||
/**
|
||||
* The permission variable to edit.
|
||||
*/
|
||||
payload: any;
|
||||
};
|
||||
|
||||
export function EditUserRoleModal({
|
||||
payload: originalRole,
|
||||
...props
|
||||
}: EditUserRoleModalProps) {
|
||||
const [error, setError] = useState<Error>();
|
||||
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const form = useForm<EditUserRoleFormData>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
roleName: originalRole.name || '',
|
||||
},
|
||||
});
|
||||
|
||||
const [updateApp, { loading: loadingUpdateAppMutation }] =
|
||||
useUpdateAppMutation({
|
||||
refetchQueries: [refetchGetRolesQuery({ id: currentApplication.id })],
|
||||
});
|
||||
|
||||
const {
|
||||
data: currentRolesData,
|
||||
loading,
|
||||
error: getRolesError,
|
||||
} = useGetRolesQuery({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (getRolesError) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2.5xl">
|
||||
<Alert severity="error">{error.message}</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSubmit(data: EditUserRoleFormData) {
|
||||
setError(undefined);
|
||||
|
||||
const currentUserRoles =
|
||||
currentRolesData.app.authUserDefaultAllowedRoles.split(',');
|
||||
|
||||
const roleBeingEdited = currentUserRoles.find(
|
||||
(role) => role === originalRole.name,
|
||||
);
|
||||
|
||||
const indexofRoleBeingEdited = currentUserRoles.indexOf(roleBeingEdited);
|
||||
const newRoleName = data.roleName;
|
||||
|
||||
const newAuthUserDefaultAllowedRoles = currentUserRoles.slice();
|
||||
|
||||
if (data.roleName !== originalRole.name) {
|
||||
newAuthUserDefaultAllowedRoles[indexofRoleBeingEdited] = newRoleName;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateApp({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
authUserDefaultAllowedRoles:
|
||||
newAuthUserDefaultAllowedRoles.join(','),
|
||||
},
|
||||
},
|
||||
});
|
||||
triggerToast(`Role "${data.roleName}" updated successfully`);
|
||||
props.onClose();
|
||||
} catch (updateError) {
|
||||
setError(updateError);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(data: GetRolesQuery) {
|
||||
setError(undefined);
|
||||
|
||||
// Get the current roles of this application.
|
||||
const currentUserRoles = data.app.authUserDefaultAllowedRoles.split(',');
|
||||
|
||||
// Remove the role from the current roles.
|
||||
const filteredCurrentUserRoles = currentUserRoles.filter(
|
||||
(role) => role !== originalRole.name,
|
||||
);
|
||||
|
||||
const newAuthUserDefaultAllowedRoles = filteredCurrentUserRoles.join(',');
|
||||
|
||||
try {
|
||||
await updateApp({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
authUserDefaultAllowedRoles: newAuthUserDefaultAllowedRoles,
|
||||
},
|
||||
},
|
||||
});
|
||||
props.onClose();
|
||||
triggerToast(`Role "${originalRole.name}" removed successfully`);
|
||||
} catch (updateError) {
|
||||
setError(updateError);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
showModal={showRemoveModal}
|
||||
close={() => setShowRemoveModal(false)}
|
||||
>
|
||||
<div className="px-6 pt-5 text-center text-greyscaleDark">
|
||||
<Text variant="heading" className="mb-2 text-lg font-medium">
|
||||
Remove Role "{originalRole.name}"?
|
||||
</Text>
|
||||
|
||||
<div className="my-4">
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => handleRemove(currentRolesData)}
|
||||
className="w-full"
|
||||
loading={loadingUpdateAppMutation}
|
||||
>
|
||||
Remove Role
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowRemoveModal(false)}
|
||||
className="w-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<FormProvider {...form}>
|
||||
<CreateUserRoleModalBase
|
||||
title="Edit Role"
|
||||
type="edit"
|
||||
onSubmit={handleSubmit}
|
||||
onRemove={() => setShowRemoveModal(true)}
|
||||
errorComponent={
|
||||
error && (
|
||||
<Alert className="mt-4" severity="error">
|
||||
{error.message}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
</FormProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,9 @@ export type DialogType =
|
||||
| 'CREATE_TABLE'
|
||||
| 'EDIT_TABLE'
|
||||
| 'CREATE_FOREIGN_KEY'
|
||||
| 'EDIT_FOREIGN_KEY';
|
||||
| 'EDIT_FOREIGN_KEY'
|
||||
| 'MANAGE_ROLE'
|
||||
| 'MANAGE_PERMISSION_VARIABLE';
|
||||
|
||||
export interface DialogConfig<TPayload = unknown> {
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
|
||||
import { CreateForeignKeyForm } from '@/components/data-browser/CreateForeignKeyForm';
|
||||
import { EditForeignKeyForm } from '@/components/data-browser/EditForeignKeyForm';
|
||||
import CreateForeignKeyForm from '@/components/data-browser/CreateForeignKeyForm';
|
||||
import EditForeignKeyForm from '@/components/data-browser/EditForeignKeyForm';
|
||||
import PermissionVariableForm from '@/components/settings/permissions/PermissionVariableForm';
|
||||
import RoleForm from '@/components/settings/roles/RoleForm';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import AlertDialog from '@/ui/v2/AlertDialog';
|
||||
import { BaseDialog } from '@/ui/v2/Dialog';
|
||||
@@ -8,6 +10,7 @@ import Drawer from '@/ui/v2/Drawer';
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { BaseSyntheticEvent, PropsWithChildren } from 'react';
|
||||
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import type { DialogConfig, DialogType } from './DialogContext';
|
||||
import DialogContext from './DialogContext';
|
||||
import {
|
||||
@@ -249,7 +252,13 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
open={dialogOpen}
|
||||
onClose={closeDialogWithDirtyGuard}
|
||||
TransitionProps={{ onExited: clearDialogContent, unmountOnExit: false }}
|
||||
PaperProps={{ className: 'max-w-md w-full' }}
|
||||
PaperProps={{
|
||||
...dialogProps?.PaperProps,
|
||||
className: twMerge(
|
||||
'max-w-md w-full',
|
||||
dialogProps?.PaperProps?.className,
|
||||
),
|
||||
}}
|
||||
>
|
||||
<RetryableErrorBoundary
|
||||
errorMessageProps={{ className: 'pt-0 pb-5 px-6' }}
|
||||
@@ -258,7 +267,7 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
<CreateForeignKeyForm
|
||||
{...dialogPayload}
|
||||
onSubmit={async (values) => {
|
||||
await dialogPayload?.onSubmit(values);
|
||||
await dialogPayload?.onSubmit?.(values);
|
||||
|
||||
closeDialog();
|
||||
}}
|
||||
@@ -270,7 +279,31 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
<EditForeignKeyForm
|
||||
{...dialogPayload}
|
||||
onSubmit={async (values) => {
|
||||
await dialogPayload?.onSubmit(values);
|
||||
await dialogPayload?.onSubmit?.(values);
|
||||
|
||||
closeDialog();
|
||||
}}
|
||||
onCancel={closeDialogWithDirtyGuard}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeDialogType === 'MANAGE_ROLE' && (
|
||||
<RoleForm
|
||||
{...dialogPayload}
|
||||
onSubmit={async (values) => {
|
||||
await dialogPayload?.onSubmit?.(values);
|
||||
|
||||
closeDialog();
|
||||
}}
|
||||
onCancel={closeDialogWithDirtyGuard}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeDialogType === 'MANAGE_PERMISSION_VARIABLE' && (
|
||||
<PermissionVariableForm
|
||||
{...dialogPayload}
|
||||
onSubmit={async (values) => {
|
||||
await dialogPayload?.onSubmit?.(values);
|
||||
|
||||
closeDialog();
|
||||
}}
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface CreateForeignKeyFormProps
|
||||
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void>;
|
||||
}
|
||||
|
||||
export function CreateForeignKeyForm({
|
||||
export default function CreateForeignKeyForm({
|
||||
onSubmit,
|
||||
selectedColumn,
|
||||
...props
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './CreateForeignKeyForm';
|
||||
export { CreateForeignKeyForm as default } from './CreateForeignKeyForm';
|
||||
export { default } from './CreateForeignKeyForm';
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface EditForeignKeyFormProps
|
||||
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void>;
|
||||
}
|
||||
|
||||
export function EditForeignKeyForm({
|
||||
export default function EditForeignKeyForm({
|
||||
foreignKeyRelation,
|
||||
selectedColumn,
|
||||
onSubmit,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './EditForeignKeyForm';
|
||||
export { EditForeignKeyForm as default } from './EditForeignKeyForm';
|
||||
export { default } from './EditForeignKeyForm';
|
||||
|
||||
@@ -4,19 +4,19 @@ import { twMerge } from 'tailwind-merge';
|
||||
export interface ContainerProps
|
||||
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> {
|
||||
/**
|
||||
* Class name passed to the wrapper element.
|
||||
* Class name passed to the root element.
|
||||
*/
|
||||
wrapperClassName?: string;
|
||||
rootClassName?: string;
|
||||
}
|
||||
|
||||
export default function Container({
|
||||
children,
|
||||
className,
|
||||
wrapperClassName,
|
||||
rootClassName,
|
||||
...props
|
||||
}: ContainerProps) {
|
||||
return (
|
||||
<div className={twMerge('mx-auto w-full bg-white', wrapperClassName)}>
|
||||
<div className={twMerge('mx-auto w-full bg-white', rootClassName)}>
|
||||
<div
|
||||
className={twMerge(
|
||||
'mx-auto max-w-7xl bg-white px-5 pt-6 pb-20',
|
||||
|
||||
@@ -8,7 +8,6 @@ import Switch from '@/ui/v2/Switch';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import Image from 'next/image';
|
||||
import type { DetailedHTMLProps, HTMLProps, ReactNode } from 'react';
|
||||
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface SettingsContainerProps
|
||||
@@ -31,7 +30,7 @@ export interface SettingsContainerProps
|
||||
/**
|
||||
* The description for the section.
|
||||
*/
|
||||
description: string | ReactNode;
|
||||
description?: string | ReactNode;
|
||||
/**
|
||||
* Link to the documentation.
|
||||
*
|
||||
@@ -40,6 +39,8 @@ export interface SettingsContainerProps
|
||||
docsLink?: string;
|
||||
/**
|
||||
* Props for the primary action.
|
||||
*
|
||||
* @deprecated Use `slotProps.submitButtonProps` instead.
|
||||
*/
|
||||
primaryActionButtonProps?: ButtonProps;
|
||||
/**
|
||||
@@ -76,8 +77,31 @@ export interface SettingsContainerProps
|
||||
className?: string;
|
||||
/**
|
||||
* Props to be passed to the Switch component.
|
||||
*
|
||||
* @deprecated Use `slotProps.switchProps` instead.
|
||||
*/
|
||||
switchProps?: SwitchProps;
|
||||
/**
|
||||
* Props to be passed to different slots inside the component.
|
||||
*/
|
||||
slotProps?: {
|
||||
/**
|
||||
* Props to be passed to the root element.
|
||||
*/
|
||||
rootProps?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
|
||||
/**
|
||||
* Props to be passed to the `<Switch />` component.
|
||||
*/
|
||||
switchProps?: SwitchProps;
|
||||
/**
|
||||
* Props to be passed to the footer element.
|
||||
*/
|
||||
submitButtonProps?: ButtonProps;
|
||||
/**
|
||||
* Props to be passed to the footer element.
|
||||
*/
|
||||
footerProps?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
|
||||
};
|
||||
}
|
||||
|
||||
export default function SettingsContainer({
|
||||
@@ -94,14 +118,16 @@ export default function SettingsContainer({
|
||||
switchId,
|
||||
showSwitch = false,
|
||||
rootClassName,
|
||||
switchProps,
|
||||
switchProps: oldSwitchProps,
|
||||
docsTitle,
|
||||
slotProps: { rootProps, switchProps, submitButtonProps, footerProps } = {},
|
||||
}: SettingsContainerProps) {
|
||||
return (
|
||||
<div
|
||||
{...rootProps}
|
||||
className={twMerge(
|
||||
'grid grid-flow-row gap-4 rounded-lg border-1 border-gray-200 bg-white py-4',
|
||||
rootClassName,
|
||||
rootProps?.className || rootClassName,
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-flow-col place-content-between gap-3 px-4">
|
||||
@@ -131,14 +157,14 @@ export default function SettingsContainer({
|
||||
checked={enabled}
|
||||
onChange={(e) => onEnabledChange(e.target.checked)}
|
||||
className="self-center"
|
||||
{...switchProps}
|
||||
{...(switchProps || oldSwitchProps)}
|
||||
/>
|
||||
)}
|
||||
{switchId && showSwitch && (
|
||||
<ControlledSwitch
|
||||
className="self-center"
|
||||
name={switchId}
|
||||
{...switchProps}
|
||||
{...(switchProps || oldSwitchProps)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -148,9 +174,11 @@ export default function SettingsContainer({
|
||||
</div>
|
||||
|
||||
<div
|
||||
{...footerProps}
|
||||
className={twMerge(
|
||||
'grid grid-flow-col items-center gap-x-2 border-t border-gray-200 px-4 pt-3.5',
|
||||
docsLink ? 'place-content-between' : 'justify-end',
|
||||
footerProps?.className,
|
||||
)}
|
||||
>
|
||||
{docsLink && (
|
||||
@@ -173,11 +201,17 @@ export default function SettingsContainer({
|
||||
|
||||
<Button
|
||||
variant={
|
||||
primaryActionButtonProps?.disabled ? 'outlined' : 'contained'
|
||||
(submitButtonProps || primaryActionButtonProps)?.disabled
|
||||
? 'outlined'
|
||||
: 'contained'
|
||||
}
|
||||
color={
|
||||
(submitButtonProps || primaryActionButtonProps)?.disabled
|
||||
? 'secondary'
|
||||
: 'primary'
|
||||
}
|
||||
color={primaryActionButtonProps?.disabled ? 'secondary' : 'primary'}
|
||||
type="submit"
|
||||
{...primaryActionButtonProps}
|
||||
{...(submitButtonProps || primaryActionButtonProps)}
|
||||
>
|
||||
{submitButtonText}
|
||||
</Button>
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function SettingsLayout({
|
||||
{...sidebarProps}
|
||||
/>
|
||||
|
||||
<div className="flex w-full flex-auto flex-col overflow-x-hidden">
|
||||
<div className="flex w-full flex-auto flex-col overflow-x-hidden bg-[#fafafa]">
|
||||
{children}
|
||||
</div>
|
||||
</ProjectLayout>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
import type { CustomClaim } from '@/types/application';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface PermissionVariableFormValues {
|
||||
/**
|
||||
* Permission variable key.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Permission variable value.
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PermissionVariableFormProps {
|
||||
/**
|
||||
* List of available permission variables.
|
||||
*/
|
||||
availableVariables: CustomClaim[];
|
||||
/**
|
||||
* Original permission variable. This is defined only if the form was
|
||||
* opened to edit an existing permission variable.
|
||||
*/
|
||||
originalVariable?: CustomClaim;
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
onSubmit: (values: PermissionVariableFormValues) => void;
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
*/
|
||||
onCancel?: VoidFunction;
|
||||
/**
|
||||
* Submit button text.
|
||||
*
|
||||
* @default 'Save'
|
||||
*/
|
||||
submitButtonText?: string;
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
key: Yup.string().required('This field is required.'),
|
||||
value: Yup.string().required('This field is required.'),
|
||||
});
|
||||
|
||||
export default function PermissionVariableForm({
|
||||
availableVariables,
|
||||
originalVariable,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitButtonText = 'Save',
|
||||
}: PermissionVariableFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
const form = useForm<PermissionVariableFormValues>({
|
||||
defaultValues: {
|
||||
key: originalVariable?.key || '',
|
||||
value: originalVariable?.value || '',
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
formState: { dirtyFields, errors, isSubmitting },
|
||||
} = form;
|
||||
|
||||
// react-hook-form's isDirty gets true even if an input field is focused, then
|
||||
// immediately unfocused - we can't rely on that information
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, 'dialog');
|
||||
}, [isDirty, onDirtyStateChange]);
|
||||
|
||||
async function handleSubmit(values: PermissionVariableFormValues) {
|
||||
if (
|
||||
availableVariables.some(
|
||||
(variable) =>
|
||||
variable.key === values.key && variable.key !== originalVariable?.key,
|
||||
)
|
||||
) {
|
||||
setError('key', { message: 'This key is already in use.' });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit?.(values);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-flow-row gap-4 px-6 pb-6"
|
||||
>
|
||||
<Input
|
||||
{...register('key', {
|
||||
onChange: (event) => {
|
||||
if (
|
||||
event.target.value &&
|
||||
!/^[a-zA-Z-]+$/gi.test(event.target.value)
|
||||
) {
|
||||
// we need to prevent invalid characters from being entered
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
event.target.value = event.target.value.replace(
|
||||
/[^a-zA-Z-]/gi,
|
||||
'',
|
||||
);
|
||||
}
|
||||
},
|
||||
})}
|
||||
id="key"
|
||||
label="Field Name"
|
||||
hideEmptyHelperText
|
||||
error={!!errors.key}
|
||||
helperText={errors?.key?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
slotProps={{ input: { className: '!pl-px' } }}
|
||||
startAdornment={
|
||||
<Text className="shrink-0 pl-2 text-greyscaleGrey">X-Hasura-</Text>
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('value', {
|
||||
onChange: (event) => {
|
||||
if (
|
||||
event.target.value &&
|
||||
!/^[a-zA-Z-_.[\]]+$/gi.test(event.target.value)
|
||||
) {
|
||||
// we need to prevent invalid characters from being entered
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
event.target.value = event.target.value.replace(
|
||||
/[^a-zA-Z-.[\]]/gi,
|
||||
'',
|
||||
);
|
||||
}
|
||||
},
|
||||
})}
|
||||
id="value"
|
||||
label="Path"
|
||||
hideEmptyHelperText
|
||||
error={!!errors.value}
|
||||
helperText={errors?.value?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
slotProps={{ input: { className: '!pl-px' } }}
|
||||
startAdornment={
|
||||
<Text className="shrink-0 pl-2 text-greyscaleGrey">user.</Text>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{submitButtonText}
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './PermissionVariableForm';
|
||||
export { default } from './PermissionVariableForm';
|
||||
@@ -0,0 +1,356 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
import type { PermissionVariableFormValues } from '@/components/settings/permissions/PermissionVariableForm';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import useLeaveConfirm from '@/hooks/common/useLeaveConfirm';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import type { CustomClaim } from '@/types/application';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Divider from '@/ui/v2/Divider';
|
||||
import { Dropdown } from '@/ui/v2/Dropdown';
|
||||
import IconButton from '@/ui/v2/IconButton';
|
||||
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
|
||||
import LockIcon from '@/ui/v2/icons/LockIcon';
|
||||
import PlusIcon from '@/ui/v2/icons/PlusIcon';
|
||||
import List from '@/ui/v2/List';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import Tooltip from '@/ui/v2/Tooltip';
|
||||
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useGetAppCustomClaimsQuery,
|
||||
useUpdateAppMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { Fragment, useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface PermissionVariableSettingsFormValues {
|
||||
/**
|
||||
* Permission variables.
|
||||
*/
|
||||
authJwtCustomClaims: CustomClaim[];
|
||||
}
|
||||
|
||||
function getPermissionVariables(customClaims?: Record<string, any>) {
|
||||
const systemClaims: CustomClaim[] = [
|
||||
{ key: 'User-Id', value: 'id', isSystemClaim: true },
|
||||
];
|
||||
|
||||
if (!customClaims) {
|
||||
return systemClaims;
|
||||
}
|
||||
|
||||
return systemClaims.concat(
|
||||
Object.keys(customClaims)
|
||||
.sort()
|
||||
.map((key) => ({
|
||||
key,
|
||||
value: customClaims[key],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export default function PermissionVariableSettings() {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { openDialog, openAlertDialog } = useDialog();
|
||||
|
||||
const { data, loading, error } = useGetAppCustomClaimsQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
});
|
||||
|
||||
const [updateApp] = useUpdateAppMutation({
|
||||
refetchQueries: ['getAppCustomClaims'],
|
||||
});
|
||||
|
||||
const form = useForm<PermissionVariableSettingsFormValues>({
|
||||
defaultValues: {
|
||||
authJwtCustomClaims: getPermissionVariables(
|
||||
data?.app?.authJwtCustomClaims,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
reset,
|
||||
formState: { dirtyFields },
|
||||
} = form;
|
||||
|
||||
useLeaveConfirm({ isDirty: Object.keys(dirtyFields).length > 0 });
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
authJwtCustomClaims: getPermissionVariables(
|
||||
data?.app?.authJwtCustomClaims,
|
||||
),
|
||||
});
|
||||
}, [data, reset]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator delay={1000} label="Loading permission variables..." />
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { setValue, formState, watch } = form;
|
||||
const availableCustomClaims = watch('authJwtCustomClaims');
|
||||
|
||||
function handleAddVariable({ key, value }: PermissionVariableFormValues) {
|
||||
setValue(
|
||||
'authJwtCustomClaims',
|
||||
[...availableCustomClaims, { key, value }],
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handleEditVariable(
|
||||
{ key, value }: PermissionVariableFormValues,
|
||||
originalVariable: CustomClaim,
|
||||
) {
|
||||
const originalIndex = availableCustomClaims.findIndex(
|
||||
(customClaim) => customClaim.key === originalVariable.key,
|
||||
);
|
||||
const updatedVariables = availableCustomClaims.map((customClaim, index) =>
|
||||
index === originalIndex ? { key, value } : customClaim,
|
||||
);
|
||||
|
||||
setValue('authJwtCustomClaims', updatedVariables, { shouldDirty: true });
|
||||
}
|
||||
|
||||
function handleRemoveVariable({ key }: CustomClaim) {
|
||||
const filteredCustomClaims = availableCustomClaims.filter(
|
||||
(customClaim) => customClaim.key !== key,
|
||||
);
|
||||
|
||||
setValue('authJwtCustomClaims', filteredCustomClaims, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
|
||||
function handleOpenCreator() {
|
||||
openDialog('MANAGE_PERMISSION_VARIABLE', {
|
||||
title: (
|
||||
<span className="grid grid-flow-row">
|
||||
<span>Add Permission Variable</span>
|
||||
|
||||
<Text variant="subtitle1" component="span">
|
||||
Enter the field name and the path you want to use in this permission
|
||||
variable.
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
payload: {
|
||||
availableVariables: availableCustomClaims,
|
||||
submitButtonText: 'Add',
|
||||
onSubmit: handleAddVariable,
|
||||
},
|
||||
props: { PaperProps: { className: 'max-w-sm' } },
|
||||
});
|
||||
}
|
||||
|
||||
function handleOpenEditor(originalVariable: CustomClaim) {
|
||||
openDialog('MANAGE_PERMISSION_VARIABLE', {
|
||||
title: (
|
||||
<span className="grid grid-flow-row">
|
||||
<span>Edit Permission Variable</span>
|
||||
|
||||
<Text variant="subtitle1" component="span">
|
||||
Enter the field name and the path you want to use in this permission
|
||||
variable.
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
payload: {
|
||||
availableVariables: availableCustomClaims,
|
||||
originalVariable,
|
||||
onSubmit: (values: PermissionVariableFormValues) =>
|
||||
handleEditVariable(values, originalVariable),
|
||||
},
|
||||
props: { PaperProps: { className: 'max-w-sm' } },
|
||||
});
|
||||
}
|
||||
|
||||
function handleConfirmRemove(originalVariable: CustomClaim) {
|
||||
openAlertDialog({
|
||||
title: 'Remove Permission Variable',
|
||||
payload: (
|
||||
<Text>
|
||||
Are you sure you want to remove the "
|
||||
<strong>X-Hasura-{originalVariable.key}</strong>" permission
|
||||
variable?
|
||||
</Text>
|
||||
),
|
||||
props: {
|
||||
onPrimaryAction: () => handleRemoveVariable(originalVariable),
|
||||
primaryButtonColor: 'error',
|
||||
primaryButtonText: 'Remove',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubmit(values: PermissionVariableSettingsFormValues) {
|
||||
const updateAppPromise = updateApp({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
authJwtCustomClaims: values.authJwtCustomClaims
|
||||
.filter((customClaim) => !customClaim.isSystemClaim)
|
||||
.reduce(
|
||||
(authJwtCustomClaims, claim) => ({
|
||||
...authJwtCustomClaims,
|
||||
[claim.key]: claim.value,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
{
|
||||
loading: 'Updating permission variables...',
|
||||
success: 'Permission variables have been updated successfully.',
|
||||
error: 'An error occurred while updating permission variables.',
|
||||
},
|
||||
toastStyleProps,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Permission Variables"
|
||||
description="Permission variables are used to define permission rules in the GraphQL API."
|
||||
docsLink="https://docs.nhost.io/graphql/permissions"
|
||||
rootClassName="gap-0"
|
||||
className="px-0 my-2"
|
||||
slotProps={{
|
||||
submitButtonProps: {
|
||||
loading: formState.isSubmitting,
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 border-b-1 border-gray-200 px-4 py-3">
|
||||
<Text className="font-medium">Field name</Text>
|
||||
<Text className="font-medium">Path</Text>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<List>
|
||||
{availableCustomClaims.map((customClaim, index) => (
|
||||
<Fragment key={customClaim.key}>
|
||||
<ListItem.Root
|
||||
className="px-4 grid grid-cols-2"
|
||||
secondaryAction={
|
||||
<Dropdown.Root>
|
||||
<Tooltip
|
||||
title={
|
||||
customClaim.isSystemClaim
|
||||
? "You can't edit system permission variables"
|
||||
: ''
|
||||
}
|
||||
placement="right"
|
||||
disableHoverListener={!customClaim.isSystemClaim}
|
||||
hasDisabledChildren={customClaim.isSystemClaim}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<Dropdown.Trigger asChild hideChevron>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
disabled={customClaim.isSystemClaim}
|
||||
>
|
||||
<DotsVerticalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
</Tooltip>
|
||||
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-32' }}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<Dropdown.Item
|
||||
onClick={() => handleOpenEditor(customClaim)}
|
||||
>
|
||||
<Text className="font-medium">Edit</Text>
|
||||
</Dropdown.Item>
|
||||
|
||||
<Divider component="li" />
|
||||
|
||||
<Dropdown.Item
|
||||
onClick={() => handleConfirmRemove(customClaim)}
|
||||
>
|
||||
<Text
|
||||
className="font-medium"
|
||||
sx={{
|
||||
color: (theme) => theme.palette.error.main,
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Text
|
||||
primary={
|
||||
<>
|
||||
X-Hasura-{customClaim.key}{' '}
|
||||
{customClaim.isSystemClaim && (
|
||||
<LockIcon className="w-4 h-4" />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Text className="font-medium">
|
||||
user.{customClaim.value}
|
||||
</Text>
|
||||
</ListItem.Root>
|
||||
|
||||
<Divider
|
||||
component="li"
|
||||
className={twMerge(
|
||||
index === availableCustomClaims.length - 1
|
||||
? '!mt-4'
|
||||
: '!my-4',
|
||||
)}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Button
|
||||
className="justify-self-start mx-4"
|
||||
variant="borderless"
|
||||
startIcon={<PlusIcon />}
|
||||
onClick={handleOpenCreator}
|
||||
>
|
||||
Add Permission Variable
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './PermissionVariableSettings';
|
||||
export { default } from './PermissionVariableSettings';
|
||||
119
dashboard/src/components/settings/roles/RoleForm/RoleForm.tsx
Normal file
119
dashboard/src/components/settings/roles/RoleForm/RoleForm.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
import type { Role } from '@/types/application';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface RoleFormValues {
|
||||
/**
|
||||
* The name of the role.
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RoleFormProps {
|
||||
/**
|
||||
* Available roles.
|
||||
*/
|
||||
availableRoles: Role[];
|
||||
/**
|
||||
* Original role. This is defined only if the form was opened to edit an
|
||||
* existing role.
|
||||
*/
|
||||
originalRole?: Role;
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
onSubmit: (values: RoleFormValues) => void;
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
*/
|
||||
onCancel?: VoidFunction;
|
||||
/**
|
||||
* Submit button text.
|
||||
*
|
||||
* @default 'Save'
|
||||
*/
|
||||
submitButtonText?: string;
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
name: Yup.string().required('This field is required.'),
|
||||
});
|
||||
|
||||
export default function RoleForm({
|
||||
availableRoles,
|
||||
originalRole,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitButtonText = 'Save',
|
||||
}: RoleFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
const form = useForm<RoleFormValues>({
|
||||
defaultValues: {
|
||||
name: originalRole?.name || '',
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
formState: { errors, dirtyFields, isSubmitting },
|
||||
} = form;
|
||||
|
||||
// react-hook-form's isDirty gets true even if an input field is focused, then
|
||||
// immediately unfocused - we can't rely on that information
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, 'dialog');
|
||||
}, [isDirty, onDirtyStateChange]);
|
||||
|
||||
async function handleSubmit(values: RoleFormValues) {
|
||||
if (availableRoles.some((role) => role.name === values.name)) {
|
||||
setError('name', { message: 'This role already exists.' });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit?.(values);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-flow-row gap-4 px-6 pb-6"
|
||||
>
|
||||
<Input
|
||||
{...register('name')}
|
||||
inputProps={{ maxLength: 100 }}
|
||||
id="name"
|
||||
label="Name"
|
||||
placeholder="Enter value"
|
||||
hideEmptyHelperText
|
||||
error={!!errors.name}
|
||||
helperText={errors?.name?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{submitButtonText}
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './RoleForm';
|
||||
export { default } from './RoleForm';
|
||||
@@ -0,0 +1,353 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
import type { RoleFormValues } from '@/components/settings/roles/RoleForm';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import useLeaveConfirm from '@/hooks/common/useLeaveConfirm';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import type { Role } from '@/types/application';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Chip from '@/ui/v2/Chip';
|
||||
import Divider from '@/ui/v2/Divider';
|
||||
import { Dropdown } from '@/ui/v2/Dropdown';
|
||||
import IconButton from '@/ui/v2/IconButton';
|
||||
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
|
||||
import LockIcon from '@/ui/v2/icons/LockIcon';
|
||||
import PlusIcon from '@/ui/v2/icons/PlusIcon';
|
||||
import List from '@/ui/v2/List';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useGetRolesQuery,
|
||||
useUpdateAppMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { Fragment, useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface RoleSettingsFormValues {
|
||||
/**
|
||||
* Default role.
|
||||
*/
|
||||
authUserDefaultRole: string;
|
||||
/**
|
||||
* Allowed roles for the project.
|
||||
*/
|
||||
authUserDefaultAllowedRoles: Role[];
|
||||
}
|
||||
|
||||
function getUserRoles(roles?: string) {
|
||||
if (!roles) {
|
||||
return [] as Role[];
|
||||
}
|
||||
|
||||
return roles.split(',').map((role) => ({
|
||||
name: role.trim(),
|
||||
isSystemRole: role === 'user' || role === 'me',
|
||||
})) as Role[];
|
||||
}
|
||||
|
||||
export default function RoleSettings() {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { openDialog, openAlertDialog } = useDialog();
|
||||
|
||||
const { data, loading, error } = useGetRolesQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
});
|
||||
|
||||
const [updateApp] = useUpdateAppMutation({
|
||||
refetchQueries: ['getRoles'],
|
||||
});
|
||||
|
||||
const form = useForm<RoleSettingsFormValues>({
|
||||
defaultValues: {
|
||||
authUserDefaultRole: data?.app?.authUserDefaultRole || 'user',
|
||||
authUserDefaultAllowedRoles: getUserRoles(
|
||||
data?.app?.authUserDefaultAllowedRoles,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
reset,
|
||||
formState: { dirtyFields },
|
||||
} = form;
|
||||
|
||||
useLeaveConfirm({ isDirty: Object.keys(dirtyFields).length > 0 });
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
authUserDefaultRole: data?.app?.authUserDefaultRole || 'user',
|
||||
authUserDefaultAllowedRoles: getUserRoles(
|
||||
data?.app?.authUserDefaultAllowedRoles,
|
||||
),
|
||||
});
|
||||
}, [data, reset]);
|
||||
|
||||
if (loading) {
|
||||
return <ActivityIndicator delay={1000} label="Loading user roles..." />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { setValue, formState, watch } = form;
|
||||
const defaultRole = watch('authUserDefaultRole');
|
||||
const availableRoles = watch('authUserDefaultAllowedRoles');
|
||||
|
||||
function handleAddRole({ name }: RoleFormValues) {
|
||||
setValue(
|
||||
'authUserDefaultAllowedRoles',
|
||||
[...availableRoles, { name, isSystemRole: false }],
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handleEditRole({ name }: RoleFormValues, originalRole: Role) {
|
||||
const originalIndex = availableRoles.findIndex(
|
||||
(role) => role.name === originalRole.name,
|
||||
);
|
||||
const updatedRoles = availableRoles.map((role, index) =>
|
||||
index === originalIndex ? { name, isSystemRole: false } : role,
|
||||
);
|
||||
|
||||
setValue('authUserDefaultAllowedRoles', updatedRoles, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
|
||||
function handleRemoveRole({ name }: Role) {
|
||||
const filteredRoles = availableRoles.filter((role) => role.name !== name);
|
||||
|
||||
if (name === defaultRole) {
|
||||
setValue('authUserDefaultRole', 'user', { shouldDirty: true });
|
||||
}
|
||||
|
||||
setValue('authUserDefaultAllowedRoles', filteredRoles, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
|
||||
function handleOpenCreator() {
|
||||
openDialog('MANAGE_ROLE', {
|
||||
title: (
|
||||
<span className="grid grid-flow-row">
|
||||
<span>Add Role</span>
|
||||
|
||||
<Text variant="subtitle1" component="span">
|
||||
Enter the name for the role below.
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
payload: {
|
||||
availableRoles,
|
||||
submitButtonText: 'Create',
|
||||
onSubmit: handleAddRole,
|
||||
},
|
||||
props: { PaperProps: { className: 'max-w-sm' } },
|
||||
});
|
||||
}
|
||||
|
||||
function handleOpenEditor(originalRole: Role) {
|
||||
openDialog('MANAGE_ROLE', {
|
||||
title: (
|
||||
<span className="grid grid-flow-row">
|
||||
<span>Edit Role</span>
|
||||
|
||||
<Text variant="subtitle1" component="span">
|
||||
Enter the name for the role below.
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
payload: {
|
||||
originalRole,
|
||||
availableRoles,
|
||||
onSubmit: (values: RoleFormValues) =>
|
||||
handleEditRole(values, originalRole),
|
||||
},
|
||||
props: { PaperProps: { className: 'max-w-sm' } },
|
||||
});
|
||||
}
|
||||
|
||||
function handleConfirmRemove(originalRole: Role) {
|
||||
openAlertDialog({
|
||||
title: 'Remove Role',
|
||||
payload: (
|
||||
<Text>
|
||||
Are you sure you want to remove the "
|
||||
<strong>{originalRole.name}</strong>" role?
|
||||
</Text>
|
||||
),
|
||||
props: {
|
||||
onPrimaryAction: () => handleRemoveRole(originalRole),
|
||||
primaryButtonColor: 'error',
|
||||
primaryButtonText: 'Remove',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleSetAsDefault(role: Role) {
|
||||
setValue('authUserDefaultRole', role.name, { shouldDirty: true });
|
||||
}
|
||||
|
||||
async function handleSubmit(values: RoleSettingsFormValues) {
|
||||
const updateAppPromise = updateApp({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
app: {
|
||||
authUserDefaultRole: values.authUserDefaultRole,
|
||||
authUserDefaultAllowedRoles: values.authUserDefaultAllowedRoles
|
||||
.map(({ name }) => name)
|
||||
.join(','),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
{
|
||||
loading: 'Updating roles...',
|
||||
success: 'Roles have been updated successfully.',
|
||||
error: 'An error occurred while updating roles.',
|
||||
},
|
||||
toastStyleProps,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Roles"
|
||||
description="Roles are used to control access to your application."
|
||||
docsLink="https://docs.nhost.io/authentication/users#roles"
|
||||
rootClassName="gap-0"
|
||||
className="px-0 my-2"
|
||||
slotProps={{
|
||||
submitButtonProps: {
|
||||
loading: formState.isSubmitting,
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="border-b-1 border-gray-200 px-4 py-3">
|
||||
<Text className="font-medium">Name</Text>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<List>
|
||||
{availableRoles.map((role, index) => (
|
||||
<Fragment key={role.name}>
|
||||
<ListItem.Root
|
||||
className="px-4"
|
||||
secondaryAction={
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<IconButton variant="borderless" color="secondary">
|
||||
<DotsVerticalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-32' }}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<Dropdown.Item
|
||||
onClick={() => handleSetAsDefault(role)}
|
||||
>
|
||||
<Text className="font-medium">Set as Default</Text>
|
||||
</Dropdown.Item>
|
||||
|
||||
<Divider component="li" />
|
||||
|
||||
<Dropdown.Item
|
||||
disabled={role.isSystemRole}
|
||||
onClick={() => handleOpenEditor(role)}
|
||||
>
|
||||
<Text className="font-medium">Edit</Text>
|
||||
</Dropdown.Item>
|
||||
|
||||
<Divider component="li" />
|
||||
|
||||
<Dropdown.Item
|
||||
disabled={role.isSystemRole}
|
||||
onClick={() => handleConfirmRemove(role)}
|
||||
>
|
||||
<Text
|
||||
className="font-medium"
|
||||
sx={{
|
||||
color: (theme) => theme.palette.error.main,
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Text
|
||||
primaryTypographyProps={{
|
||||
className:
|
||||
'inline-grid grid-flow-col gap-1 items-center h-6 font-medium',
|
||||
}}
|
||||
primary={
|
||||
<>
|
||||
{role.name}
|
||||
|
||||
{role.isSystemRole && (
|
||||
<LockIcon className="w-4 h-4" />
|
||||
)}
|
||||
|
||||
{defaultRole === role.name && (
|
||||
<Chip
|
||||
component="span"
|
||||
color="info"
|
||||
size="small"
|
||||
label="Default"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ListItem.Root>
|
||||
|
||||
<Divider
|
||||
component="li"
|
||||
className={twMerge(
|
||||
index === availableRoles.length - 1 ? '!mt-4' : '!my-4',
|
||||
)}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Button
|
||||
className="justify-self-start mx-4"
|
||||
variant="borderless"
|
||||
startIcon={<PlusIcon />}
|
||||
onClick={handleOpenCreator}
|
||||
>
|
||||
Add Role
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './RoleSettings';
|
||||
@@ -79,7 +79,7 @@ export default function AnonymousSignInSettings() {
|
||||
<Form onSubmit={handlePasswordProtectionSettingsChange}>
|
||||
<SettingsContainer
|
||||
title="Anonymous Users"
|
||||
description="Allow users to sign-in anonymously."
|
||||
description="Allow users to sign in anonymously."
|
||||
primaryActionButtonProps={{
|
||||
disabled:
|
||||
form.formState.isSubmitting ||
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function AppleProviderSettings() {
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<SettingsContainer
|
||||
title="Apple"
|
||||
description="Allows users to sign in with Apple."
|
||||
description="Allow users to sign in with Apple."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function DiscordProviderSettings() {
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<SettingsContainer
|
||||
title="Discord"
|
||||
description="Allows users to sign in with Discord."
|
||||
description="Allow users to sign in with Discord."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface EmailAndPasswordFormValues {
|
||||
authPasswordHibpEnabled: boolean;
|
||||
}
|
||||
|
||||
export default function EmailSettings() {
|
||||
export default function EmailAndPasswordSettings() {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation({
|
||||
refetchQueries: [GetAppLoginDataDocument],
|
||||
@@ -61,7 +61,7 @@ export default function EmailSettings() {
|
||||
|
||||
const { formState } = form;
|
||||
|
||||
const handleEmailSettingsChange = async (
|
||||
const handleEmailAndPasswordSettingsChange = async (
|
||||
values: EmailAndPasswordFormValues,
|
||||
) => {
|
||||
const updateAppMutation = updateApp({
|
||||
@@ -90,10 +90,10 @@ export default function EmailSettings() {
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleEmailSettingsChange}>
|
||||
<Form onSubmit={handleEmailAndPasswordSettingsChange}>
|
||||
<SettingsContainer
|
||||
title="Email and Password"
|
||||
description="Sign in users using email and password."
|
||||
description="Allow users to sign in with email and password."
|
||||
docsLink="https://docs.nhost.io/authentication/sign-in-with-email-and-password"
|
||||
docsTitle="how to sign in users with email and password"
|
||||
className="grid grid-flow-row"
|
||||
@@ -109,10 +109,8 @@ export default function EmailSettings() {
|
||||
name="authEmailSigninEmailVerifiedRequired"
|
||||
id="authEmailSigninEmailVerifiedRequired"
|
||||
label={
|
||||
<span className="inline-grid grid-flow-row gap-y-[2px] text-[15px]">
|
||||
<span className="text-[15px] font-medium">
|
||||
Require Verified Emails
|
||||
</span>
|
||||
<span className="inline-grid grid-flow-row gap-y-0.5 text-sm+">
|
||||
<span className="font-medium">Require Verified Emails</span>
|
||||
<span className="font-normal text-greyscaleMedium">
|
||||
Users must verify their email to be able to sign in.
|
||||
</span>
|
||||
@@ -124,11 +122,9 @@ export default function EmailSettings() {
|
||||
name="authPasswordHibpEnabled"
|
||||
id="authPasswordHibpEnabled"
|
||||
label={
|
||||
<span className="inline-grid grid-flow-row gap-y-[2px] text-[15px]">
|
||||
<span className="text-[15px] font-medium">
|
||||
Password Protection
|
||||
</span>
|
||||
<span className="text-[12px] font-normal text-greyscaleMedium">
|
||||
<span className="inline-grid grid-flow-row gap-y-0.5 text-sm+">
|
||||
<span className="font-medium">Password Protection</span>
|
||||
<span className="font-normal text-greyscaleMedium">
|
||||
Passwords must pass haveibeenpwned.com during sign-up.
|
||||
</span>
|
||||
</span>
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './EmailAndPasswordSettings';
|
||||
export { default } from './EmailAndPasswordSettings';
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './EmailSettings';
|
||||
export { default } from './EmailSettings';
|
||||
@@ -88,7 +88,7 @@ export default function FacebookProviderSettings() {
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<SettingsContainer
|
||||
title="Facebook"
|
||||
description="Allows users to sign in with Facebook."
|
||||
description="Allow users to sign in with Facebook."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function GitHubProviderSettings() {
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<SettingsContainer
|
||||
title="GitHub"
|
||||
description="Allows users to sign in with GitHub."
|
||||
description="Allow users to sign in with GitHub."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function GoogleProviderSettings() {
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<SettingsContainer
|
||||
title="Google"
|
||||
description="Allows users to sign in with Google."
|
||||
description="Allow users to sign in with Google."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function LinkedInProviderSettings() {
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<SettingsContainer
|
||||
title="LinkedIn"
|
||||
description="Allows users to sign in with LinkedIn"
|
||||
description="Allow users to sign in with LinkedIn."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function MagicLinkSettings() {
|
||||
<Form onSubmit={handleMagicLinkSettingsUpdate}>
|
||||
<SettingsContainer
|
||||
title="Magic Link"
|
||||
description="Allow users to sign-in with a magic link."
|
||||
description="Allow users to sign in with a magic link."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function SpotifyProviderSettings() {
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<SettingsContainer
|
||||
title="Spotify"
|
||||
description="Allows users to sign in with Spotify."
|
||||
description="Allow users to sign in with Spotify."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function TwitchProviderSettings() {
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<SettingsContainer
|
||||
title="Twitch"
|
||||
description="Allows users to sign in with Twitch."
|
||||
description="Allow users to sign in with Twitch."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function TwitterProviderSettings() {
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<SettingsContainer
|
||||
title="Twitter"
|
||||
description="Allows users to sign in with Twitter."
|
||||
description="Allow users to sign in with Twitter."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function WebAuthnSettings() {
|
||||
<Form onSubmit={handleWebAuthnSettingsUpdate}>
|
||||
<SettingsContainer
|
||||
title="Security Keys"
|
||||
description="Allow users to sign-in with security keys using WebAuthn."
|
||||
description="Allow users to sign in with security keys using WebAuthn."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function WindowsLiveProviderSettings() {
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<SettingsContainer
|
||||
title="Windows Live"
|
||||
description="Allows users to sign in with Windows Live."
|
||||
description="Allow users to sign in with Windows Live."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
|
||||
@@ -94,11 +94,13 @@ export default function WorkOsProviderSettings() {
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<SettingsContainer
|
||||
title="WorkOS"
|
||||
description="Allows users to sign in with WorkOS."
|
||||
description="Allow users to sign in with WorkOS."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication/sign-in-with-workos"
|
||||
docsTitle="how to sign in users with WorkOS"
|
||||
icon="/logos/WorkOs.svg"
|
||||
switchId="authWorkOsEnabled"
|
||||
showSwitch
|
||||
@@ -112,8 +114,8 @@ export default function WorkOsProviderSettings() {
|
||||
{...register(`authWorkOsClientId`)}
|
||||
name="authWorkOsClientId"
|
||||
id="authWorkOsClientId"
|
||||
label="WorkOS Client ID"
|
||||
placeholder="WorkOS Client ID"
|
||||
label="Client ID"
|
||||
placeholder="Enter your Client ID"
|
||||
className="col-span-3"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
@@ -122,28 +124,28 @@ export default function WorkOsProviderSettings() {
|
||||
{...register('authWorkOsClientSecret')}
|
||||
name="authWorkOsClientSecret"
|
||||
id="authWorkOsClientSecret"
|
||||
label="WorkOS Client Secret"
|
||||
placeholder="WorkOS Client Secret"
|
||||
label="Client Secret"
|
||||
placeholder="Enter your Client Secret"
|
||||
className="col-span-3"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
/>
|
||||
<Input
|
||||
{...register('authWorkOsDefaultDomain')}
|
||||
name="authWorkOsDefaultDomain"
|
||||
id="authWorkOsDefaultDomain"
|
||||
label="Default Domain"
|
||||
placeholder="Default Domain"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
/>
|
||||
<Input
|
||||
{...register('authWorkOsDefaultOrganization')}
|
||||
name="authWorkOsDefaultOrganization"
|
||||
id="authWorkOsDefaultOrganization"
|
||||
label="Default Organization"
|
||||
placeholder="Default Organization"
|
||||
label="Default Organization ID (optional)"
|
||||
placeholder="Default Organization ID"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
/>
|
||||
<Input
|
||||
{...register('authWorkOsDefaultDomain')}
|
||||
name="authWorkOsDefaultDomain"
|
||||
id="authWorkOsDefaultDomain"
|
||||
label="Default Domain (optional)"
|
||||
placeholder="Default Domain"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
@@ -152,7 +154,7 @@ export default function WorkOsProviderSettings() {
|
||||
{...register('authWorkOsDefaultConnection')}
|
||||
name="authWorkOsDefaultConnection"
|
||||
id="authWorkOsDefaultConnection"
|
||||
label="Default Connection"
|
||||
label="Default Connection (optional)"
|
||||
placeholder="Default Connection"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { styled } from '@mui/material';
|
||||
import type { ChipProps as MaterialChipProps } from '@mui/material/Chip';
|
||||
import MaterialChip from '@mui/material/Chip';
|
||||
import MaterialChip, { chipClasses } from '@mui/material/Chip';
|
||||
import type { ElementType } from 'react';
|
||||
|
||||
export interface ChipProps extends MaterialChipProps {}
|
||||
export interface ChipProps extends MaterialChipProps {
|
||||
/**
|
||||
* Custom component for the root node.
|
||||
*/
|
||||
component?: string | ElementType;
|
||||
}
|
||||
|
||||
const Chip = styled(MaterialChip)(({ theme }) => ({
|
||||
const Chip = styled(MaterialChip)<ChipProps>(({ theme }) => ({
|
||||
fontFamily: theme.typography.fontFamily,
|
||||
fontSize: '0.75rem',
|
||||
fontSize: theme.typography.pxToRem(12),
|
||||
lineHeight: theme.typography.pxToRem(16),
|
||||
fontWeight: 500,
|
||||
lineHeight: '16px',
|
||||
padding: theme.spacing(1.5, 0.25),
|
||||
color: theme.palette.text.primary,
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: '#EAEDF0',
|
||||
padding: theme.spacing(0, 0.25),
|
||||
[`&.${chipClasses.colorInfo}`]: {
|
||||
backgroundColor: theme.palette.primary.light,
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
|
||||
Chip.displayName = 'NhostChip';
|
||||
|
||||
@@ -32,6 +32,9 @@ const StyledMenu = styled(MaterialMenu)({
|
||||
[`& .${materialMenuClasses.list}`]: {
|
||||
padding: 0,
|
||||
},
|
||||
[`& .${materialMenuClasses.paper}`]: {
|
||||
boxShadow: '0px 4px 10px rgba(33, 50, 75, 0.25)',
|
||||
},
|
||||
});
|
||||
|
||||
function DropdownContent({
|
||||
@@ -68,8 +71,7 @@ function DropdownContent({
|
||||
sx: [
|
||||
{
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow:
|
||||
'0px 1px 4px rgba(14, 24, 39, 0.1), 0px 8px 24px rgba(14, 24, 39, 0.1)',
|
||||
boxShadow: '0px 4px 10px rgba(33, 50, 75, 0.25)',
|
||||
fontFamily: (theme) => theme.typography.fontFamily,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -37,7 +37,7 @@ const StyledListItemButton = styled(MaterialListItemButton)(({ theme }) => ({
|
||||
padding: theme.spacing(0.75, 1.25),
|
||||
},
|
||||
[`&.${listItemButtonClasses.selected}`]: {
|
||||
backgroundColor: `#ebf3ff`,
|
||||
backgroundColor: theme.palette.primary.light,
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
[`&.${listItemButtonClasses.selected} > .${listItemTextClasses.root}`]: {
|
||||
@@ -50,7 +50,7 @@ const StyledListItemButton = styled(MaterialListItemButton)(({ theme }) => ({
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
[`&.${listItemButtonClasses.selected}:hover`]: {
|
||||
backgroundColor: `#ebf3ff`,
|
||||
backgroundColor: theme.palette.primary.light,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -8,11 +8,15 @@ export interface ListItemTextProps extends MaterialListItemTextProps {}
|
||||
|
||||
const StyledListItemText = styled(MaterialListItemText)(({ theme }) => ({
|
||||
color: theme.palette.text.primary,
|
||||
display: 'grid',
|
||||
justifyContent: 'start',
|
||||
gridAutoFlow: 'row',
|
||||
gap: theme.spacing(0.5),
|
||||
fontSize: theme.typography.pxToRem(15),
|
||||
[`&.${listItemTextClasses.root}`]: {
|
||||
margin: 0,
|
||||
},
|
||||
[`& > .${listItemTextClasses.primary}`]: {
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: 500,
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { IconProps } from '@/ui/v2/icons';
|
||||
import SvgIcon from '@mui/material/SvgIcon';
|
||||
|
||||
function DotsVerticalIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="16"
|
||||
height="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
aria-label="Three vertical dots"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM8 4.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM8 14.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
DotsVerticalIcon.displayName = 'NhostDotsVerticalIcon';
|
||||
|
||||
export default DotsVerticalIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './DotsVerticalIcon';
|
||||
2
dashboard/src/hooks/common/useLeaveConfirm/index.ts
Normal file
2
dashboard/src/hooks/common/useLeaveConfirm/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './useLeaveConfirm';
|
||||
export { default } from './useLeaveConfirm';
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export interface UseLeaveConfirmProps {
|
||||
isDirty?: boolean;
|
||||
}
|
||||
|
||||
export default function useLeaveConfirm({ isDirty }: UseLeaveConfirmProps) {
|
||||
const router = useRouter();
|
||||
const { openAlertDialog } = useDialog();
|
||||
const [isConfirmed, setConfirmed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onRouteChangeStart(route: string) {
|
||||
if (!isDirty || isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
openAlertDialog({
|
||||
title: 'Unsaved changes',
|
||||
payload:
|
||||
'You have unsaved local changes. Are you sure you want to discard them?',
|
||||
props: {
|
||||
primaryButtonColor: 'error',
|
||||
primaryButtonText: 'Discard',
|
||||
onPrimaryAction: () => {
|
||||
setConfirmed(true);
|
||||
router.push(route);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
throw new Error('Route change aborted');
|
||||
}
|
||||
|
||||
router.events.on('routeChangeStart', onRouteChangeStart);
|
||||
|
||||
return () => router.events.off('routeChangeStart', onRouteChangeStart);
|
||||
}, [isConfirmed, isDirty, openAlertDialog, router, router.events]);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from '@/types/data-browser';
|
||||
import { getPreparedHasuraQuery } from '@/utils/dataBrowser/hasuraQueryHelpers';
|
||||
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
|
||||
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
|
||||
import prepareCreateColumnQuery from './prepareCreateColumnQuery';
|
||||
|
||||
export interface CreateColumnMigrationVariables {
|
||||
@@ -33,30 +34,27 @@ export default async function createColumnMigration({
|
||||
column,
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_NHOST_MIGRATIONS_URL}/apis/migrate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-hasura-admin-secret': adminSecret,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
dataSource,
|
||||
skip_execution: false,
|
||||
name: `alter_table_${schema}_${table}_add_column_${column.name}`,
|
||||
down: [
|
||||
getPreparedHasuraQuery(
|
||||
dataSource,
|
||||
'ALTER TABLE %I.%I DROP COLUMN IF EXISTS %I',
|
||||
schema,
|
||||
table,
|
||||
column.name,
|
||||
),
|
||||
],
|
||||
up: args,
|
||||
}),
|
||||
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-hasura-admin-secret': adminSecret,
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({
|
||||
dataSource,
|
||||
skip_execution: false,
|
||||
name: `alter_table_${schema}_${table}_add_column_${column.name}`,
|
||||
down: [
|
||||
getPreparedHasuraQuery(
|
||||
dataSource,
|
||||
'ALTER TABLE %I.%I DROP COLUMN IF EXISTS %I',
|
||||
schema,
|
||||
table,
|
||||
column.name,
|
||||
),
|
||||
],
|
||||
up: args,
|
||||
}),
|
||||
});
|
||||
|
||||
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
||||
await response.json();
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from '@/types/data-browser';
|
||||
import { getPreparedHasuraQuery } from '@/utils/dataBrowser/hasuraQueryHelpers';
|
||||
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
|
||||
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
|
||||
import prepareCreateTableQuery from './prepareCreateTableQuery';
|
||||
|
||||
export interface CreateTableMigrationVariables {
|
||||
@@ -27,29 +28,26 @@ export default async function createTableMigration({
|
||||
}: CreateTableMigrationOptions & CreateTableMigrationVariables) {
|
||||
const args = prepareCreateTableQuery({ dataSource, schema, table });
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_NHOST_MIGRATIONS_URL}/apis/migrate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-hasura-admin-secret': adminSecret,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
dataSource,
|
||||
skip_execution: false,
|
||||
name: `create_table_${schema}_${table.name}`,
|
||||
down: [
|
||||
getPreparedHasuraQuery(
|
||||
dataSource,
|
||||
'DROP TABLE IF EXISTS %I.%I',
|
||||
schema,
|
||||
table.name,
|
||||
),
|
||||
],
|
||||
up: args,
|
||||
}),
|
||||
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-hasura-admin-secret': adminSecret,
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({
|
||||
dataSource,
|
||||
skip_execution: false,
|
||||
name: `create_table_${schema}_${table.name}`,
|
||||
down: [
|
||||
getPreparedHasuraQuery(
|
||||
dataSource,
|
||||
'DROP TABLE IF EXISTS %I.%I',
|
||||
schema,
|
||||
table.name,
|
||||
),
|
||||
],
|
||||
up: args,
|
||||
}),
|
||||
});
|
||||
|
||||
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
||||
await response.json();
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from '@/types/data-browser';
|
||||
import { getPreparedHasuraQuery } from '@/utils/dataBrowser/hasuraQueryHelpers';
|
||||
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
|
||||
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
|
||||
|
||||
export interface DeleteColumnMigrationVariables {
|
||||
/**
|
||||
@@ -45,30 +46,27 @@ export default async function deleteColumnMigration({
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_NHOST_MIGRATIONS_URL}/apis/migrate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-hasura-admin-secret': adminSecret,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
dataSource,
|
||||
skip_execution: false,
|
||||
name: `alter_table_${schema}_${table}_drop_column_${column.id}`,
|
||||
down: recreateColumnArgs,
|
||||
up: [
|
||||
getPreparedHasuraQuery(
|
||||
dataSource,
|
||||
'ALTER TABLE %I.%I DROP COLUMN IF EXISTS %I CASCADE',
|
||||
schema,
|
||||
table,
|
||||
column.id,
|
||||
),
|
||||
],
|
||||
}),
|
||||
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-hasura-admin-secret': adminSecret,
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({
|
||||
dataSource,
|
||||
skip_execution: false,
|
||||
name: `alter_table_${schema}_${table}_drop_column_${column.id}`,
|
||||
down: recreateColumnArgs,
|
||||
up: [
|
||||
getPreparedHasuraQuery(
|
||||
dataSource,
|
||||
'ALTER TABLE %I.%I DROP COLUMN IF EXISTS %I CASCADE',
|
||||
schema,
|
||||
table,
|
||||
column.id,
|
||||
),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
||||
await response.json();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getPreparedHasuraQuery,
|
||||
} from '@/utils/dataBrowser/hasuraQueryHelpers';
|
||||
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
|
||||
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
|
||||
|
||||
export interface DeleteTableMigrationVariables {
|
||||
/**
|
||||
@@ -39,32 +40,29 @@ export default async function deleteTable({
|
||||
),
|
||||
];
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_NHOST_MIGRATIONS_URL}/apis/migrate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-hasura-admin-secret': adminSecret,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
dataSource,
|
||||
skip_execution: false,
|
||||
name: `drop_table_${schema}_${table}`,
|
||||
down: [
|
||||
{
|
||||
type: 'run_sql',
|
||||
args: {
|
||||
cascade: false,
|
||||
read_only: false,
|
||||
source: '',
|
||||
sql: getEmptyDownMigrationMessage(deleteTableArgs),
|
||||
},
|
||||
},
|
||||
],
|
||||
up: deleteTableArgs,
|
||||
}),
|
||||
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-hasura-admin-secret': adminSecret,
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({
|
||||
dataSource,
|
||||
skip_execution: false,
|
||||
name: `drop_table_${schema}_${table}`,
|
||||
down: [
|
||||
{
|
||||
type: 'run_sql',
|
||||
args: {
|
||||
cascade: false,
|
||||
read_only: false,
|
||||
source: '',
|
||||
sql: getEmptyDownMigrationMessage(deleteTableArgs),
|
||||
},
|
||||
},
|
||||
],
|
||||
up: deleteTableArgs,
|
||||
}),
|
||||
});
|
||||
|
||||
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
||||
await response.json();
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
QueryResult,
|
||||
} from '@/types/data-browser';
|
||||
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
|
||||
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
|
||||
import prepareTrackForeignKeyRelationsMetadata from './prepareTrackForeignKeyRelationsMetadata';
|
||||
|
||||
export interface TrackForeignKeyRelationsMigrationVariables {
|
||||
@@ -45,23 +46,20 @@ export default async function trackForeignKeyRelationsMigration({
|
||||
foreignKeyRelations,
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_NHOST_MIGRATIONS_URL}/apis/migrate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-hasura-admin-secret': adminSecret,
|
||||
},
|
||||
|
||||
body: JSON.stringify({
|
||||
dataSource,
|
||||
skip_execution: false,
|
||||
name: `track_foreign_key_relations_${schema}_${table}`,
|
||||
down: [],
|
||||
up: creatableRelationships,
|
||||
}),
|
||||
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-hasura-admin-secret': adminSecret,
|
||||
},
|
||||
);
|
||||
|
||||
body: JSON.stringify({
|
||||
dataSource,
|
||||
skip_execution: false,
|
||||
name: `track_foreign_key_relations_${schema}_${table}`,
|
||||
down: [],
|
||||
up: creatableRelationships,
|
||||
}),
|
||||
});
|
||||
|
||||
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
||||
await response.json();
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
QueryResult,
|
||||
} from '@/types/data-browser';
|
||||
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
|
||||
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
|
||||
|
||||
export interface TrackTableMigrationVariables {
|
||||
/**
|
||||
@@ -23,32 +24,29 @@ export default async function trackTableMigration({
|
||||
adminSecret,
|
||||
table,
|
||||
}: TrackTableMigrationOptions & TrackTableMigrationVariables) {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_NHOST_MIGRATIONS_URL}/apis/migrate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-hasura-admin-secret': adminSecret,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
dataSource,
|
||||
skip_execution: false,
|
||||
name: `add_existing_table_or_view_${schema}_${table.name}`,
|
||||
down: [
|
||||
{
|
||||
type: 'pg_untrack_table',
|
||||
args: { source: dataSource, table: { schema, name: table.name } },
|
||||
},
|
||||
],
|
||||
up: [
|
||||
{
|
||||
args: { source: dataSource, table: { schema, name: table.name } },
|
||||
type: 'pg_track_table',
|
||||
},
|
||||
],
|
||||
}),
|
||||
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-hasura-admin-secret': adminSecret,
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({
|
||||
dataSource,
|
||||
skip_execution: false,
|
||||
name: `add_existing_table_or_view_${schema}_${table.name}`,
|
||||
down: [
|
||||
{
|
||||
type: 'pg_untrack_table',
|
||||
args: { source: dataSource, table: { schema, name: table.name } },
|
||||
},
|
||||
],
|
||||
up: [
|
||||
{
|
||||
args: { source: dataSource, table: { schema, name: table.name } },
|
||||
type: 'pg_track_table',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
||||
await response.json();
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from '@/types/data-browser';
|
||||
import { getEmptyDownMigrationMessage } from '@/utils/dataBrowser/hasuraQueryHelpers';
|
||||
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
|
||||
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
|
||||
import prepareUpdateColumnQuery from './prepareUpdateColumnQuery';
|
||||
|
||||
export interface UpdateColumnMigrationVariables {
|
||||
@@ -65,22 +66,19 @@ export default async function updateColumnMigration({
|
||||
];
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_NHOST_MIGRATIONS_URL}/apis/migrate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-hasura-admin-secret': adminSecret,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
dataSource,
|
||||
skip_execution: false,
|
||||
name: `alter_table_${schema}_${table}_alter_column_${originalColumn.name}`,
|
||||
down: columnUpdateDownMigration,
|
||||
up: columnUpdateUpMigration,
|
||||
}),
|
||||
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-hasura-admin-secret': adminSecret,
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({
|
||||
dataSource,
|
||||
skip_execution: false,
|
||||
name: `alter_table_${schema}_${table}_alter_column_${originalColumn.name}`,
|
||||
down: columnUpdateDownMigration,
|
||||
up: columnUpdateUpMigration,
|
||||
}),
|
||||
});
|
||||
|
||||
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
||||
await response.json();
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from '@/types/data-browser';
|
||||
import { getEmptyDownMigrationMessage } from '@/utils/dataBrowser/hasuraQueryHelpers';
|
||||
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
|
||||
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
|
||||
import prepareUpdateTableQuery from './prepareUpdateTableQuery';
|
||||
|
||||
export interface UpdateTableMigrationVariables {
|
||||
@@ -56,32 +57,29 @@ export default async function updateTableMigration({
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_NHOST_MIGRATIONS_URL}/apis/migrate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-hasura-admin-secret': adminSecret,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
dataSource,
|
||||
skip_execution: false,
|
||||
name: `alter_table_${schema}_${originalTable.table_name}`,
|
||||
down: [
|
||||
{
|
||||
type: 'run_sql',
|
||||
args: {
|
||||
cascade: false,
|
||||
read_only: false,
|
||||
source: '',
|
||||
sql: getEmptyDownMigrationMessage(args),
|
||||
},
|
||||
},
|
||||
],
|
||||
up: args,
|
||||
}),
|
||||
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-hasura-admin-secret': adminSecret,
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({
|
||||
dataSource,
|
||||
skip_execution: false,
|
||||
name: `alter_table_${schema}_${originalTable.table_name}`,
|
||||
down: [
|
||||
{
|
||||
type: 'run_sql',
|
||||
args: {
|
||||
cascade: false,
|
||||
read_only: false,
|
||||
source: '',
|
||||
sql: getEmptyDownMigrationMessage(args),
|
||||
},
|
||||
},
|
||||
],
|
||||
up: args,
|
||||
}),
|
||||
});
|
||||
|
||||
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
||||
await response.json();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LOCAL_SUBDOMAIN } from '@/utils/env';
|
||||
import { isDevOrStaging } from '@/utils/helpers';
|
||||
import type { NhostClientConstructorParams } from '@nhost/nhost-js';
|
||||
import { NhostClient } from '@nhost/nhost-js';
|
||||
@@ -20,7 +21,7 @@ export function useAppClient(
|
||||
|
||||
if (process.env.NEXT_PUBLIC_ENV === 'dev') {
|
||||
return new NhostClient({
|
||||
subdomain: 'localhost:1337',
|
||||
subdomain: LOCAL_SUBDOMAIN,
|
||||
start: false,
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { CustomClaim } from '@/types/application';
|
||||
import { useGetAppCustomClaimsQuery } from '@/utils/__generated__/graphql';
|
||||
|
||||
export type UseCustomClaimsProps = {
|
||||
/**
|
||||
* Application identifier.
|
||||
*/
|
||||
appId: string;
|
||||
};
|
||||
|
||||
export default function useCustomClaims({ appId }: UseCustomClaimsProps) {
|
||||
const { data, loading, error } = useGetAppCustomClaimsQuery({
|
||||
variables: { id: appId },
|
||||
});
|
||||
|
||||
const systemClaims: CustomClaim[] = [
|
||||
{ key: 'User-Id', value: 'id', system: true },
|
||||
];
|
||||
|
||||
if (data?.app) {
|
||||
const storedClaims: CustomClaim[] = Object.keys(
|
||||
data.app.authJwtCustomClaims,
|
||||
)
|
||||
.sort()
|
||||
.map((key) => ({
|
||||
key,
|
||||
value: data.app.authJwtCustomClaims[key],
|
||||
}));
|
||||
|
||||
return {
|
||||
data: systemClaims.concat(storedClaims),
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: systemClaims,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -37,8 +37,8 @@ export default function SettingsAuthenticationPage() {
|
||||
|
||||
return (
|
||||
<Container
|
||||
className="grid max-w-5xl grid-flow-row gap-y-6 bg-fafafa"
|
||||
wrapperClassName="bg-fafafa"
|
||||
className="grid max-w-5xl grid-flow-row gap-y-6 bg-transparent"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<ClientURLSettings />
|
||||
<AllowedRedirectURLsSettings />
|
||||
@@ -52,13 +52,5 @@ export default function SettingsAuthenticationPage() {
|
||||
}
|
||||
|
||||
SettingsAuthenticationPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<SettingsLayout
|
||||
mainContainerProps={{
|
||||
className: 'bg-fafafa',
|
||||
}}
|
||||
>
|
||||
{page}
|
||||
</SettingsLayout>
|
||||
);
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
@@ -81,8 +81,8 @@ export default function DatabaseSettingsPage() {
|
||||
|
||||
return (
|
||||
<Container
|
||||
className="grid max-w-5xl grid-flow-row gap-y-6 bg-fafafa"
|
||||
wrapperClassName="bg-fafafa"
|
||||
className="grid max-w-5xl grid-flow-row gap-y-6 bg-transparent"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<SettingsContainer
|
||||
title="Connection Info"
|
||||
@@ -136,13 +136,5 @@ export default function DatabaseSettingsPage() {
|
||||
}
|
||||
|
||||
DatabaseSettingsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<SettingsLayout
|
||||
mainContainerProps={{
|
||||
className: 'bg-fafafa',
|
||||
}}
|
||||
>
|
||||
{page}
|
||||
</SettingsLayout>
|
||||
);
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function SettingsGeneralPage() {
|
||||
return (
|
||||
<Container
|
||||
className="grid max-w-5xl grid-flow-row gap-8 bg-transparent"
|
||||
wrapperClassName="bg-fafafa"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleProjectNameChange}>
|
||||
@@ -194,13 +194,5 @@ export default function SettingsGeneralPage() {
|
||||
}
|
||||
|
||||
SettingsGeneralPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<SettingsLayout
|
||||
mainContainerProps={{
|
||||
className: 'bg-fafafa',
|
||||
}}
|
||||
>
|
||||
{page}
|
||||
</SettingsLayout>
|
||||
);
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
@@ -25,8 +25,8 @@ export default function SettingsGitPage() {
|
||||
|
||||
return (
|
||||
<Container
|
||||
className="grid max-w-5xl grid-flow-row gap-y-6 bg-fafafa"
|
||||
wrapperClassName="bg-fafafa"
|
||||
className="grid max-w-5xl grid-flow-row gap-y-6 bg-transparent"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<SettingsContainer
|
||||
title="Git Repository"
|
||||
@@ -95,13 +95,5 @@ export default function SettingsGitPage() {
|
||||
}
|
||||
|
||||
SettingsGitPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<SettingsLayout
|
||||
mainContainerProps={{
|
||||
className: 'bg-fafafa',
|
||||
}}
|
||||
>
|
||||
{page}
|
||||
</SettingsLayout>
|
||||
);
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
@@ -1,386 +1,21 @@
|
||||
import CreatePermissionVariableModal from '@/components/applications/users/permissions/modal/CreatePermissionVariableModal';
|
||||
import EditPermissionVariableModal from '@/components/applications/users/permissions/modal/EditPermissionVariableModal';
|
||||
import { PermissionSetting } from '@/components/applications/users/PermissionSetting';
|
||||
import { RolesTable } from '@/components/applications/users/RolesTable';
|
||||
import { SettingsSection } from '@/components/applications/users/SettingsSection';
|
||||
import ErrorBoundaryFallback from '@/components/common/ErrorBoundaryFallback';
|
||||
import Container from '@/components/layout/Container';
|
||||
import PermissionVariableSettings from '@/components/settings/permissions/PermissionVariableSettings';
|
||||
import RolesSettings from '@/components/settings/roles/RoleSettings/RoleSettings';
|
||||
import SettingsLayout from '@/components/settings/SettingsLayout';
|
||||
import { useWorkspaceContext } from '@/context/workspace-context';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import useCustomClaims from '@/hooks/useCustomClaims';
|
||||
import { useSubmitState } from '@/hooks/useSubmitState';
|
||||
import type { CustomClaim } from '@/types/application';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Loading from '@/ui/Loading';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { showLoadingToast, triggerToast } from '@/utils/toast';
|
||||
import {
|
||||
useGetRolesQuery,
|
||||
useUpdateAppMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { ChevronRightIcon } from '@heroicons/react/solid';
|
||||
import clsx from 'clsx';
|
||||
import Link from 'next/link';
|
||||
import type { KeyboardEvent, MouseEvent, ReactElement } from 'react';
|
||||
import { useEffect, useReducer, useRef, useState } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
type ModalState = {
|
||||
visible: boolean;
|
||||
type: 'create' | 'edit';
|
||||
payload: CustomClaim;
|
||||
};
|
||||
|
||||
type ModalAction = {
|
||||
type: 'OPEN_CREATE_MODAL' | 'OPEN_EDIT_MODAL' | 'CLOSE_MODAL';
|
||||
payload?: CustomClaim;
|
||||
};
|
||||
|
||||
function modalStateReducer(state: ModalState, action: ModalAction): ModalState {
|
||||
switch (action.type) {
|
||||
case 'OPEN_CREATE_MODAL':
|
||||
return { ...state, visible: true, type: 'create', payload: null };
|
||||
case 'OPEN_EDIT_MODAL':
|
||||
return { ...state, visible: true, type: 'edit', payload: action.payload };
|
||||
case 'CLOSE_MODAL':
|
||||
return { ...state, visible: false };
|
||||
default:
|
||||
throw new Error(`Action type ${action.type} is not supported.`);
|
||||
}
|
||||
}
|
||||
|
||||
function PermissionVariablesTable({ appId }: any) {
|
||||
const [
|
||||
{ visible: modalVisible, type: modalType, payload: modalPayload },
|
||||
dispatch,
|
||||
] = useReducer(modalStateReducer, {
|
||||
visible: false,
|
||||
type: null,
|
||||
payload: null,
|
||||
});
|
||||
|
||||
const { data: customClaims, loading, error } = useCustomClaims({ appId });
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
function handlePermissionSelect(
|
||||
event: MouseEvent<HTMLTableRowElement> | KeyboardEvent<HTMLTableRowElement>,
|
||||
claim: CustomClaim,
|
||||
) {
|
||||
if ('key' in event && event.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: 'OPEN_EDIT_MODAL', payload: claim });
|
||||
}
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export default function RolesAndPermissionsPage() {
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
showModal={modalVisible}
|
||||
close={() => dispatch({ type: 'CLOSE_MODAL' })}
|
||||
>
|
||||
{modalType === 'create' ? (
|
||||
<CreatePermissionVariableModal
|
||||
onClose={() => dispatch({ type: 'CLOSE_MODAL' })}
|
||||
/>
|
||||
) : (
|
||||
<EditPermissionVariableModal
|
||||
onClose={() => dispatch({ type: 'CLOSE_MODAL' })}
|
||||
payload={modalPayload}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<table className="w-full table-fixed overflow-x-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-60 p-2 text-left">
|
||||
<Text className="text-xs font-bold text-greyscaleDark">
|
||||
Field name
|
||||
</Text>
|
||||
</th>
|
||||
<th className="w-full p-2 text-left">
|
||||
<Text className="text-xs font-bold text-greyscaleDark">Path</Text>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customClaims.map((claim, index) => (
|
||||
<tr
|
||||
role={claim.system ? undefined : 'button'}
|
||||
tabIndex={claim.system ? undefined : 0}
|
||||
onClick={
|
||||
claim.system
|
||||
? undefined
|
||||
: (event) => handlePermissionSelect(event, claim)
|
||||
}
|
||||
onKeyDown={
|
||||
claim.system
|
||||
? undefined
|
||||
: (event) => handlePermissionSelect(event, claim)
|
||||
}
|
||||
aria-label={claim.key}
|
||||
className="border-t-1 border-solid border-gray-300"
|
||||
key={claim.key || index}
|
||||
>
|
||||
<td className="p-2">
|
||||
<Text
|
||||
className={clsx(
|
||||
claim.system ? 'text-greyscaleGrey' : 'text-greyscaleDark',
|
||||
'text-sm+ font-medium',
|
||||
)}
|
||||
>
|
||||
X-Hasura-{claim.key}
|
||||
</Text>
|
||||
</td>
|
||||
<td className="flex items-center justify-between p-2">
|
||||
<Text
|
||||
className={clsx(
|
||||
claim.system ? 'text-greyscaleGrey' : 'text-greyscaleDark',
|
||||
'text-sm+',
|
||||
)}
|
||||
>
|
||||
user.{claim.value}
|
||||
</Text>
|
||||
|
||||
{claim.system ? (
|
||||
<Text className="text-sm+ font-medium uppercase tracking-wide text-greyscaleGrey">
|
||||
System
|
||||
</Text>
|
||||
) : (
|
||||
<ChevronRightIcon className="h-4.5 w-4.5 text-greyscaleDark" />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
<tr className="border-y-1 border-solid border-gray-300">
|
||||
<td className="p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: 'OPEN_CREATE_MODAL' })}
|
||||
>
|
||||
<Text className="text-sm+ font-medium text-blue">
|
||||
New Permission Variable
|
||||
</Text>
|
||||
</button>
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRoles() {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
|
||||
const { data, loading, error } = useGetRolesQuery({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2.5xl">
|
||||
<Alert severity="error">{error.message}</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Default Allowed Roles"
|
||||
wrapperProps={{
|
||||
className: 'mt-12 mb-32',
|
||||
}}
|
||||
<Container
|
||||
className="grid grid-flow-row gap-6 max-w-5xl bg-transparent"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<RolesTable data={data} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultRoleInAPIRequests() {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const toastId = useRef(null);
|
||||
|
||||
const { data, loading, error } = useGetRolesQuery({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
},
|
||||
});
|
||||
|
||||
const [currentDefaultRole, setCurrentDefaultRole] = useState({
|
||||
id: 'user',
|
||||
name: 'user',
|
||||
disabled: false,
|
||||
slug: 'user',
|
||||
});
|
||||
|
||||
const [currentAvailableRoles, setCurrentAvailableRoles] = useState([
|
||||
{
|
||||
id: 'user',
|
||||
name: 'user',
|
||||
disabled: false,
|
||||
slug: 'user',
|
||||
},
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentDefaultRole({
|
||||
disabled: false,
|
||||
id: data.app.authUserDefaultRole,
|
||||
slug: data.app.authUserDefaultRole,
|
||||
name: data.app.authUserDefaultRole,
|
||||
});
|
||||
|
||||
setCurrentAvailableRoles(
|
||||
data.app.authUserDefaultAllowedRoles.split(',').map((role) => ({
|
||||
disabled: false,
|
||||
id: role,
|
||||
slug: role,
|
||||
name: role,
|
||||
})),
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const { submitState, setSubmitState } = useSubmitState();
|
||||
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
const client = useApolloClient();
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2.5xl">
|
||||
<Alert severity="error">{error.message}</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-col divide-y-1 divide-divide border-t border-b">
|
||||
{submitState.error && (
|
||||
<Alert severity="error">{submitState.error.message}</Alert>
|
||||
)}
|
||||
<PermissionSetting
|
||||
text="Default Role"
|
||||
options={currentAvailableRoles}
|
||||
value={currentDefaultRole}
|
||||
onChange={async (v: { id: string }) => {
|
||||
try {
|
||||
toastId.current = showLoadingToast('Changing default role');
|
||||
await updateApp({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
authUserDefaultRole: v.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
await client.refetchQueries({
|
||||
include: ['getRoles'],
|
||||
});
|
||||
toast.remove(toastId.current);
|
||||
triggerToast(
|
||||
`Successfully changed default role to: ${currentApplication.name}`,
|
||||
);
|
||||
} catch (appUpdateError) {
|
||||
if (toastId) {
|
||||
toast.remove(toastId.current);
|
||||
}
|
||||
|
||||
if (appUpdateError instanceof Error) {
|
||||
triggerToast(appUpdateError.message);
|
||||
}
|
||||
|
||||
setSubmitState({
|
||||
loading: false,
|
||||
error: appUpdateError,
|
||||
fieldsWithError: ['authUserDefaultRole'],
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UsersRolesPage() {
|
||||
const { workspaceContext } = useWorkspaceContext();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<SettingsSection
|
||||
title="Roles"
|
||||
wrapperProps={{
|
||||
className: 'mt-0 mb-20',
|
||||
}}
|
||||
>
|
||||
<DefaultRoleInAPIRequests />
|
||||
</SettingsSection>
|
||||
|
||||
<UserRoles />
|
||||
|
||||
<SettingsSection
|
||||
title={<span>Permission Variables</span>}
|
||||
titleProps={{
|
||||
className:
|
||||
'grid gap-2 grid-flow-col items-center place-content-start',
|
||||
}}
|
||||
desc={
|
||||
<p>
|
||||
These variables can be used to defined permissions. They are sent
|
||||
from client to the GraphQL API, and must match the specified
|
||||
property of a queried user.{' '}
|
||||
<Link
|
||||
href="https://docs.nhost.io/platform/graphql/permissions"
|
||||
passHref
|
||||
>
|
||||
<Text
|
||||
variant="a"
|
||||
className="font-medium text-blue"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Read More
|
||||
</Text>
|
||||
</Link>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<ErrorBoundary fallbackRender={ErrorBoundaryFallback}>
|
||||
<PermissionVariablesTable appId={workspaceContext.appId} />
|
||||
</ErrorBoundary>
|
||||
</SettingsSection>
|
||||
<RolesSettings />
|
||||
<PermissionVariableSettings />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
UsersRolesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
RolesAndPermissionsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import SettingsLayout from '@/components/settings/SettingsLayout';
|
||||
import AnonymousSignInSettings from '@/components/settings/signInMethods/AnonymousSignInSettings';
|
||||
import AppleProviderSettings from '@/components/settings/signInMethods/AppleProviderSettings';
|
||||
import DiscordProviderSettings from '@/components/settings/signInMethods/DiscordProviderSettings';
|
||||
import EmailSettings from '@/components/settings/signInMethods/EmailSettings';
|
||||
import EmailAndPasswordSettings from '@/components/settings/signInMethods/EmailAndPasswordSettings';
|
||||
import FacebookProviderSettings from '@/components/settings/signInMethods/FacebookProviderSettings';
|
||||
import GitHubProviderSettings from '@/components/settings/signInMethods/GitHubProviderSettings';
|
||||
import GoogleProviderSettings from '@/components/settings/signInMethods/GoogleProviderSettings';
|
||||
@@ -35,7 +35,7 @@ export default function SettingsSignInMethodsPage() {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading Sign-In Methods Settings..."
|
||||
label="Loading sign-in method settings..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
@@ -47,37 +47,29 @@ export default function SettingsSignInMethodsPage() {
|
||||
|
||||
return (
|
||||
<Container
|
||||
className="max-w-5xl space-y-8 bg-fafafa"
|
||||
wrapperClassName="bg-fafafa"
|
||||
className="max-w-5xl space-y-8 bg-transparent"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<EmailSettings />
|
||||
<EmailAndPasswordSettings />
|
||||
<MagicLinkSettings />
|
||||
<WebAuthnSettings />
|
||||
<AnonymousSignInSettings />
|
||||
<SMSSettings />
|
||||
<GoogleProviderSettings />
|
||||
<GitHubProviderSettings />
|
||||
<LinkedInProviderSettings />
|
||||
<AppleProviderSettings />
|
||||
<WindowsLiveProviderSettings />
|
||||
<DiscordProviderSettings />
|
||||
<FacebookProviderSettings />
|
||||
<GitHubProviderSettings />
|
||||
<GoogleProviderSettings />
|
||||
<LinkedInProviderSettings />
|
||||
<SpotifyProviderSettings />
|
||||
<TwitchProviderSettings />
|
||||
<DiscordProviderSettings />
|
||||
<TwitterProviderSettings />
|
||||
<WindowsLiveProviderSettings />
|
||||
<WorkOsProviderSettings />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
SettingsSignInMethodsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<SettingsLayout
|
||||
mainContainerProps={{
|
||||
className: 'bg-fafafa',
|
||||
}}
|
||||
>
|
||||
{page}
|
||||
</SettingsLayout>
|
||||
);
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
@@ -100,8 +100,8 @@ export default function SMTPSettingsPage() {
|
||||
if (currentApplication.plan.isFree) {
|
||||
return (
|
||||
<Container
|
||||
className="grid max-w-5xl grid-flow-row gap-4 bg-fafafa"
|
||||
wrapperClassName="bg-fafafa"
|
||||
className="grid max-w-5xl grid-flow-row gap-4 bg-transparent"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<UnlockFeatureByUpgrading
|
||||
message="Unlock SMTP settings by upgrading your project to the Pro plan."
|
||||
@@ -165,8 +165,8 @@ export default function SMTPSettingsPage() {
|
||||
|
||||
return (
|
||||
<Container
|
||||
className="grid max-w-5xl grid-flow-row gap-4 bg-fafafa"
|
||||
wrapperClassName="bg-fafafa"
|
||||
className="grid max-w-5xl grid-flow-row gap-4 bg-transparent"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleEditSMTPSettings}>
|
||||
@@ -275,13 +275,5 @@ export default function SMTPSettingsPage() {
|
||||
}
|
||||
|
||||
SMTPSettingsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<SettingsLayout
|
||||
mainContainerProps={{
|
||||
className: 'bg-fafafa',
|
||||
}}
|
||||
>
|
||||
{page}
|
||||
</SettingsLayout>
|
||||
);
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
@@ -11,8 +11,8 @@ export default class MyDocument extends Document {
|
||||
|
||||
render() {
|
||||
const meta = {
|
||||
title: 'Nhost 2.0 | Console',
|
||||
description: 'Nhost Console 2.0',
|
||||
title: 'Dashboard - Nhost',
|
||||
description: 'Nhost Dashboard',
|
||||
image: '/assets/splash.png',
|
||||
};
|
||||
|
||||
|
||||
@@ -26,5 +26,5 @@ export default function IndexPage() {
|
||||
}
|
||||
|
||||
IndexPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <AuthenticatedLayout title="Console">{page}</AuthenticatedLayout>;
|
||||
return <AuthenticatedLayout title="Dashboard">{page}</AuthenticatedLayout>;
|
||||
};
|
||||
|
||||
@@ -33,7 +33,7 @@ export const theme = createTheme({
|
||||
},
|
||||
palette: {
|
||||
primary: {
|
||||
light: '#3787ff',
|
||||
light: '#ebf3ff',
|
||||
main: '#0052cd',
|
||||
dark: '#063799',
|
||||
},
|
||||
|
||||
@@ -79,5 +79,10 @@ export type Application = {
|
||||
export type CustomClaim = {
|
||||
key: string;
|
||||
value: string;
|
||||
system?: boolean;
|
||||
isSystemClaim?: boolean;
|
||||
};
|
||||
|
||||
export type Role = {
|
||||
name: string;
|
||||
isSystemRole?: boolean;
|
||||
};
|
||||
|
||||
36
dashboard/src/utils/env.ts
Normal file
36
dashboard/src/utils/env.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* URL of Hasura's Migration API. This is only used when local development is
|
||||
* enabled.
|
||||
*/
|
||||
export const LOCAL_MIGRATIONS_URL = `http://localhost:${
|
||||
process.env.NEXT_PUBLIC_NHOST_LOCAL_MIGRATIONS_PORT || 9693
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Port of the locally running backend.s
|
||||
*/
|
||||
export const LOCAL_BACKEND_PORT =
|
||||
process.env.NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT;
|
||||
|
||||
/**
|
||||
* Local subdomain. This is only used when local development is enabled.
|
||||
*/
|
||||
export const LOCAL_SUBDOMAIN = LOCAL_BACKEND_PORT
|
||||
? `localhost:${LOCAL_BACKEND_PORT}`
|
||||
: 'localhost';
|
||||
|
||||
/**
|
||||
* URL of Hasura Console. This is only used when running the Nhost Dashboard
|
||||
* locally.
|
||||
*/
|
||||
export const LOCAL_HASURA_URL = `http://localhost:${
|
||||
process.env.NEXT_PUBLIC_NHOST_LOCAL_HASURA_PORT || 9695
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Backend URL for the locally running instance. This is only used when running
|
||||
* the Nhost Dashboard locally.
|
||||
*/
|
||||
export const LOCAL_BACKEND_URL = `http://localhost:${
|
||||
process.env.NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT || 1337
|
||||
}`;
|
||||
@@ -6,6 +6,7 @@ import features from '@/data/features.json';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import type { NextRouter } from 'next/router';
|
||||
import slugify from 'slugify';
|
||||
import { LOCAL_BACKEND_URL } from './env';
|
||||
import type { DeploymentRowFragment } from './__generated__/graphql';
|
||||
|
||||
export function getLastLiveDeployment(deployments: DeploymentRowFragment[]) {
|
||||
@@ -57,11 +58,11 @@ export function getCurrentEnvironment(): Environment {
|
||||
|
||||
export function generateRemoteAppUrl(subdomain: string): string {
|
||||
if (process.env.NEXT_PUBLIC_NHOST_PLATFORM !== 'true') {
|
||||
return 'http://localhost:1337';
|
||||
return LOCAL_BACKEND_URL;
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_ENV === 'dev') {
|
||||
return process.env.NEXT_PUBLIC_NHOST_BACKEND_URL || 'http://localhost:1337';
|
||||
return process.env.NEXT_PUBLIC_NHOST_BACKEND_URL || LOCAL_BACKEND_URL;
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_ENV === 'staging') {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NhostClient } from '@nhost/nextjs';
|
||||
import { LOCAL_SUBDOMAIN } from './env';
|
||||
|
||||
const nhost = new NhostClient({
|
||||
backendUrl: process.env.NEXT_PUBLIC_NHOST_BACKEND_URL as string,
|
||||
});
|
||||
export const nhost =
|
||||
process.env.NEXT_PUBLIC_NHOST_PLATFORM === 'true'
|
||||
? new NhostClient({ backendUrl: process.env.NEXT_PUBLIC_NHOST_BACKEND_URL })
|
||||
: new NhostClient({ subdomain: LOCAL_SUBDOMAIN });
|
||||
|
||||
export { nhost };
|
||||
export default nhost;
|
||||
|
||||
@@ -55,7 +55,6 @@ module.exports = {
|
||||
log: '#F4F7F9',
|
||||
input: '#C2CAD6',
|
||||
ish: '#f6f9fc',
|
||||
fafafa: '#fafafa',
|
||||
picker: 'rgba(33, 50, 75, 1)',
|
||||
divide: 'rgba(0, 35, 88, 0.16)',
|
||||
greyscaleDark: '#21324B',
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @nhost/docs
|
||||
|
||||
## 0.0.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 23274dee: chore(docs): update permission variables image
|
||||
|
||||
## 0.0.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ca012d79: docs(workos): WorkOS Docs
|
||||
|
||||
## 0.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -21,6 +21,7 @@ Nhost Authentication lets you authenticate users using different sign-in methods
|
||||
- [LinkedIn](/authentication/sign-in-with-linkedin)
|
||||
- [Spotify](/authentication/sign-in-with-spotify)
|
||||
- [Twitch](/authentication/sign-in-with-twitch)
|
||||
- [WorkOS](/authentication/sign-in-with-workos)
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ Follow this guide to sign in users with GitHub.
|
||||
|
||||
- Fill in Application Name.
|
||||
- Fill in Homepage URL.
|
||||
- Fill in **Authorization callback URL** with your OAuth Callbacke URL from Nhost.
|
||||
- Fill in **Authorization callback URL** with your OAuth Callback URL from Nhost.
|
||||
|
||||
## Configure Nhost
|
||||
|
||||
|
||||
67
docs/docs/authentication/sign-in-methods/4-workos.mdx
Normal file
67
docs/docs/authentication/sign-in-methods/4-workos.mdx
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: Sign In with WorkOS
|
||||
sidebar_label: WorkOS
|
||||
slug: /authentication/sign-in-with-workos
|
||||
image: /img/og/sign-in-with-workos.png
|
||||
---
|
||||
|
||||
Follow this guide to sign in users with WorkOS.
|
||||
|
||||
<p align="center">
|
||||
<img
|
||||
alt="WorkOS Sign In Preview"
|
||||
src="/img/social-providers/workos-preview.svg"
|
||||
width={480}
|
||||
height={267}
|
||||
/>
|
||||
</p>
|
||||
|
||||
## Create WorkOS Account
|
||||
|
||||
- Go to [WorkOS's website](https://workos.com/).
|
||||
- Click on **Sign In** in the top menu and create a WorkOS account.
|
||||
|
||||
## Get the Client ID
|
||||
|
||||
- In the WorkOS dashboard, click on **Configuration** in the left menu.
|
||||
- Copy the **Client ID**, which starts with `project_`.
|
||||
- Paste the **Client ID** in the **Client ID** field in the WorkOS settings in the Nhost Dashboard.
|
||||
|
||||
## Set correct Redirect URI
|
||||
|
||||
- In the WorkOS dashboard, click on **Configuration** in the left menu.
|
||||
- Click on **Edit Redirect URIs** in the Redirect URIs section.
|
||||
- Add the **Redirect URL** from the Nhost Dashboard to the list of Redirect URIs.
|
||||
- Set the newly added redirect URI as the **Default Redirect URI**.
|
||||
- Click on **Close**.
|
||||
|
||||
## Get the Client Secret
|
||||
|
||||
- In the WorkOS dashboard, click on **API Keys** in the left menu.
|
||||
- Click on the eye icon next to the **Client Secret** to reveal it.
|
||||
- Copy the **Client Secret**, which stars with `sk_`.
|
||||
- Paste the **Client Secret** in the **Client Secret** field in the WorkOS settings in the Nhost Dashboard.
|
||||
|
||||
## Get Organization ID
|
||||
|
||||
- In the WorkOS dashboard, click on **Organizations** in the left menu.
|
||||
- Click on **Create an Organization**.
|
||||
- Fill in the organization details and click on **Create Organization**.
|
||||
- Click on the newly created organization.
|
||||
- Copy the **Organization ID**, which stars with `org_`.
|
||||
- Paste the **Organization ID** in the **Organization ID** field in the WorkOS settings in the Nhost Dashboard.
|
||||
- Click **Save** in the Nhost Dashboard to save all WorkOS settings.
|
||||
|
||||
The WorkOS configuration is now completed with Nhost.
|
||||
|
||||
See the [WorkOS documentation](https://workos.com/docs/) to learn more about how to configure WorkOS.
|
||||
|
||||
## Sign In Users
|
||||
|
||||
Use the [Nhost JavaScript client](/reference/javascript) to sign in users:
|
||||
|
||||
```js
|
||||
nhost.auth.signIn({
|
||||
provider: 'workos'
|
||||
})
|
||||
```
|
||||
@@ -38,9 +38,9 @@ The `x-hasura-user-id` permission variable is always available for all signed-in
|
||||
|
||||
You can add custom permission variables in the Nhost console under **Users** and then **Roles and permissions**. These permission variables are then available when creating permissions for your GraphQL API in the Hasura console.
|
||||
|
||||

|
||||

|
||||
|
||||
**Example:**: Let's say you add a new permission variable `x-hasura-organisation-id` with path `user.profile.organisation.id`. This means that Nhost Auth will get the value for `x-hasura-organisation-id` by internally generating the following GraphQL query:
|
||||
**Example:** Let's say you add a new permission variable `x-hasura-organisation-id` with path `user.profile.organisation.id`. This means that Nhost Auth will get the value for `x-hasura-organisation-id` by internally generating the following GraphQL query:
|
||||
|
||||
```graphql
|
||||
query {
|
||||
@@ -88,9 +88,29 @@ custom_claims: '{"organisation-id":"user.profile.organisation.id"}'
|
||||
|
||||
JSON columns cannot be used in custom claims, with the exception of the `users.metadata` column.
|
||||
|
||||
### Arrays
|
||||
|
||||
When the target value is expected to be an array, it is important to explicitly add a `[]` at the end of the expression. For instance: if `organisationIds` is expected to be an array, you must set the expression to `organisationIds[]`. It will otherwise return a litteral when the array is a singleton.
|
||||
|
||||
✅ Singleton array with `'{"organisation-ids":"organisationIds[]"}'`
|
||||
|
||||
```json
|
||||
{
|
||||
"x-hasura-organisation-ids": "{\"org-id-1\"}"
|
||||
}
|
||||
```
|
||||
|
||||
🛑 Singleton array with `'{"organisation-ids":"organisationIds"}'`
|
||||
|
||||
```json
|
||||
{
|
||||
"x-hasura-organisation-ids": "org-id-1"
|
||||
}
|
||||
```
|
||||
|
||||
## Roles
|
||||
|
||||
Every GraphQL request is resolved based on a **single role**. Roles are added in the Hasura Console when selecting a table and clicking **Permisisons**.
|
||||
Every GraphQL request is resolved based on a **single role**. Roles are added in the Hasura Console when selecting a table and clicking **Permissions**.
|
||||
|
||||
If the user is not signed in, the GraphQL API resolves permissions using the `public` role.
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ older (`functions/_utils/<utils-files>.js`).
|
||||
|
||||
## Examples
|
||||
|
||||
We have multiple examples of Serverless Functions in our [Nhost repository](https://github.com/nhost/nhost/tree/main/examples/serverless-functions).
|
||||
We have multiple examples of Serverless Functions in our [Nhost repository](https://github.com/nhost/nhost/tree/main/examples/serverless-functions/functions).
|
||||
|
||||
## Billing
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/docs",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
|
||||
BIN
docs/static/img/og/sign-in-with-workos.png
vendored
Normal file
BIN
docs/static/img/og/sign-in-with-workos.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
BIN
docs/static/img/permission-variables-preview.png
vendored
Normal file
BIN
docs/static/img/permission-variables-preview.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
95
docs/static/img/permission-variables-preview.svg
vendored
95
docs/static/img/permission-variables-preview.svg
vendored
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 227 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user