Compare commits
22 Commits
@nhost/rea
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70433187cc | ||
|
|
39b10a2e9f | ||
|
|
4b8478004e | ||
|
|
61eb6cdc2d | ||
|
|
14187d381f | ||
|
|
99b78f147e | ||
|
|
2aa81a6cb9 | ||
|
|
a1edaf18ea | ||
|
|
4d835c4b9c | ||
|
|
44a3e6bd41 | ||
|
|
6ee2d1f5bf | ||
|
|
df51c3e64e | ||
|
|
9acae7d1c4 | ||
|
|
f6947a2194 | ||
|
|
31e636a9c8 | ||
|
|
0fdff345ac | ||
|
|
97db63791b | ||
|
|
a0931e282f | ||
|
|
e87505c564 | ||
|
|
c0635ae1c7 | ||
|
|
d2a9a9ae1d | ||
|
|
c97b43f149 |
19
.github/labeler.yml
vendored
19
.github/labeler.yml
vendored
@@ -1,24 +1,25 @@
|
||||
dashboard:
|
||||
- dashboard/**/*
|
||||
- any:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['dashboard/**/*']
|
||||
|
||||
documentation:
|
||||
- any:
|
||||
- docs/**/*
|
||||
- any: ['docs/**/*']
|
||||
|
||||
examples:
|
||||
- examples/**/*
|
||||
- any: ['examples/**/*']
|
||||
|
||||
sdk:
|
||||
- packages/**/*
|
||||
- any: ['packages/**/*']
|
||||
|
||||
integrations:
|
||||
- integrations/**/*
|
||||
- any: ['integrations/**/*']
|
||||
|
||||
react:
|
||||
- '{packages,examples,integrations}/*react*/**/*'
|
||||
- any: ['{packages,examples,integrations}/*react*/**/*']
|
||||
|
||||
nextjs:
|
||||
- '{packages,examples}/*next*/**/*'
|
||||
- any: ['{packages,examples}/*next*/**/*']
|
||||
|
||||
vue:
|
||||
- '{packages,examples,integrations}/*vue*/**/*'
|
||||
- any: ['{packages,examples,integrations}/*vue*/**/*']
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -29,7 +29,7 @@ env:
|
||||
NHOST_PRO_TEST_PROJECT_NAME: ${{ vars.NHOST_PRO_TEST_PROJECT_NAME }}
|
||||
NHOST_TEST_USER_EMAIL: ${{ secrets.NHOST_TEST_USER_EMAIL }}
|
||||
NHOST_TEST_USER_PASSWORD: ${{ secrets.NHOST_TEST_USER_PASSWORD }}
|
||||
NHOST_TEST_PROJECT_ADMIN_SECRET: ${{ secrets.NHOST_TEST_PROJECT_ADMIN_SECRET }}
|
||||
NHOST_TEST_PROJECT_ADMIN_SECRET: '${{ secrets.NHOST_TEST_PROJECT_ADMIN_SECRET }}'
|
||||
NHOST_TEST_FREE_USER_EMAILS: ${{ secrets.NHOST_TEST_FREE_USER_EMAILS }}
|
||||
|
||||
jobs:
|
||||
|
||||
7
.github/workflows/labeler.yaml
vendored
7
.github/workflows/labeler.yaml
vendored
@@ -3,13 +3,12 @@ on:
|
||||
- pull_request_target
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
labeler:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v4
|
||||
- uses: actions/labeler@v5
|
||||
with:
|
||||
repo-token: '${{ secrets.GH_PAT }}'
|
||||
sync-labels: ''
|
||||
repo-token: ${{ secrets.GH_PAT }}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
## Requirements
|
||||
|
||||
### Node.js v18
|
||||
|
||||
_⚠️ Node.js v16 is also supported for the time being but support will be dropped in the near future_.
|
||||
### Node.js v20 or later
|
||||
|
||||
### [pnpm](https://pnpm.io/) package manager
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
// $schema provides code completion hints to IDEs.
|
||||
"$schema": "https://github.com/IBM/audit-ci/raw/main/docs/schema.json",
|
||||
"moderate": true,
|
||||
"allowlist": ["vue-template-compiler"]
|
||||
"allowlist": ["vue-template-compiler", { "id": "CVE-2025-48068", "path": "next" }]
|
||||
}
|
||||
|
||||
@@ -25,4 +25,6 @@ NEXT_PUBLIC_ZENDESK_USER_EMAIL=
|
||||
|
||||
CODEGEN_GRAPHQL_URL=https://local.graphql.local.nhost.run/v1
|
||||
CODEGEN_HASURA_ADMIN_SECRET=nhost-admin-secret
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=FIXME
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=FIXME
|
||||
|
||||
NEXT_PUBLIC_SOC2_REPORT_FILE_ID=
|
||||
@@ -76,6 +76,13 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
],
|
||||
'jsx-a11y/label-has-associated-control': [
|
||||
2,
|
||||
{
|
||||
controlComponents: ['Input'],
|
||||
depth: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 2.31.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 39b10a2: feat (dashboard): Add multi-factor authentication
|
||||
- 4b84780: feat (dashboard): Add Webauthn to dashboard
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 61eb6cd: fix (dashboard): Fix update project e2e test
|
||||
- @nhost/react-apollo@18.0.0
|
||||
- @nhost/nextjs@2.2.8
|
||||
|
||||
## 2.30.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- f6947a2: fix: fetch job-backup services logs using Live filter
|
||||
- 44a3e6b: fix: collapsed main navigation sidebar overlaps mobile navbar
|
||||
- 99b78f1: feat: dashboard: add download button for soc2 report
|
||||
- 9acae7d: fix: e2e tests, stop on error when refreshing metadata
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 31e636a: fix (dashboard): Use the correct payload to reset metadata before the e2e tests
|
||||
|
||||
## 2.29.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c97b43f: fix: update vite to address vulnerability audit
|
||||
- a0931e2: fix: improve logs time range and filter selection
|
||||
- c0635ae: feat (dashboard): Add information about that free organization cannot be upgraded.
|
||||
- e87505c: fix: can downsize postgres storage capacity using local dashboard
|
||||
|
||||
## 2.28.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* eslint-disable no-console */
|
||||
import { TEST_PROJECT_ADMIN_SECRET, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { test as setup } from '@playwright/test';
|
||||
|
||||
setup('refresh metadata', async () => {
|
||||
try {
|
||||
await fetch(
|
||||
const response = await fetch(
|
||||
`https://${TEST_PROJECT_SUBDOMAIN}.hasura.eu-central-1.staging.nhost.run/v1/metadata`,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -15,8 +16,7 @@ setup('refresh metadata', async () => {
|
||||
{
|
||||
type: 'reload_metadata',
|
||||
args: {
|
||||
reload_remote_schemas: [],
|
||||
reload_sources: [],
|
||||
reload_sources: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -29,6 +29,21 @@ setup('refresh metadata', async () => {
|
||||
}),
|
||||
},
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const message = `[${body.code}]:${body.error}`;
|
||||
throw new Error(message);
|
||||
} else {
|
||||
const isConsistent = body[0].is_consistent;
|
||||
if (isConsistent) {
|
||||
console.log('Metadata is consistent.');
|
||||
} else {
|
||||
console.error('Metadata is not consistent.');
|
||||
console.error(body[0].inconsistent_objects);
|
||||
throw new Error('Metadata is not consistent');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log safe error information
|
||||
console.error(
|
||||
|
||||
@@ -24,23 +24,20 @@ test.beforeAll(async ({ browser }) => {
|
||||
});
|
||||
|
||||
test('should create a new project', async () => {
|
||||
await await gotoUrl(
|
||||
page,
|
||||
`/orgs/${getFreeUserStarterOrgSlug()}/projects/new`,
|
||||
);
|
||||
await gotoUrl(page, `/orgs/${getFreeUserStarterOrgSlug()}/projects/new`);
|
||||
const projectName = faker.lorem.words(3);
|
||||
|
||||
await page.getByLabel('Project Name').fill(projectName);
|
||||
await page.getByText('Create Project').click();
|
||||
|
||||
expect(await page.getByText('Creating the project...')).toBeVisible();
|
||||
expect(await page.getByText('Internal info')).toBeVisible();
|
||||
expect(page.getByText('Creating the project...')).toBeVisible();
|
||||
expect(page.getByText('Internal info')).toBeVisible();
|
||||
|
||||
await page.waitForSelector('button:has-text("Upgrade project")', {
|
||||
timeout: 120000,
|
||||
});
|
||||
|
||||
const newProjectSlug = getProjectSlugFromUrl(await page.url());
|
||||
const newProjectSlug = getProjectSlugFromUrl(page.url());
|
||||
setNewProjectSlug(newProjectSlug);
|
||||
setNewProjectName(projectName);
|
||||
});
|
||||
@@ -50,7 +47,7 @@ test('should upgrade the project', async () => {
|
||||
page,
|
||||
`/orgs/${getFreeUserStarterOrgSlug()}/projects/${getNewProjectSlug()}`,
|
||||
);
|
||||
const upgradeProject = await page.getByText('Upgrade project');
|
||||
const upgradeProject = page.getByText('Upgrade project');
|
||||
expect(upgradeProject).toBeVisible();
|
||||
|
||||
await upgradeProject.click();
|
||||
@@ -67,10 +64,10 @@ test('should upgrade the project', async () => {
|
||||
await page.waitForSelector('button:has-text("Create organization")', {
|
||||
state: 'hidden',
|
||||
});
|
||||
const stripeFrame = await page
|
||||
const stripeFrame = page
|
||||
.frameLocator('iframe[name="embedded-checkout"]')
|
||||
.first();
|
||||
await stripeFrame.getByText('Subscribe to Nhost');
|
||||
stripeFrame.getByText('Subscribe to Nhost');
|
||||
await stripeFrame.getByLabel('Email').fill(faker.internet.email());
|
||||
|
||||
await stripeFrame
|
||||
@@ -85,7 +82,7 @@ test('should upgrade the project', async () => {
|
||||
await stripeFrame.locator('#billingCountry').scrollIntoViewIfNeeded();
|
||||
// Need to comment out for local testing START
|
||||
await stripeFrame.getByPlaceholder('Address', { exact: true }).click();
|
||||
await stripeFrame.locator('span:has-text("Enter address manually")');
|
||||
stripeFrame.locator('span:has-text("Enter address manually")');
|
||||
await stripeFrame.getByText('Enter address manually').click();
|
||||
await stripeFrame
|
||||
.getByPlaceholder('Address line 1', { exact: true })
|
||||
@@ -94,6 +91,7 @@ test('should upgrade the project', async () => {
|
||||
.getByPlaceholder('City', { exact: true })
|
||||
.fill('Springfield');
|
||||
await stripeFrame.getByPlaceholder('ZIP', { exact: true }).fill('62701');
|
||||
await stripeFrame.locator('#enableStripePass').click({ force: true });
|
||||
// local Comment end
|
||||
await stripeFrame
|
||||
.getByTestId('hosted-payment-submit-button')
|
||||
@@ -110,12 +108,12 @@ test('should upgrade the project', async () => {
|
||||
'div:has-text("Project has been upgraded successfully!")',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Create project' });
|
||||
page.getByRole('button', { name: 'Create project' });
|
||||
|
||||
await page.waitForSelector(`div:has-text("${newOrgName}")`);
|
||||
await page.waitForSelector(`p:has-text("${getNewProjectName()}")`);
|
||||
|
||||
setNewOrgSlug(getOrgSlugFromUrl(await page.url()));
|
||||
setNewOrgSlug(getOrgSlugFromUrl(page.url()));
|
||||
});
|
||||
|
||||
test('should delete the new organization', async () => {
|
||||
@@ -126,12 +124,12 @@ test('should delete the new organization', async () => {
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForSelector('h2:has-text("Delete Organization")');
|
||||
expect(await page.getByTestId('deleteOrgButton')).toBeDisabled();
|
||||
expect(page.getByTestId('deleteOrgButton')).toBeDisabled();
|
||||
|
||||
await page.getByLabel("I'm sure I want to delete this Organization").click();
|
||||
expect(await page.getByTestId('deleteOrgButton')).toBeDisabled();
|
||||
expect(page.getByTestId('deleteOrgButton')).toBeDisabled();
|
||||
await page.getByLabel('I understand this action cannot be undone').click();
|
||||
expect(await page.getByTestId('deleteOrgButton')).not.toBeDisabled();
|
||||
expect(page.getByTestId('deleteOrgButton')).not.toBeDisabled();
|
||||
|
||||
await page.getByTestId('deleteOrgButton').click();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "2.28.0",
|
||||
"version": "2.31.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -16,7 +16,7 @@
|
||||
"storybook": "start-storybook -p 6006 -s public",
|
||||
"build-storybook": "build-storybook",
|
||||
"install-browsers": "pnpm playwright install && pnpm playwright install-deps",
|
||||
"e2e:tests": "pnpm install-browsers && pnpm playwright test --config=playwright.config.ts",
|
||||
"e2e:tests": "pnpm install-browsers && pnpm playwright test --config=playwright.config.ts -x",
|
||||
"e2e": "pnpm e2e:tests --project=main",
|
||||
"e2e:local": "pnpm e2e:tests --project=local",
|
||||
"e2e:upgrade-project": "pnpm e2e:tests --project=upgrade-project"
|
||||
@@ -39,6 +39,7 @@
|
||||
"@heroicons/react": "^1.0.6",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@icons-pack/react-simple-icons": "^9.6.0",
|
||||
"@marsidev/react-turnstile": "^1.0.2",
|
||||
"@mui/base": "5.0.0-beta.31",
|
||||
"@mui/material": "^5.15.14",
|
||||
@@ -196,7 +197,7 @@
|
||||
"tailwindcss": "^3.4.12",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths-webpack-plugin": "^4.1.0",
|
||||
"vite": "^5.4.18",
|
||||
"vite": "^5.4.19",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"vitest": "^0.32.4"
|
||||
},
|
||||
|
||||
61
dashboard/src/components/common/MfaOtpForm/MfaOtpForm.tsx
Normal file
61
dashboard/src/components/common/MfaOtpForm/MfaOtpForm.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { Input } from '@/components/ui/v3/input';
|
||||
import { type ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
sendMfaOtp: (code: string) => Promise<any>;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function MfaOtpForm({ sendMfaOtp, loading }: Props) {
|
||||
const [otpValue, setOtpValue] = useState<string>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function sendMfa(code: string) {
|
||||
if (code.length === 6 && !isSubmitting) {
|
||||
setIsSubmitting(true);
|
||||
const result = await sendMfaOtp(code);
|
||||
if (!result) {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
async function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const code = event.target.value.replace(/[^0-9]/g, '').slice(0, 6);
|
||||
setOtpValue(code);
|
||||
|
||||
sendMfa(code);
|
||||
}
|
||||
|
||||
const isInputDisabled = loading || isSubmitting;
|
||||
const isButtonDisabled = isInputDisabled || otpValue.length !== 6;
|
||||
|
||||
return (
|
||||
<div className="relative grid w-full grid-flow-row gap-4 bg-transparent">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={otpValue}
|
||||
placeholder="Enter TOTP"
|
||||
className="!bg-transparent"
|
||||
disabled={isInputDisabled}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Button disabled={isButtonDisabled}>
|
||||
{loading ? 'Verifying...' : 'Verify'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MfaOtpForm;
|
||||
1
dashboard/src/components/common/MfaOtpForm/index.ts
Normal file
1
dashboard/src/components/common/MfaOtpForm/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as MfaOtpForm } from './MfaOtpForm';
|
||||
59
dashboard/src/components/form/FormInput/FormInput.tsx
Normal file
59
dashboard/src/components/form/FormInput/FormInput.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/v3/form';
|
||||
import { Input } from '@/components/ui/v3/input';
|
||||
import type { Control, FieldPath, FieldValues } from 'react-hook-form';
|
||||
|
||||
const inputClasses =
|
||||
'!bg-transparent aria-[invalid=true]:border-red-500 aria-[invalid=true]:focus:border-red-500 aria-[invalid=true]:focus:ring-red-500';
|
||||
|
||||
interface FormInputProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> {
|
||||
control: Control<TFieldValues>;
|
||||
name: TName;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
function FormInput<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
placeholder,
|
||||
className = '',
|
||||
type = 'text',
|
||||
}: FormInputProps<TFieldValues, TName>) {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type={type}
|
||||
placeholder={placeholder || label}
|
||||
{...field}
|
||||
className={`${inputClasses} ${className}`}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormInput;
|
||||
1
dashboard/src/components/form/FormInput/index.ts
Normal file
1
dashboard/src/components/form/FormInput/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as FormInput } from './FormInput';
|
||||
@@ -41,7 +41,7 @@ export default function MainNav({ container }: MainNavProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<div
|
||||
className="min- absolute left-0 z-50 flex h-full w-6 justify-center border-r-[1px] bg-background pt-1 hover:bg-accent"
|
||||
className="min- absolute left-0 z-[39] flex h-full w-6 justify-center border-r-[1px] bg-background pt-1 hover:bg-accent"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
|
||||
import { CopyToClipboardButton as CopyToClipboardButtonOriginal } from '@/components/presentational/CopyToClipboardButton';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { CopyToClipboardButton as CopyToClipboardButtonOriginal } from './CopyToClipboardButton';
|
||||
import { getNodeText } from './getNodeText';
|
||||
|
||||
export interface CodeBlockPropsBase {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { Button, type ButtonProps } from '@/components/ui/v3/button';
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { clsx } from 'clsx';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
IconButton,
|
||||
type IconButtonProps,
|
||||
} from '@/components/ui/v2/IconButton';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { copy } from '@/utils/copy';
|
||||
|
||||
export function CopyToClipboardButton({
|
||||
function CopyToClipboardButton({
|
||||
textToCopy,
|
||||
className,
|
||||
title,
|
||||
@@ -16,7 +13,7 @@ export function CopyToClipboardButton({
|
||||
}: {
|
||||
textToCopy: string;
|
||||
title: string;
|
||||
} & IconButtonProps) {
|
||||
} & ButtonProps) {
|
||||
const [disabled, setDisabled] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,12 +32,18 @@ export function CopyToClipboardButton({
|
||||
if (!textToCopy || disabled) {
|
||||
return null;
|
||||
}
|
||||
const hasChildren = isNotEmptyValue(props.children);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className={clsx('group', className)}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className={clsx(
|
||||
'group h-fit w-fit border-0 bg-transparent p-[2px] hover:bg-[#d6eefb] dark:hover:bg-[#1e2942]',
|
||||
className,
|
||||
{ 'gap-3': hasChildren },
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -49,7 +52,9 @@ export function CopyToClipboardButton({
|
||||
aria-label={textToCopy}
|
||||
{...props}
|
||||
>
|
||||
<CopyIcon className="top-5 h-4 w-4" />
|
||||
</IconButton>
|
||||
{props.children}
|
||||
<Copy className="top-5 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
export default CopyToClipboardButton;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CopyToClipboardButton } from './CopyToClipboardButton';
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Badge } from '@/components/ui/v3/badge';
|
||||
import useMfaEnabled from '@/features/account/settings/components/AccountMfaSettings/hooks/useMfaEnabled';
|
||||
import DisableMfaButton from './DisableMfaButton/DisableMfaButton';
|
||||
import EnableMfaButton from './EnableMfaButton/EnableMfaButton';
|
||||
|
||||
function MFaEnabledBadge() {
|
||||
return (
|
||||
<Badge variant="outline" className="border-green-400 text-green-400">
|
||||
Enabled
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function MFaDisabledBadge() {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text- border-destructive text-destructive"
|
||||
>
|
||||
Disabled
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountMfaSettings() {
|
||||
const { isMfaEnabled } = useMfaEnabled();
|
||||
return (
|
||||
<div className="rounded-lg border border-[#EAEDF0] bg-white font-['Inter_var'] dark:border-[#2F363D] dark:bg-paper">
|
||||
<div className="flex w-full flex-col items-start gap-6 p-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h3 className="flex items-center text-[1.125rem] font-semibold leading-[1.75]">
|
||||
<span className="mr-4">Multi-Factor Authentication </span>
|
||||
{isMfaEnabled ? <MFaEnabledBadge /> : <MFaDisabledBadge />}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center border-t border-[#EAEDF0] px-4 py-2 dark:border-[#2F363D]">
|
||||
{isMfaEnabled ? <DisableMfaButton /> : <EnableMfaButton />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountMfaSettings;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { MfaOtpForm } from '@/components/common/MfaOtpForm';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/v3/dialog';
|
||||
import useMfaEnabled from '@/features/account/settings/components/AccountMfaSettings/hooks/useMfaEnabled';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useConfigMfa } from '@nhost/nextjs';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
const defaultErrorMessage =
|
||||
'An error occurred while trying to enable multi-factor authentication. Please try again.';
|
||||
|
||||
function DisableMfaButton() {
|
||||
const { disableMfa, isDisabling } = useConfigMfa();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { loading, refetch } = useMfaEnabled();
|
||||
const buttonDisabled = loading || isDisabling;
|
||||
|
||||
async function onSendMfaOtp(code: string) {
|
||||
const result = await disableMfa(code);
|
||||
if (result.error) {
|
||||
toast.error(
|
||||
result.error.message || defaultErrorMessage,
|
||||
getToastStyleProps(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
toast.success(
|
||||
'Multi-factor authentication has been disabled.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
await refetch();
|
||||
setOpen(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={buttonDisabled}
|
||||
className="p-y[0.375rem] h-9 gap-2 border-destructive px-2 text-destructive hover:bg-destructive"
|
||||
>
|
||||
Disable multi-factor authentication
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="z-[9999] max-w-[28rem] text-foreground">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Disable multi-factor authentication</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="hidden">
|
||||
Disable multi-factor authentication
|
||||
</DialogDescription>
|
||||
|
||||
<MfaOtpForm loading={isDisabling} sendMfaOtp={onSendMfaOtp} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default DisableMfaButton;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DisableMfaButton } from './DisableMfaButton';
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CopyToClipboardButton } from '@/components/presentational/CopyToClipboardButton';
|
||||
|
||||
interface Props {
|
||||
totpSecret: string | null;
|
||||
}
|
||||
|
||||
function CopyMfaTOTPSecret({ totpSecret }: Props) {
|
||||
return (
|
||||
<CopyToClipboardButton
|
||||
className="p-2"
|
||||
textToCopy={totpSecret}
|
||||
title="TOTP secret"
|
||||
>
|
||||
OR Copy TOTP secret
|
||||
</CopyToClipboardButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default CopyMfaTOTPSecret;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/v3/dialog';
|
||||
import useMfaEnabled from '@/features/account/settings/components/AccountMfaSettings/hooks/useMfaEnabled';
|
||||
import { useState } from 'react';
|
||||
import MfaQRCodeAndTOTPSecret from './MfaQRCodeAndTOTPSecret';
|
||||
|
||||
function EnableMfaButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { isMfaEnabled, loading, refetch } = useMfaEnabled();
|
||||
const buttonDisabled = loading || isMfaEnabled;
|
||||
|
||||
async function handleOnSuccess() {
|
||||
setOpen(false);
|
||||
await refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={buttonDisabled}
|
||||
className="p-y[0.375rem] h-9 gap-2 border-green-600 px-2 text-green-600 hover:bg-destructive hover:bg-green-600"
|
||||
>
|
||||
Enable multi-factor authentication
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="z-[9999] max-w-[28rem] text-foreground">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable multi-factor authentication</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="hidden">
|
||||
Enable multi-factor authentication
|
||||
</DialogDescription>
|
||||
<MfaQRCodeAndTOTPSecret onSuccess={handleOnSuccess} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default EnableMfaButton;
|
||||
@@ -0,0 +1,74 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { MfaOtpForm } from '@/components/common/MfaOtpForm';
|
||||
import { Spinner } from '@/components/ui/v3/spinner';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useConfigMfa } from '@nhost/nextjs';
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import CopyMfaTOTPSecret from './CopyMfaTOTPSecret';
|
||||
|
||||
const defaultErrorMessage =
|
||||
'An error occurred while trying to enable multi-factor authentication. Please try again.';
|
||||
|
||||
interface Props {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function MfaQRCodeAndTOTPSecret({ onSuccess }: Props) {
|
||||
const {
|
||||
generateQrCode,
|
||||
qrCodeDataUrl,
|
||||
isGenerated,
|
||||
isGenerating,
|
||||
activateMfa,
|
||||
isActivating,
|
||||
totpSecret,
|
||||
} = useConfigMfa();
|
||||
|
||||
async function onSendMfaOtp(code: string) {
|
||||
const result = await activateMfa(code);
|
||||
if (result.error) {
|
||||
toast.error(
|
||||
result.error.message || defaultErrorMessage,
|
||||
getToastStyleProps(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
toast.success(
|
||||
'Multi-factor authentication has been enabled.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
onSuccess();
|
||||
return true;
|
||||
}
|
||||
useEffect(() => {
|
||||
async function generate() {
|
||||
const result = await generateQrCode();
|
||||
if (result.error) {
|
||||
toast.error(result.error.message, getToastStyleProps());
|
||||
}
|
||||
}
|
||||
generate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-4">
|
||||
{isGenerating && <Spinner />}
|
||||
{isGenerated && qrCodeDataUrl && (
|
||||
<>
|
||||
<div className="flex flex-col justify-center gap-4">
|
||||
<p className="text-base">
|
||||
Scan the QR Code with your authenticator app
|
||||
</p>
|
||||
<img alt="qrcode" src={qrCodeDataUrl} className="mx-auto w-64" />
|
||||
</div>
|
||||
<CopyMfaTOTPSecret totpSecret={totpSecret} />
|
||||
<MfaOtpForm loading={isActivating} sendMfaOtp={onSendMfaOtp} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MfaQRCodeAndTOTPSecret;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as EnableMfaButton } from './EnableMfaButton';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import { useGetActiveMfaTypeQuery } from '@/utils/__generated__/graphql';
|
||||
import { useUserId } from '@nhost/nextjs';
|
||||
|
||||
function useMfaEnabled() {
|
||||
const userId = useUserId();
|
||||
const { data, loading, refetch } = useGetActiveMfaTypeQuery({
|
||||
variables: { id: userId },
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
const isMfaEnabled = isNotEmptyValue(data?.user.activeMfaType);
|
||||
|
||||
return { loading, isMfaEnabled, refetch };
|
||||
}
|
||||
|
||||
export default useMfaEnabled;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AccountMfaSettings } from './components/AccountMfaSettings';
|
||||
@@ -10,9 +10,9 @@ import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import useActionWithElevatedPermissions from '@/features/account/settings/hooks/useActionWithElevatedPermissions';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { GetPersonalAccessTokensDocument } from '@/utils/__generated__/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { getDateComponents } from '@/utils/getDateComponents';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
@@ -20,7 +20,6 @@ import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useNhostClient } from '@nhost/nextjs';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const createPATFormValidationSchema = Yup.object({
|
||||
@@ -84,6 +83,25 @@ export default function CreatePATForm({
|
||||
resolver: yupResolver(createPATFormValidationSchema),
|
||||
});
|
||||
|
||||
const createPAT = useActionWithElevatedPermissions({
|
||||
actionFn: async (
|
||||
expiresAt: Date,
|
||||
metadata?: Record<string, string | number>,
|
||||
) => {
|
||||
const result = await nhostClient.auth.createPAT(expiresAt, metadata);
|
||||
return result;
|
||||
},
|
||||
successMessage: 'The personal access token has been created successfully.',
|
||||
onSuccess: ({ data }) => {
|
||||
setPersonalAccessToken(data?.personalAccessToken);
|
||||
apolloClient.refetchQueries({
|
||||
include: [GetPersonalAccessTokensDocument],
|
||||
});
|
||||
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
const { register, formState } = form;
|
||||
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
@@ -93,44 +111,11 @@ export default function CreatePATForm({
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
async function handleSubmit(formValues: CreatePATFormValues) {
|
||||
try {
|
||||
const { error, data } = await nhostClient.auth.createPAT(
|
||||
new Date(formValues.expiresAt),
|
||||
{
|
||||
name: formValues.name,
|
||||
application: 'dashboard',
|
||||
userAgent: window.navigator.userAgent,
|
||||
},
|
||||
);
|
||||
|
||||
const toastStyle = getToastStyleProps();
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, {
|
||||
style: toastStyle.style,
|
||||
...toastStyle.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
'The personal access token has been created successfully.',
|
||||
{
|
||||
style: toastStyle.style,
|
||||
...toastStyle.success,
|
||||
},
|
||||
);
|
||||
|
||||
setPersonalAccessToken(data?.personalAccessToken);
|
||||
|
||||
apolloClient.refetchQueries({
|
||||
include: [GetPersonalAccessTokensDocument],
|
||||
});
|
||||
|
||||
form.reset();
|
||||
} catch {
|
||||
// Note: This error is handled by the toast.
|
||||
}
|
||||
await createPAT(new Date(formValues.expiresAt), {
|
||||
name: formValues.name,
|
||||
application: 'dashboard',
|
||||
userAgent: window.navigator.userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
if (personalAccessToken) {
|
||||
|
||||
@@ -19,7 +19,7 @@ export type DisplayNameSettingFormValues = Yup.InferType<
|
||||
>;
|
||||
|
||||
export default function DisplayNameSetting() {
|
||||
const { id: userID, displayName } = useUserData();
|
||||
const { id: userID, displayName } = useUserData() || {};
|
||||
|
||||
const [updateUserDisplayName] = useUpdateUserDisplayNameMutation();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import useActionWithElevatedPermissions from '@/features/account/settings/hooks/useActionWithElevatedPermissions';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useNhostClient, useUserData } from '@nhost/nextjs';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -15,7 +15,7 @@ export type EmailSettingFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function EmailSetting() {
|
||||
const nhost = useNhostClient();
|
||||
const { email } = useUserData();
|
||||
const { email } = useUserData() || {};
|
||||
|
||||
const form = useForm<EmailSettingFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
@@ -26,25 +26,23 @@ export default function EmailSetting() {
|
||||
const { register, formState } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const changeEmail = useActionWithElevatedPermissions({
|
||||
actionFn: async (newEmail: string) => {
|
||||
const result = await nhost.auth.changeEmail({
|
||||
newEmail,
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/account`,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
},
|
||||
successMessage:
|
||||
'Please check your inbox. Follow the link to finalize changing your email.',
|
||||
onSuccess: () => form.reset(),
|
||||
});
|
||||
|
||||
async function handleSubmit(formValues: EmailSettingFormValues) {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await nhost.auth.changeEmail({
|
||||
newEmail: formValues.email,
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/account`,
|
||||
},
|
||||
});
|
||||
form.reset({ email: formValues.email });
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Updating your email...',
|
||||
successMessage:
|
||||
'Please check your inbox. Follow the link to finalize changing your email.',
|
||||
errorMessage:
|
||||
'An error occurred while trying to update your email. Please try again.',
|
||||
},
|
||||
);
|
||||
await changeEmail(formValues.email);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useChangePassword } from '@nhost/nextjs';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
newPassword: Yup.string()
|
||||
.label('New Password')
|
||||
.nullable()
|
||||
.required('This field is required.'),
|
||||
confirmPassword: Yup.string()
|
||||
.label('Confirm Password')
|
||||
.nullable()
|
||||
.required('This field is required.')
|
||||
.test(
|
||||
'passwords-match',
|
||||
'Passwords must match.',
|
||||
(value, ctx) => ctx.parent.newPassword === value,
|
||||
),
|
||||
});
|
||||
|
||||
export type PasswordSettingsFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function PasswordSettings() {
|
||||
const { changePassword } = useChangePassword();
|
||||
const form = useForm<PasswordSettingsFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { register, formState } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
async function handleSubmit(formValues: PasswordSettingsFormValues) {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
// TODO fix changePassword should throw an error if something happens
|
||||
await changePassword(formValues.newPassword);
|
||||
form.reset();
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Changing password...',
|
||||
successMessage: 'The password has been changed successfully.',
|
||||
errorMessage:
|
||||
'An error occurred while trying to update the password. Please try again.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Change Password"
|
||||
description="Update your account password."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
{...register('newPassword')}
|
||||
className="col-span-2"
|
||||
type="password"
|
||||
id="new-password"
|
||||
label="New Password"
|
||||
fullWidth
|
||||
helperText={formState.errors.newPassword?.message}
|
||||
error={Boolean(formState.errors.newPassword)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('confirmPassword')}
|
||||
className="col-span-2 row-start-2"
|
||||
type="password"
|
||||
id="confirm-password"
|
||||
label="Confirm Password"
|
||||
fullWidth
|
||||
helperText={formState.errors.confirmPassword?.message}
|
||||
error={Boolean(formState.errors.confirmPassword)}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FormInput } from '@/components/form/FormInput';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { Form } from '@/components/ui/v3/form';
|
||||
import useChangePasswordForm from '@/features/account/settings/components/PasswordSettings/hooks/useChangePasswordForm';
|
||||
import useOnChangePasswordHandler from '@/features/account/settings/components/PasswordSettings/hooks/useOnChangePasswordHandler';
|
||||
|
||||
export default function PasswordSettings() {
|
||||
const form = useChangePasswordForm();
|
||||
const onSubmit = useOnChangePasswordHandler({
|
||||
onSuccess: () => form.reset(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="rounded-lg border border-[#EAEDF0] bg-white font-['Inter_var'] dark:border-[#2F363D] dark:bg-paper">
|
||||
<div className="flex w-full flex-col items-start gap-4 p-4">
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<h3 className="text-[1.125rem] font-semibold leading-[1.75]">
|
||||
Change Password
|
||||
</h3>
|
||||
<p className="text-[#556378] dark:text-[#A2B3BE]">
|
||||
Update your account password.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-[370px] flex-col gap-4">
|
||||
<FormInput
|
||||
control={form.control}
|
||||
name="newPassword"
|
||||
type="password"
|
||||
label="New Password"
|
||||
/>
|
||||
<FormInput
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
label="Confirm Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-end border-t border-[#EAEDF0] px-4 py-2 dark:border-[#2F363D]">
|
||||
<Button type="submit" variant="outline">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const validationSchema = z
|
||||
.object({
|
||||
newPassword: z
|
||||
.string({
|
||||
required_error: 'This field is required.',
|
||||
})
|
||||
.min(1, 'This field is required.'),
|
||||
confirmPassword: z
|
||||
.string({
|
||||
required_error: 'This field is required.',
|
||||
})
|
||||
.min(1, 'This field is required.'),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: 'Passwords must match.',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
export type ChangePasswordFormValues = z.infer<typeof validationSchema>;
|
||||
|
||||
function useChangePasswordForm() {
|
||||
const form = useForm<ChangePasswordFormValues>({
|
||||
mode: 'onTouched',
|
||||
reValidateMode: 'onBlur',
|
||||
defaultValues: {
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
resolver: zodResolver(validationSchema),
|
||||
});
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
export default useChangePasswordForm;
|
||||
@@ -0,0 +1,27 @@
|
||||
import useActionWithElevatedPermissions from '@/features/account/settings/hooks/useActionWithElevatedPermissions';
|
||||
import { useChangePassword } from '@nhost/nextjs';
|
||||
import { type ChangePasswordFormValues } from './useChangePasswordForm';
|
||||
|
||||
interface Props {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function useOnChangePasswordHandler({ onSuccess }: Props) {
|
||||
const { changePassword: actionFn } = useChangePassword();
|
||||
|
||||
const changePassword = useActionWithElevatedPermissions({
|
||||
actionFn,
|
||||
onSuccess,
|
||||
successMessage: 'The password has been changed successfully.',
|
||||
});
|
||||
|
||||
async function onSubmit(values: ChangePasswordFormValues) {
|
||||
const { newPassword } = values;
|
||||
|
||||
await changePassword(newPassword);
|
||||
}
|
||||
|
||||
return onSubmit;
|
||||
}
|
||||
|
||||
export default useOnChangePasswordHandler;
|
||||
@@ -1,2 +1 @@
|
||||
export * from './PasswordSettings';
|
||||
export { default as PasswordSettings } from './PasswordSettings';
|
||||
export { default as PasswordSettings } from './components/PasswordSettings';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/v3/dialog';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import SecurityKeyForm from './NewSecurityKeyForm';
|
||||
|
||||
function AddSecurityKeyButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-9 gap-2 px-2 py-[0.375rem] hover:bg-[#d6eefb] dark:hover:bg-[#1e2942]"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
Add New Security Key
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="sr z-[9999] text-foreground sm:max-w-xl"
|
||||
aria-describedby="Add a Security Key"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a Security Key</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="sr-only">
|
||||
Add a Security Key
|
||||
</DialogDescription>
|
||||
<SecurityKeyForm onSuccess={() => setOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddSecurityKeyButton;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/v3/form';
|
||||
import { Input } from '@/components/ui/v3/input';
|
||||
import useNewSecurityKeyForm from '@/features/account/settings/components/SecurityKeysSettings/hooks/useNewSecurityKeyForm';
|
||||
import useOnAddNewSecurityKeyHandler from '@/features/account/settings/components/SecurityKeysSettings/hooks/useOnAddNewSecurityKeyHandler';
|
||||
|
||||
interface Props {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function NewSecurityKeyForm({ onSuccess }: Props) {
|
||||
const form = useNewSecurityKeyForm();
|
||||
const onSubmit = useOnAddNewSecurityKeyHandler({ onSuccess });
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid grid-flow-row gap-4 bg-transparent"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nickname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Name"
|
||||
{...field}
|
||||
className="!bg-transparent"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full !bg-transparent"
|
||||
>
|
||||
Add new security key
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewSecurityKeyForm;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import useRemoveSecurityKey from '@/features/account/settings/components/SecurityKeysSettings/hooks/useRemoveSecurityKey';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { Trash } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function RemoveSecurityKeyButton({ id }: Props) {
|
||||
const removeSecurityKey = useRemoveSecurityKey();
|
||||
|
||||
function handleClick() {
|
||||
execPromiseWithErrorToast(async () => removeSecurityKey(id), {
|
||||
loadingMessage: 'Removing security key...',
|
||||
successMessage: 'Security key has been removed successfully.',
|
||||
errorMessage:
|
||||
'An error occurred while trying to remove security key. Please try again.',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClick}
|
||||
aria-label={`Remove security key ${id}`}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(RemoveSecurityKeyButton);
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Spinner } from '@/components/ui/v3/spinner';
|
||||
import useGetSecurityKeys from '@/features/account/settings/hooks/useGetSecurityKeys';
|
||||
import { InfoAlert } from '@/features/orgs/components/InfoAlert';
|
||||
import { Fingerprint } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import RemoveSecurityKeyButton from './RemoveSecurityKeyButton';
|
||||
|
||||
type SecurityKeyProps = {
|
||||
id: string;
|
||||
nickname?: string;
|
||||
};
|
||||
|
||||
function SecurityKey({ id, nickname }: SecurityKeyProps) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between rounded-lg border border-[#EAEDF0] px-2 py-2 dark:border-[#2F363D]">
|
||||
<div className="flex justify-start gap-3">
|
||||
<Fingerprint />
|
||||
<span>{nickname || id}</span>
|
||||
</div>
|
||||
<RemoveSecurityKeyButton id={id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MemoizedSecurityKey = memo(SecurityKey);
|
||||
|
||||
function SecurityKeyList() {
|
||||
const { data, loading } = useGetSecurityKeys();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{loading && <Spinner />}
|
||||
{!loading && data?.authUserSecurityKeys.length === 0 && (
|
||||
<InfoAlert>No security keys have been added yet!</InfoAlert>
|
||||
)}
|
||||
{data?.authUserSecurityKeys.map(({ id, nickname }) => (
|
||||
<MemoizedSecurityKey key={id} id={id} nickname={nickname} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SecurityKeyList;
|
||||
@@ -0,0 +1,24 @@
|
||||
import AddSecurityKeyButton from './AddSecurityKeyButton';
|
||||
import SecurityKeyList from './SecurityKeyList';
|
||||
|
||||
function SecurityKeysSettings() {
|
||||
return (
|
||||
<div className="rounded-lg border border-[#EAEDF0] bg-white font-display dark:border-[#2F363D] dark:bg-paper">
|
||||
<div className="flex w-full flex-col items-start gap-6 p-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h3 className="text-[1.125rem] font-semibold leading-[1.75]">
|
||||
Manage your security keys
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<SecurityKeyList />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center border-t border-[#EAEDF0] px-4 py-2 dark:border-[#2F363D]">
|
||||
<AddSecurityKeyButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SecurityKeysSettings;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const validationSchema = z
|
||||
.object({
|
||||
nickname: z.string().min(1, { message: 'Nickname is required' }),
|
||||
})
|
||||
.required();
|
||||
|
||||
export type NewSecurityKeyFormValues = z.infer<typeof validationSchema>;
|
||||
|
||||
function useNewSecurityKeyForm() {
|
||||
const form = useForm<NewSecurityKeyFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
nickname: '',
|
||||
},
|
||||
resolver: zodResolver(validationSchema),
|
||||
});
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
export default useNewSecurityKeyForm;
|
||||
@@ -0,0 +1,30 @@
|
||||
import useActionWithElevatedPermissions from '@/features/account/settings/hooks/useActionWithElevatedPermissions';
|
||||
import useGetSecurityKeys from '@/features/account/settings/hooks/useGetSecurityKeys';
|
||||
import { useAddSecurityKey } from '@nhost/nextjs';
|
||||
import { type NewSecurityKeyFormValues } from './useNewSecurityKeyForm';
|
||||
|
||||
interface Props {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function useOnAddNewSecurityKeyHandler({ onSuccess }: Props) {
|
||||
const { refetch } = useGetSecurityKeys();
|
||||
const { add: actionFn } = useAddSecurityKey();
|
||||
const addSecurityKey = useActionWithElevatedPermissions({
|
||||
actionFn,
|
||||
onSuccess: async () => {
|
||||
await refetch();
|
||||
onSuccess();
|
||||
},
|
||||
successMessage: 'Security key has been added.',
|
||||
});
|
||||
|
||||
async function onSubmit(values: NewSecurityKeyFormValues) {
|
||||
const { nickname } = values;
|
||||
await addSecurityKey(nickname);
|
||||
}
|
||||
|
||||
return onSubmit;
|
||||
}
|
||||
|
||||
export default useOnAddNewSecurityKeyHandler;
|
||||
@@ -0,0 +1,22 @@
|
||||
import useElevatedPermissions from '@/features/account/settings/hooks/useElevatedPermissions';
|
||||
import useGetSecurityKeys from '@/features/account/settings/hooks/useGetSecurityKeys';
|
||||
import { useRemoveSecurityKeyMutation } from '@/utils/__generated__/graphql';
|
||||
|
||||
function useRemoveSecurityKey() {
|
||||
const [removeSecurityKeyMutation] = useRemoveSecurityKeyMutation();
|
||||
const { elevatePermissions } = useElevatedPermissions();
|
||||
const { refetch: refetchSecurityKeys } = useGetSecurityKeys();
|
||||
|
||||
async function removeSecurityKey(id: string) {
|
||||
const permissionGranted = await elevatePermissions(true);
|
||||
|
||||
if (permissionGranted) {
|
||||
await removeSecurityKeyMutation({ variables: { id } });
|
||||
await refetchSecurityKeys();
|
||||
}
|
||||
}
|
||||
|
||||
return removeSecurityKey;
|
||||
}
|
||||
|
||||
export default useRemoveSecurityKey;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SecurityKeysSettings } from './components/SecurityKeysSettings';
|
||||
@@ -0,0 +1,5 @@
|
||||
query getActiveMfaType($id: uuid!) {
|
||||
user(id: $id) {
|
||||
activeMfaType
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
query securityKeys($userId: uuid!) {
|
||||
authUserSecurityKeys(where: { userId: { _eq: $userId } }) {
|
||||
id
|
||||
nickname
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation removeSecurityKey($id: uuid!) {
|
||||
deleteAuthUserSecurityKey(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import useElevatedPermissions from '@/features/account/settings/hooks/useElevatedPermissions';
|
||||
import useGetSecurityKeys from '@/features/account/settings/hooks/useGetSecurityKeys';
|
||||
import type { AuthErrorPayload } from '@nhost/nextjs';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
type Action = (...args: any[]) => Promise<ActionResult>;
|
||||
|
||||
type ActionResult = {
|
||||
isError?: boolean;
|
||||
error: AuthErrorPayload;
|
||||
};
|
||||
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
|
||||
|
||||
interface Props<Fn extends Action> {
|
||||
actionFn: Fn;
|
||||
onSuccess?: (result: UnwrapPromise<ReturnType<Fn>>) => void;
|
||||
onError?: () => void;
|
||||
successMessage?: string;
|
||||
}
|
||||
|
||||
function useActionWithElevatedPermissions<F extends Action>({
|
||||
actionFn,
|
||||
onSuccess,
|
||||
onError,
|
||||
successMessage,
|
||||
}: Props<F>) {
|
||||
const { elevated, elevatePermissions } = useElevatedPermissions();
|
||||
const { data } = useGetSecurityKeys();
|
||||
|
||||
async function requestPermissions() {
|
||||
if (elevated || data?.authUserSecurityKeys.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const isPermissionsElevated = await elevatePermissions();
|
||||
return isPermissionsElevated;
|
||||
}
|
||||
|
||||
async function actionWithElevatedPermissions(...args: Parameters<F>) {
|
||||
let isSuccess = false;
|
||||
const permissionGranted = await requestPermissions();
|
||||
|
||||
if (!permissionGranted) {
|
||||
return isSuccess;
|
||||
}
|
||||
|
||||
const response = await actionFn(...args);
|
||||
if (response.error) {
|
||||
toast.error(response.error?.message || 'Something went wrong.');
|
||||
onError?.();
|
||||
} else {
|
||||
toast.success(successMessage || 'Success');
|
||||
onSuccess?.(response as UnwrapPromise<ReturnType<F>>);
|
||||
isSuccess = true;
|
||||
}
|
||||
|
||||
return isSuccess;
|
||||
}
|
||||
return actionWithElevatedPermissions;
|
||||
}
|
||||
|
||||
export default useActionWithElevatedPermissions;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useElevateSecurityKeyEmail, useUserData } from '@nhost/nextjs';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
function useElevatedPermissions() {
|
||||
const user = useUserData();
|
||||
|
||||
const { elevated, elevateEmailSecurityKey } = useElevateSecurityKeyEmail();
|
||||
|
||||
async function elevatePermissions(shouldThrowError = false) {
|
||||
if (elevated) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const response = await elevateEmailSecurityKey(user.email);
|
||||
if (response.isError) {
|
||||
const errorMessage =
|
||||
response.error?.message || 'Permissions were not elevated';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (shouldThrowError) {
|
||||
throw e;
|
||||
} else {
|
||||
const message = e?.message || 'Could not elevate permissions';
|
||||
toast.error(message, getToastStyleProps());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { elevated, elevatePermissions };
|
||||
}
|
||||
|
||||
export default useElevatedPermissions;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useSecurityKeysQuery } from '@/utils/__generated__/graphql';
|
||||
import { useUserId } from '@nhost/nextjs';
|
||||
|
||||
function useGetSecurityKeys() {
|
||||
const currentUserId = useUserId();
|
||||
const query = useSecurityKeysQuery({
|
||||
variables: {
|
||||
userId: currentUserId,
|
||||
},
|
||||
});
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export default useGetSecurityKeys;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ButtonWithLoading as Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
useGithubAuthentication,
|
||||
type UseGithubAuthenticationHookProps,
|
||||
} from '@/features/auth/AuthProviders/Github/hooks/useGithubAuthentication';
|
||||
import { SiGithub } from '@icons-pack/react-simple-icons';
|
||||
|
||||
interface Props extends UseGithubAuthenticationHookProps {
|
||||
buttonText?: string;
|
||||
withAnonId?: boolean;
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
function GithubAuthButton({
|
||||
buttonText = 'Continue with GitHub',
|
||||
withAnonId = false,
|
||||
redirectTo,
|
||||
}: Props) {
|
||||
const { mutate: signInWithGithub, isLoading } = useGithubAuthentication({
|
||||
withAnonId,
|
||||
redirectTo,
|
||||
});
|
||||
return (
|
||||
<Button
|
||||
className="gap-2 !bg-white text-sm+ !text-black hover:ring-2 hover:ring-white hover:ring-opacity-50 disabled:!text-black disabled:!text-opacity-60"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
onClick={() => signInWithGithub()}
|
||||
>
|
||||
<SiGithub size={14} /> {buttonText}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default GithubAuthButton;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as GithubAuthButton } from './GithubAuthButton';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './useGithubAuthentication';
|
||||
export { default as useGithubAuthentication } from './useGithubAuthentication';
|
||||
@@ -0,0 +1,39 @@
|
||||
import { getAnonId } from '@/lib/segment';
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export interface UseGithubAuthenticationHookProps {
|
||||
withAnonId?: boolean;
|
||||
redirectTo?: string;
|
||||
errorText?: string;
|
||||
}
|
||||
|
||||
function useGithubAuthentication({
|
||||
withAnonId = false,
|
||||
redirectTo,
|
||||
errorText,
|
||||
}: UseGithubAuthenticationHookProps) {
|
||||
const githubAuthenticationMutation = useMutation(
|
||||
async () => {
|
||||
const options = {
|
||||
...(isNotEmptyValue(redirectTo) && { redirectTo }),
|
||||
...(withAnonId && { metadata: { anonId: await getAnonId() } }),
|
||||
};
|
||||
return nhost.auth.signIn({
|
||||
provider: 'github',
|
||||
...(isNotEmptyValue(options) && { options }),
|
||||
});
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
toast.error(errorText, getToastStyleProps());
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return githubAuthenticationMutation;
|
||||
}
|
||||
export default useGithubAuthentication;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { useSignInWithSecurityKey } from '@/features/auth/SignIn/SecurityKey/hooks/useSignInWithSecurityKey';
|
||||
import { Fingerprint } from 'lucide-react';
|
||||
import { VerifyEmailDialog } from './VerifyEmailDialog';
|
||||
|
||||
function SignInWithSecurityKey() {
|
||||
const { disabled, signInWithSecurityKey, needsEmailVerification } =
|
||||
useSignInWithSecurityKey();
|
||||
return (
|
||||
<>
|
||||
<VerifyEmailDialog needsEmailVerification={needsEmailVerification} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-2 !bg-white text-sm+ !text-black hover:ring-2 hover:ring-white hover:ring-opacity-50 disabled:!text-black disabled:!text-opacity-60"
|
||||
disabled={disabled}
|
||||
onClick={signInWithSecurityKey}
|
||||
>
|
||||
<Fingerprint size={14} />
|
||||
Continue with a security key
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignInWithSecurityKey;
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/v3/dialog';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
needsEmailVerification: boolean;
|
||||
}
|
||||
|
||||
export function VerifyEmailDialog({ needsEmailVerification }: Props) {
|
||||
const [open, setOpen] = useState(needsEmailVerification);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(needsEmailVerification);
|
||||
}, [needsEmailVerification, open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Email verification required</DialogTitle>
|
||||
<DialogDescription>
|
||||
You need to verify your email first. Please check your mailbox and
|
||||
follow the confirmation link to complete the registration.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useSignInWithSecurityKey } from './useSignInWithSecurityKey';
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useSignInSecurityKey } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
function useSignInWithSecurityKey() {
|
||||
const { signInSecurityKey } = useSignInSecurityKey();
|
||||
const [needsEmailVerification, setNeedsEmailVerification] = useState(false);
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const { replace } = useRouter();
|
||||
|
||||
async function signInWithSecurityKey() {
|
||||
setDisabled(true);
|
||||
const {
|
||||
isError,
|
||||
isSuccess,
|
||||
needsEmailVerification: _needsEmailVerification,
|
||||
error,
|
||||
} = await signInSecurityKey();
|
||||
if (isError) {
|
||||
toast.error(error?.message, getToastStyleProps());
|
||||
} else if (_needsEmailVerification) {
|
||||
setNeedsEmailVerification(true);
|
||||
} else if (isSuccess) {
|
||||
replace('/');
|
||||
}
|
||||
setDisabled(false);
|
||||
}
|
||||
|
||||
return { disabled, signInWithSecurityKey, needsEmailVerification };
|
||||
}
|
||||
|
||||
export default useSignInWithSecurityKey;
|
||||
1
dashboard/src/features/auth/SignIn/SecurityKey/index.ts
Normal file
1
dashboard/src/features/auth/SignIn/SecurityKey/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as SignInWithSecurityKey } from './components/SignInWithSecurityKey/SignInWithSecurityKey';
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MfaOtpForm } from '@/components/common/MfaOtpForm';
|
||||
import type { SendMfaOtpHandler } from '@nhost/nextjs';
|
||||
import { Smartphone } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
sendMfaOtp: SendMfaOtpHandler;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function MfaSignInOtpForm({ sendMfaOtp, loading }: Props) {
|
||||
return (
|
||||
<div className="ws-full relative grid grid-flow-row gap-4 bg-transparent">
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3">
|
||||
<Smartphone size={32} />
|
||||
<h2 className="text-[1.25rem]">Authentication Code</h2>
|
||||
</div>
|
||||
<MfaOtpForm loading={loading} sendMfaOtp={sendMfaOtp} />
|
||||
<p className="text-center">
|
||||
Open your authenticator app or browser extension to view your
|
||||
authentication code.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MfaSignInOtpForm;
|
||||
@@ -0,0 +1,16 @@
|
||||
import useOnSignUpWithPasswordHandler from '@/features/auth/SignIn/SignInWithEmailAndPassword/hooks/useOnSignInWithEmailAndPasswordHandler';
|
||||
import MfaSignInOtpForm from './MfaSignInOtpForm';
|
||||
import SignInWithEmailAndPasswordForm from './SignInWithEmailAndPasswordForm';
|
||||
|
||||
function SignInWithEmailAndPassword() {
|
||||
const { onSignIWithEmailAndPassword, sendMfaOtp, isLoading, needsMfaOtp } =
|
||||
useOnSignUpWithPasswordHandler();
|
||||
|
||||
return needsMfaOtp ? (
|
||||
<MfaSignInOtpForm sendMfaOtp={sendMfaOtp} loading={isLoading} />
|
||||
) : (
|
||||
<SignInWithEmailAndPasswordForm onSubmit={onSignIWithEmailAndPassword} />
|
||||
);
|
||||
}
|
||||
|
||||
export default SignInWithEmailAndPassword;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { FormInput } from '@/components/form/FormInput';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { Form } from '@/components/ui/v3/form';
|
||||
import useSignInWithEmailAndPasswordForm, {
|
||||
type SignInWithEmailAndPasswordFormValues,
|
||||
} from '@/features/auth/SignIn/SignInWithEmailAndPassword/hooks/useSignInWithEmailAndPasswordForm';
|
||||
import NextLink from 'next/link';
|
||||
|
||||
interface Props {
|
||||
onSubmit: (values: SignInWithEmailAndPasswordFormValues) => void;
|
||||
}
|
||||
|
||||
function SignInWithEmailAndPassword({ onSubmit }: Props) {
|
||||
const form = useSignInWithEmailAndPasswordForm();
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid grid-flow-row gap-4 bg-transparent"
|
||||
>
|
||||
<FormInput
|
||||
control={form.control}
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
/>
|
||||
<FormInput
|
||||
control={form.control}
|
||||
label="Password"
|
||||
name="password"
|
||||
type="password"
|
||||
/>
|
||||
<NextLink
|
||||
href="/password/new"
|
||||
className="justify-self-start font-semibold"
|
||||
>
|
||||
Forgot password?
|
||||
</NextLink>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full !bg-white !text-black disabled:!text-black disabled:!text-opacity-60"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
<p color="secondary" className="text-center">
|
||||
<span className="text-[#A2B3BE]">or </span>
|
||||
<NextLink className="font-semibold" href="/signin">
|
||||
sign in with GitHub
|
||||
</NextLink>
|
||||
</p>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignInWithEmailAndPassword;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import {
|
||||
useSendVerificationEmail,
|
||||
useSignInEmailPassword,
|
||||
} from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { SignInWithEmailAndPasswordFormValues } from './useSignInWithEmailAndPasswordForm';
|
||||
|
||||
function useOnSignInWithEmailAndPasswordHandler() {
|
||||
const router = useRouter();
|
||||
const { signInEmailPassword, needsMfaOtp, sendMfaOtp, isLoading } =
|
||||
useSignInEmailPassword();
|
||||
|
||||
const { sendEmail } = useSendVerificationEmail();
|
||||
|
||||
async function onSignIWithEmailAndPassword({
|
||||
email,
|
||||
password,
|
||||
}: SignInWithEmailAndPasswordFormValues) {
|
||||
try {
|
||||
const { needsEmailVerification, error } = await signInEmailPassword(
|
||||
email,
|
||||
password,
|
||||
);
|
||||
if (error) {
|
||||
toast.error(
|
||||
error?.message ||
|
||||
'An error occurred while signing in. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsEmailVerification) {
|
||||
await sendEmail(email);
|
||||
router.push(`/email/verify?email=${encodeURIComponent(email)}`);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
'An error occurred while signing in. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { onSignIWithEmailAndPassword, needsMfaOtp, sendMfaOtp, isLoading };
|
||||
}
|
||||
|
||||
export default useOnSignInWithEmailAndPasswordHandler;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const validationSchema = z
|
||||
.object({
|
||||
email: z.string().email({ message: 'Invalid email address' }),
|
||||
password: z.string().min(1, { message: 'Password is required' }),
|
||||
})
|
||||
.required();
|
||||
|
||||
export type SignInWithEmailAndPasswordFormValues = z.infer<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
function useSignInWithEmailAndPasswordForm() {
|
||||
const form = useForm<SignInWithEmailAndPasswordFormValues>({
|
||||
mode: 'onTouched',
|
||||
reValidateMode: 'onBlur',
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
resolver: zodResolver(validationSchema),
|
||||
});
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
export default useSignInWithEmailAndPasswordForm;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SignInWithEmailAndPassword } from './components/SignInWithEmailAndPassword';
|
||||
@@ -0,0 +1,15 @@
|
||||
import { GithubAuthButton } from '@/features/auth/AuthProviders/Github/components/GithubAuthButton';
|
||||
import { useHostName } from '@/features/orgs/projects/common/hooks/useHostName';
|
||||
|
||||
function SignInWithGithub() {
|
||||
const redirectTo = useHostName();
|
||||
return (
|
||||
<GithubAuthButton
|
||||
redirectTo={redirectTo}
|
||||
buttonText="Continue with GitHub"
|
||||
errorText="An error occurred while trying to sign in using GitHub. Please try again later."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignInWithGithub;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SignInWithGithub } from './SignInWithGithub';
|
||||
39
dashboard/src/features/auth/SignUp/SignUpTabs/SignUpTabs.tsx
Normal file
39
dashboard/src/features/auth/SignUp/SignUpTabs/SignUpTabs.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/ui/v3/tabs';
|
||||
import { useState } from 'react';
|
||||
import { SignUpWithEmailAndPasswordForm } from './SignUpWithEmailAndPassword';
|
||||
import { SignUpWithSecurityKeyForm } from './SignUpWithSecurityKey';
|
||||
|
||||
function SignUpTabs() {
|
||||
const [tab, setTab] = useState<string>('password');
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={setTab} className="w-full">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="password" className="w-full">
|
||||
Sign Up with a Password
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="security-key" className="w-full">
|
||||
Sign Up with a Security key
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="pt-7">
|
||||
{tab === 'password' && (
|
||||
<TabsContent value="password">
|
||||
<SignUpWithEmailAndPasswordForm />
|
||||
</TabsContent>
|
||||
)}
|
||||
{tab === 'security-key' && (
|
||||
<TabsContent value="security-key">
|
||||
<SignUpWithSecurityKeyForm />
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignUpTabs;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { FormInput } from '@/components/form/FormInput';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/v3/form';
|
||||
import useOnSignUpWithPasswordHandler from '@/features/auth/SignUp/SignUpTabs/SignUpWithEmailAndPassword/hooks/useOnSignUpWithPasswordHandler';
|
||||
import useSignUpWithEmailAndPasswordForm from '@/features/auth/SignUp/SignUpTabs/SignUpWithEmailAndPassword/hooks/useSignUpWithEmailAndPasswordForm';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
|
||||
function SignUpWithEmailAndPasswordForm() {
|
||||
const form = useSignUpWithEmailAndPasswordForm();
|
||||
const onSignUpWithPassword = useOnSignUpWithPasswordHandler();
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSignUpWithPassword)}
|
||||
className="grid grid-flow-row gap-4 bg-transparent"
|
||||
>
|
||||
<FormInput control={form.control} label="Name" name="displayName" />
|
||||
<FormInput
|
||||
control={form.control}
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
/>
|
||||
<FormInput
|
||||
control={form.control}
|
||||
label="Password"
|
||||
name="password"
|
||||
type="password"
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="turnstileToken"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Verification</FormLabel>
|
||||
<FormControl>
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
|
||||
options={{ theme: 'dark', size: 'flexible' }}
|
||||
onSuccess={(token) => {
|
||||
form.setValue('turnstileToken', token, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
onError={() => {
|
||||
form.setValue('turnstileToken', '', {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
onExpire={() => {
|
||||
form.setValue('turnstileToken', '', {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full !bg-transparent"
|
||||
>
|
||||
Sign Up
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignUpWithEmailAndPasswordForm;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { getAnonId } from '@/lib/segment';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useSignUpEmailPassword } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { SignUpWithEmailAndPasswordFormValues } from './useSignUpWithEmailAndPasswordForm';
|
||||
|
||||
function useOnSignUpWithPasswordHandler() {
|
||||
const { signUpEmailPassword } = useSignUpEmailPassword();
|
||||
const router = useRouter();
|
||||
|
||||
async function onSignUpWithPassword({
|
||||
email,
|
||||
password,
|
||||
displayName,
|
||||
turnstileToken,
|
||||
}: SignUpWithEmailAndPasswordFormValues) {
|
||||
try {
|
||||
const { needsEmailVerification, error } = await signUpEmailPassword(
|
||||
email,
|
||||
password,
|
||||
{
|
||||
displayName,
|
||||
metadata: { anonId: await getAnonId() },
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'x-cf-turnstile-response': turnstileToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
'An error occurred while signing up. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsEmailVerification) {
|
||||
router.push(`/email/verify?email=${encodeURIComponent(email)}`);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
'An error occurred while signing up. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return onSignUpWithPassword;
|
||||
}
|
||||
|
||||
export default useOnSignUpWithPasswordHandler;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const validationSchema = z
|
||||
.object({
|
||||
email: z.string().email({ message: 'Invalid email address' }),
|
||||
password: z.string().min(1, { message: 'Password is required' }),
|
||||
displayName: z
|
||||
.string()
|
||||
.regex(
|
||||
/^[\p{L}\p{N}\p{S} ,.'-]+$/u,
|
||||
'Use only letters, numbers, symbols and basic punctuation',
|
||||
)
|
||||
.min(1, { message: 'Name is required' })
|
||||
.max(32, { message: 'Name must be 32 characters or less' }),
|
||||
turnstileToken: z
|
||||
.string()
|
||||
.min(1, { message: 'Please complete the CAPTCHA' }),
|
||||
})
|
||||
.required();
|
||||
|
||||
export type SignUpWithEmailAndPasswordFormValues = z.infer<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
function useSignUpWithEmailAndPasswordForm() {
|
||||
const form = useForm<SignUpWithEmailAndPasswordFormValues>({
|
||||
mode: 'onTouched',
|
||||
reValidateMode: 'onBlur',
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
displayName: '',
|
||||
turnstileToken: '',
|
||||
},
|
||||
resolver: zodResolver(validationSchema),
|
||||
});
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
export default useSignUpWithEmailAndPasswordForm;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SignUpWithEmailAndPasswordForm } from './components/SignUpWithEmailAndPasswordForm';
|
||||
@@ -0,0 +1,75 @@
|
||||
import { FormInput } from '@/components/form/FormInput';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/v3/form';
|
||||
import useSignupWithSecurityKeyForm from '@/features/auth/SignUp/SignUpTabs/SignUpWithSecurityKey/hooks/useSignupWithSecurityKeyForm';
|
||||
import useSignupWithSecurityKeyHandler from '@/features/auth/SignUp/SignUpTabs/SignUpWithSecurityKey/hooks/useSignupWithSecurityKeyHandler';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
|
||||
function SignUpWithSecurityKeyForm() {
|
||||
const form = useSignupWithSecurityKeyForm();
|
||||
const onSignUpWithSecurityKey = useSignupWithSecurityKeyHandler();
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSignUpWithSecurityKey)}
|
||||
className="grid grid-flow-row gap-4 bg-transparent"
|
||||
>
|
||||
<FormInput control={form.control} label="Name" name="displayName" />
|
||||
<FormInput
|
||||
control={form.control}
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="turnstileToken"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Verification</FormLabel>
|
||||
<FormControl>
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
|
||||
options={{ theme: 'dark', size: 'flexible' }}
|
||||
onSuccess={(token) => {
|
||||
form.setValue('turnstileToken', token, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
onError={() => {
|
||||
form.setValue('turnstileToken', '', {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
onExpire={() => {
|
||||
form.setValue('turnstileToken', '', {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full !bg-transparent"
|
||||
>
|
||||
Sign Up
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignUpWithSecurityKeyForm;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const validationSchema = z
|
||||
.object({
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: 'Invalid email address' })
|
||||
.min(1, { message: 'Email is required' }),
|
||||
displayName: z
|
||||
.string()
|
||||
.regex(
|
||||
/^[\p{L}\p{N}\p{S} ,.'-]+$/u,
|
||||
'Use only letters, numbers, symbols and basic punctuation',
|
||||
)
|
||||
.min(1, { message: 'Name is required' })
|
||||
.max(32, { message: 'Name must be 32 characters or less' }),
|
||||
turnstileToken: z
|
||||
.string()
|
||||
.min(1, { message: 'Please complete the CAPTCHA' }),
|
||||
})
|
||||
.required();
|
||||
|
||||
export type SignUpWithSecurityKeyFormValues = z.infer<typeof validationSchema>;
|
||||
|
||||
function useSignUpWithSecurityKey() {
|
||||
const form = useForm<SignUpWithSecurityKeyFormValues>({
|
||||
mode: 'onTouched',
|
||||
reValidateMode: 'onBlur',
|
||||
defaultValues: {
|
||||
email: '',
|
||||
displayName: '',
|
||||
turnstileToken: '',
|
||||
},
|
||||
resolver: zodResolver(validationSchema),
|
||||
});
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
export default useSignUpWithSecurityKey;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { getAnonId } from '@/lib/segment';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useSignUpEmailSecurityKeyEmail } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { SignUpWithSecurityKeyFormValues } from './useSignupWithSecurityKeyForm';
|
||||
|
||||
function useOnSignUpWithSecurityKeyHandler() {
|
||||
const { signUpEmailSecurityKey } = useSignUpEmailSecurityKeyEmail();
|
||||
const router = useRouter();
|
||||
|
||||
async function onSignUpWithSecurityKey({
|
||||
email,
|
||||
displayName,
|
||||
turnstileToken,
|
||||
}: SignUpWithSecurityKeyFormValues) {
|
||||
try {
|
||||
const { needsEmailVerification, error } = await signUpEmailSecurityKey(
|
||||
email,
|
||||
{
|
||||
displayName,
|
||||
metadata: { anonId: await getAnonId() },
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'x-cf-turnstile-response': turnstileToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
'An error occurred while signing up. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsEmailVerification) {
|
||||
router.push(`/email/verify?email=${encodeURIComponent(email)}`);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
'An error occurred while signing up. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return onSignUpWithSecurityKey;
|
||||
}
|
||||
|
||||
export default useOnSignUpWithSecurityKeyHandler;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SignUpWithSecurityKeyForm } from './components/SignUpWithSecurityKeyForm';
|
||||
1
dashboard/src/features/auth/SignUp/SignUpTabs/index.ts
Normal file
1
dashboard/src/features/auth/SignUp/SignUpTabs/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as SignUpTabs } from './SignUpTabs';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { GithubAuthButton } from '@/features/auth/AuthProviders/Github/components/GithubAuthButton';
|
||||
|
||||
function SignUpWithGithub() {
|
||||
return (
|
||||
<GithubAuthButton
|
||||
withAnonId
|
||||
buttonText="Sign Up with GitHub"
|
||||
errorText="An error occurred while trying to sign up using GitHub. Please try again."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignUpWithGithub;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SignUpWithGithub } from './SignUpWithGithub';
|
||||
@@ -26,7 +26,7 @@ function InfoAlert({
|
||||
<Alert className={alertClassNames}>
|
||||
{icon && <div>{icon}</div>}
|
||||
<div>
|
||||
{title && <AlertTitle>{title}</AlertTitle>}
|
||||
{title && <AlertTitle className="font-semibold">{title}</AlertTitle>}
|
||||
{children && (
|
||||
<AlertDescription className={descClassNames}>
|
||||
{children}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -21,6 +20,8 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/v3/form';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/v3/radio-group';
|
||||
import { InfoAlert } from '@/features/orgs/components/InfoAlert';
|
||||
import TextLink from '@/features/orgs/projects/common/components/TextLink/TextLink';
|
||||
import { planDescriptions } from '@/features/orgs/projects/common/utils/planDescriptions';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
@@ -35,6 +36,14 @@ import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
function NewOrgButton() {
|
||||
return (
|
||||
<strong className="inline-flex items-center justify-center gap-2 px-1">
|
||||
<span>+ New Organization</span>
|
||||
</strong>
|
||||
);
|
||||
}
|
||||
|
||||
const changeOrgPlanForm = z.object({
|
||||
plan: z.string(),
|
||||
});
|
||||
@@ -48,6 +57,8 @@ export default function SubscriptionPlan() {
|
||||
const [fetchOrganizationCustomePortalLink, { loading }] =
|
||||
useBillingOrganizationCustomePortalLazyQuery();
|
||||
|
||||
const isFreeOrg = org?.plan.isFree;
|
||||
|
||||
const form = useForm<z.infer<typeof changeOrgPlanForm>>({
|
||||
resolver: zodResolver(changeOrgPlanForm),
|
||||
defaultValues: {
|
||||
@@ -125,7 +136,7 @@ export default function SubscriptionPlan() {
|
||||
<div className="flex w-full flex-col gap-1 border-b p-4">
|
||||
<h4 className="font-medium">Subscription plan</h4>
|
||||
</div>
|
||||
<div className="flex w-full flex-col justify-between gap-8 border-b p-4 md:flex-row">
|
||||
<div className="flex w-full flex-col justify-between gap-8 p-4 md:flex-row">
|
||||
<div className="flex basis-1/2 flex-col gap-4">
|
||||
<span className="font-medium">Organization name</span>
|
||||
<span className="font-medium">{org?.name}</span>
|
||||
@@ -152,31 +163,26 @@ export default function SubscriptionPlan() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col-reverse items-end justify-between gap-2 p-4 md:flex-row md:items-center md:gap-0">
|
||||
{isFreeOrg && (
|
||||
<div className="flex w-full flex-col justify-between gap-8 p-4 md:flex-row">
|
||||
<InfoAlert title="Personal Organizations can not be upgraded.">
|
||||
You may create a new organization with premium features by
|
||||
clicking on the <NewOrgButton /> button in the left sidebar.
|
||||
</InfoAlert>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full flex-col-reverse items-end justify-between gap-2 border-t p-4 md:flex-row md:items-center md:gap-0">
|
||||
<div>
|
||||
<span>For a complete list of features, visit our </span>
|
||||
<Link
|
||||
href="https://nhost.io/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="font-medium"
|
||||
>
|
||||
<TextLink href="https://nhost.io/pricing">
|
||||
pricing
|
||||
<ArrowSquareOutIcon className="mb-[2px] ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</TextLink>
|
||||
<span> You can also visit our </span>
|
||||
<Link
|
||||
href="https://docs.nhost.io/platform/cloud/billing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="font-medium"
|
||||
>
|
||||
<TextLink href="https://docs.nhost.io/platform/cloud/billing">
|
||||
documentation
|
||||
<ArrowSquareOutIcon className="mb-[2px] ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</TextLink>
|
||||
<span> for billing information</span>
|
||||
</div>
|
||||
<div className="flex w-full flex-row items-center justify-end gap-2">
|
||||
@@ -245,7 +251,7 @@ export default function SubscriptionPlan() {
|
||||
</div>
|
||||
|
||||
<div className="mt-0 flex h-full items-center text-xl font-semibold">
|
||||
{plan.isFree ? 'Free' : `${plan.price}/mo`}
|
||||
{isFreeOrg ? 'Free' : `${plan.price}/mo`}
|
||||
</div>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
@@ -264,16 +270,10 @@ export default function SubscriptionPlan() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="mailto:hello@nhost.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="font-medium"
|
||||
>
|
||||
<TextLink href="mailto:hello@nhost.io">
|
||||
Contact us
|
||||
<ArrowSquareOutIcon className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</TextLink>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { Organization_Status_Enum } from '@/utils/__generated__/graphql';
|
||||
import nhost from '@/utils/nhost/nhost';
|
||||
import { Download } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Soc2Download() {
|
||||
const { org } = useCurrentOrg();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const showSoc2Download =
|
||||
(org?.plan?.name === 'Team' || org?.plan?.name?.startsWith('Enterprise')) &&
|
||||
org?.status === Organization_Status_Enum.Ok;
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!org || !showSoc2Download) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDownloading(true);
|
||||
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
const fileId = process.env.NEXT_PUBLIC_SOC2_REPORT_FILE_ID;
|
||||
|
||||
if (!fileId) {
|
||||
throw new Error('SOC2 report file ID not configured');
|
||||
}
|
||||
|
||||
const { file, error } = await nhost.storage.download({
|
||||
fileId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || 'Failed to download SOC2 report');
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
throw new Error('No file data available');
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'Nhost-SOC2-Report.pdf';
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Downloading SOC2 report...',
|
||||
successMessage: 'SOC2 report downloaded successfully',
|
||||
errorMessage:
|
||||
'Failed to download SOC2 report. Please try again or contact support.',
|
||||
},
|
||||
);
|
||||
|
||||
setDownloading(false);
|
||||
};
|
||||
|
||||
if (!showSoc2Download) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col rounded-md border bg-background">
|
||||
<div className="w-full border-b p-4 font-medium">
|
||||
SOC2 Compliance Report
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-4 p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Download Nhost's SOC2 Type II compliance report. This report
|
||||
demonstrates our commitment to security controls.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start">
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{downloading ? 'Downloading...' : 'Download SOC2 Report'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Soc2Download } from './Soc2Download';
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SquareArrowUpRightIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
@@ -5,7 +6,8 @@ function TextLink({
|
||||
href,
|
||||
children,
|
||||
target = '_blank',
|
||||
}: PropsWithChildren<{ href: string; target?: string }>) {
|
||||
withIcon = false,
|
||||
}: PropsWithChildren<{ href: string; target?: string; withIcon?: boolean }>) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
@@ -14,6 +16,7 @@ function TextLink({
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
{withIcon && <SquareArrowUpRightIcon className="h-4 w-4" />}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function useGetPostgresVersion() {
|
||||
});
|
||||
|
||||
const { version } = postgresSettingsData?.config?.postgres || {};
|
||||
const { major, minor } = splitPostgresMajorMinorVersions(version);
|
||||
const { major, minor } = splitPostgresMajorMinorVersions(version || '');
|
||||
|
||||
return {
|
||||
version,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
useGetPostgresSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
@@ -46,7 +47,9 @@ export default function DatabaseStorageCapacity() {
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const { project } = useProject();
|
||||
|
||||
const isFreeProject = !!org?.plan?.isFree;
|
||||
const isFreeProject = isEmptyValue(org) ? false : org.plan.isFree;
|
||||
|
||||
const shouldShowUpdateCapacityWarning = !isFreeProject && isPlatform;
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -98,6 +101,10 @@ export default function DatabaseStorageCapacity() {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isPlatform) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (maintenanceActive) {
|
||||
return true;
|
||||
}
|
||||
@@ -107,7 +114,13 @@ export default function DatabaseStorageCapacity() {
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [isDirty, maintenanceActive, decreasingSize, applicationPause]);
|
||||
}, [
|
||||
isDirty,
|
||||
maintenanceActive,
|
||||
decreasingSize,
|
||||
applicationPause,
|
||||
isPlatform,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !loading) {
|
||||
@@ -195,7 +208,7 @@ export default function DatabaseStorageCapacity() {
|
||||
helperText={formState.errors.capacity?.message}
|
||||
/>
|
||||
</Box>
|
||||
{!isFreeProject && (
|
||||
{shouldShowUpdateCapacityWarning && (
|
||||
<DatabaseStorageCapacityWarning
|
||||
state={state}
|
||||
decreasingSize={decreasingSize}
|
||||
|
||||
@@ -6,11 +6,13 @@ import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { CalendarIcon } from '@/components/ui/v2/icons/CalendarIcon';
|
||||
import { ChevronDownIcon } from '@/components/ui/v2/icons/ChevronDownIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import type { LogsFilterFormValues } from '@/features/orgs/projects/logs/components/LogsHeader';
|
||||
import { LogsTimePicker } from '@/features/orgs/projects/logs/components/LogsTimePicker';
|
||||
import { DATEPICKER_DISPLAY_FORMAT } from '@/features/orgs/projects/logs/utils/constants/datePicker';
|
||||
import { usePreviousData } from '@/hooks/usePreviousData';
|
||||
import { format } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface LogsDatePickerProps extends DatePickerProps {
|
||||
@@ -36,6 +38,7 @@ function LogsDatePicker({
|
||||
value,
|
||||
}: LogsDatePickerProps) {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(value);
|
||||
const { setValue } = useFormContext<LogsFilterFormValues>();
|
||||
const { button: buttonSlotProps } = {
|
||||
button: componentsProps?.button || {},
|
||||
};
|
||||
@@ -45,6 +48,11 @@ function LogsDatePicker({
|
||||
// going to display the last state set.
|
||||
const previousDate = usePreviousData(selectedDate);
|
||||
|
||||
const handleDateChange = (newValue: Date) => {
|
||||
setSelectedDate(new Date(newValue));
|
||||
setValue('interval', null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown.Root>
|
||||
<div className="grid grid-flow-col gap-x-3">
|
||||
@@ -84,9 +92,7 @@ function LogsDatePicker({
|
||||
<Dropdown.Content>
|
||||
<DatePicker
|
||||
value={disabled ? previousDate : selectedDate}
|
||||
onChange={(newValue) => {
|
||||
setSelectedDate(new Date(newValue));
|
||||
}}
|
||||
onChange={handleDateChange}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@/features/orgs/projects/logs/utils/constants/services';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
import { useGetServiceLabelValuesQuery } from '@/utils/__generated__/graphql';
|
||||
import { MINUTES_TO_DECREASE_FROM_CURRENT_DATE } from '@/utils/constants/common';
|
||||
import { DEFAULT_LOG_INTERVAL } from '@/utils/constants/common';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { subMinutes } from 'date-fns';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
@@ -28,6 +28,7 @@ import LogsServiceFilter from './LogsServiceFilter';
|
||||
export const validationSchema = Yup.object({
|
||||
from: Yup.date(),
|
||||
to: Yup.date().nullable(),
|
||||
interval: Yup.number().nullable(), // in minutes
|
||||
service: Yup.string().oneOf(Object.values(AvailableLogsService)),
|
||||
regexFilter: Yup.string(),
|
||||
});
|
||||
@@ -44,11 +45,17 @@ interface LogsHeaderProps extends Omit<BoxProps, 'children'> {
|
||||
* Function to be called when the user submits the filters form
|
||||
*/
|
||||
onSubmitFilterValues: (value: LogsFilterFormValues) => void;
|
||||
/**
|
||||
*
|
||||
* Function to be called to force a refetch of the logs when the form is not dirty and the user submits the form
|
||||
*/
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
export default function LogsHeader({
|
||||
loading,
|
||||
onSubmitFilterValues,
|
||||
onRefetch,
|
||||
...props
|
||||
}: LogsHeaderProps) {
|
||||
const { project } = useProject();
|
||||
@@ -83,16 +90,20 @@ export default function LogsHeader({
|
||||
|
||||
const form = useForm<LogsFilterFormValues>({
|
||||
defaultValues: {
|
||||
from: subMinutes(new Date(), MINUTES_TO_DECREASE_FROM_CURRENT_DATE),
|
||||
from: subMinutes(new Date(), DEFAULT_LOG_INTERVAL),
|
||||
to: new Date(),
|
||||
regexFilter: '',
|
||||
service: AvailableLogsService.ALL,
|
||||
interval: DEFAULT_LOG_INTERVAL,
|
||||
},
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
reValidateMode: 'onSubmit',
|
||||
});
|
||||
const { formState } = form;
|
||||
|
||||
const { register, watch, getValues } = form;
|
||||
const isNotDirty = Object.keys(formState.dirtyFields).length === 0;
|
||||
|
||||
const { register, watch, getValues, setValue } = form;
|
||||
|
||||
const service = watch('service');
|
||||
|
||||
@@ -100,8 +111,33 @@ export default function LogsHeader({
|
||||
onSubmitFilterValues(getValues());
|
||||
}, [service, getValues, onSubmitFilterValues]);
|
||||
|
||||
const handleSubmit = (values: LogsFilterFormValues) =>
|
||||
const handleSubmit = (values: LogsFilterFormValues) => {
|
||||
// If there's an interval set, recalculate the dates
|
||||
if (values.interval) {
|
||||
const now = new Date();
|
||||
const newValues = {
|
||||
...values,
|
||||
from: subMinutes(now, values.interval),
|
||||
to: now,
|
||||
interval: values.interval,
|
||||
};
|
||||
|
||||
// Update form values before submitting, to ensure the dates have the current date if selected an interval
|
||||
setValue('from', newValues.from);
|
||||
setValue('to', newValues.to);
|
||||
setValue('interval', newValues.interval);
|
||||
|
||||
onSubmitFilterValues(newValues);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the form is not dirty, force a refetch of the logs
|
||||
if (isNotDirty) {
|
||||
onRefetch();
|
||||
}
|
||||
|
||||
onSubmitFilterValues(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -198,11 +234,13 @@ export default function LogsHeader({
|
||||
type="submit"
|
||||
className="h-10"
|
||||
startIcon={
|
||||
loading ? (
|
||||
<ActivityIndicator className="h-4 w-4" />
|
||||
) : (
|
||||
<SearchIcon />
|
||||
)
|
||||
<div className="flex h-5 w-5 items-center justify-center">
|
||||
{loading ? (
|
||||
<ActivityIndicator className="h-5 w-5" />
|
||||
) : (
|
||||
<SearchIcon className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
disabled={loading}
|
||||
>
|
||||
|
||||
@@ -27,11 +27,18 @@ function LogsToDatePickerLiveButton() {
|
||||
if (isLive) {
|
||||
setValue('from', subMinutes(new Date(), 20));
|
||||
setValue('to', new Date());
|
||||
setValue('interval', null);
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('to', null);
|
||||
setCurrentTime(new Date());
|
||||
setValue('interval', null);
|
||||
}
|
||||
|
||||
function handleChangeToDate(date: Date) {
|
||||
setValue('to', date);
|
||||
setValue('interval', null);
|
||||
}
|
||||
|
||||
useInterval(() => setCurrentTime(new Date()), isLive ? 1000 : 0);
|
||||
@@ -43,7 +50,7 @@ function LogsToDatePickerLiveButton() {
|
||||
label="To"
|
||||
value={!isLive ? to : currentTime}
|
||||
disabled={isLive}
|
||||
onChange={(date: Date) => setValue('to', date)}
|
||||
onChange={handleChangeToDate}
|
||||
minDate={from}
|
||||
maxDate={new Date()}
|
||||
componentsProps={{
|
||||
@@ -84,7 +91,7 @@ function LogsRangeSelectorIntervalPickers({
|
||||
const applicationCreationDate = new Date(project.createdAt);
|
||||
|
||||
const { setValue, getValues } = useFormContext<LogsFilterFormValues>();
|
||||
const { from } = useWatch<LogsFilterFormValues>();
|
||||
const { from, interval } = useWatch<LogsFilterFormValues>();
|
||||
|
||||
const { handleClose } = useDropdown();
|
||||
|
||||
@@ -101,6 +108,12 @@ function LogsRangeSelectorIntervalPickers({
|
||||
}: LogsCustomInterval) {
|
||||
setValue('from', subMinutes(new Date(), minutesToDecreaseFromCurrentDate));
|
||||
setValue('to', new Date());
|
||||
setValue('interval', minutesToDecreaseFromCurrentDate);
|
||||
}
|
||||
|
||||
function handleChangeFromDate(date: Date) {
|
||||
setValue('from', date);
|
||||
setValue('interval', null);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -109,7 +122,7 @@ function LogsRangeSelectorIntervalPickers({
|
||||
<LogsDatePicker
|
||||
label="From"
|
||||
value={from}
|
||||
onChange={(date) => setValue('from', date)}
|
||||
onChange={handleChangeFromDate}
|
||||
minDate={applicationCreationDate}
|
||||
maxDate={new Date()}
|
||||
/>
|
||||
@@ -122,7 +135,11 @@ function LogsRangeSelectorIntervalPickers({
|
||||
<Button
|
||||
key={logInterval.label}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
color={
|
||||
interval === logInterval.minutesToDecreaseFromCurrentDate
|
||||
? 'primary'
|
||||
: 'secondary'
|
||||
}
|
||||
className="self-center"
|
||||
onClick={() => handleIntervalChange(logInterval)}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { AnalyticsBrowser } from "@segment/analytics-next";
|
||||
import { isDevOrStaging } from '@/utils/helpers';
|
||||
import { AnalyticsBrowser } from '@segment/analytics-next';
|
||||
import { isPlatform } from '@/utils/env';
|
||||
|
||||
export const analytics = AnalyticsBrowser.load({
|
||||
cdnURL: process.env.NEXT_PUBLIC_SEGMENT_CDN_URL!,
|
||||
writeKey: process.env.NEXT_PUBLIC_ANALYTICS_WRITE_KEY!,
|
||||
|
||||
export const analytics = AnalyticsBrowser.load(
|
||||
{
|
||||
cdnURL: process.env.NEXT_PUBLIC_SEGMENT_CDN_URL!,
|
||||
writeKey: process.env.NEXT_PUBLIC_ANALYTICS_WRITE_KEY!,
|
||||
},
|
||||
{
|
||||
disable: !isPlatform() || isDevOrStaging()
|
||||
});
|
||||
|
||||
|
||||
export async function getAnonId() {
|
||||
let anonId: string;
|
||||
try {
|
||||
const user = await analytics.user();
|
||||
anonId = user.anonymousId();
|
||||
} catch (err) {
|
||||
console.error('Failed to get anonymous ID:', err);
|
||||
}
|
||||
return anonId;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import { TreeNavStateProvider } from '@/components/layout/MainNav/TreeNavStateCo
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { ThemeProvider } from '@/components/ui/v2/ThemeProvider';
|
||||
import { TooltipProvider } from '@/components/ui/v3/tooltip';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { isDevOrStaging } from '@/utils/helpers';
|
||||
// eslint-disable-next-line import/extensions
|
||||
import '@/styles/fonts.css';
|
||||
// eslint-disable-next-line import/extensions
|
||||
@@ -59,7 +57,6 @@ function MyApp({
|
||||
pageProps,
|
||||
emotionCache = clientSideEmotionCache,
|
||||
}: MyAppProps) {
|
||||
const isPlatform = useIsPlatform();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,7 +86,7 @@ function MyApp({
|
||||
<UIProvider>
|
||||
<Toaster position="bottom-center" />
|
||||
|
||||
{isPlatform && !isDevOrStaging() && <Analytics />}
|
||||
<Analytics />
|
||||
|
||||
<ThemeProvider
|
||||
colorPreferenceStorageKey={COLOR_PREFERENCE_STORAGE_KEY}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { AccountMfaSettings } from '@/features/account/settings/components/AccountMfaSettings';
|
||||
import { AccountSettingsLayout } from '@/features/account/settings/components/AccountSettingsLayout';
|
||||
import { DeleteAccount } from '@/features/account/settings/components/DeleteAccount';
|
||||
import { DisplayNameSetting } from '@/features/account/settings/components/DisplayNameSetting';
|
||||
import { EmailSetting } from '@/features/account/settings/components/EmailSetting';
|
||||
import { PasswordSettings } from '@/features/account/settings/components/PasswordSettings';
|
||||
import { PATSettings } from '@/features/account/settings/components/PATSettings';
|
||||
import { SecurityKeysSettings } from '@/features/account/settings/components/SecurityKeysSettings';
|
||||
import { SocialProvidersSettings } from '@/features/account/settings/components/SocialProvidersSettings';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
@@ -26,7 +28,12 @@ export default function AccountSettingsPage() {
|
||||
<RetryableErrorBoundary>
|
||||
<PasswordSettings />
|
||||
</RetryableErrorBoundary>
|
||||
|
||||
<RetryableErrorBoundary>
|
||||
<AccountMfaSettings />
|
||||
</RetryableErrorBoundary>
|
||||
<RetryableErrorBoundary>
|
||||
<SecurityKeysSettings />
|
||||
</RetryableErrorBoundary>
|
||||
<RetryableErrorBoundary>
|
||||
<SocialProvidersSettings />
|
||||
</RetryableErrorBoundary>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
GetLogsSubscriptionDocument,
|
||||
useGetProjectLogsQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { MINUTES_TO_DECREASE_FROM_CURRENT_DATE } from '@/utils/constants/common';
|
||||
import { DEFAULT_LOG_INTERVAL } from '@/utils/constants/common';
|
||||
import { subMinutes } from 'date-fns';
|
||||
import {
|
||||
useCallback,
|
||||
@@ -37,7 +37,7 @@ export default function LogsPage() {
|
||||
const subscriptionReturn = useRef(null);
|
||||
|
||||
const [filters, setFilters] = useState<LogsFilters>({
|
||||
from: subMinutes(new Date(), MINUTES_TO_DECREASE_FROM_CURRENT_DATE),
|
||||
from: subMinutes(new Date(), DEFAULT_LOG_INTERVAL),
|
||||
to: new Date(),
|
||||
regexFilter: '',
|
||||
service: AvailableLogsService.ALL,
|
||||
@@ -48,6 +48,7 @@ export default function LogsPage() {
|
||||
error,
|
||||
subscribeToMore,
|
||||
client,
|
||||
refetch,
|
||||
loading: loadingLogs,
|
||||
} = useGetProjectLogsQuery({
|
||||
variables: { appID: project?.id, ...filters },
|
||||
@@ -63,7 +64,10 @@ export default function LogsPage() {
|
||||
document: GetLogsSubscriptionDocument,
|
||||
variables: {
|
||||
appID: project?.id,
|
||||
service: filters.service,
|
||||
service:
|
||||
filters.service === AvailableLogsService.JOB_BACKUP
|
||||
? 'job-backup.+' // Use regex pattern to match any job-backup services
|
||||
: filters.service,
|
||||
from: filters.from,
|
||||
regexFilter: filters.regexFilter,
|
||||
},
|
||||
@@ -147,6 +151,7 @@ export default function LogsPage() {
|
||||
<LogsHeader
|
||||
loading={loading}
|
||||
onSubmitFilterValues={onSubmitFilterValues}
|
||||
onRefetch={refetch}
|
||||
/>
|
||||
<LogsBody error={error} loading={loading} logsData={data} />
|
||||
</RetryableErrorBoundary>
|
||||
|
||||
@@ -78,7 +78,10 @@ export default function SettingsGeneralPage() {
|
||||
usePauseApplicationMutation({
|
||||
variables: { appId: project?.id },
|
||||
refetchQueries: [
|
||||
{ query: GetOrganizationsDocument, variables: { userId: userData.id } },
|
||||
{
|
||||
query: GetOrganizationsDocument,
|
||||
variables: { userId: userData?.id },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -86,7 +89,10 @@ export default function SettingsGeneralPage() {
|
||||
useUnpauseApplicationMutation({
|
||||
variables: { appId: project?.id },
|
||||
refetchQueries: [
|
||||
{ query: GetOrganizationsDocument, variables: { userId: userData.id } },
|
||||
{
|
||||
query: GetOrganizationsDocument,
|
||||
variables: { userId: userData?.id },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DeleteOrg } from '@/features/orgs/components/general/components/DeleteOrg';
|
||||
import { GeneralSettings } from '@/features/orgs/components/general/components/GeneralSettings';
|
||||
import { Soc2Download } from '@/features/orgs/components/general/components/Soc2Download';
|
||||
import { ProjectLayout } from '@/features/orgs/layout/ProjectLayout';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
@@ -7,6 +8,7 @@ export default function OrgSettings() {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4 overflow-auto bg-accent p-4">
|
||||
<GeneralSettings />
|
||||
<Soc2Download />
|
||||
<DeleteOrg />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,106 +1,68 @@
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { UnauthenticatedLayout } from '@/components/layout/UnauthenticatedLayout';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { GitHubIcon } from '@/components/ui/v2/icons/GitHubIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useHostName } from '@/features/orgs/projects/common/hooks/useHostName';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import NextLink from 'next/link';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function SignUpPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const redirectTo = useHostName();
|
||||
import { SignInWithSecurityKey } from '@/features/auth/SignIn/SecurityKey';
|
||||
import { SignInWithGithub } from '@/features/auth/SignIn/SignInWithGithub';
|
||||
|
||||
export default function SigninPage() {
|
||||
return (
|
||||
<>
|
||||
<Text
|
||||
variant="h2"
|
||||
component="h1"
|
||||
className="text-center text-3.5xl font-semibold lg:text-4.5xl"
|
||||
>
|
||||
<div className="grid gap-12 font-[Inter]">
|
||||
<h2 className="text-center text-3.5xl font-semibold lg:text-4.5xl">
|
||||
It's time to build
|
||||
</Text>
|
||||
</h2>
|
||||
|
||||
<Box className="grid grid-flow-row gap-4 rounded-md border bg-transparent p-6 lg:p-12">
|
||||
<div className="grid grid-flow-row gap-4 rounded-md border bg-transparent p-6 lg:p-12">
|
||||
<SignInWithGithub />
|
||||
<SignInWithSecurityKey />
|
||||
|
||||
<div className="relative py-2">
|
||||
<p className="absolute left-0 right-0 top-1/2 mx-auto w-12 -translate-y-1/2 bg-black px-2 text-center text-sm text-[#68717A]">
|
||||
OR
|
||||
</p>
|
||||
|
||||
<Divider className="!my-2" />
|
||||
</div>
|
||||
<Button
|
||||
className="!bg-white !text-black hover:ring-2 hover:ring-white hover:ring-opacity-50 disabled:!text-black disabled:!text-opacity-60"
|
||||
startIcon={<GitHubIcon />}
|
||||
size="large"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await nhost.auth.signIn({
|
||||
provider: 'github',
|
||||
options: { redirectTo },
|
||||
});
|
||||
} catch {
|
||||
toast.error(
|
||||
`An error occurred while trying to sign in using GitHub. Please try again later.`,
|
||||
getToastStyleProps(),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Continue with GitHub
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
variant="ghost"
|
||||
className="!text-white hover:!bg-white hover:!bg-opacity-10 focus:!bg-white focus:!bg-opacity-10"
|
||||
size="large"
|
||||
href="/signin/email"
|
||||
LinkComponent={NavLink}
|
||||
>
|
||||
Continue with Email
|
||||
<NextLink href="/signin/email">Continue with Email</NextLink>
|
||||
</Button>
|
||||
|
||||
<Divider className="!my-2" />
|
||||
|
||||
<Text color="secondary" className="text-center text-sm">
|
||||
<p className="text-center text-sm">
|
||||
By clicking continue, you agree to our{' '}
|
||||
<NavLink
|
||||
<NextLink
|
||||
href="https://nhost.io/legal/terms-of-service"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold"
|
||||
color="white"
|
||||
className="font-semibold text-white"
|
||||
>
|
||||
Terms of Service
|
||||
</NavLink>{' '}
|
||||
</NextLink>{' '}
|
||||
and{' '}
|
||||
<NavLink
|
||||
<NextLink
|
||||
href="https://nhost.io/legal/privacy-policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold"
|
||||
color="white"
|
||||
className="font-semibold text-white"
|
||||
>
|
||||
Privacy Policy
|
||||
</NavLink>
|
||||
</Text>
|
||||
</Box>
|
||||
</NextLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Text color="secondary" className="text-center lg:text-lg">
|
||||
<p className="text-center lg:text-lg">
|
||||
Don't have an account?{' '}
|
||||
<NavLink href="/signup" color="white" className="font-medium">
|
||||
<NextLink href="/signup" className="font-medium text-white">
|
||||
Sign Up
|
||||
</NavLink>
|
||||
</Text>
|
||||
</>
|
||||
</NextLink>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SignUpPage.getLayout = function getLayout(page: ReactElement) {
|
||||
SigninPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <UnauthenticatedLayout title="Sign In">{page}</UnauthenticatedLayout>;
|
||||
};
|
||||
|
||||
@@ -1,165 +1,29 @@
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { UnauthenticatedLayout } from '@/components/layout/UnauthenticatedLayout';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { styled } from '@mui/material';
|
||||
import { useNhostClient, useSignInEmailPassword } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, type ReactElement } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
email: Yup.string().label('Email').email().required(),
|
||||
password: Yup.string().label('Password').required(),
|
||||
});
|
||||
|
||||
export type EmailSignUpFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
const StyledInput = styled(Input)({
|
||||
backgroundColor: 'transparent',
|
||||
[`& .${inputClasses.input}`]: {
|
||||
backgroundColor: 'transparent !important',
|
||||
},
|
||||
});
|
||||
|
||||
export default function EmailSignUpPage() {
|
||||
const router = useRouter();
|
||||
const nhost = useNhostClient();
|
||||
const { signInEmailPassword, error } = useSignInEmailPassword();
|
||||
|
||||
const form = useForm<EmailSignUpFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { register, formState } = form;
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(
|
||||
error?.message || 'An error occurred while signing in. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}, [error]);
|
||||
|
||||
async function handleSubmit({ email, password }: EmailSignUpFormValues) {
|
||||
try {
|
||||
const { needsEmailVerification } = await signInEmailPassword(
|
||||
email,
|
||||
password,
|
||||
);
|
||||
|
||||
if (needsEmailVerification) {
|
||||
await nhost.auth.sendVerificationEmail({ email: email as string });
|
||||
router.push(`/email/verify?email=${email}`);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
'An error occurred while signing in. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
}
|
||||
import { SignInWithEmailAndPassword } from '@/features/auth/SignIn/SignInWithEmailAndPassword';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
function SigninPage() {
|
||||
return (
|
||||
<>
|
||||
<Text
|
||||
variant="h2"
|
||||
component="h1"
|
||||
className="text-center text-3.5xl font-semibold lg:text-4.5xl"
|
||||
>
|
||||
<div className="grid gap-12 font-[Inter]">
|
||||
<h1 className="text-center text-3.5xl font-semibold lg:text-4.5xl">
|
||||
Sign In
|
||||
</Text>
|
||||
|
||||
<Box className="grid grid-flow-row gap-4 rounded-md border bg-transparent p-6 lg:p-12">
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-flow-row gap-4 bg-transparent"
|
||||
>
|
||||
<StyledInput
|
||||
{...register('email')}
|
||||
id="email"
|
||||
placeholder="Email"
|
||||
inputProps={{ min: 2, max: 128 }}
|
||||
spellCheck="false"
|
||||
autoCapitalize="none"
|
||||
type="email"
|
||||
label="Email"
|
||||
hideEmptyHelperText
|
||||
fullWidth
|
||||
error={!!formState.errors.email}
|
||||
helperText={formState.errors.email?.message}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<StyledInput
|
||||
{...register('password')}
|
||||
id="password"
|
||||
placeholder="Password"
|
||||
inputProps={{ min: 2, max: 128 }}
|
||||
spellCheck="false"
|
||||
autoCapitalize="none"
|
||||
type="password"
|
||||
label="Password"
|
||||
hideEmptyHelperText
|
||||
fullWidth
|
||||
error={!!formState.errors.password}
|
||||
helperText={formState.errors.password?.message}
|
||||
/>
|
||||
|
||||
<NavLink
|
||||
href="/password/new"
|
||||
color="white"
|
||||
className="justify-self-start font-semibold"
|
||||
>
|
||||
Forgot password?
|
||||
</NavLink>
|
||||
|
||||
<Button
|
||||
className="!bg-white !text-black disabled:!text-black disabled:!text-opacity-60"
|
||||
size="large"
|
||||
disabled={formState.isSubmitting}
|
||||
loading={formState.isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
|
||||
<Text color="secondary" className="text-center">
|
||||
or{' '}
|
||||
<NavLink color="white" className="font-semibold" href="/signin">
|
||||
sign in with GitHub
|
||||
</NavLink>
|
||||
</Text>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Box>
|
||||
|
||||
<Text color="secondary" className="text-center text-base lg:text-lg">
|
||||
</h1>
|
||||
<div className="grid grid-flow-row gap-4 rounded-md border bg-transparent p-6 lg:p-12">
|
||||
<SignInWithEmailAndPassword />
|
||||
</div>
|
||||
<p className="text-center text-base lg:text-lg">
|
||||
Don't have an account?{' '}
|
||||
<NavLink href="/signup" color="white">
|
||||
Sign Up
|
||||
</NavLink>
|
||||
</Text>
|
||||
</>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EmailSignUpPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <UnauthenticatedLayout title="Sign In">{page}</UnauthenticatedLayout>;
|
||||
SigninPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <UnauthenticatedLayout title="Sign Up">{page}</UnauthenticatedLayout>;
|
||||
};
|
||||
|
||||
export default SigninPage;
|
||||
|
||||
@@ -1,268 +1,60 @@
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { UnauthenticatedLayout } from '@/components/layout/UnauthenticatedLayout';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { GitHubIcon } from '@/components/ui/v2/icons/GitHubIcon';
|
||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { analytics } from '@/lib/segment';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { styled } from '@mui/material';
|
||||
import { useSignUpEmailPassword } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { SignUpTabs } from '@/features/auth/SignUp/SignUpTabs';
|
||||
import { SignUpWithEmailAndPasswordForm } from '@/features/auth/SignUp/SignUpTabs/SignUpWithEmailAndPassword';
|
||||
import { SignUpWithGithub } from '@/features/auth/SignUp/SignUpWithGithub';
|
||||
import NextLink from 'next/link';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
email: Yup.string().label('Email').email().required(),
|
||||
password: Yup.string().label('Password').required(),
|
||||
displayName: Yup.string().label('Name').required(),
|
||||
});
|
||||
|
||||
export type SignUpFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
const StyledInput = styled(Input)({
|
||||
backgroundColor: 'transparent',
|
||||
[`& .${inputClasses.input}`]: {
|
||||
backgroundColor: 'transparent !important',
|
||||
},
|
||||
});
|
||||
|
||||
export default function SignUpPage() {
|
||||
const { signUpEmailPassword, error } = useSignUpEmailPassword();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const [anonId, setAnonId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const getAnonId = async () => {
|
||||
try {
|
||||
const user = await analytics.user();
|
||||
setAnonId(user.anonymousId());
|
||||
} catch (err) {
|
||||
console.error('Failed to get anonymous ID:', err);
|
||||
}
|
||||
};
|
||||
getAnonId();
|
||||
}, []);
|
||||
|
||||
// x-cf-turnstile-response
|
||||
const [turnstileResponse, setTurnstileResponse] = useState(null);
|
||||
|
||||
const form = useForm<SignUpFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { register, formState } = form;
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(
|
||||
error?.message || 'An error occurred while signing up. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}, [error]);
|
||||
|
||||
async function handleSubmit({
|
||||
email,
|
||||
password,
|
||||
displayName,
|
||||
}: SignUpFormValues) {
|
||||
if (!turnstileResponse) {
|
||||
toast.error(
|
||||
'Please complete the signup verification challenge to continue.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { needsEmailVerification } = await signUpEmailPassword(
|
||||
email,
|
||||
password,
|
||||
{
|
||||
displayName,
|
||||
metadata: { anonId },
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'x-cf-turnstile-response': turnstileResponse,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (needsEmailVerification) {
|
||||
router.push(`/email/verify?email=${email}`);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
'An error occurred while signing up. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text
|
||||
variant="h2"
|
||||
component="h1"
|
||||
className="text-center text-3.5xl font-semibold lg:text-4.5xl"
|
||||
>
|
||||
<div className="flex flex-col gap-12 font-[Inter]">
|
||||
<h1 className="text-center text-3.5xl font-semibold lg:text-4.5xl">
|
||||
Sign Up
|
||||
</Text>
|
||||
</h1>
|
||||
|
||||
<Box className="grid grid-flow-row gap-4 rounded-md border bg-transparent p-6 lg:p-12">
|
||||
<Button
|
||||
variant="borderless"
|
||||
className="!bg-white !text-black hover:ring-2 hover:ring-white hover:ring-opacity-50 disabled:!text-black disabled:!text-opacity-60"
|
||||
startIcon={<GitHubIcon />}
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
size="large"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await nhost.auth.signIn({
|
||||
provider: 'github',
|
||||
options: { metadata: { anonId } },
|
||||
});
|
||||
} catch {
|
||||
toast.error(
|
||||
`An error occurred while trying to sign up using GitHub. Please try again.`,
|
||||
getToastStyleProps(),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Sign Up with GitHub
|
||||
</Button>
|
||||
<div className="grid grid-flow-row gap-4 rounded-md border bg-transparent p-6 lg:p-12">
|
||||
<SignUpWithGithub />
|
||||
|
||||
<div className="relative py-2">
|
||||
<Text
|
||||
className="absolute left-0 right-0 top-1/2 mx-auto w-12 -translate-y-1/2 bg-black px-2 text-center text-sm"
|
||||
color="disabled"
|
||||
>
|
||||
<p className="absolute left-0 right-0 top-1/2 mx-auto w-12 -translate-y-1/2 bg-black px-2 text-center text-sm text-[#68717A]">
|
||||
OR
|
||||
</Text>
|
||||
</p>
|
||||
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-flow-row gap-4 bg-transparent"
|
||||
>
|
||||
<StyledInput
|
||||
{...register('displayName')}
|
||||
id="displayName"
|
||||
label="Name"
|
||||
placeholder="Name"
|
||||
fullWidth
|
||||
autoFocus
|
||||
inputProps={{ min: 2, max: 128 }}
|
||||
error={!!formState.errors.email}
|
||||
helperText={formState.errors.email?.message}
|
||||
/>
|
||||
|
||||
<StyledInput
|
||||
{...register('email')}
|
||||
type="email"
|
||||
id="email"
|
||||
label="Email"
|
||||
placeholder="Email"
|
||||
fullWidth
|
||||
inputProps={{ min: 2, max: 128 }}
|
||||
error={!!formState.errors.email}
|
||||
helperText={formState.errors.email?.message}
|
||||
/>
|
||||
|
||||
<StyledInput
|
||||
{...register('password')}
|
||||
type="password"
|
||||
id="password"
|
||||
label="Password"
|
||||
placeholder="Password"
|
||||
fullWidth
|
||||
inputProps={{ min: 2, max: 128 }}
|
||||
error={!!formState.errors.password}
|
||||
helperText={formState.errors.password?.message}
|
||||
/>
|
||||
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
|
||||
options={{ theme: 'dark', size: 'flexible' }}
|
||||
onSuccess={setTurnstileResponse}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className="hover:!bg-white hover:!bg-opacity-10 focus:ring-0"
|
||||
size="large"
|
||||
type="submit"
|
||||
disabled={formState.isSubmitting}
|
||||
loading={formState.isSubmitting}
|
||||
>
|
||||
Sign Up
|
||||
</Button>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
|
||||
{/* TODO: https://github.com/nhost/nhost/issues/3340 */}
|
||||
<SignUpTabs />
|
||||
{false && <SignUpWithEmailAndPasswordForm />}
|
||||
<Divider className="!my-2" />
|
||||
|
||||
<Text color="secondary" className="text-center text-sm">
|
||||
<p className="text-center text-sm text-[#A2B3BE]">
|
||||
By signing up, you agree to our{' '}
|
||||
<NavLink
|
||||
<NextLink
|
||||
href="https://nhost.io/legal/terms-of-service"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold"
|
||||
color="white"
|
||||
className="font-semibold text-white"
|
||||
>
|
||||
Terms of Service
|
||||
</NavLink>{' '}
|
||||
</NextLink>{' '}
|
||||
and{' '}
|
||||
<NavLink
|
||||
<NextLink
|
||||
href="https://nhost.io/legal/privacy-policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold"
|
||||
color="white"
|
||||
className="font-semibold text-white"
|
||||
>
|
||||
Privacy Policy
|
||||
</NavLink>
|
||||
</Text>
|
||||
</Box>
|
||||
</NextLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Text color="secondary" className="text-center text-base lg:text-lg">
|
||||
<p className="text-center text-base text-[#A2B3BE] lg:text-lg">
|
||||
Already have an account?{' '}
|
||||
<NavLink href="/signin" color="white" className="font-medium">
|
||||
<NextLink href="/signin" className="font-medium text-white">
|
||||
Sign In
|
||||
</NavLink>
|
||||
</Text>
|
||||
</>
|
||||
</NextLink>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
141
dashboard/src/utils/__generated__/graphql.ts
generated
141
dashboard/src/utils/__generated__/graphql.ts
generated
@@ -6307,9 +6307,7 @@ export enum AuthUserProviders_Constraint {
|
||||
/** unique or primary key constraint on columns "id" */
|
||||
UserProvidersPkey = 'user_providers_pkey',
|
||||
/** unique or primary key constraint on columns "provider_user_id", "provider_id" */
|
||||
UserProvidersProviderIdProviderUserIdKey = 'user_providers_provider_id_provider_user_id_key',
|
||||
/** unique or primary key constraint on columns "user_id", "provider_id" */
|
||||
UserProvidersUserIdProviderIdKey = 'user_providers_user_id_provider_id_key'
|
||||
UserProvidersProviderIdProviderUserIdKey = 'user_providers_provider_id_provider_user_id_key'
|
||||
}
|
||||
|
||||
/** input type for inserting data into table "auth.user_providers" */
|
||||
@@ -21937,7 +21935,7 @@ export type Regions_Allowed_Organization_Bool_Exp = {
|
||||
export enum Regions_Allowed_Organization_Constraint {
|
||||
/** unique or primary key constraint on columns "id" */
|
||||
RegionsAllowedOrganizationPkey = 'regions_allowed_organization_pkey',
|
||||
/** unique or primary key constraint on columns "region_id", "organization_id" */
|
||||
/** unique or primary key constraint on columns "organization_id", "region_id" */
|
||||
RegionsAllowedOrganizationRegionIdOrganizationIdKey = 'regions_allowed_organization_region_id_organization_id_key'
|
||||
}
|
||||
|
||||
@@ -26663,7 +26661,7 @@ export type WorkspaceMemberInvites_Bool_Exp = {
|
||||
|
||||
/** unique or primary key constraints on table "workspace_member_invites" */
|
||||
export enum WorkspaceMemberInvites_Constraint {
|
||||
/** unique or primary key constraint on columns "workspace_id", "email" */
|
||||
/** unique or primary key constraint on columns "email", "workspace_id" */
|
||||
WorkspaceMemberInvitesEmailWorkspaceIdKey = 'workspace_member_invites_email_workspace_id_key',
|
||||
/** unique or primary key constraint on columns "id" */
|
||||
WorkspaceMemberInvitesPkey = 'workspace_member_invites_pkey'
|
||||
@@ -26946,7 +26944,7 @@ export type WorkspaceMembers_Bool_Exp = {
|
||||
export enum WorkspaceMembers_Constraint {
|
||||
/** unique or primary key constraint on columns "id" */
|
||||
WorkspaceMembersPkey = 'workspace_members_pkey',
|
||||
/** unique or primary key constraint on columns "workspace_id", "user_id" */
|
||||
/** unique or primary key constraint on columns "user_id", "workspace_id" */
|
||||
WorkspaceMembersUserIdWorkspaceIdKey = 'workspace_members_user_id_workspace_id_key'
|
||||
}
|
||||
|
||||
@@ -27716,6 +27714,13 @@ export type DeleteUserAccountMutationVariables = Exact<{
|
||||
|
||||
export type DeleteUserAccountMutation = { __typename?: 'mutation_root', deleteUser?: { __typename: 'users' } | null };
|
||||
|
||||
export type GetActiveMfaTypeQueryVariables = Exact<{
|
||||
id: Scalars['uuid'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetActiveMfaTypeQuery = { __typename?: 'query_root', user?: { __typename?: 'users', activeMfaType?: string | null } | null };
|
||||
|
||||
export type GetAuthUserProvidersQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
@@ -27726,6 +27731,20 @@ export type GetPersonalAccessTokensQueryVariables = Exact<{ [key: string]: never
|
||||
|
||||
export type GetPersonalAccessTokensQuery = { __typename?: 'query_root', personalAccessTokens: Array<{ __typename?: 'authRefreshTokens', id: any, metadata?: any | null, createdAt: any, expiresAt: any }> };
|
||||
|
||||
export type SecurityKeysQueryVariables = Exact<{
|
||||
userId: Scalars['uuid'];
|
||||
}>;
|
||||
|
||||
|
||||
export type SecurityKeysQuery = { __typename?: 'query_root', authUserSecurityKeys: Array<{ __typename?: 'authUserSecurityKeys', id: any, nickname?: string | null }> };
|
||||
|
||||
export type RemoveSecurityKeyMutationVariables = Exact<{
|
||||
id: Scalars['uuid'];
|
||||
}>;
|
||||
|
||||
|
||||
export type RemoveSecurityKeyMutation = { __typename?: 'mutation_root', deleteAuthUserSecurityKey?: { __typename?: 'authUserSecurityKeys', id: any } | null };
|
||||
|
||||
export type DeletePersonalAccessTokenMutationVariables = Exact<{
|
||||
patId: Scalars['uuid'];
|
||||
}>;
|
||||
@@ -28999,6 +29018,44 @@ export function useDeleteUserAccountMutation(baseOptions?: Apollo.MutationHookOp
|
||||
export type DeleteUserAccountMutationHookResult = ReturnType<typeof useDeleteUserAccountMutation>;
|
||||
export type DeleteUserAccountMutationResult = Apollo.MutationResult<DeleteUserAccountMutation>;
|
||||
export type DeleteUserAccountMutationOptions = Apollo.BaseMutationOptions<DeleteUserAccountMutation, DeleteUserAccountMutationVariables>;
|
||||
export const GetActiveMfaTypeDocument = gql`
|
||||
query getActiveMfaType($id: uuid!) {
|
||||
user(id: $id) {
|
||||
activeMfaType
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useGetActiveMfaTypeQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useGetActiveMfaTypeQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useGetActiveMfaTypeQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useGetActiveMfaTypeQuery({
|
||||
* variables: {
|
||||
* id: // value for 'id'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useGetActiveMfaTypeQuery(baseOptions: Apollo.QueryHookOptions<GetActiveMfaTypeQuery, GetActiveMfaTypeQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<GetActiveMfaTypeQuery, GetActiveMfaTypeQueryVariables>(GetActiveMfaTypeDocument, options);
|
||||
}
|
||||
export function useGetActiveMfaTypeLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetActiveMfaTypeQuery, GetActiveMfaTypeQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<GetActiveMfaTypeQuery, GetActiveMfaTypeQueryVariables>(GetActiveMfaTypeDocument, options);
|
||||
}
|
||||
export type GetActiveMfaTypeQueryHookResult = ReturnType<typeof useGetActiveMfaTypeQuery>;
|
||||
export type GetActiveMfaTypeLazyQueryHookResult = ReturnType<typeof useGetActiveMfaTypeLazyQuery>;
|
||||
export type GetActiveMfaTypeQueryResult = Apollo.QueryResult<GetActiveMfaTypeQuery, GetActiveMfaTypeQueryVariables>;
|
||||
export function refetchGetActiveMfaTypeQuery(variables: GetActiveMfaTypeQueryVariables) {
|
||||
return { query: GetActiveMfaTypeDocument, variables: variables }
|
||||
}
|
||||
export const GetAuthUserProvidersDocument = gql`
|
||||
query getAuthUserProviders {
|
||||
authUserProviders {
|
||||
@@ -29080,6 +29137,78 @@ export type GetPersonalAccessTokensQueryResult = Apollo.QueryResult<GetPersonalA
|
||||
export function refetchGetPersonalAccessTokensQuery(variables?: GetPersonalAccessTokensQueryVariables) {
|
||||
return { query: GetPersonalAccessTokensDocument, variables: variables }
|
||||
}
|
||||
export const SecurityKeysDocument = gql`
|
||||
query securityKeys($userId: uuid!) {
|
||||
authUserSecurityKeys(where: {userId: {_eq: $userId}}) {
|
||||
id
|
||||
nickname
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useSecurityKeysQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useSecurityKeysQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useSecurityKeysQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useSecurityKeysQuery({
|
||||
* variables: {
|
||||
* userId: // value for 'userId'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useSecurityKeysQuery(baseOptions: Apollo.QueryHookOptions<SecurityKeysQuery, SecurityKeysQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<SecurityKeysQuery, SecurityKeysQueryVariables>(SecurityKeysDocument, options);
|
||||
}
|
||||
export function useSecurityKeysLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<SecurityKeysQuery, SecurityKeysQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<SecurityKeysQuery, SecurityKeysQueryVariables>(SecurityKeysDocument, options);
|
||||
}
|
||||
export type SecurityKeysQueryHookResult = ReturnType<typeof useSecurityKeysQuery>;
|
||||
export type SecurityKeysLazyQueryHookResult = ReturnType<typeof useSecurityKeysLazyQuery>;
|
||||
export type SecurityKeysQueryResult = Apollo.QueryResult<SecurityKeysQuery, SecurityKeysQueryVariables>;
|
||||
export function refetchSecurityKeysQuery(variables: SecurityKeysQueryVariables) {
|
||||
return { query: SecurityKeysDocument, variables: variables }
|
||||
}
|
||||
export const RemoveSecurityKeyDocument = gql`
|
||||
mutation removeSecurityKey($id: uuid!) {
|
||||
deleteAuthUserSecurityKey(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type RemoveSecurityKeyMutationFn = Apollo.MutationFunction<RemoveSecurityKeyMutation, RemoveSecurityKeyMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useRemoveSecurityKeyMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useRemoveSecurityKeyMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useRemoveSecurityKeyMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [removeSecurityKeyMutation, { data, loading, error }] = useRemoveSecurityKeyMutation({
|
||||
* variables: {
|
||||
* id: // value for 'id'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useRemoveSecurityKeyMutation(baseOptions?: Apollo.MutationHookOptions<RemoveSecurityKeyMutation, RemoveSecurityKeyMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<RemoveSecurityKeyMutation, RemoveSecurityKeyMutationVariables>(RemoveSecurityKeyDocument, options);
|
||||
}
|
||||
export type RemoveSecurityKeyMutationHookResult = ReturnType<typeof useRemoveSecurityKeyMutation>;
|
||||
export type RemoveSecurityKeyMutationResult = Apollo.MutationResult<RemoveSecurityKeyMutation>;
|
||||
export type RemoveSecurityKeyMutationOptions = Apollo.BaseMutationOptions<RemoveSecurityKeyMutation, RemoveSecurityKeyMutationVariables>;
|
||||
export const DeletePersonalAccessTokenDocument = gql`
|
||||
mutation DeletePersonalAccessToken($patId: uuid!) {
|
||||
deletePersonalAccessToken: deleteAuthRefreshToken(id: $patId) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user