diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 3eb997128f494..9a6a5040ef7bc 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -100,6 +100,7 @@ Jean-Paul Argudo Jeezy Jeff Smick Jenny Kibiri +Jeremias Menichelli Jess Shears Jim Brodeur Jim Chanco Jr diff --git a/apps/studio/data/config/project-settings-v2-query.ts b/apps/studio/data/config/project-settings-v2-query.ts index c957b930eaa33..abec2e802368e 100644 --- a/apps/studio/data/config/project-settings-v2-query.ts +++ b/apps/studio/data/config/project-settings-v2-query.ts @@ -19,13 +19,15 @@ export type ProjectSettings = components['schemas']['ProjectSettingsResponse'] & export async function getProjectSettings( { projectRef }: ProjectSettingsVariables, - signal?: AbortSignal + signal?: AbortSignal, + headers?: Record ) { if (!projectRef) throw new Error('projectRef is required') const { data, error } = await get('/platform/projects/{ref}/settings', { params: { path: { ref: projectRef } }, signal, + headers, }) if (error) handleError(error) diff --git a/apps/studio/data/subscriptions/org-subscription-query.ts b/apps/studio/data/subscriptions/org-subscription-query.ts index 4cc1d9292b594..82ba5b9979d58 100644 --- a/apps/studio/data/subscriptions/org-subscription-query.ts +++ b/apps/studio/data/subscriptions/org-subscription-query.ts @@ -13,13 +13,15 @@ export type OrgSubscriptionVariables = { export async function getOrgSubscription( { orgSlug }: OrgSubscriptionVariables, - signal?: AbortSignal + signal?: AbortSignal, + headers?: Record ) { if (!orgSlug) throw new Error('orgSlug is required') const { error, data } = await get('/platform/organizations/{slug}/billing/subscription', { params: { path: { slug: orgSlug } }, signal, + headers, }) if (error) handleError(error) diff --git a/apps/studio/lib/ai/generate-assistant-response.ts b/apps/studio/lib/ai/generate-assistant-response.ts index 66ef0922aec60..d0bc75ba7331e 100644 --- a/apps/studio/lib/ai/generate-assistant-response.ts +++ b/apps/studio/lib/ai/generate-assistant-response.ts @@ -45,6 +45,8 @@ export async function generateAssistantResponse({ getSchemas?: () => Promise projectRef?: string chatName?: string + // TODO(mattrossman): use for excluding HIPAA projects from assistant tracing + isHipaaEnabled?: boolean promptProviderOptions?: Record providerOptions?: Record abortSignal?: AbortSignal diff --git a/apps/studio/lib/ai/org-ai-details.test.ts b/apps/studio/lib/ai/org-ai-details.test.ts index 2a913fe95c053..e0926a2b4d463 100644 --- a/apps/studio/lib/ai/org-ai-details.test.ts +++ b/apps/studio/lib/ai/org-ai-details.test.ts @@ -10,25 +10,53 @@ vi.mock('data/projects/project-detail-query', () => ({ getProjectDetail: vi.fn(), })) +vi.mock('data/subscriptions/org-subscription-query', () => ({ + getOrgSubscription: vi.fn(), +})) + +vi.mock('data/config/project-settings-v2-query', () => ({ + getProjectSettings: vi.fn(), +})) + vi.mock('hooks/misc/useOrgOptedIntoAi', () => ({ getAiOptInLevel: vi.fn(), })) +vi.mock('components/interfaces/Billing/Subscription/Subscription.utils', () => ({ + subscriptionHasHipaaAddon: vi.fn(), +})) + describe('ai/org-ai-details', () => { let mockGetOrganizations: ReturnType let mockGetProjectDetail: ReturnType + let mockGetOrgSubscription: ReturnType + let mockGetProjectSettings: ReturnType let mockGetAiOptInLevel: ReturnType + let mockSubscriptionHasHipaaAddon: ReturnType beforeEach(async () => { vi.clearAllMocks() const orgsQuery = await import('data/organizations/organizations-query') const projectQuery = await import('data/projects/project-detail-query') + const subscriptionQuery = await import('data/subscriptions/org-subscription-query') + const settingsQuery = await import('data/config/project-settings-v2-query') const aiHook = await import('hooks/misc/useOrgOptedIntoAi') + const subscriptionUtils = await import( + 'components/interfaces/Billing/Subscription/Subscription.utils' + ) mockGetOrganizations = vi.mocked(orgsQuery.getOrganizations) mockGetProjectDetail = vi.mocked(projectQuery.getProjectDetail) + mockGetOrgSubscription = vi.mocked(subscriptionQuery.getOrgSubscription) + mockGetProjectSettings = vi.mocked(settingsQuery.getProjectSettings) mockGetAiOptInLevel = vi.mocked(aiHook.getAiOptInLevel) + mockSubscriptionHasHipaaAddon = vi.mocked(subscriptionUtils.subscriptionHasHipaaAddon) + + // Default mocks for subscription/settings (no HIPAA) + mockGetOrgSubscription.mockResolvedValue({ addons: [] }) + mockGetProjectSettings.mockResolvedValue({ is_sensitive: false }) + mockSubscriptionHasHipaaAddon.mockReturnValue(false) }) describe('getOrgAIDetails', () => { @@ -92,6 +120,7 @@ describe('ai/org-ai-details', () => { expect(result).toEqual({ aiOptInLevel: 'schema_only', isLimited: true, + isHipaaEnabled: false, }) }) @@ -239,5 +268,112 @@ describe('ai/org-ai-details', () => { expect(result.isLimited).toBe(false) // Pro plan }) + + it('should return isHipaaEnabled true when subscription has HIPAA addon and project is sensitive', async () => { + const mockOrg = { + id: 1, + slug: 'test-org', + plan: { id: 'enterprise' }, + opt_in_tags: [], + } + const mockProject = { organization_id: 1 } + + mockGetOrganizations.mockResolvedValue([mockOrg]) + mockGetProjectDetail.mockResolvedValue(mockProject) + mockGetAiOptInLevel.mockReturnValue('schema') + mockSubscriptionHasHipaaAddon.mockReturnValue(true) + mockGetProjectSettings.mockResolvedValue({ is_sensitive: true }) + + const result = await getOrgAIDetails({ + orgSlug: 'test-org', + authorization: 'Bearer token', + projectRef: 'test-project', + }) + + expect(result.isHipaaEnabled).toBe(true) + }) + + it('should return isHipaaEnabled false when subscription has HIPAA addon but project is not sensitive', async () => { + const mockOrg = { + id: 1, + slug: 'test-org', + plan: { id: 'enterprise' }, + opt_in_tags: [], + } + const mockProject = { organization_id: 1 } + + mockGetOrganizations.mockResolvedValue([mockOrg]) + mockGetProjectDetail.mockResolvedValue(mockProject) + mockGetAiOptInLevel.mockReturnValue('schema') + mockSubscriptionHasHipaaAddon.mockReturnValue(true) + mockGetProjectSettings.mockResolvedValue({ is_sensitive: false }) + + const result = await getOrgAIDetails({ + orgSlug: 'test-org', + authorization: 'Bearer token', + projectRef: 'test-project', + }) + + expect(result.isHipaaEnabled).toBe(false) + }) + + it('should return isHipaaEnabled false when project is sensitive but no HIPAA addon', async () => { + const mockOrg = { + id: 1, + slug: 'test-org', + plan: { id: 'pro' }, + opt_in_tags: [], + } + const mockProject = { organization_id: 1 } + + mockGetOrganizations.mockResolvedValue([mockOrg]) + mockGetProjectDetail.mockResolvedValue(mockProject) + mockGetAiOptInLevel.mockReturnValue('schema') + mockSubscriptionHasHipaaAddon.mockReturnValue(false) + mockGetProjectSettings.mockResolvedValue({ is_sensitive: true }) + + const result = await getOrgAIDetails({ + orgSlug: 'test-org', + authorization: 'Bearer token', + projectRef: 'test-project', + }) + + expect(result.isHipaaEnabled).toBe(false) + }) + + it('should fetch subscription and project settings with authorization headers', async () => { + const mockOrg = { + id: 1, + slug: 'test-org', + plan: { id: 'pro' }, + } + const mockProject = { organization_id: 1 } + + mockGetOrganizations.mockResolvedValue([mockOrg]) + mockGetProjectDetail.mockResolvedValue(mockProject) + mockGetAiOptInLevel.mockReturnValue('schema') + + await getOrgAIDetails({ + orgSlug: 'test-org', + authorization: 'Bearer token', + projectRef: 'test-project', + }) + + const expectedHeaders = { + 'Content-Type': 'application/json', + Authorization: 'Bearer token', + } + + expect(mockGetOrgSubscription).toHaveBeenCalledWith( + { orgSlug: 'test-org' }, + undefined, + expectedHeaders + ) + expect(mockGetProjectSettings).toHaveBeenCalledWith( + { projectRef: 'test-project' }, + undefined, + expectedHeaders + ) + }) }) }) diff --git a/apps/studio/lib/ai/org-ai-details.ts b/apps/studio/lib/ai/org-ai-details.ts index 7f9cf99576ece..6457d390914fe 100644 --- a/apps/studio/lib/ai/org-ai-details.ts +++ b/apps/studio/lib/ai/org-ai-details.ts @@ -1,5 +1,8 @@ +import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils' +import { getProjectSettings } from 'data/config/project-settings-v2-query' import { getOrganizations } from 'data/organizations/organizations-query' import { getProjectDetail } from 'data/projects/project-detail-query' +import { getOrgSubscription } from 'data/subscriptions/org-subscription-query' import { getAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' export const getOrgAIDetails = async ({ @@ -16,9 +19,11 @@ export const getOrgAIDetails = async ({ ...(authorization && { Authorization: authorization }), } - const [organizations, selectedProject] = await Promise.all([ + const [organizations, selectedProject, subscription, projectSettings] = await Promise.all([ getOrganizations({ headers }), getProjectDetail({ ref: projectRef }, undefined, headers), + getOrgSubscription({ orgSlug }, undefined, headers), + getProjectSettings({ projectRef }, undefined, headers), ]) const selectedOrg = organizations.find((org) => org.slug === orgSlug) @@ -30,9 +35,11 @@ export const getOrgAIDetails = async ({ const aiOptInLevel = getAiOptInLevel(selectedOrg?.opt_in_tags) const isLimited = selectedOrg?.plan.id === 'free' + const isHipaaEnabled = subscriptionHasHipaaAddon(subscription) && !!projectSettings?.is_sensitive return { aiOptInLevel, isLimited, + isHipaaEnabled, } } diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index 92de85ef6d6c1..354a30f4b9383 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -89,6 +89,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) { let aiOptInLevel: AiOptInLevel = 'disabled' let isLimited = false + let isHipaaEnabled = false if (!IS_PLATFORM) { aiOptInLevel = 'schema' @@ -97,7 +98,11 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) { if (IS_PLATFORM && orgSlug && authorization && projectRef) { try { // Get organizations and compute opt in level server-side - const { aiOptInLevel: orgAIOptInLevel, isLimited: orgAILimited } = await getOrgAIDetails({ + const { + aiOptInLevel: orgAIOptInLevel, + isLimited: orgAILimited, + isHipaaEnabled: orgIsHipaaEnabled, + } = await getOrgAIDetails({ orgSlug, authorization, projectRef, @@ -105,6 +110,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) { aiOptInLevel = orgAIOptInLevel isLimited = orgAILimited + isHipaaEnabled = orgIsHipaaEnabled } catch (error) { return res.status(400).json({ error: 'There was an error fetching your organization details', @@ -174,6 +180,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) { getSchemas: aiOptInLevel !== 'disabled' ? getSchemas : undefined, projectRef, chatName, + isHipaaEnabled, promptProviderOptions, providerOptions, abortSignal: abortController.signal, diff --git a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx index ef130664bfbb8..68b1b8bc0aeec 100644 --- a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx @@ -29,6 +29,7 @@ import { } from 'ui-patterns/PageHeader' import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' const DatabasePhysicalBackups: NextPageWithLayout = () => { return ( @@ -65,18 +66,18 @@ DatabasePhysicalBackups.getLayout = (page) => ( const PITR = () => { const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() - const { data: organization } = useSelectedOrganizationQuery() + const { hasAccess: hasAccessToPitr, isLoading: isLoadingEntitlements } = + useCheckEntitlements('pitr.available_variants') const isOrioleDbInAws = useIsOrioleDbInAws() const { data: backups, error, - isPending: isLoading, + isPending: isLoadingBackups, isError, isSuccess, } = useBackupsQuery({ projectRef }) - const plan = organization?.plan?.id - const isFreePlan = plan === 'free' + const isLoading = isLoadingBackups || isLoadingEntitlements const isEnabled = backups?.pitr_enabled const isActiveHealthy = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY @@ -109,12 +110,12 @@ const PITR = () => { <> {!isEnabled ? ( { } }) - test('Tab selects column from dropdown', async ({ page, ref }) => { - const tableName = `${tableNamePrefix}_tab_col` + test('Tab exits filter bar to next DOM element', async ({ page, ref }) => { + const tableName = `${tableNamePrefix}_tab_exit` const columnName = 'name' await createTable(tableName, columnName, [{ name: 'Alice' }]) @@ -159,14 +159,16 @@ test.describe('Filter Bar', () => { await setupFilterBarPage(page, ref, toUrl(`/project/${ref}/editor?schema=public`)) await navigateToTable(page, ref, tableName) + await addFilter(page, ref, columnName, '=', 'Alice') + const freeformInput = getFilterBarInput(page) await freeformInput.click() - await freeformInput.fill(columnName) - await expect(page.getByTestId(`filter-menu-item-${columnName}`)).toBeVisible() + await expect(freeformInput).toBeFocused() + // Tab should exit the filter bar (freeform input should lose focus) await page.keyboard.press('Tab') - await expect(page.getByTestId(`filter-condition-${columnName}`)).toBeVisible() + await expect(freeformInput).not.toBeFocused() } finally { await dropTable(tableName) } @@ -784,27 +786,26 @@ test.describe('Filter Bar', () => { } }) - test('Tab selects dropdown value option', async ({ page, ref }) => { - const tableName = `${tableNamePrefix}_kb_tab` + test('Shift+Tab exits filter bar to previous DOM element', async ({ page, ref }) => { + const tableName = `${tableNamePrefix}_shifttab_exit` + const columnName = 'name' - await query( - `CREATE TABLE IF NOT EXISTS ${tableName} ( - id bigint generated by default as identity primary key, - is_active boolean - )` - ) - await query(`INSERT INTO ${tableName} (is_active) VALUES (true), (false)`) + await createTable(tableName, columnName, [{ name: 'Alice' }]) try { await setupFilterBarPage(page, ref, toUrl(`/project/${ref}/editor?schema=public`)) await navigateToTable(page, ref, tableName) - await selectColumnFilter(page, 'is_active') - await selectOperator(page, 'is_active', '=') + await addFilter(page, ref, columnName, '=', 'Alice') - await page.keyboard.press('Tab') + const freeformInput = getFilterBarInput(page) + await freeformInput.click() + await expect(freeformInput).toBeFocused() - await expect(page.getByTestId('filter-condition-is_active')).toBeVisible() + // Shift+Tab should exit the filter bar (freeform input should lose focus) + await page.keyboard.press('Shift+Tab') + + await expect(freeformInput).not.toBeFocused() } finally { await dropTable(tableName) } diff --git a/packages/ui-patterns/src/FilterBar/FilterCondition.tsx b/packages/ui-patterns/src/FilterBar/FilterCondition.tsx index f94ea8626db18..8f7b2425a3f07 100644 --- a/packages/ui-patterns/src/FilterBar/FilterCondition.tsx +++ b/packages/ui-patterns/src/FilterBar/FilterCondition.tsx @@ -246,6 +246,7 @@ export function FilterCondition({ disabled={isLoading} aria-label={`Operator for ${property.label}`} data-testid={`filter-operator-${property.name}`} + tabIndex={-1} /> {condition.operator || ' '} @@ -289,6 +290,7 @@ export function FilterCondition({ disabled={isLoading} aria-label={`Value for ${property.label}`} data-testid={`filter-value-${property.name}`} + tabIndex={-1} /> {localValue || ' '} diff --git a/packages/ui-patterns/src/FilterBar/hooks.ts b/packages/ui-patterns/src/FilterBar/hooks.ts index 45bd4de2237e9..aa42aa73db7ff 100644 --- a/packages/ui-patterns/src/FilterBar/hooks.ts +++ b/packages/ui-patterns/src/FilterBar/hooks.ts @@ -154,7 +154,7 @@ export function useHighlightNavigation( setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0)) return } - if (e.key === 'Enter' || e.key === 'Tab') { + if (e.key === 'Enter') { // Edge case: when a filter is highlighted, skip dropdown selection and let fallback handle it if (options?.skipEnterWhenFilterHighlighted) { if (fallbackKeyDown) fallbackKeyDown(e)