Compare commits
9 Commits
@nhost/das
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fed49e05b | ||
|
|
aee9a80ac8 | ||
|
|
5ef3f76ea0 | ||
|
|
4ca9641304 | ||
|
|
fd3b5c77e4 | ||
|
|
9ed8ce8a5e | ||
|
|
e7762cb2b5 | ||
|
|
e353d99de8 | ||
|
|
c4d289a4d5 |
@@ -11,20 +11,9 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "node",
|
||||
"target": "ES6",
|
||||
"target": "ESNext",
|
||||
"module": "CommonJS",
|
||||
"lib": [
|
||||
"es5",
|
||||
"dom",
|
||||
"es2015.promise",
|
||||
"es2015.symbol",
|
||||
"es2015.iterable",
|
||||
"es2015.collection",
|
||||
"es2015.symbol.wellknown",
|
||||
"es2015.core",
|
||||
"es2017.object",
|
||||
"es2017.string"
|
||||
],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"sourceMap": true,
|
||||
@@ -79,4 +68,4 @@
|
||||
"**/*/__tests__",
|
||||
"**/*/__mocks__"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NhostProvider } from '@/providers/nhost';
|
||||
import '@fontsource/inter';
|
||||
import '@fontsource/inter/500.css';
|
||||
import '@fontsource/inter/700.css';
|
||||
import { CssBaseline, ThemeProvider } from '@mui/material';
|
||||
import { NhostClient, NhostProvider } from '@nhost/nextjs';
|
||||
import { createClient } from '@nhost/nhost-js-beta';
|
||||
import { NhostApolloProvider } from '@nhost/react-apollo';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Buffer } from 'buffer';
|
||||
@@ -58,7 +59,9 @@ export const decorators = [
|
||||
</NhostApolloProvider>
|
||||
),
|
||||
(Story) => (
|
||||
<NhostProvider nhost={new NhostClient({ subdomain: 'local' })}>
|
||||
<NhostProvider
|
||||
nhost={createClient({ subdomain: 'local', region: 'local' })}
|
||||
>
|
||||
<Story />
|
||||
</NhostProvider>
|
||||
),
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 2.33.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- aee9a80: chore: update typescript version to the latest
|
||||
- 5ef3f76: chore (dashboard): Use the new SDK in the Dashboard
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9ed8ce8: fix (dashboard): Request new Mfa ticket after an invalid totp when signing in
|
||||
- fd3b5c7: fix (dashboard): Limit new project's name to a maximum of 32 charachters in E2E tests
|
||||
|
||||
## 2.32.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -14,6 +14,7 @@ export const test = base.extend<{ authenticatedNhostPage: Page }>({
|
||||
);
|
||||
await use(page);
|
||||
// update the context to get the new refresh token
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.context().storageState({ path: AUTH_CONTEXT });
|
||||
await page.close();
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@ test.beforeAll(async ({ browser }) => {
|
||||
|
||||
test('should create a new project', async () => {
|
||||
await gotoUrl(page, `/orgs/${getFreeUserStarterOrgSlug()}/projects/new`);
|
||||
const projectName = faker.lorem.words(3);
|
||||
const projectName = faker.lorem.words(3).slice(0, 32);
|
||||
|
||||
await page.getByLabel('Project Name').fill(projectName);
|
||||
await page.getByText('Create Project').click();
|
||||
@@ -34,7 +34,7 @@ test('should create a new project', async () => {
|
||||
expect(page.getByText('Internal info')).toBeVisible();
|
||||
|
||||
await page.waitForSelector('button:has-text("Upgrade project")', {
|
||||
timeout: 120000,
|
||||
timeout: 180000,
|
||||
});
|
||||
|
||||
const newProjectSlug = getProjectSlugFromUrl(page.url());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "2.32.0",
|
||||
"version": "2.33.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -45,8 +45,7 @@
|
||||
"@mui/material": "^5.15.14",
|
||||
"@mui/system": "^5.15.14",
|
||||
"@mui/x-date-pickers": "^5.0.20",
|
||||
"@nhost/nextjs": "workspace:*",
|
||||
"@nhost/react-apollo": "workspace:*",
|
||||
"@nhost/nhost-js-beta": "npm:@nhost/nhost-js@5.0.0-beta.7",
|
||||
"@radix-ui/react-accordion": "^1.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
@@ -63,6 +62,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@segment/analytics-next": "^1.77.0",
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@stripe/react-stripe-js": "^2.6.2",
|
||||
"@stripe/stripe-js": "^1.54.2",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
@@ -88,6 +88,7 @@
|
||||
"graphql-tag": "^2.12.6",
|
||||
"graphql-ws": "^5.16.0",
|
||||
"just-kebab-case": "^4.2.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lucide-react": "^0.416.0",
|
||||
"next": "^14.2.26",
|
||||
@@ -108,7 +109,7 @@
|
||||
"react-is": "18.2.0",
|
||||
"react-loading-skeleton": "^2.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-merge-refs": "^1.1.0",
|
||||
"react-merge-refs": "^3.0.2",
|
||||
"react-resizable-layout": "^0.7.2",
|
||||
"react-table": "^7.8.0",
|
||||
"recoil": "^0.7.7",
|
||||
|
||||
518
dashboard/src/components/common/MfaOtpForm/MfaOtpForm.test.tsx
Normal file
518
dashboard/src/components/common/MfaOtpForm/MfaOtpForm.test.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
import { render, screen, TestUserEvent, waitFor } from '@/tests/testUtils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import MfaOtpForm from './MfaOtpForm';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
toastError: vi.fn(),
|
||||
}));
|
||||
// Mock react-hot-toast
|
||||
vi.mock('react-hot-toast', async () => {
|
||||
const actualToast = await vi.importActual<any>('react-hot-toast');
|
||||
return {
|
||||
...actualToast,
|
||||
default: {
|
||||
...actualToast.default,
|
||||
error: mocks.toastError,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the toast style props utility
|
||||
vi.mock('@/utils/constants/settings', () => ({
|
||||
getToastStyleProps: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
describe('MfaOtpForm', () => {
|
||||
const mockSendMfaOtp = vi.fn();
|
||||
const mockRequestNewMfaTicket = vi.fn();
|
||||
const user = new TestUserEvent();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
sendMfaOtp: mockSendMfaOtp,
|
||||
loading: false,
|
||||
requestNewMfaTicket: mockRequestNewMfaTicket,
|
||||
} as any;
|
||||
|
||||
describe('Rendering and Initial State', () => {
|
||||
it('renders with correct initial state', () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveValue('');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it('focuses input on mount', () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
expect(input).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input Validation and Formatting', () => {
|
||||
it('only accepts numeric characters', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
await user.type(input, 'abc123def456');
|
||||
|
||||
expect(input).toHaveValue('123456');
|
||||
});
|
||||
|
||||
it('filters out non-numeric characters in real time', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
await user.type(input, '1a2b3c');
|
||||
|
||||
expect(input).toHaveValue('123');
|
||||
});
|
||||
|
||||
it('button is disabled when input has fewer than 6 digits', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '12345');
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it('button is enabled when input has exactly 6 digits', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
expect(button).toBeEnabled();
|
||||
});
|
||||
|
||||
it('button is disabled when input has more than 6 digits', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '6123457');
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('disables input and button when loading prop is true', () => {
|
||||
render(<MfaOtpForm {...defaultProps} loading />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button');
|
||||
|
||||
expect(input).toBeDisabled();
|
||||
expect(button).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Verifying...' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('input and button are disabled during submission', async () => {
|
||||
// Mock sendMfaOtp to return a promise that we can control
|
||||
const promise = new Promise(() => {}); // Never resolves
|
||||
mockSendMfaOtp.mockReturnValue(promise);
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
expect(input).toBeDisabled();
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('triggers sendMfaOtp with correct code on button click', async () => {
|
||||
mockSendMfaOtp.mockResolvedValue({ success: true });
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
expect(mockSendMfaOtp).toHaveBeenCalledWith('123456');
|
||||
expect(mockSendMfaOtp).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not submit when input has fewer than 6 digits', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '12345');
|
||||
await user.click(button);
|
||||
|
||||
expect(mockSendMfaOtp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not submit multiple times when already submitting', async () => {
|
||||
let resolvePromise: (value: any) => void;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
mockSendMfaOtp.mockReturnValue(promise);
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
await user.click(button); // Second click should be ignored
|
||||
|
||||
expect(mockSendMfaOtp).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Resolve the promise to clean up
|
||||
resolvePromise!({ success: true });
|
||||
await waitFor(async () => {
|
||||
await promise;
|
||||
});
|
||||
});
|
||||
|
||||
it('manages submission state properly', async () => {
|
||||
let resolvePromise: (value: any) => void;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
mockSendMfaOtp.mockReturnValue(promise);
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
// During submission
|
||||
expect(button).toBeDisabled();
|
||||
expect(input).toBeDisabled();
|
||||
|
||||
// Resolve the promise
|
||||
resolvePromise!({ success: true });
|
||||
await waitFor(async () => {
|
||||
await promise;
|
||||
});
|
||||
|
||||
// After submission
|
||||
await waitFor(() => {
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(input).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('displays error toast when sendMfaOtp returns an error', async () => {
|
||||
const errorMessage = 'Invalid TOTP code';
|
||||
|
||||
mockSendMfaOtp.mockRejectedValueOnce({ message: errorMessage });
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(errorMessage, {});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows generic error message when no specific error message is provided', async () => {
|
||||
mockSendMfaOtp.mockRejectedValueOnce({});
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(
|
||||
'An error occurred. Please try again.',
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles undefined error gracefully', async () => {
|
||||
mockSendMfaOtp.mockResolvedValue({
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
// Should not throw an error
|
||||
await waitFor(() => {
|
||||
expect(mockSendMfaOtp).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MFA Ticket Renewal', () => {
|
||||
it('calls requestNewMfaTicket when ticket is invalid', async () => {
|
||||
// First call - set ticket as invalid
|
||||
mockSendMfaOtp.mockRejectedValueOnce({ message: 'Invalid ticket' });
|
||||
// Second call - should work
|
||||
mockSendMfaOtp.mockResolvedValueOnce({ success: true });
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
// First submission - creates error and marks ticket invalid
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Clear input and try again
|
||||
await user.clear(input);
|
||||
await user.type(input, '654321');
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRequestNewMfaTicket).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call requestNewMfaTicket on first submission', async () => {
|
||||
mockSendMfaOtp.mockResolvedValue({ success: true });
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
expect(mockRequestNewMfaTicket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('works correctly when requestNewMfaTicket is not provided', async () => {
|
||||
const propsWithoutTicketRenewal = {
|
||||
sendMfaOtp: mockSendMfaOtp,
|
||||
loading: false,
|
||||
} as any;
|
||||
|
||||
mockSendMfaOtp.mockRejectedValueOnce({ message: 'Some error' });
|
||||
|
||||
render(<MfaOtpForm {...propsWithoutTicketRenewal} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
// Should not throw an error even without requestNewMfaTicket
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('updates input value correctly when typing', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
|
||||
await user.type(input, '123');
|
||||
expect(input).toHaveValue('123');
|
||||
|
||||
await user.type(input, '456');
|
||||
expect(input).toHaveValue('123456');
|
||||
});
|
||||
|
||||
it('can clear and retype input value', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
|
||||
await user.type(input, '123456');
|
||||
expect(input).toHaveValue('123456');
|
||||
|
||||
await user.clear(input);
|
||||
expect(input).toHaveValue('');
|
||||
|
||||
await user.type(input, '654321');
|
||||
expect(input).toHaveValue('654321');
|
||||
});
|
||||
|
||||
it('button triggers submission with valid code', async () => {
|
||||
mockSendMfaOtp.mockResolvedValue({ success: true });
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
expect(mockSendMfaOtp).toHaveBeenCalledWith('123456');
|
||||
});
|
||||
it('submits form when pressing Enter key with valid code', async () => {
|
||||
mockSendMfaOtp.mockResolvedValue({ success: true });
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.type(input, '{Enter}');
|
||||
|
||||
expect(mockSendMfaOtp).toHaveBeenCalledWith('123456');
|
||||
expect(mockSendMfaOtp).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not submit when pressing Enter with invalid code length', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
|
||||
await user.type(input, '12345');
|
||||
await user.type(input, '{Enter}');
|
||||
|
||||
expect(mockSendMfaOtp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not submit when pressing Enter while loading', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} loading />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.type(input, '{Enter}');
|
||||
|
||||
expect(mockSendMfaOtp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not submit multiple times when pressing Enter while submitting', async () => {
|
||||
let resolvePromise: (value: any) => void;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
mockSendMfaOtp.mockReturnValue(promise);
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.type(input, '{Enter}');
|
||||
await user.type(input, '{Enter}'); // Second Enter should be ignored
|
||||
|
||||
expect(mockSendMfaOtp).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Clean up
|
||||
resolvePromise!({ success: true });
|
||||
await waitFor(async () => {
|
||||
await promise;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles null error message gracefully', async () => {
|
||||
mockSendMfaOtp.mockRejectedValueOnce({ message: null });
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(
|
||||
'An error occurred. Please try again.',
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('prevents multiple rapid submissions', async () => {
|
||||
let resolvePromise: (value: any) => void;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
mockSendMfaOtp.mockReturnValue(promise);
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
|
||||
// Rapid clicks
|
||||
await user.click(button);
|
||||
await user.click(button);
|
||||
await user.click(button);
|
||||
|
||||
expect(mockSendMfaOtp).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Clean up
|
||||
resolvePromise!({ success: true });
|
||||
await waitFor(async () => {
|
||||
await promise;
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty input correctly', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.click(button);
|
||||
|
||||
expect(mockSendMfaOtp).not.toHaveBeenCalled();
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,26 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { Input } from '@/components/ui/v3/input';
|
||||
import { type ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface Props {
|
||||
sendMfaOtp: (code: string) => Promise<any>;
|
||||
loading: boolean;
|
||||
requestNewMfaTicket?: () => Promise<void>;
|
||||
}
|
||||
|
||||
function MfaOtpForm({ sendMfaOtp, loading }: Props) {
|
||||
function MfaOtpForm({ sendMfaOtp, loading, requestNewMfaTicket }: Props) {
|
||||
const [otpValue, setOtpValue] = useState<string>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const isMfaTicketInvalid = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
@@ -18,24 +28,39 @@ function MfaOtpForm({ sendMfaOtp, loading }: Props) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function sendMfa(code: string) {
|
||||
if (code.length === 6 && !isSubmitting) {
|
||||
setIsSubmitting(true);
|
||||
const result = await sendMfaOtp(code);
|
||||
if (!result) {
|
||||
async function submitTOTP() {
|
||||
if (otpValue.length === 6 && !isSubmitting) {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
if (requestNewMfaTicket && isMfaTicketInvalid.current) {
|
||||
await requestNewMfaTicket();
|
||||
}
|
||||
await sendMfaOtp(otpValue);
|
||||
} catch (error) {
|
||||
isMfaTicketInvalid.current = true;
|
||||
toast.error(
|
||||
error?.message || 'An error occurred. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 10);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
async function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const code = event.target.value.replace(/[^0-9]/g, '').slice(0, 6);
|
||||
const code = event.target.value.replace(/[^0-9]/g, '');
|
||||
setOtpValue(code);
|
||||
}
|
||||
|
||||
sendMfa(code);
|
||||
async function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
submitTOTP();
|
||||
}
|
||||
}
|
||||
|
||||
const isInputDisabled = loading || isSubmitting;
|
||||
@@ -50,8 +75,9 @@ function MfaOtpForm({ sendMfaOtp, loading }: Props) {
|
||||
className="!bg-transparent"
|
||||
disabled={isInputDisabled}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<Button disabled={isButtonDisabled}>
|
||||
<Button disabled={isButtonDisabled} onClick={submitTOTP}>
|
||||
{loading ? 'Verifying...' : 'Verify'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import type { FieldValues, UseControllerProps } from 'react-hook-form';
|
||||
import { useController, useFormContext } from 'react-hook-form';
|
||||
import mergeRefs from 'react-merge-refs';
|
||||
import { mergeRefs } from 'react-merge-refs';
|
||||
|
||||
export interface ControlledAutocompleteProps<
|
||||
TOption extends AutocompleteOption = AutocompleteOption,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import type { FieldValues, UseControllerProps } from 'react-hook-form';
|
||||
import { useController, useFormContext } from 'react-hook-form';
|
||||
import mergeRefs from 'react-merge-refs';
|
||||
import { mergeRefs } from 'react-merge-refs';
|
||||
|
||||
export interface ControlledCheckboxProps<TFieldValues extends FieldValues = any>
|
||||
extends CheckboxProps {
|
||||
@@ -38,7 +38,7 @@ function ControlledCheckbox(
|
||||
uncheckWhenDisabled,
|
||||
...props
|
||||
}: ControlledCheckboxProps,
|
||||
ref: ForwardedRef<HTMLInputElement>,
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
const { setValue } = useFormContext();
|
||||
const { field } = useController({
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import type { FieldValues, UseControllerProps } from 'react-hook-form';
|
||||
import { useController, useFormContext } from 'react-hook-form';
|
||||
import mergeRefs from 'react-merge-refs';
|
||||
import { mergeRefs } from 'react-merge-refs';
|
||||
|
||||
export interface ControlledSelectProps<TFieldValues extends FieldValues = any>
|
||||
extends SelectProps<TFieldValues> {
|
||||
@@ -24,7 +24,7 @@ export interface ControlledSelectProps<TFieldValues extends FieldValues = any>
|
||||
|
||||
function ControlledSelect(
|
||||
{ controllerProps, name, control, ...props }: ControlledSelectProps,
|
||||
ref: ForwardedRef<HTMLInputElement>,
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
const { setValue } = useFormContext();
|
||||
const { field } = useController({
|
||||
|
||||
@@ -2,13 +2,13 @@ import type { SwitchProps } from '@/components/ui/v2/Switch';
|
||||
import { Switch } from '@/components/ui/v2/Switch';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { useController, useFormContext } from 'react-hook-form';
|
||||
import type {
|
||||
ControllerProps,
|
||||
FieldValues,
|
||||
UseControllerProps,
|
||||
} from 'react-hook-form/dist/types';
|
||||
import mergeRefs from 'react-merge-refs';
|
||||
} from 'react-hook-form';
|
||||
import { useController, useFormContext } from 'react-hook-form';
|
||||
import { mergeRefs } from 'react-merge-refs';
|
||||
|
||||
export interface ControlledSwitchProps<TFieldValues extends FieldValues = any>
|
||||
extends SwitchProps {
|
||||
|
||||
@@ -6,19 +6,24 @@ import { Button } from '@/components/ui/v2/Button';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { Dropdown, useDropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { useSignOut, useUserData } from '@nhost/nextjs';
|
||||
import getConfig from 'next/config';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
function AccountMenuContent() {
|
||||
const user = useUserData();
|
||||
const { signOut } = useSignOut();
|
||||
const router = useRouter();
|
||||
const { signout } = useAuth();
|
||||
const apolloClient = useApolloClient();
|
||||
const { handleClose } = useDropdown();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
async function handleSignOut() {
|
||||
handleClose();
|
||||
await apolloClient.clearStore();
|
||||
await signout();
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="grid grid-flow-row">
|
||||
<Box className="grid grid-flow-col items-center justify-start gap-3 p-4">
|
||||
@@ -70,12 +75,7 @@ function AccountMenuContent() {
|
||||
color="error"
|
||||
variant="borderless"
|
||||
className="w-full justify-start"
|
||||
onClick={async () => {
|
||||
handleClose();
|
||||
await apolloClient.clearStore();
|
||||
await signOut();
|
||||
await router.push('/signin');
|
||||
}}
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
|
||||
@@ -2,22 +2,23 @@ import type { BaseLayoutProps } from '@/components/layout/BaseLayout';
|
||||
import { BaseLayout } from '@/components/layout/BaseLayout';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { MainNav } from '@/components/layout/MainNav';
|
||||
import { useTreeNavState } from '@/components/layout/MainNav/TreeNavStateContext';
|
||||
import { HighlightedText } from '@/components/presentational/HighlightedText';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useAuthenticationStatus } from '@nhost/nextjs';
|
||||
|
||||
import Analytics from '@/components/analytics/analytics';
|
||||
import { useMediaQuery } from '@/components/common/useMediaQuery';
|
||||
import { MainNav } from '@/components/layout/MainNav';
|
||||
import PinnedMainNav from '@/components/layout/MainNav/PinnedMainNav';
|
||||
import { useTreeNavState } from '@/components/layout/MainNav/TreeNavStateContext';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { OrgStatus } from '@/features/orgs/components/OrgStatus';
|
||||
import { useIsHealthy } from '@/features/orgs/projects/common/hooks/useIsHealthy';
|
||||
import { useNotFoundRedirect } from '@/features/orgs/projects/common/hooks/useNotFoundRedirect';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -32,7 +33,7 @@ export default function AuthenticatedLayout({
|
||||
const isPlatform = useIsPlatform();
|
||||
const isMdOrLarger = useMediaQuery('md');
|
||||
|
||||
const { isAuthenticated, isLoading } = useAuthenticationStatus();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const isHealthy = useIsHealthy();
|
||||
const [mainNavContainer, setMainNavContainer] = useState(null);
|
||||
const { mainNavPinned } = useTreeNavState();
|
||||
@@ -43,7 +44,6 @@ export default function AuthenticatedLayout({
|
||||
if (!isPlatform || isLoading || isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/signin');
|
||||
}, [isLoading, isAuthenticated, router, isPlatform]);
|
||||
|
||||
@@ -65,11 +65,15 @@ export default function AuthenticatedLayout({
|
||||
if (isPlatform && isLoading) {
|
||||
return (
|
||||
<BaseLayout className="h-full" {...props}>
|
||||
<Header className="flex max-h-[59px] flex-auto" />
|
||||
<Header className="flex max-h-[59px] flex-auto py-1" />
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// if (isPlatform && !isLoading && !isAuthenticated) {
|
||||
// return null;
|
||||
// }
|
||||
|
||||
if (!isPlatform && !isHealthy) {
|
||||
return (
|
||||
<BaseLayout className="h-full" {...props}>
|
||||
@@ -142,6 +146,7 @@ export default function AuthenticatedLayout({
|
||||
>
|
||||
<div className="flex h-full w-full flex-col overflow-auto">
|
||||
<OrgStatus />
|
||||
<Analytics />
|
||||
{children}
|
||||
</div>
|
||||
</RetryableErrorBoundary>
|
||||
|
||||
@@ -10,8 +10,8 @@ import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { useSignOut } from '@nhost/nextjs';
|
||||
import getConfig from 'next/config';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
@@ -22,11 +22,18 @@ export interface MobileNavProps extends ButtonProps {}
|
||||
export default function MobileNav({ className, ...props }: MobileNavProps) {
|
||||
const isPlatform = useIsPlatform();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { signOut } = useSignOut();
|
||||
const { signout } = useAuth();
|
||||
const apolloClient = useApolloClient();
|
||||
const router = useRouter();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
async function handleSignOut() {
|
||||
setMenuOpen(false);
|
||||
await apolloClient.clearStore();
|
||||
await signout();
|
||||
await router.push('/signin');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
@@ -120,12 +127,7 @@ export default function MobileNav({ className, ...props }: MobileNavProps) {
|
||||
variant="borderless"
|
||||
sx={{ color: 'error.main' }}
|
||||
className="justify-start border-none px-2 py-2.5 text-[16px]"
|
||||
onClick={async () => {
|
||||
setMenuOpen(false);
|
||||
await apolloClient.clearStore();
|
||||
await signOut();
|
||||
await router.push('/signin');
|
||||
}}
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
Sign Out
|
||||
</ListItem.Button>
|
||||
|
||||
@@ -6,8 +6,8 @@ import { RetryableErrorBoundary } from '@/components/presentational/RetryableErr
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { ThemeProvider } from '@/components/ui/v2/ThemeProvider';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
import GlobalStyles from '@mui/material/GlobalStyles';
|
||||
import { useAuthenticationStatus } from '@nhost/nextjs';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
@@ -20,7 +20,7 @@ export default function UnauthenticatedLayout({
|
||||
}: UnauthenticatedLayoutProps) {
|
||||
const router = useRouter();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { isAuthenticated, isLoading } = useAuthenticationStatus();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const isOnResetPassword = router.route === '/password/reset';
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -92,7 +92,7 @@ function Checkbox(
|
||||
'aria-label': ariaLabel,
|
||||
...props
|
||||
}: CheckboxProps,
|
||||
ref: ForwardedRef<HTMLInputElement>,
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
if (!label) {
|
||||
return (
|
||||
|
||||
@@ -3,10 +3,10 @@ import { ChevronUpIcon } from '@/components/ui/v2/icons/ChevronUpIcon';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { XIcon } from '@/components/ui/v2/icons/XIcon';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { getToastBackgroundColor } from '@/utils/constants/settings';
|
||||
import { copy } from '@/utils/copy';
|
||||
import type { ApolloError } from '@apollo/client';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { InputBaseProps as MaterialInputBaseProps } from '@mui/material/Inp
|
||||
import MaterialInputBase, { inputBaseClasses } from '@mui/material/InputBase';
|
||||
import type { DetailedHTMLProps, ForwardedRef, HTMLProps } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import mergeRefs from 'react-merge-refs';
|
||||
import { mergeRefs } from 'react-merge-refs';
|
||||
|
||||
export interface InputProps
|
||||
extends Omit<MaterialInputBaseProps, 'componentsProps' | 'slotProps'>,
|
||||
|
||||
@@ -57,7 +57,7 @@ const StyledRadio = styled(MaterialRadio)(({ theme }) => ({
|
||||
|
||||
function Radio(
|
||||
{ label, value, slotProps, ...props }: RadioProps,
|
||||
ref: ForwardedRef<HTMLInputElement>,
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
return (
|
||||
<StyledFormControlLabel
|
||||
|
||||
@@ -9,37 +9,36 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/v3/dialog';
|
||||
import useMfaEnabled from '@/features/account/settings/components/AccountMfaSettings/hooks/useMfaEnabled';
|
||||
import { useNhostClient } from '@/providers/nhost';
|
||||
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 nhost = useNhostClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isDisabling, setIsDisabling] = 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,
|
||||
try {
|
||||
setIsDisabling(true);
|
||||
await nhost.auth.verifyChangeUserMfa({
|
||||
code,
|
||||
activeMfaType: '',
|
||||
});
|
||||
toast.success(
|
||||
'Multi-factor authentication has been disabled.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
toast.success(
|
||||
'Multi-factor authentication has been disabled.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
await refetch();
|
||||
setOpen(false);
|
||||
await refetch();
|
||||
setOpen(false);
|
||||
|
||||
return true;
|
||||
return true;
|
||||
} finally {
|
||||
setIsDisabling(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,51 +1,52 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { MfaOtpForm } from '@/components/common/MfaOtpForm';
|
||||
import { Spinner } from '@/components/ui/v3/spinner';
|
||||
import { useNhostClient } from '@/providers/nhost';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useConfigMfa } from '@nhost/nextjs';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } 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();
|
||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string | undefined>();
|
||||
const [totpSecret, setTotpSecret] = useState<string | undefined>();
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
const nhost = useNhostClient();
|
||||
|
||||
async function onSendMfaOtp(code: string) {
|
||||
const result = await activateMfa(code);
|
||||
if (result.error) {
|
||||
toast.error(
|
||||
result.error.message || defaultErrorMessage,
|
||||
try {
|
||||
setIsActivating(true);
|
||||
await nhost.auth.verifyChangeUserMfa({
|
||||
code,
|
||||
activeMfaType: 'totp',
|
||||
});
|
||||
toast.success(
|
||||
'Multi-factor authentication has been enabled.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
return false;
|
||||
onSuccess();
|
||||
return true;
|
||||
} finally {
|
||||
setIsActivating(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());
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
const response = await nhost.auth.changeUserMfa();
|
||||
setQrCodeDataUrl(response.body.imageUrl);
|
||||
setTotpSecret(response.body.totpSecret);
|
||||
} catch (error) {
|
||||
toast.error(error?.message, getToastStyleProps());
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}
|
||||
generate();
|
||||
@@ -55,7 +56,7 @@ function MfaQRCodeAndTOTPSecret({ onSuccess }: Props) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-4">
|
||||
{isGenerating && <Spinner />}
|
||||
{isGenerated && qrCodeDataUrl && (
|
||||
{qrCodeDataUrl && (
|
||||
<>
|
||||
<div className="flex flex-col justify-center gap-4">
|
||||
<p className="text-base">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import { useGetActiveMfaTypeQuery } from '@/utils/__generated__/graphql';
|
||||
import { useUserId } from '@nhost/nextjs';
|
||||
|
||||
function useMfaEnabled() {
|
||||
const userId = useUserId();
|
||||
const userData = useUserData();
|
||||
const { data, loading, refetch } = useGetActiveMfaTypeQuery({
|
||||
variables: { id: userId },
|
||||
variables: { id: userData?.id },
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@ 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 { useNhostClient } from '@/providers/nhost';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { GetPersonalAccessTokensDocument } from '@/utils/__generated__/graphql';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { getDateComponents } from '@/utils/getDateComponents';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useNhostClient } from '@nhost/nextjs';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
@@ -85,15 +85,15 @@ export default function CreatePATForm({
|
||||
|
||||
const createPAT = useActionWithElevatedPermissions({
|
||||
actionFn: async (
|
||||
expiresAt: Date,
|
||||
expiresAt: string,
|
||||
metadata?: Record<string, string | number>,
|
||||
) => {
|
||||
const result = await nhostClient.auth.createPAT(expiresAt, metadata);
|
||||
const result = await nhostClient.auth.createPAT({ expiresAt, metadata });
|
||||
return result;
|
||||
},
|
||||
successMessage: 'The personal access token has been created successfully.',
|
||||
onSuccess: ({ data }) => {
|
||||
setPersonalAccessToken(data?.personalAccessToken);
|
||||
onSuccess: ({ body }) => {
|
||||
setPersonalAccessToken(body.personalAccessToken);
|
||||
apolloClient.refetchQueries({
|
||||
include: [GetPersonalAccessTokensDocument],
|
||||
});
|
||||
@@ -111,7 +111,8 @@ export default function CreatePATForm({
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
async function handleSubmit(formValues: CreatePATFormValues) {
|
||||
await createPAT(new Date(formValues.expiresAt), {
|
||||
const expiresAt = new Date(formValues.expiresAt).toISOString();
|
||||
await createPAT(expiresAt, {
|
||||
name: formValues.name,
|
||||
application: 'dashboard',
|
||||
userAgent: window.navigator.userAgent,
|
||||
|
||||
@@ -5,9 +5,9 @@ import { Button } from '@/components/ui/v2/Button';
|
||||
import { Checkbox } from '@/components/ui/v2/Checkbox';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
import { useDeleteUserAccountMutation } from '@/utils/__generated__/graphql';
|
||||
import { useSignOut, useUserData } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
@@ -82,14 +82,12 @@ function ConfirmDeleteAccountModal({
|
||||
}
|
||||
|
||||
export default function DeleteAccount() {
|
||||
const router = useRouter();
|
||||
const { signOut } = useSignOut();
|
||||
const { signout } = useAuth();
|
||||
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
|
||||
const onDelete = async () => {
|
||||
await signOut();
|
||||
await router.push('/signin');
|
||||
await signout();
|
||||
};
|
||||
|
||||
const confirmDeleteAccount = async () => {
|
||||
|
||||
@@ -2,9 +2,9 @@ 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 { useUserData } from '@/hooks/useUserData';
|
||||
import { useUpdateUserDisplayNameMutation } from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
@@ -19,7 +19,9 @@ export type DisplayNameSettingFormValues = Yup.InferType<
|
||||
>;
|
||||
|
||||
export default function DisplayNameSetting() {
|
||||
const { id: userID, displayName } = useUserData() || {};
|
||||
const user = useUserData();
|
||||
|
||||
const { id: userID, displayName } = user || {};
|
||||
|
||||
const [updateUserDisplayName] = useUpdateUserDisplayNameMutation();
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import useActionWithElevatedPermissions from '@/features/account/settings/hooks/useActionWithElevatedPermissions';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { useNhostClient } from '@/providers/nhost';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useNhostClient, useUserData } from '@nhost/nextjs';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
@@ -15,11 +16,11 @@ export type EmailSettingFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function EmailSetting() {
|
||||
const nhost = useNhostClient();
|
||||
const { email } = useUserData() || {};
|
||||
const user = useUserData();
|
||||
|
||||
const form = useForm<EmailSettingFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: { email },
|
||||
defaultValues: { email: user?.email },
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
@@ -28,7 +29,7 @@ export default function EmailSetting() {
|
||||
|
||||
const changeEmail = useActionWithElevatedPermissions({
|
||||
actionFn: async (newEmail: string) => {
|
||||
const result = await nhost.auth.changeEmail({
|
||||
const result = await nhost.auth.changeUserEmail({
|
||||
newEmail,
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/account`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import useActionWithElevatedPermissions from '@/features/account/settings/hooks/useActionWithElevatedPermissions';
|
||||
import { useChangePassword } from '@nhost/nextjs';
|
||||
import { useNhostClient } from '@/providers/nhost';
|
||||
import { type ChangePasswordFormValues } from './useChangePasswordForm';
|
||||
|
||||
interface Props {
|
||||
@@ -7,10 +7,10 @@ interface Props {
|
||||
}
|
||||
|
||||
function useOnChangePasswordHandler({ onSuccess }: Props) {
|
||||
const { changePassword: actionFn } = useChangePassword();
|
||||
const nhost = useNhostClient();
|
||||
|
||||
const changePassword = useActionWithElevatedPermissions({
|
||||
actionFn,
|
||||
actionFn: nhost.auth.changeUserPassword,
|
||||
onSuccess,
|
||||
successMessage: 'The password has been changed successfully.',
|
||||
});
|
||||
@@ -18,7 +18,7 @@ function useOnChangePasswordHandler({ onSuccess }: Props) {
|
||||
async function onSubmit(values: ChangePasswordFormValues) {
|
||||
const { newPassword } = values;
|
||||
|
||||
await changePassword(newPassword);
|
||||
await changePassword({ newPassword });
|
||||
}
|
||||
|
||||
return onSubmit;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import useActionWithElevatedPermissions from '@/features/account/settings/hooks/useActionWithElevatedPermissions';
|
||||
import useGetSecurityKeys from '@/features/account/settings/hooks/useGetSecurityKeys';
|
||||
import { useAddSecurityKey } from '@nhost/nextjs';
|
||||
import { useNhostClient } from '@/providers/nhost';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import { type NewSecurityKeyFormValues } from './useNewSecurityKeyForm';
|
||||
|
||||
interface Props {
|
||||
@@ -9,7 +10,14 @@ interface Props {
|
||||
|
||||
function useOnAddNewSecurityKeyHandler({ onSuccess }: Props) {
|
||||
const { refetch } = useGetSecurityKeys();
|
||||
const { add: actionFn } = useAddSecurityKey();
|
||||
const nhost = useNhostClient();
|
||||
|
||||
async function actionFn(nickname: string) {
|
||||
const webAuthnOptions = await nhost.auth.addSecurityKey();
|
||||
const credential = await startRegistration(webAuthnOptions.body);
|
||||
await nhost.auth.verifyAddSecurityKey({ credential, nickname });
|
||||
}
|
||||
|
||||
const addSecurityKey = useActionWithElevatedPermissions({
|
||||
actionFn,
|
||||
onSuccess: async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRemoveSecurityKeyMutation } from '@/utils/__generated__/graphql';
|
||||
|
||||
function useRemoveSecurityKey() {
|
||||
const [removeSecurityKeyMutation] = useRemoveSecurityKeyMutation();
|
||||
const { elevatePermissions } = useElevatedPermissions();
|
||||
const elevatePermissions = useElevatedPermissions();
|
||||
const { refetch: refetchSecurityKeys } = useGetSecurityKeys();
|
||||
|
||||
async function removeSecurityKey(id: string) {
|
||||
|
||||
@@ -4,20 +4,29 @@ import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { GitHubIcon } from '@/components/ui/v2/icons/GitHubIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useAccessToken } from '@/hooks/useAccessToken';
|
||||
import { useNhostClient } from '@/providers/nhost';
|
||||
import { useGetAuthUserProvidersQuery } from '@/utils/__generated__/graphql';
|
||||
import { useProviderLink } from '@nhost/nextjs';
|
||||
import NavLink from 'next/link';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export default function SocialProvidersSettings() {
|
||||
const nhost = useNhostClient();
|
||||
const token = useAccessToken();
|
||||
const { data, loading, error } = useGetAuthUserProvidersQuery();
|
||||
const isGithubConnected = data?.authUserProviders?.some(
|
||||
(item) => item.providerId === 'github',
|
||||
);
|
||||
|
||||
const { github } = useProviderLink({
|
||||
connect: true,
|
||||
redirectTo: `${window.location.origin}/account`,
|
||||
});
|
||||
const github = useMemo(
|
||||
() =>
|
||||
nhost.auth.signInProviderURL('github', {
|
||||
connect: token,
|
||||
redirectTo: `${window.location.origin}/account`,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[token],
|
||||
);
|
||||
|
||||
if (!data && loading) {
|
||||
return (
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
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 Action = (...args: any[]) => Promise<any>;
|
||||
|
||||
type ActionResult = {
|
||||
isError?: boolean;
|
||||
error: AuthErrorPayload;
|
||||
};
|
||||
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
|
||||
|
||||
interface Props<Fn extends Action> {
|
||||
@@ -24,11 +19,11 @@ function useActionWithElevatedPermissions<F extends Action>({
|
||||
onError,
|
||||
successMessage,
|
||||
}: Props<F>) {
|
||||
const { elevated, elevatePermissions } = useElevatedPermissions();
|
||||
const elevatePermissions = useElevatedPermissions();
|
||||
const { data } = useGetSecurityKeys();
|
||||
|
||||
async function requestPermissions() {
|
||||
if (elevated || data?.authUserSecurityKeys.length === 0) {
|
||||
if (data?.authUserSecurityKeys.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const isPermissionsElevated = await elevatePermissions();
|
||||
@@ -38,19 +33,17 @@ function useActionWithElevatedPermissions<F extends Action>({
|
||||
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');
|
||||
try {
|
||||
const response = await actionFn(...args);
|
||||
toast.success(successMessage || 'Success.');
|
||||
onSuccess?.(response as UnwrapPromise<ReturnType<F>>);
|
||||
isSuccess = true;
|
||||
} catch (error) {
|
||||
toast.error(error?.message || 'Something went wrong.');
|
||||
onError?.();
|
||||
}
|
||||
|
||||
return isSuccess;
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { useElevateEmail } from '@/hooks/useElevateEmail';
|
||||
import { useHasuraClaims } from '@/hooks/useHasuraClaims';
|
||||
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
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();
|
||||
const elevateEmail = useElevateEmail();
|
||||
const claims = useHasuraClaims();
|
||||
|
||||
async function elevatePermissions(shouldThrowError = false) {
|
||||
const elevated = user
|
||||
? claims?.['x-hasura-auth-elevated'] === user?.id
|
||||
: 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);
|
||||
}
|
||||
await elevateEmail();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (shouldThrowError) {
|
||||
@@ -30,7 +31,7 @@ function useElevatedPermissions() {
|
||||
}
|
||||
}
|
||||
|
||||
return { elevated, elevatePermissions };
|
||||
return elevatePermissions;
|
||||
}
|
||||
|
||||
export default useElevatedPermissions;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { useSecurityKeysQuery } from '@/utils/__generated__/graphql';
|
||||
import { useUserId } from '@nhost/nextjs';
|
||||
|
||||
function useGetSecurityKeys() {
|
||||
const currentUserId = useUserId();
|
||||
const user = useUserData();
|
||||
const query = useSecurityKeysQuery({
|
||||
variables: {
|
||||
userId: currentUserId,
|
||||
userId: user?.id,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getAnonId } from '@/lib/segment';
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import type { SignInProviderParams } from '@nhost/nhost-js-beta/auth';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
@@ -18,14 +19,21 @@ function useGithubAuthentication({
|
||||
}: UseGithubAuthenticationHookProps) {
|
||||
const githubAuthenticationMutation = useMutation(
|
||||
async () => {
|
||||
const options = {
|
||||
...(isNotEmptyValue(redirectTo) && { redirectTo }),
|
||||
...(withAnonId && { metadata: { anonId: await getAnonId() } }),
|
||||
};
|
||||
return nhost.auth.signIn({
|
||||
provider: 'github',
|
||||
...(isNotEmptyValue(options) && { options }),
|
||||
});
|
||||
let options: SignInProviderParams | undefined;
|
||||
if (isNotEmptyValue(redirectTo)) {
|
||||
options = {
|
||||
redirectTo,
|
||||
};
|
||||
}
|
||||
if (withAnonId) {
|
||||
options = {
|
||||
metadata: { anonId: await getAnonId() },
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
const redirectURl = nhost.auth.signInProviderURL('github', options);
|
||||
window.location.href = redirectURl;
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { useSignInWithSecurityKey } from '@/features/auth/SignIn/SecurityKey/hooks/useSignInWithSecurityKey';
|
||||
import { Fingerprint } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { VerifyEmailDialog } from './VerifyEmailDialog';
|
||||
|
||||
function SignInWithSecurityKey() {
|
||||
const { disabled, signInWithSecurityKey, needsEmailVerification } =
|
||||
useSignInWithSecurityKey();
|
||||
const [open, setOpen] = useState(false);
|
||||
function onNeedsEmailVerification() {
|
||||
setOpen(true);
|
||||
}
|
||||
const { disabled, signInWithSecurityKey } = useSignInWithSecurityKey({
|
||||
onNeedsEmailVerification,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<VerifyEmailDialog needsEmailVerification={needsEmailVerification} />
|
||||
<VerifyEmailDialog open={open} setOpen={setOpen} />
|
||||
<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"
|
||||
|
||||
@@ -5,22 +5,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/v3/dialog';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
needsEmailVerification: boolean;
|
||||
open: boolean;
|
||||
setOpen: (openState: boolean) => void;
|
||||
}
|
||||
|
||||
export function VerifyEmailDialog({ needsEmailVerification }: Props) {
|
||||
const [open, setOpen] = useState(needsEmailVerification);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(needsEmailVerification);
|
||||
}, [needsEmailVerification, open]);
|
||||
|
||||
export function VerifyEmailDialog({ open, setOpen }: Props) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent className="text-foreground sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Email verification required</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -1,34 +1,47 @@
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import { useNhostClient } from '@/providers/nhost';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useSignInSecurityKey } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
function useSignInWithSecurityKey() {
|
||||
const { signInSecurityKey } = useSignInSecurityKey();
|
||||
const [needsEmailVerification, setNeedsEmailVerification] = useState(false);
|
||||
interface Props {
|
||||
onNeedsEmailVerification: () => void;
|
||||
}
|
||||
|
||||
function useSignInWithSecurityKey({ onNeedsEmailVerification }: Props) {
|
||||
const nhost = useNhostClient();
|
||||
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('/');
|
||||
try {
|
||||
setDisabled(true);
|
||||
const signInWebauthnResponse = await nhost.auth.signInWebauthn();
|
||||
const { body: options } = signInWebauthnResponse;
|
||||
const credential = await startAuthentication(options);
|
||||
await nhost.auth.verifySignInWebauthn({
|
||||
credential,
|
||||
});
|
||||
} catch (error) {
|
||||
let errorMessage =
|
||||
error?.message ||
|
||||
'An error occurred while signing in. Please try again.';
|
||||
|
||||
if (isNotEmptyValue(error?.body)) {
|
||||
const errorCode = error.body.error;
|
||||
if (errorCode === 'unverified-user') {
|
||||
onNeedsEmailVerification();
|
||||
return;
|
||||
}
|
||||
errorMessage = error.body.message;
|
||||
}
|
||||
toast.error(errorMessage, getToastStyleProps());
|
||||
} finally {
|
||||
setDisabled(false);
|
||||
}
|
||||
setDisabled(false);
|
||||
}
|
||||
|
||||
return { disabled, signInWithSecurityKey, needsEmailVerification };
|
||||
return { disabled, signInWithSecurityKey };
|
||||
}
|
||||
|
||||
export default useSignInWithSecurityKey;
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { MfaOtpForm } from '@/components/common/MfaOtpForm';
|
||||
import type { SendMfaOtpHandler } from '@nhost/nextjs';
|
||||
import { Smartphone } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
sendMfaOtp: SendMfaOtpHandler;
|
||||
sendMfaOtp: (code: string) => Promise<any>;
|
||||
loading: boolean;
|
||||
requestNewMfaTicket: () => Promise<void>;
|
||||
}
|
||||
|
||||
function MfaSignInOtpForm({ sendMfaOtp, loading }: Props) {
|
||||
function MfaSignInOtpForm({ sendMfaOtp, loading, requestNewMfaTicket }: 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} />
|
||||
<MfaOtpForm
|
||||
loading={loading}
|
||||
sendMfaOtp={sendMfaOtp}
|
||||
requestNewMfaTicket={requestNewMfaTicket}
|
||||
/>
|
||||
<p className="text-center">
|
||||
Open your authenticator app or browser extension to view your
|
||||
authentication code.
|
||||
|
||||
@@ -1,15 +1,53 @@
|
||||
import useOnSignUpWithPasswordHandler from '@/features/auth/SignIn/SignInWithEmailAndPassword/hooks/useOnSignInWithEmailAndPasswordHandler';
|
||||
import useOnSignInWithEmailAndPasswordHandler from '@/features/auth/SignIn/SignInWithEmailAndPassword/hooks/useOnSignInWithEmailAndPasswordHandler';
|
||||
import useRequestNewMfaTicket from '@/features/auth/SignIn/SignInWithEmailAndPassword/hooks/useRequestNewMfaTicket';
|
||||
import { useNhostClient } from '@/providers/nhost';
|
||||
import { useRef, useState } from 'react';
|
||||
import MfaSignInOtpForm from './MfaSignInOtpForm';
|
||||
import SignInWithEmailAndPasswordForm from './SignInWithEmailAndPasswordForm';
|
||||
|
||||
function SignInWithEmailAndPassword() {
|
||||
const { onSignIWithEmailAndPassword, sendMfaOtp, isLoading, needsMfaOtp } =
|
||||
useOnSignUpWithPasswordHandler();
|
||||
const [needsMfaOtp, setNeedsMfaOtp] = useState(false);
|
||||
const mfaTicket = useRef<string | undefined>();
|
||||
const [isMfaLoading, setIsMfaLoading] = useState(false);
|
||||
const nhost = useNhostClient();
|
||||
|
||||
function onNeedsMfa(ticket: string) {
|
||||
mfaTicket.current = ticket;
|
||||
setNeedsMfaOtp(true);
|
||||
}
|
||||
|
||||
const { onSignInWithEmailAndPassword, isLoading, emailAndPasswordRef } =
|
||||
useOnSignInWithEmailAndPasswordHandler({ onNeedsMfa });
|
||||
const requestNewMfaTicketFn = useRequestNewMfaTicket();
|
||||
|
||||
async function requestNewMfaTicket() {
|
||||
const { email, password } = emailAndPasswordRef.current;
|
||||
mfaTicket.current = await requestNewMfaTicketFn(email, password);
|
||||
}
|
||||
|
||||
async function onHandleSendMfaOtp(otp: string) {
|
||||
try {
|
||||
setIsMfaLoading(true);
|
||||
await nhost.auth.verifySignInMfaTotp({
|
||||
ticket: mfaTicket.current,
|
||||
otp,
|
||||
});
|
||||
} finally {
|
||||
setIsMfaLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return needsMfaOtp ? (
|
||||
<MfaSignInOtpForm sendMfaOtp={sendMfaOtp} loading={isLoading} />
|
||||
<MfaSignInOtpForm
|
||||
sendMfaOtp={onHandleSendMfaOtp}
|
||||
loading={isMfaLoading}
|
||||
requestNewMfaTicket={requestNewMfaTicket}
|
||||
/>
|
||||
) : (
|
||||
<SignInWithEmailAndPasswordForm onSubmit={onSignIWithEmailAndPassword} />
|
||||
<SignInWithEmailAndPasswordForm
|
||||
onSubmit={onSignInWithEmailAndPassword}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormInput } from '@/components/form/FormInput';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { ButtonWithLoading as Button } from '@/components/ui/v3/button';
|
||||
import { Form } from '@/components/ui/v3/form';
|
||||
import useSignInWithEmailAndPasswordForm, {
|
||||
type SignInWithEmailAndPasswordFormValues,
|
||||
@@ -8,9 +8,10 @@ import NextLink from 'next/link';
|
||||
|
||||
interface Props {
|
||||
onSubmit: (values: SignInWithEmailAndPasswordFormValues) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function SignInWithEmailAndPassword({ onSubmit }: Props) {
|
||||
function SignInWithEmailAndPassword({ onSubmit, isLoading }: Props) {
|
||||
const form = useSignInWithEmailAndPasswordForm();
|
||||
return (
|
||||
<Form {...form}>
|
||||
@@ -40,6 +41,8 @@ function SignInWithEmailAndPassword({ onSubmit }: Props) {
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full !bg-white !text-black disabled:!text-black disabled:!text-opacity-60"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
|
||||
@@ -1,50 +1,65 @@
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import { useNhostClient } from '@/providers/nhost';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import {
|
||||
useSendVerificationEmail,
|
||||
useSignInEmailPassword,
|
||||
} from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useRef, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { SignInWithEmailAndPasswordFormValues } from './useSignInWithEmailAndPasswordForm';
|
||||
|
||||
function useOnSignInWithEmailAndPasswordHandler() {
|
||||
interface Props {
|
||||
onNeedsMfa: (mfaTicket: string) => void;
|
||||
}
|
||||
|
||||
type EmailAndPasswordRef = {
|
||||
email: string;
|
||||
password: string;
|
||||
} | null;
|
||||
|
||||
function useOnSignInWithEmailAndPasswordHandler({ onNeedsMfa }: Props) {
|
||||
const [isLoading, setIsloading] = useState(false);
|
||||
const nhost = useNhostClient();
|
||||
const router = useRouter();
|
||||
const { signInEmailPassword, needsMfaOtp, sendMfaOtp, isLoading } =
|
||||
useSignInEmailPassword();
|
||||
const emailAndPasswordRef = useRef<EmailAndPasswordRef>();
|
||||
|
||||
const { sendEmail } = useSendVerificationEmail();
|
||||
|
||||
async function onSignIWithEmailAndPassword({
|
||||
async function onSignInWithEmailAndPassword({
|
||||
email,
|
||||
password,
|
||||
}: SignInWithEmailAndPasswordFormValues) {
|
||||
try {
|
||||
const { needsEmailVerification, error } = await signInEmailPassword(
|
||||
setIsloading(true);
|
||||
const response = await nhost.auth.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)}`);
|
||||
emailAndPasswordRef.current = {
|
||||
email,
|
||||
password,
|
||||
};
|
||||
if (response.body.mfa) {
|
||||
onNeedsMfa(response.body.mfa.ticket);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
'An error occurred while signing in. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
} catch (error) {
|
||||
let errorMessage =
|
||||
error?.message ||
|
||||
'An error occurred while signing in. Please try again.';
|
||||
|
||||
if (isNotEmptyValue(error?.body)) {
|
||||
const errorCode = error.body.error;
|
||||
if (errorCode === 'unverified-user') {
|
||||
await nhost.auth.sendVerificationEmail({ email });
|
||||
router.push(`/email/verify?email=${encodeURIComponent(email)}`);
|
||||
return;
|
||||
}
|
||||
errorMessage = error.body.message;
|
||||
}
|
||||
toast.error(errorMessage, getToastStyleProps());
|
||||
} finally {
|
||||
setIsloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return { onSignIWithEmailAndPassword, needsMfaOtp, sendMfaOtp, isLoading };
|
||||
return { onSignInWithEmailAndPassword, isLoading, emailAndPasswordRef };
|
||||
}
|
||||
|
||||
export default useOnSignInWithEmailAndPasswordHandler;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useNhostClient } from '@/providers/nhost';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
function useRequestNewMfaTicket() {
|
||||
const nhost = useNhostClient();
|
||||
async function requestNewMfaTicket(email: string, password: string) {
|
||||
let mfaTicket: string;
|
||||
try {
|
||||
const response = await nhost.auth.signInEmailPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
mfaTicket = response.body?.mfa.ticket;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error?.message ||
|
||||
'An error occurred while verifying TOTP. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
|
||||
return mfaTicket;
|
||||
}
|
||||
|
||||
return requestNewMfaTicket;
|
||||
}
|
||||
|
||||
export default useRequestNewMfaTicket;
|
||||
@@ -1,12 +1,13 @@
|
||||
import { getAnonId } from '@/lib/segment';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
import { useNhostClient } from '@/providers/nhost';
|
||||
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 nhost = useNhostClient();
|
||||
const router = useRouter();
|
||||
|
||||
async function onSignUpWithPassword({
|
||||
@@ -16,12 +17,14 @@ function useOnSignUpWithPasswordHandler() {
|
||||
turnstileToken,
|
||||
}: SignUpWithEmailAndPasswordFormValues) {
|
||||
try {
|
||||
const { needsEmailVerification, error } = await signUpEmailPassword(
|
||||
email,
|
||||
password,
|
||||
const response = await nhost.auth.signUpEmailPassword(
|
||||
{
|
||||
displayName,
|
||||
metadata: { anonId: await getAnonId() },
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
displayName,
|
||||
metadata: { anonId: await getAnonId() },
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
@@ -30,21 +33,13 @@ function useOnSignUpWithPasswordHandler() {
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
'An error occurred while signing up. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsEmailVerification) {
|
||||
if (response.status === 200 && isEmptyValue(response.body)) {
|
||||
router.push(`/email/verify?email=${encodeURIComponent(email)}`);
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
'An error occurred while signing up. Please try again.',
|
||||
error.message ||
|
||||
'An error occurred while signing up. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { getAnonId } from '@/lib/segment';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
import { useNhostClient } from '@/providers/nhost';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useSignUpEmailSecurityKeyEmail } from '@nhost/nextjs';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import { useRouter } from 'next/router';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { SignUpWithSecurityKeyFormValues } from './useSignupWithSecurityKeyForm';
|
||||
|
||||
function useOnSignUpWithSecurityKeyHandler() {
|
||||
const { signUpEmailSecurityKey } = useSignUpEmailSecurityKeyEmail();
|
||||
const router = useRouter();
|
||||
const nhost = useNhostClient();
|
||||
|
||||
async function onSignUpWithSecurityKey({
|
||||
email,
|
||||
displayName,
|
||||
turnstileToken,
|
||||
}: SignUpWithSecurityKeyFormValues) {
|
||||
const metadata = { anonId: await getAnonId() };
|
||||
try {
|
||||
const { needsEmailVerification, error } = await signUpEmailSecurityKey(
|
||||
email,
|
||||
const webAuthnResponse = await nhost.auth.signUpWebauthn(
|
||||
{
|
||||
displayName,
|
||||
metadata: { anonId: await getAnonId() },
|
||||
email,
|
||||
options: {
|
||||
displayName,
|
||||
metadata,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
@@ -27,22 +32,24 @@ function useOnSignUpWithSecurityKeyHandler() {
|
||||
},
|
||||
},
|
||||
);
|
||||
const { body: webAuthnOptions } = webAuthnResponse;
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
'An error occurred while signing up. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const credential = await startRegistration(webAuthnOptions);
|
||||
|
||||
if (needsEmailVerification) {
|
||||
const verifyResponse = await nhost.auth.verifySignUpWebauthn({
|
||||
credential,
|
||||
options: {
|
||||
displayName,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
if (verifyResponse.status === 200 && isEmptyValue(verifyResponse.body)) {
|
||||
router.push(`/email/verify?email=${encodeURIComponent(email)}`);
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
'An error occurred while signing up. Please try again.',
|
||||
error.message ||
|
||||
'An error occurred while signing up. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { StripeEmbeddedForm } from '@/features/orgs/components/StripeEmbeddedFor
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { planDescriptions } from '@/features/orgs/projects/common/utils/planDescriptions';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useCreateOrganizationRequestMutation,
|
||||
@@ -34,7 +35,6 @@ import {
|
||||
type PrefetchNewAppPlansFragment,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { DialogDescription } from '@radix-ui/react-dialog';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
@@ -197,7 +197,7 @@ export default function CreateOrgDialog({
|
||||
const isPlatform = useIsPlatform();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data, loading, error } = usePrefetchNewAppQuery({
|
||||
skip: !user,
|
||||
skip: !user || !isPlatform,
|
||||
});
|
||||
const [createOrganizationRequest] = useCreateOrganizationRequestMutation();
|
||||
const [stripeClientSecret, setStripeClientSecret] = useState('');
|
||||
|
||||
@@ -18,13 +18,13 @@ import {
|
||||
import { useOrgs, type Org } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { cn, isNotEmptyValue } from '@/lib/utils';
|
||||
import {
|
||||
Organization_Members_Role_Enum,
|
||||
useBillingTransferAppMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useUserId } from '@nhost/nextjs';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
@@ -53,7 +53,7 @@ function TransferProjectForm({
|
||||
const { push } = useRouter();
|
||||
const { orgs, currentOrg } = useOrgs();
|
||||
const { project } = useProject();
|
||||
const currentUserId = useUserId();
|
||||
const user = useUserData();
|
||||
const [transferProject] = useBillingTransferAppMutation();
|
||||
|
||||
const form = useForm<z.infer<typeof transferProjectFormSchema>>({
|
||||
@@ -133,7 +133,7 @@ function TransferProjectForm({
|
||||
disabled={
|
||||
org.plan.isFree || // disable the personal org
|
||||
org.id === currentOrg.id || // disable the current org as it can't be a destination org
|
||||
!isUserAdminOfOrg(org, currentUserId) // disable orgs that the current user is not admin of
|
||||
!isUserAdminOfOrg(org, user?.id) // disable orgs that the current user is not admin of
|
||||
}
|
||||
>
|
||||
{org.name}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { useNhostClient } from '@/providers/nhost';
|
||||
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 nhost = useNhostClient();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const showSoc2Download =
|
||||
@@ -28,26 +29,18 @@ export default function Soc2Download() {
|
||||
if (!fileId) {
|
||||
throw new Error('SOC2 report file ID not configured');
|
||||
}
|
||||
try {
|
||||
const response = await nhost.storage.getFile(fileId);
|
||||
const url = URL.createObjectURL(response.body);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'Nhost-SOC2-Report.pdf';
|
||||
link.click();
|
||||
|
||||
const { file, error } = await nhost.storage.download({
|
||||
fileId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (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...',
|
||||
|
||||
@@ -14,18 +14,18 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/v3/sheet';
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
import {
|
||||
useDeleteAnnouncementReadMutation,
|
||||
useGetAnnouncementsQuery,
|
||||
useInsertAnnouncementReadMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useAuthenticationStatus } from '@nhost/nextjs';
|
||||
import { formatDistance } from 'date-fns';
|
||||
import { EllipsisVertical, Megaphone } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AnnouncementsTray() {
|
||||
const { isAuthenticated } = useAuthenticationStatus();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const {
|
||||
data,
|
||||
|
||||
@@ -16,8 +16,10 @@ import {
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/v3/sheet';
|
||||
import { StripeEmbeddedForm } from '@/features/orgs/components/StripeEmbeddedForm';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
import {
|
||||
CheckoutStatus,
|
||||
@@ -29,7 +31,6 @@ import {
|
||||
type OrganizationMemberInvitesQuery,
|
||||
type PostOrganizationRequestResponse,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { formatDistance } from 'date-fns';
|
||||
import { Bell } from 'lucide-react';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -43,6 +44,7 @@ export default function NotificationsTray() {
|
||||
const { session_id } = query;
|
||||
const { refetch: refetchOrgs } = useOrgs();
|
||||
const [open, setOpen] = useState(false);
|
||||
const isPlatForm = useIsPlatform();
|
||||
|
||||
const [stripeFormDialogOpen, setStripeFormDialogOpen] = useState(false);
|
||||
|
||||
@@ -61,21 +63,21 @@ export default function NotificationsTray() {
|
||||
const [postOrganizationRequest] = usePostOrganizationRequestMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (userData) {
|
||||
if (userData?.id) {
|
||||
getInvites({
|
||||
variables: {
|
||||
userId: userData.id,
|
||||
userId: userData?.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [asPath, userData, getInvites]);
|
||||
}, [asPath, userData?.id, getInvites]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkForPendingOrgRequests = async () => {
|
||||
const { data: { organizationNewRequests = [] } = {} } =
|
||||
await getOrganizationNewRequests({
|
||||
variables: {
|
||||
userID: userData.id,
|
||||
userID: userData?.id,
|
||||
},
|
||||
});
|
||||
if (organizationNewRequests.length > 0) {
|
||||
@@ -111,6 +113,7 @@ export default function NotificationsTray() {
|
||||
};
|
||||
|
||||
if (
|
||||
isPlatForm &&
|
||||
userData &&
|
||||
!['/', '/orgs/verify'].includes(route) &&
|
||||
isRouterReady &&
|
||||
@@ -125,6 +128,7 @@ export default function NotificationsTray() {
|
||||
getOrganizationNewRequests,
|
||||
postOrganizationRequest,
|
||||
session_id,
|
||||
isPlatForm,
|
||||
]);
|
||||
|
||||
const [acceptInvite] = useOrganizationMemberInviteAcceptMutation();
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/v3/select';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import {
|
||||
Organization_Members_Role_Enum,
|
||||
useDeleteOrganizationMemberMutation,
|
||||
@@ -55,7 +56,6 @@ import {
|
||||
type GetOrganizationQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { Ellipsis } from 'lucide-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
@@ -76,7 +76,7 @@ const updateMemberRoleFormSchema = z.object({
|
||||
|
||||
export default function OrgMember({ member, isAdmin }: OrgMemberProps) {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { id } = useUserData();
|
||||
const user = useUserData();
|
||||
const { push } = useRouter();
|
||||
const { refetch: refetchOrgs } = useOrgs();
|
||||
const { org: { plan: { isFree } = {} } = {}, refetch: refetchCurrentOrg } =
|
||||
@@ -87,7 +87,7 @@ export default function OrgMember({ member, isAdmin }: OrgMemberProps) {
|
||||
const [updateMemberRoleDialogOpen, setUpdateMemberRoleDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
const isSelf = id === member.user.id;
|
||||
const isSelf = user?.id === member.user.id;
|
||||
|
||||
const [deleteMember] = useDeleteOrganizationMemberMutation({
|
||||
variables: {
|
||||
@@ -98,7 +98,7 @@ export default function OrgMember({ member, isAdmin }: OrgMemberProps) {
|
||||
const handleRemoveMemberFromOrg = async () => {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
const isRemovingSelf = id === member.user.id;
|
||||
const isRemovingSelf = user?.id === member.user.id;
|
||||
await deleteMember();
|
||||
// TODO see if it makes sense to unify both of these
|
||||
await refetchCurrentOrg();
|
||||
@@ -191,7 +191,7 @@ export default function OrgMember({ member, isAdmin }: OrgMemberProps) {
|
||||
<DropdownMenu open={dropDownOpen} onOpenChange={setDropDownOpen}>
|
||||
<DropdownMenuTrigger
|
||||
disabled={
|
||||
(!isAdmin && id !== member.user.id) ||
|
||||
(!isAdmin && user?.id !== member.user.id) ||
|
||||
isFree ||
|
||||
maintenanceActive
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ function ProjectCard({ project }: { project: Project }) {
|
||||
}
|
||||
|
||||
export default function ProjectsGrid() {
|
||||
const { org } = useCurrentOrg();
|
||||
const { org, loading: orgLoading } = useCurrentOrg();
|
||||
|
||||
const { data, loading, error } = useGetProjectsQuery({
|
||||
variables: {
|
||||
@@ -76,7 +76,7 @@ export default function ProjectsGrid() {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
if (loading || orgLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
import {
|
||||
CheckoutStatus,
|
||||
type PostOrganizationRequestMutation,
|
||||
usePostOrganizationRequestMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useAuthenticationStatus } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
@@ -24,7 +24,7 @@ function useFinishOrgCreation({
|
||||
const router = useRouter();
|
||||
const { session_id } = router.query;
|
||||
|
||||
const { isAuthenticated } = useAuthenticationStatus();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [postOrganizationRequest] = usePostOrganizationRequestMutation();
|
||||
const [status, setPostOrganizationRequestStatus] =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { Organization_Members_Role_Enum } from '@/utils/__generated__/graphql';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
|
||||
export default function useIsOrgAdmin() {
|
||||
const { org: { members = [] } = {} } = useCurrentOrg();
|
||||
|
||||
@@ -5,9 +5,9 @@ import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { GraphiteIcon } from '@/components/ui/v2/icons/GraphiteIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { type Message } from '@/features/orgs/projects/ai/DevAssistant';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { onlyText } from 'react-children-utilities';
|
||||
import Markdown, { type ExtraProps } from 'react-markdown';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
mockApplication,
|
||||
mockMatchMediaValue,
|
||||
} from '@/tests/mocks';
|
||||
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
|
||||
import {
|
||||
mockPointerEvent,
|
||||
render,
|
||||
@@ -41,7 +40,7 @@ Object.defineProperty(window, 'matchMedia', {
|
||||
value: vi.fn().mockImplementation(mockMatchMediaValue),
|
||||
});
|
||||
|
||||
const server = setupServer(tokenQuery);
|
||||
const server = setupServer();
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useGetPiTrBaseBackupsLazyQuery: vi.fn(),
|
||||
@@ -85,6 +84,10 @@ describe('ImportBackupContent', () => {
|
||||
server.listen();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
vi.restoreAllMocks();
|
||||
@@ -97,7 +100,7 @@ describe('ImportBackupContent', () => {
|
||||
|
||||
render(<TestComponent />);
|
||||
expect(
|
||||
await screen.getByText(
|
||||
screen.getByText(
|
||||
`${mockApplication.name} (${mockApplication.region.name})`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
@@ -118,7 +121,7 @@ describe('ImportBackupContent', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('that warning is displayed if there are no other projects in the same organization', async () => {
|
||||
test('that warning is displayed if there is no other project in the same organization', async () => {
|
||||
server.use(getOrganization);
|
||||
server.use(getEmptyProjectsQuery);
|
||||
|
||||
@@ -143,7 +146,7 @@ describe('ImportBackupContent', () => {
|
||||
|
||||
render(<TestComponent />);
|
||||
expect(
|
||||
await screen.getByText(
|
||||
screen.getByText(
|
||||
`${mockApplication.name} (${mockApplication.region.name})`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
@@ -161,21 +164,21 @@ describe('ImportBackupContent', () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.getByText('Import backup from pitr14 (us-east-1)'),
|
||||
screen.getByText('Import backup from pitr14 (us-east-1)'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const startImportButton = await screen.getByRole('button', {
|
||||
const startImportButton = screen.getByRole('button', {
|
||||
name: 'Start import',
|
||||
});
|
||||
await user.click(startImportButton);
|
||||
|
||||
await waitFor(async () =>
|
||||
expect(
|
||||
await screen.getByRole('button', { name: 'Import backup' }),
|
||||
screen.getByRole('button', { name: 'Import backup' }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const dateTimePickerButton = await screen.getByRole('button', {
|
||||
const dateTimePickerButton = screen.getByRole('button', {
|
||||
name: /UTC/i,
|
||||
});
|
||||
|
||||
@@ -183,27 +186,27 @@ describe('ImportBackupContent', () => {
|
||||
|
||||
await waitFor(async () =>
|
||||
expect(
|
||||
await screen.getByRole('button', { name: 'Select' }),
|
||||
screen.getByRole('button', { name: 'Select' }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(await screen.getByText('13'));
|
||||
await user.click(screen.getByText('13'));
|
||||
|
||||
const hoursInput = await screen.getByLabelText('Hours');
|
||||
const hoursInput = screen.getByLabelText('Hours');
|
||||
await waitFor(async () => {
|
||||
await user.type(hoursInput, '18');
|
||||
});
|
||||
const updatedDateTimeButton = await screen.getByRole('button', {
|
||||
const updatedDateTimeButton = screen.getByRole('button', {
|
||||
name: /UTC/i,
|
||||
});
|
||||
expect(updatedDateTimeButton).toHaveTextContent(
|
||||
'13 Mar 2025, 18:00:05 (UTC+02:00)',
|
||||
);
|
||||
await user.click(await screen.getByRole('button', { name: 'Select' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Select' }));
|
||||
|
||||
await waitFor(async () =>
|
||||
expect(
|
||||
await screen.queryByRole('button', { name: 'Select' }),
|
||||
screen.queryByRole('button', { name: 'Select' }),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
@@ -214,22 +217,20 @@ describe('ImportBackupContent', () => {
|
||||
// check checkboxes
|
||||
|
||||
await user.click(
|
||||
await screen.getByLabelText(/I understand that restoring this backup/),
|
||||
screen.getByLabelText(/I understand that restoring this backup/),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
await screen.getByLabelText(/I understand this cannot be undone/),
|
||||
screen.getByLabelText(/I understand this cannot be undone/),
|
||||
);
|
||||
|
||||
await waitFor(async () =>
|
||||
expect(
|
||||
await screen.getByRole('button', { name: 'Import backup' }),
|
||||
screen.getByRole('button', { name: 'Import backup' }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
await screen.getByRole('button', { name: 'Import backup' }),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'Import backup' }));
|
||||
|
||||
expect(mocks.restoreApplicationDatabase.mock.calls[0][0].fromAppId).toBe(
|
||||
'pitr14-id',
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
GetOrganizationsDocument,
|
||||
useBillingDeleteAppMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { getApplicationStatusString } from '@/utils/helpers';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { formatDistance } from 'date-fns';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function ApplicationInfo() {
|
||||
|
||||
const [deleteApplication] = useBillingDeleteAppMutation({
|
||||
refetchQueries: [
|
||||
{ query: GetOrganizationsDocument, variables: { userId: userData.id } },
|
||||
{ query: GetOrganizationsDocument, variables: { userId: userData?.id } },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import {
|
||||
GetOrganizationsDocument,
|
||||
useUnpauseApplicationMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import Image from 'next/image';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function ApplicationPausedBanner({
|
||||
refetchQueries: [
|
||||
{
|
||||
query: GetOrganizationsDocument,
|
||||
variables: { userId: userData.id },
|
||||
variables: { userId: userData?.id },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ type LogsServiceFilterProps = UseFormRegisterReturn<
|
||||
service?: AvailableLogsService;
|
||||
}
|
||||
>;
|
||||
const LogsServiceFilter = forwardRef<HTMLInputElement, LogsServiceFilterProps>(
|
||||
const LogsServiceFilter = forwardRef<HTMLButtonElement, LogsServiceFilterProps>(
|
||||
(props, ref) => {
|
||||
const { project } = useProject();
|
||||
const { data } = useGetServiceLabelValuesQuery({
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
import {
|
||||
GetOrganizationsDocument,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import router from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -53,7 +53,7 @@ export default function RemoveApplicationModal({
|
||||
const [loadingRemove, setLoadingRemove] = useState(false);
|
||||
const [deleteApplication] = useBillingDeleteAppMutation({
|
||||
refetchQueries: [
|
||||
{ query: GetOrganizationsDocument, variables: { userId: userData.id } },
|
||||
{ query: GetOrganizationsDocument, variables: { userId: userData?.id } },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { MAX_FREE_PROJECTS } from '@/utils/constants/common';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import {
|
||||
useGetFreeAndActiveProjectsQuery,
|
||||
useGetProjectIsLockedQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { MAX_FREE_PROJECTS } from '@/utils/constants/common';
|
||||
|
||||
/**
|
||||
* This hook returns the reason why the application is paused.
|
||||
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
useGetApplicationStateQuery,
|
||||
useGetOrganizationsLazyQuery,
|
||||
} from '@/generated/graphql';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
type ApplicationStateMetadata = {
|
||||
@@ -35,7 +35,7 @@ export default function useCheckProvisioning() {
|
||||
});
|
||||
|
||||
async function updateOwnCache() {
|
||||
await getOrgs({ variables: { userId: userData.id } });
|
||||
await getOrgs({ variables: { userId: userData?.id } });
|
||||
}
|
||||
|
||||
const memoizedUpdateCache = useCallback(updateOwnCache, [
|
||||
|
||||
@@ -5,7 +5,8 @@ export default function useHostName() {
|
||||
|
||||
useEffect(() => {
|
||||
const { port, hostname, protocol } = window.location;
|
||||
setHostName(`${protocol}//${hostname}:${port}`);
|
||||
const portSuffix = port ? `:${port}` : '';
|
||||
setHostName(`${protocol}//${hostname}${portSuffix}`);
|
||||
}, []);
|
||||
|
||||
return hostName;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { Organization_Members_Role_Enum } from '@/utils/__generated__/graphql';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
|
||||
/**
|
||||
* Returns true if the current user is the owner of the current organization.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
@@ -11,6 +12,7 @@ import { useEffect } from 'react';
|
||||
*/
|
||||
export default function useNotFoundRedirect() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const {
|
||||
query: {
|
||||
orgSlug: urlOrgSlug,
|
||||
@@ -30,6 +32,8 @@ export default function useNotFoundRedirect() {
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isAuthenticated ||
|
||||
isLoading ||
|
||||
// If we're updating, we don't want to redirect to 404
|
||||
updating ||
|
||||
// If the router is not ready, we don't want to redirect to 404
|
||||
@@ -69,5 +73,7 @@ export default function useNotFoundRedirect() {
|
||||
projectSubdomain,
|
||||
urlOrgSlug,
|
||||
isPlatform,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import type {
|
||||
GetApplicationStateQuery,
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
useGetOrganizationsLazyQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import type { QueryHookOptions } from '@apollo/client';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface UseProjectRedirectWhenReadyOptions
|
||||
@@ -37,7 +37,7 @@ export default function useProjectRedirectWhenReady(
|
||||
|
||||
useEffect(() => {
|
||||
async function updateOwnCache() {
|
||||
await getOrgs({ variables: { userId: userData.id } });
|
||||
await getOrgs({ variables: { userId: userData?.id } });
|
||||
}
|
||||
if (!data) {
|
||||
return;
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface ReferencedSchemaSelectProps
|
||||
|
||||
function ReferencedSchemaSelect(
|
||||
{ options, ...props }: ReferencedSchemaSelectProps,
|
||||
ref: ForwardedRef<HTMLInputElement>,
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
const { setValue } = useFormContext<BaseForeignKeyFormValues>();
|
||||
const { errors } = useFormState({ name: 'referencedSchema' });
|
||||
|
||||
@@ -61,6 +61,19 @@ vi.mock('@/utils/__generated__/graphql', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock(
|
||||
'@/features/orgs/components/common/TransferOrUpgradeProjectDialog',
|
||||
async () => {
|
||||
const actual = await vi.importActual<any>(
|
||||
'@/features/orgs/components/common/TransferOrUpgradeProjectDialog',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
TransferOrUpgradeProjectDialog: () => null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
mocks.useCurrentOrg.mockRestore();
|
||||
mocks.updateConfigMock.mockRestore();
|
||||
@@ -83,7 +96,7 @@ test('If the org is free the switch should not be available and the save button
|
||||
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
expect(await screen.queryByRole('checkbox')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the Save button is disabled until the switch in the header is not touched', async () => {
|
||||
|
||||
@@ -14,12 +14,12 @@ import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
|
||||
import { useResetDatabasePasswordMutation } from '@/generated/graphql';
|
||||
import { useLeaveConfirm } from '@/hooks/useLeaveConfirm';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { alpha } from '@mui/system';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function ResetDatabasePasswordSettings() {
|
||||
`An error occured while trying to update the database password for ${project.name}`,
|
||||
);
|
||||
await discordAnnounce(
|
||||
`An error occurred while trying to update the database password: ${project.name} (${user.email}): ${e.message}`,
|
||||
`An error occurred while trying to update the database password: ${project.name} (${user?.email}): ${e.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ interface Props {
|
||||
description: string;
|
||||
}
|
||||
|
||||
// P
|
||||
|
||||
function UpgradeNotification({ description }: Props) {
|
||||
const [transferProjectDialogOpen, setTransferProjectDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
@@ -12,12 +12,12 @@ import { DeploymentDurationLabel } from '@/features/orgs/projects/deployments/co
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import type { DeploymentRowFragment } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
GetOrganizationsDocument,
|
||||
useInsertDeploymentMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -60,7 +60,7 @@ export default function DeploymentListItem({
|
||||
|
||||
const [insertDeployment, { loading }] = useInsertDeploymentMutation({
|
||||
refetchQueries: [
|
||||
{ query: GetOrganizationsDocument, variables: { userId: userData.id } },
|
||||
{ query: GetOrganizationsDocument, variables: { userId: userData?.id } },
|
||||
],
|
||||
});
|
||||
const { commitMessage } = deployment;
|
||||
|
||||
@@ -98,10 +98,13 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
{ ...defaultRemoteBackendSlugs, hasura: '/console' },
|
||||
),
|
||||
},
|
||||
{ key: 'NHOST_AUTH_URL', value: appClient.auth.url },
|
||||
{ key: 'NHOST_GRAPHQL_URL', value: appClient.graphql.httpUrl },
|
||||
{ key: 'NHOST_STORAGE_URL', value: appClient.storage.url },
|
||||
{ key: 'NHOST_FUNCTIONS_URL', value: appClient.functions.url },
|
||||
{ key: 'NHOST_AUTH_URL', value: appClient.auth.baseURL },
|
||||
{ key: 'NHOST_GRAPHQL_URL', value: appClient.graphql.url },
|
||||
{ key: 'NHOST_STORAGE_URL', value: appClient.storage.baseURL },
|
||||
{
|
||||
key: 'NHOST_FUNCTIONS_URL',
|
||||
value: appClient.functions.baseURL,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,7 +13,7 @@ import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
|
||||
export interface BaseDirectoryFormValues {
|
||||
/**
|
||||
@@ -52,7 +52,10 @@ export default function BaseDirectorySettings() {
|
||||
},
|
||||
},
|
||||
refetchQueries: [
|
||||
{ query: GetOrganizationsDocument, variables: { userId: userData.id } },
|
||||
{
|
||||
query: GetOrganizationsDocument,
|
||||
variables: { userId: userData?.id },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
|
||||
export interface DeploymentBranchFormValues {
|
||||
/**
|
||||
@@ -53,7 +53,10 @@ export default function DeploymentBranchSettings() {
|
||||
},
|
||||
},
|
||||
refetchQueries: [
|
||||
{ query: GetOrganizationsDocument, variables: { userId: userData.id } },
|
||||
{
|
||||
query: GetOrganizationsDocument,
|
||||
variables: { userId: userData?.id },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -7,12 +7,17 @@ import {
|
||||
getGraphqlServiceUrl,
|
||||
getStorageServiceUrl,
|
||||
} from '@/utils/env';
|
||||
import type { NhostNextClientConstructorParams } from '@nhost/nextjs';
|
||||
import { NhostClient } from '@nhost/nextjs';
|
||||
import { DummySessionStorage } from '@/utils/nhost';
|
||||
import {
|
||||
createClient,
|
||||
type NhostClient,
|
||||
type NhostClientOptions,
|
||||
} from '@nhost/nhost-js-beta';
|
||||
|
||||
export type UseAppClientOptions = NhostNextClientConstructorParams;
|
||||
export type UseAppClientOptions = NhostClientOptions;
|
||||
export type UseAppClientReturn = NhostClient;
|
||||
|
||||
const storage = new DummySessionStorage();
|
||||
/**
|
||||
* This hook returns an application specific Nhost client instance that can be
|
||||
* used to interact with the client's backend.
|
||||
@@ -27,7 +32,7 @@ export default function useAppClient(
|
||||
const { project } = useProject();
|
||||
|
||||
if (!isPlatform) {
|
||||
return new NhostClient({
|
||||
return createClient({
|
||||
authUrl: getAuthServiceUrl(),
|
||||
graphqlUrl: getGraphqlServiceUrl(),
|
||||
storageUrl: getStorageServiceUrl(),
|
||||
@@ -37,8 +42,9 @@ export default function useAppClient(
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_ENV === 'dev' || !project) {
|
||||
return new NhostClient({
|
||||
return createClient({
|
||||
subdomain: 'local',
|
||||
region: 'local',
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -64,11 +70,12 @@ export default function useAppClient(
|
||||
'functions',
|
||||
);
|
||||
|
||||
return new NhostClient({
|
||||
return createClient({
|
||||
authUrl,
|
||||
graphqlUrl,
|
||||
storageUrl,
|
||||
functionsUrl,
|
||||
storage,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
import {
|
||||
useGetOrganizationQuery,
|
||||
type Exact,
|
||||
type GetOrganizationQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useAuthenticationStatus } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export type Org = GetOrganizationQuery['organizations'][0];
|
||||
@@ -24,8 +24,7 @@ export interface UseCurrenOrgReturnType {
|
||||
|
||||
export default function useCurrentOrg(): UseCurrenOrgReturnType {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { isAuthenticated, isLoading: isAuthLoading } =
|
||||
useAuthenticationStatus();
|
||||
const { isAuthenticated, isLoading: isAuthLoading } = useAuth();
|
||||
|
||||
const {
|
||||
query: { orgSlug },
|
||||
@@ -41,12 +40,7 @@ export default function useCurrentOrg(): UseCurrenOrgReturnType {
|
||||
isAuthenticated &&
|
||||
!isAuthLoading;
|
||||
|
||||
const {
|
||||
data: { organizations: [org] } = { organizations: [] },
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetOrganizationQuery({
|
||||
const { data, loading, error, refetch } = useGetOrganizationQuery({
|
||||
fetchPolicy: 'cache-and-network',
|
||||
skip: !shouldFetchOrg,
|
||||
variables: {
|
||||
@@ -54,6 +48,9 @@ export default function useCurrentOrg(): UseCurrenOrgReturnType {
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
organizations: [org],
|
||||
} = data || { organizations: [] };
|
||||
return {
|
||||
org,
|
||||
loading: org ? false : loading || isAuthLoading,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { localOrganization } from '@/features/orgs/utils/local-dashboard';
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
|
||||
import {
|
||||
useGetOrganizationsQuery,
|
||||
type Exact,
|
||||
type GetOrganizationsQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useAuthenticationStatus, useUserData } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export type Org = GetOrganizationsQuery['organizations'][0];
|
||||
@@ -27,15 +28,13 @@ export interface UseOrgsReturnType {
|
||||
export default function useOrgs(): UseOrgsReturnType {
|
||||
const router = useRouter();
|
||||
const isPlatform = useIsPlatform();
|
||||
const userData = useUserData();
|
||||
const { isAuthenticated, isLoading } = useAuthenticationStatus();
|
||||
const { isAuthenticated, isLoading, user } = useAuth();
|
||||
|
||||
const shouldFetchOrg =
|
||||
isPlatform && isAuthenticated && !isLoading && userData;
|
||||
const shouldFetchOrg = isPlatform && isAuthenticated && !isLoading && user;
|
||||
|
||||
const { data, loading, error, refetch } = useGetOrganizationsQuery({
|
||||
variables: {
|
||||
userId: userData?.id,
|
||||
userId: user?.id,
|
||||
},
|
||||
fetchPolicy: 'cache-and-network',
|
||||
skip: !shouldFetchOrg,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { localApplication } from '@/features/orgs/utils/local-dashboard';
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
import { useNhostClient } from '@/providers/nhost';
|
||||
import {
|
||||
GetProjectDocument,
|
||||
type GetProjectQuery,
|
||||
type ProjectFragment,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useAuthenticationStatus, useNhostClient } from '@nhost/nextjs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useMemo } from 'react';
|
||||
@@ -24,10 +25,9 @@ export default function useProject(): UseProjectReturnType {
|
||||
query: { appSubdomain },
|
||||
isReady: isRouterReady,
|
||||
} = useRouter();
|
||||
const client = useNhostClient();
|
||||
const nhost = useNhostClient();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { isAuthenticated, isLoading: isAuthLoading } =
|
||||
useAuthenticationStatus();
|
||||
const { isAuthenticated, isLoading: isAuthLoading } = useAuth();
|
||||
|
||||
const shouldFetchProject = useMemo(
|
||||
() =>
|
||||
@@ -42,12 +42,10 @@ export default function useProject(): UseProjectReturnType {
|
||||
const { data, isLoading, refetch, error } = useQuery(
|
||||
['project', appSubdomain as string],
|
||||
async () => {
|
||||
const response = await client.graphql.request<{
|
||||
const response = await nhost.graphql.post<{
|
||||
apps: ProjectFragment[];
|
||||
}>(GetProjectDocument, {
|
||||
subdomain: (appSubdomain as string) || '',
|
||||
});
|
||||
return response;
|
||||
}>(GetProjectDocument, { subdomain: (appSubdomain as string) || '' });
|
||||
return response.body;
|
||||
},
|
||||
{
|
||||
enabled: shouldFetchProject,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { AvailableLogsService } from '@/features/orgs/projects/logs/utils/constants/services';
|
||||
import { useRemoteApplicationGQLClientWithSubscriptions } from '@/hooks/useRemoteApplicationGQLClientWithSubscriptions';
|
||||
import { mockApplication as mockProject } from '@/tests/mocks';
|
||||
import { renderHook } from '@/tests/testUtils';
|
||||
import { useGetProjectLogsQuery } from '@/utils/__generated__/graphql';
|
||||
@@ -10,27 +9,8 @@ import useProjectLogs, { type UseProjectLogsProps } from './useProjectLogs';
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock('@/features/orgs/projects/hooks/useProject');
|
||||
vi.mock('@/hooks/useRemoteApplicationGQLClientWithSubscriptions');
|
||||
vi.mock('@/utils/__generated__/graphql', async () => {
|
||||
const actual = await vi.importActual<any>('@/utils/__generated__/graphql');
|
||||
return {
|
||||
...actual,
|
||||
useGetProjectLogsQuery: vi.fn(),
|
||||
GetLogsSubscriptionDocument: 'GetLogsSubscriptionDocument',
|
||||
};
|
||||
});
|
||||
|
||||
const mockUseProject = vi.mocked(useProject);
|
||||
const mockUseRemoteApplicationGQLClientWithSubscriptions = vi.mocked(
|
||||
useRemoteApplicationGQLClientWithSubscriptions,
|
||||
);
|
||||
const mockUseGetProjectLogsQuery = vi.mocked(useGetProjectLogsQuery);
|
||||
|
||||
type ProjectLogsReturnType = ReturnType<typeof useGetProjectLogsQuery>;
|
||||
type SubscribeToMore = ProjectLogsReturnType['subscribeToMore'];
|
||||
|
||||
describe('useProjectLogs - Subscription Creation & Cleanup', () => {
|
||||
const createMockApolloClient = () => ({
|
||||
vi.mock('@/utils/splitGraphqlClient', () => ({
|
||||
splitGraphqlClient: {
|
||||
query: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
watchQuery: vi.fn(),
|
||||
@@ -64,10 +44,25 @@ describe('useProjectLogs - Subscription Creation & Cleanup', () => {
|
||||
localState: {},
|
||||
queryManager: {} as any,
|
||||
typeDefs: undefined,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
let mockClient: ReturnType<typeof createMockApolloClient>;
|
||||
vi.mock('@/utils/__generated__/graphql', async () => {
|
||||
const actual = await vi.importActual<any>('@/utils/__generated__/graphql');
|
||||
return {
|
||||
...actual,
|
||||
useGetProjectLogsQuery: vi.fn(),
|
||||
GetLogsSubscriptionDocument: 'GetLogsSubscriptionDocument',
|
||||
};
|
||||
});
|
||||
|
||||
const mockUseProject = vi.mocked(useProject);
|
||||
const mockUseGetProjectLogsQuery = vi.mocked(useGetProjectLogsQuery);
|
||||
|
||||
type ProjectLogsReturnType = ReturnType<typeof useGetProjectLogsQuery>;
|
||||
type SubscribeToMore = ProjectLogsReturnType['subscribeToMore'];
|
||||
|
||||
describe('useProjectLogs - Subscription Creation & Cleanup', () => {
|
||||
const mockSubscribeToMore = vi.fn();
|
||||
const mockUnsubscribe = vi.fn();
|
||||
|
||||
@@ -89,13 +84,6 @@ describe('useProjectLogs - Subscription Creation & Cleanup', () => {
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
mockClient = createMockApolloClient();
|
||||
|
||||
// Mock the GraphQL client
|
||||
mockUseRemoteApplicationGQLClientWithSubscriptions.mockReturnValue(
|
||||
mockClient as any,
|
||||
);
|
||||
|
||||
// Mock subscribeToMore to return an unsubscribe function
|
||||
mockSubscribeToMore.mockReturnValue(mockUnsubscribe);
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { AvailableLogsService } from '@/features/orgs/projects/logs/utils/constants/services';
|
||||
import { useRemoteApplicationGQLClientWithSubscriptions } from '@/hooks/useRemoteApplicationGQLClientWithSubscriptions';
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import {
|
||||
type GetProjectLogsQuery,
|
||||
GetLogsSubscriptionDocument,
|
||||
useGetProjectLogsQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { splitGraphqlClient } from '@/utils/splitGraphqlClient';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export interface UseProjectLogsProps {
|
||||
@@ -50,7 +50,6 @@ export function updateQuery(
|
||||
function useProjectLogs(props: UseProjectLogsProps) {
|
||||
const { project, loading: loadingProject } = useProject();
|
||||
// create a client that sends http requests to Hasura but websocket requests to Bragi
|
||||
const clientWithSplit = useRemoteApplicationGQLClientWithSubscriptions();
|
||||
const subscriptionReturn = useRef(null);
|
||||
|
||||
const {
|
||||
@@ -59,7 +58,7 @@ function useProjectLogs(props: UseProjectLogsProps) {
|
||||
...result
|
||||
} = useGetProjectLogsQuery({
|
||||
variables: { appID: project?.id, ...props },
|
||||
client: clientWithSplit,
|
||||
client: splitGraphqlClient,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
notifyOnNetworkStatusChange: true,
|
||||
skip: !project,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { localApplication } from '@/features/orgs/utils/local-dashboard';
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
import { useNhostClient } from '@/providers/nhost';
|
||||
import {
|
||||
GetProjectStateDocument,
|
||||
type GetProjectQuery,
|
||||
type ProjectFragment,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useAuthenticationStatus, useNhostClient } from '@nhost/nextjs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useMemo } from 'react';
|
||||
@@ -24,10 +25,9 @@ export default function useProjectWithState(): UseProjectWithStateReturnType {
|
||||
query: { appSubdomain },
|
||||
isReady: isRouterReady,
|
||||
} = useRouter();
|
||||
const client = useNhostClient();
|
||||
const nhost = useNhostClient();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { isAuthenticated, isLoading: isAuthLoading } =
|
||||
useAuthenticationStatus();
|
||||
const { isAuthenticated, isLoading: isAuthLoading } = useAuth();
|
||||
|
||||
const shouldFetchProject = useMemo(
|
||||
() =>
|
||||
@@ -42,12 +42,12 @@ export default function useProjectWithState(): UseProjectWithStateReturnType {
|
||||
const { data, isLoading, refetch, error } = useQuery(
|
||||
['projectWithState', appSubdomain as string],
|
||||
async () => {
|
||||
const response = await client.graphql.request<{
|
||||
const response = await nhost.graphql.post<{
|
||||
apps: ProjectFragment[];
|
||||
}>(GetProjectStateDocument, {
|
||||
subdomain: (appSubdomain as string) || '',
|
||||
});
|
||||
return response;
|
||||
return response?.body.data;
|
||||
},
|
||||
{
|
||||
enabled: shouldFetchProject,
|
||||
@@ -61,7 +61,7 @@ export default function useProjectWithState(): UseProjectWithStateReturnType {
|
||||
|
||||
if (isPlatform) {
|
||||
return {
|
||||
project: data?.data?.apps?.[0] || null,
|
||||
project: data?.apps?.[0] || null,
|
||||
loading: isLoading && shouldFetchProject,
|
||||
error: Array.isArray(error || {}) ? error[0] : error,
|
||||
refetch,
|
||||
|
||||
@@ -67,11 +67,12 @@ test('should render an empty state when GitHub is not connected', async () => {
|
||||
'https://local.graphql.local.nhost.run/v1',
|
||||
async (req, res, ctx) => {
|
||||
const { operationName } = await req.json();
|
||||
|
||||
if (operationName === 'getProject') {
|
||||
return res(
|
||||
ctx.json({
|
||||
apps: [{ ...mockApplication, githubRepository: null }],
|
||||
data: {
|
||||
apps: [{ ...mockApplication, githubRepository: null }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -79,7 +80,9 @@ test('should render an empty state when GitHub is not connected', async () => {
|
||||
if (operationName === 'getOrganization') {
|
||||
return res(
|
||||
ctx.json({
|
||||
organizations: [{ ...mockOrganization }],
|
||||
data: {
|
||||
organizations: [{ ...mockOrganization }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -102,7 +105,6 @@ test('should render an empty state when GitHub is not connected', async () => {
|
||||
await screen.findByRole('button', { name: /connect to github/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render an empty state when GitHub is connected, but there are no deployments', async () => {
|
||||
server.use(
|
||||
rest.post(
|
||||
@@ -171,7 +173,6 @@ test('should render a list of deployments', async () => {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operationName === 'getOrganization') {
|
||||
return res(
|
||||
ctx.json({
|
||||
@@ -260,7 +261,6 @@ test('should disable redeployments if a deployment is already in progress', asyn
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operationName === 'getOrganization') {
|
||||
return res(
|
||||
ctx.json({
|
||||
|
||||
@@ -8,7 +8,6 @@ test('should return the total number of allocated resources', () => {
|
||||
totalAvailableVCPU: 1,
|
||||
totalAvailableMemory: 2,
|
||||
database: {
|
||||
replicas: 1,
|
||||
vcpu: 0,
|
||||
memory: 0.5,
|
||||
},
|
||||
@@ -36,7 +35,6 @@ test('should return the total number of allocated resources', () => {
|
||||
totalAvailableVCPU: 1,
|
||||
totalAvailableMemory: 2,
|
||||
database: {
|
||||
replicas: 1,
|
||||
vcpu: 0.25,
|
||||
memory: 0,
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { DataGridHeaderProps } from '@/features/orgs/projects/storage/dataG
|
||||
import { DataGridHeader } from '@/features/orgs/projects/storage/dataGrid/components/DataGridHeader';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef, useEffect, useRef } from 'react';
|
||||
import mergeRefs from 'react-merge-refs';
|
||||
import { mergeRefs } from 'react-merge-refs';
|
||||
import type { Column, Row, SortingRule, TableOptions } from 'react-table';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import useDataGrid from './useDataGrid';
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function DataGridBooleanCell<TData extends object>({
|
||||
editCell,
|
||||
cancelEditCell,
|
||||
isSelected,
|
||||
} = useDataGridCell<HTMLInputElement>();
|
||||
} = useDataGridCell<HTMLButtonElement>();
|
||||
|
||||
async function handleMenuClick(
|
||||
event: MouseEvent<HTMLLIElement> | ReactKeyboardEvent<HTMLLIElement>,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { XIcon } from '@/components/ui/v2/icons/XIcon';
|
||||
import { useAppClient } from '@/features/orgs/projects/hooks/useAppClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { usePreviewToggle } from '@/features/orgs/projects/storage/dataGrid/hooks/usePreviewToggle';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import clsx from 'clsx';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useReducer, useState } from 'react';
|
||||
@@ -50,7 +51,7 @@ function useBlob({
|
||||
const { previewEnabled } = usePreviewToggle();
|
||||
|
||||
// This side-effect fetches the blob of the file from the server and sets the
|
||||
// relevant `objectUrl` state. Abort controller is reponsible for cancelling
|
||||
// relevant `objectUrl` state. Abort controller is responsible for cancelling
|
||||
// the fetch if the component is unmounted.
|
||||
useEffect(() => {
|
||||
if (!previewEnabled) {
|
||||
@@ -172,7 +173,6 @@ export default function DataGridPreviewCell<TData extends object>({
|
||||
value: { fetchBlob, id, mimeType, alt, blob },
|
||||
fallbackPreview = null,
|
||||
}: DataGridPreviewCellProps<TData>) {
|
||||
const { project } = useProject();
|
||||
const appClient = useAppClient();
|
||||
const { objectUrl, loading, error } = useBlob({
|
||||
fetchBlob,
|
||||
@@ -181,6 +181,7 @@ export default function DataGridPreviewCell<TData extends object>({
|
||||
});
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const { previewEnabled } = usePreviewToggle();
|
||||
const { project } = useProject();
|
||||
|
||||
const [
|
||||
{ loading: previewLoading, error: previewError, data: previewUrl },
|
||||
@@ -215,9 +216,14 @@ export default function DataGridPreviewCell<TData extends object>({
|
||||
dispatch({ type: 'PREVIEW_LOADING' });
|
||||
}
|
||||
|
||||
const { presignedUrl } = await appClient.storage
|
||||
.setAdminSecret(project?.config?.hasura.adminSecret)
|
||||
.getPresignedUrl({ fileId: id });
|
||||
const { body: presignedUrl } = await appClient.storage.getPresignedURL(id, {
|
||||
headers: {
|
||||
'x-hasura-admin-secret':
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: project?.config?.hasura.adminSecret,
|
||||
},
|
||||
});
|
||||
|
||||
if (!presignedUrl) {
|
||||
dispatch({
|
||||
|
||||
@@ -258,17 +258,26 @@ export default function FilesDataGrid(props: FilesDataGridProps) {
|
||||
}, 250);
|
||||
|
||||
try {
|
||||
const { fileMetadata, error: fileError } = await appClient.storage
|
||||
.setAdminSecret(
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: project?.config?.hasura.adminSecret,
|
||||
)
|
||||
.upload({
|
||||
file,
|
||||
name: encodeURIComponent(file.name),
|
||||
bucketId: defaultBucket.id,
|
||||
});
|
||||
const uploadResponse = await appClient.storage.uploadFiles(
|
||||
{
|
||||
'bucket-id': defaultBucket.id,
|
||||
'file[]': [file],
|
||||
'metadata[]': [
|
||||
{
|
||||
name: encodeURIComponent(file.name),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'x-hasura-admin-secret':
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: project?.config?.hasura.adminSecret,
|
||||
},
|
||||
},
|
||||
);
|
||||
const fileMetadata = uploadResponse.body.processedFiles[0];
|
||||
|
||||
uploaded = true;
|
||||
|
||||
@@ -276,10 +285,6 @@ export default function FilesDataGrid(props: FilesDataGridProps) {
|
||||
toast.remove(toastId);
|
||||
}
|
||||
|
||||
if (fileError) {
|
||||
throw new Error(fileError.message);
|
||||
}
|
||||
|
||||
if (!fileMetadata) {
|
||||
throw new Error('File metadata is missing.');
|
||||
}
|
||||
|
||||
@@ -70,39 +70,24 @@ export default function FilesDataGridControls({
|
||||
setDeleteLoading(true);
|
||||
|
||||
try {
|
||||
const storageWithAdminSecret = appClient.storage.setAdminSecret(
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: project.config?.hasura.adminSecret,
|
||||
);
|
||||
|
||||
// note: this is not an optimal solution, but we don't have a better way
|
||||
// to batch remove files for now
|
||||
const response = await Promise.allSettled(
|
||||
await Promise.allSettled(
|
||||
selectedFiles.map((file) =>
|
||||
storageWithAdminSecret.delete({ fileId: file.original.id }),
|
||||
appClient.storage.deleteFile(file.original.id, {
|
||||
headers: {
|
||||
'x-hasura-admin-secret':
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: project.config?.hasura.adminSecret,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const failedFiles = response.filter(
|
||||
(content) =>
|
||||
content.status === 'rejected' || Boolean(content.value.error),
|
||||
triggerToast(
|
||||
selectedFiles.length === 1
|
||||
? `The file was successfully deleted.`
|
||||
: `${selectedFiles.length} files were successfully deleted.`,
|
||||
);
|
||||
|
||||
if (failedFiles.length > 0) {
|
||||
triggerToast(
|
||||
`Failed to delete ${failedFiles.length} ${
|
||||
failedFiles.length === 1 ? 'file' : 'files'
|
||||
}`,
|
||||
);
|
||||
} else {
|
||||
triggerToast(
|
||||
selectedFiles.length === 1
|
||||
? `The file was successfully deleted.`
|
||||
: `${selectedFiles.length} files were successfully deleted.`,
|
||||
);
|
||||
}
|
||||
|
||||
toggleAllRowsSelected(false);
|
||||
|
||||
if (refetchData) {
|
||||
@@ -110,9 +95,9 @@ export default function FilesDataGridControls({
|
||||
}
|
||||
} catch (error) {
|
||||
triggerToast(error.message || 'Unknown error occurred');
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
1
dashboard/src/hooks/useAccessToken/index.ts
Normal file
1
dashboard/src/hooks/useAccessToken/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as useAccessToken } from './useAccessToken';
|
||||
9
dashboard/src/hooks/useAccessToken/useAccessToken.ts
Normal file
9
dashboard/src/hooks/useAccessToken/useAccessToken.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
|
||||
function useAccessToken() {
|
||||
const { session } = useAuth();
|
||||
|
||||
return session?.accessToken;
|
||||
}
|
||||
|
||||
export default useAccessToken;
|
||||
2
dashboard/src/hooks/useDecodedAccessToken/index.ts
Normal file
2
dashboard/src/hooks/useDecodedAccessToken/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './useDecodedAccessToken';
|
||||
export { default as useDecodedAccessToken } from './useDecodedAccessToken';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useAccessToken } from '@/hooks/useAccessToken';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
|
||||
export interface JWTClaims {
|
||||
sub?: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
iss?: string;
|
||||
'https://hasura.io/jwt/claims': JWTHasuraClaims;
|
||||
}
|
||||
|
||||
export interface JWTHasuraClaims {
|
||||
// ? does not work as expected: if the key does not start with `x-hasura-`, then it is typed as `any`
|
||||
// [claim: `x-hasura-${string}`]: string | string[]
|
||||
[claim: string]: string | string[] | null;
|
||||
'x-hasura-allowed-roles': string[];
|
||||
'x-hasura-default-role': string;
|
||||
'x-hasura-user-id': string;
|
||||
'x-hasura-user-is-anonymous': string;
|
||||
'x-hasura-auth-elevated': string;
|
||||
}
|
||||
|
||||
function useDecodedAccessToken() {
|
||||
const token = useAccessToken();
|
||||
return token ? jwtDecode<JWTClaims>(token) : null;
|
||||
}
|
||||
|
||||
export default useDecodedAccessToken;
|
||||
1
dashboard/src/hooks/useElevateEmail/index.ts
Normal file
1
dashboard/src/hooks/useElevateEmail/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as useElevateEmail } from './useElevateEmail';
|
||||
26
dashboard/src/hooks/useElevateEmail/useElevateEmail.ts
Normal file
26
dashboard/src/hooks/useElevateEmail/useElevateEmail.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import { useNhostClient } from '@/providers/nhost';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
|
||||
function useElevateEmail() {
|
||||
const nhost = useNhostClient();
|
||||
const user = useUserData();
|
||||
|
||||
async function elevateEmail() {
|
||||
const elevateResponse = await nhost.auth.elevateWebauthn();
|
||||
const credential = await startAuthentication(elevateResponse.body);
|
||||
const verifyResponse = await nhost.auth.verifyElevateWebauthn({
|
||||
email: user?.email,
|
||||
credential,
|
||||
});
|
||||
|
||||
if (isNotEmptyValue(verifyResponse.body.session)) {
|
||||
nhost.sessionStorage.set(verifyResponse.body.session);
|
||||
}
|
||||
}
|
||||
|
||||
return elevateEmail;
|
||||
}
|
||||
|
||||
export default useElevateEmail;
|
||||
1
dashboard/src/hooks/useHasuraClaims/index.ts
Normal file
1
dashboard/src/hooks/useHasuraClaims/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as useHasuraClaims } from './useHasuraClaims';
|
||||
12
dashboard/src/hooks/useHasuraClaims/useHasuraClaims.ts
Normal file
12
dashboard/src/hooks/useHasuraClaims/useHasuraClaims.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
useDecodedAccessToken,
|
||||
type JWTHasuraClaims,
|
||||
} from '@/hooks/useDecodedAccessToken';
|
||||
|
||||
function useHasuraClaims(): JWTHasuraClaims | null {
|
||||
const decodedToken = useDecodedAccessToken();
|
||||
|
||||
return decodedToken?.['https://hasura.io/jwt/claims'] || null;
|
||||
}
|
||||
|
||||
export default useHasuraClaims;
|
||||
@@ -1 +0,0 @@
|
||||
export { default as useRemoteApplicationGQLClientWithSubscriptions } from './useRemoteApplicationGQLClientWithSubscriptions';
|
||||
@@ -1,72 +0,0 @@
|
||||
import { ApolloClient, HttpLink, InMemoryCache, split } from '@apollo/client';
|
||||
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
|
||||
import { getMainDefinition } from '@apollo/client/utilities';
|
||||
import { useAccessToken, useNhostClient } from '@nhost/nextjs';
|
||||
import { createClient } from 'graphql-ws';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* It creates a new Apollo Client instance with a split property which recognizes the type of operation and uses a different
|
||||
* link for queries/mutations (HttpLink -- our own application querying through remote schemas) and subscriptions (GraphQLWsLink connected to the bragi endpoint).
|
||||
* @returns A function that returns a new ApolloClient instance with split functionality prepared for websockets connected to bragi.
|
||||
*/
|
||||
export default function useRemoteApplicationGQLClientWithSubscriptions() {
|
||||
const client = useNhostClient();
|
||||
const token = useAccessToken();
|
||||
|
||||
const userApplicationClient = useMemo(() => {
|
||||
const httpLink = new HttpLink({
|
||||
uri: client.graphql.getUrl(),
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const wsLink = new GraphQLWsLink(
|
||||
createClient({
|
||||
url: process.env.NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET,
|
||||
connectionParams: {
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
webSocketImpl: WebSocket,
|
||||
}),
|
||||
);
|
||||
|
||||
return new ApolloClient({
|
||||
cache: new InMemoryCache({
|
||||
typePolicies: {
|
||||
Subscription: {
|
||||
fields: {
|
||||
logs: {
|
||||
keyArgs: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
Query: {
|
||||
fields: {
|
||||
logs: {
|
||||
keyArgs: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
connectToDevTools: true,
|
||||
link: split(
|
||||
({ query }) => {
|
||||
const definition = getMainDefinition(query);
|
||||
return (
|
||||
definition.kind === 'OperationDefinition' &&
|
||||
definition.operation === 'subscription'
|
||||
);
|
||||
},
|
||||
wsLink,
|
||||
httpLink,
|
||||
),
|
||||
});
|
||||
}, [client.graphql, token]);
|
||||
|
||||
return userApplicationClient;
|
||||
}
|
||||
1
dashboard/src/hooks/useUserData/index.ts
Normal file
1
dashboard/src/hooks/useUserData/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as useUserData } from './useUserData';
|
||||
11
dashboard/src/hooks/useUserData/useUserData.ts
Normal file
11
dashboard/src/hooks/useUserData/useUserData.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
|
||||
function useUserData() {
|
||||
const authContext = useAuth();
|
||||
|
||||
const userData = authContext.user;
|
||||
|
||||
return userData;
|
||||
}
|
||||
|
||||
export default useUserData;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user