diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx index c668c505a3d..fe80860951a 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx @@ -7,7 +7,17 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' -import { AiIconAnimation, Badge } from 'ui' +import { + AiIconAnimation, + Badge, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from 'ui' +import { useRouter } from 'next/router' +import Link from 'next/link' interface PolicyTableRowHeaderProps { table: { @@ -33,6 +43,7 @@ const PolicyTableRowHeader = ({ onSelectCreatePolicy, }: PolicyTableRowHeaderProps) => { const { ref } = useParams() + const router = useRouter() const aiSnap = useAiAssistantStateSnapshot() const canCreatePolicies = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'policies') @@ -42,6 +53,10 @@ const PolicyTableRowHeader = ({ const isRealtimeMessagesTable = isRealtimeSchema && table.name === 'messages' const isTableLocked = isRealtimeSchema ? !isRealtimeMessagesTable : isLocked + const handleTestItemClick = (operation: string) => { + router.push(`/project/${ref}/editor/${table.id}?impersonate=true&as=${operation}`) + } + return (
@@ -91,6 +106,13 @@ const PolicyTableRowHeader = ({ {table.rls_enabled ? 'Disable RLS' : 'Enable RLS'} )} + { const state = useRoleImpersonationStateSnapshot() - const [isOpen, setIsOpen] = useState(false) + const [isOpen, setIsOpen] = useState(initialOpen) const currentRole = state.role?.role ?? serviceRoleLabel ?? 'service role' @@ -53,7 +57,7 @@ const RoleImpersonationPopover = ({ }} >
- Role + Role {currentRole} {state.role?.type === 'postgrest' && state.role.role === 'authenticated' && ( <> @@ -62,7 +66,7 @@ const RoleImpersonationPopover = ({ ) : state.role.userType === 'external' && state.role.externalAuth ? ( ) : null} - + {state.role.aal === 'aal2' ? 'AAL2' : 'AAL1'} @@ -80,6 +84,7 @@ const RoleImpersonationPopover = ({ diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx index fa591e1e8a0..d037b5b49aa 100644 --- a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx +++ b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx @@ -11,16 +11,21 @@ export interface RoleImpersonationSelectorProps { serviceRoleLabel?: string padded?: boolean disallowAuthenticatedOption?: boolean + initialSelectedOption?: PostgrestRole } const RoleImpersonationSelector = ({ serviceRoleLabel, padded = true, disallowAuthenticatedOption = false, + initialSelectedOption, }: RoleImpersonationSelectorProps) => { const state = useRoleImpersonationStateSnapshot() const [selectedOption, setSelectedOption] = useState(() => { + if (initialSelectedOption) { + return initialSelectedOption + } if ( state.role?.type === 'postgrest' && (state.role.role === 'anon' || state.role.role === 'authenticated') diff --git a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx index 06ace1d0d88..527168eae66 100644 --- a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx @@ -74,7 +74,11 @@ const DeleteConfirmationDialogs = ({ if (snap.confirmationDialog?.type === 'row') { snap.confirmationDialog.callback?.() } - toast.success(`Successfully deleted selected row(s)`) + if (getImpersonatedRoleState().hasTransaction) { + toast.success(`Successfully deleted selected row(s) and rolled back`) + } else { + toast.success(`Successfully deleted selected row(s)`) + } }, onSettled: () => { snap.closeConfirmationDialog() @@ -86,7 +90,11 @@ const DeleteConfirmationDialogs = ({ if (snap.confirmationDialog?.type === 'row') { snap.confirmationDialog.callback?.() } - toast.success(`Successfully deleted selected rows`) + if (getImpersonatedRoleState().hasTransaction) { + toast.success(`Successfully deleted selected rows and rolled back`) + } else { + toast.success(`Successfully deleted selected rows`) + } }, onError: (error) => { toast.error(`Failed to delete rows: ${error.message}`) diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index a14889e5736..933030e2e14 100644 --- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -26,12 +26,15 @@ import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' +import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state' import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import { Button, + Label_Shadcn_ as Label, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, + Switch, Tooltip, TooltipContent, TooltipTrigger, @@ -41,6 +44,8 @@ import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { RoleImpersonationPopover } from '../RoleImpersonationSelector' import ViewEntityAutofixSecurityModal from './ViewEntityAutofixSecurityModal' +import { useEffect } from 'react' +import { useRouter } from 'next/router' export interface GridHeaderActionsProps { table: Entity @@ -50,6 +55,15 @@ const GridHeaderActions = ({ table }: GridHeaderActionsProps) => { const { ref } = useParams() const { project } = useProjectContext() const org = useSelectedOrganization() + const router = useRouter() + const { hasTransaction, setHasTransaction } = useRoleImpersonationStateSnapshot() + + useEffect(() => { + const { impersonate } = router.query + if (impersonate === 'true') { + setHasTransaction(true) + } + }, [router.query.impersonate]) // need project lints to get security status for views const { data: lints = [] } = useProjectLintsQuery({ projectRef: project?.ref }) @@ -425,7 +439,14 @@ const GridHeaderActions = ({ table }: GridHeaderActionsProps) => { )} - + + {isTable && realtimeEnabled && ( )} {doesHaveAutoGeneratedAPIDocs && } +
+ + +
)} @@ -43,6 +45,7 @@ const RowEditor = ({ }: RowEditorProps) => { const [errors, setErrors] = useState>({}) const [rowFields, setRowFields] = useState([]) + const { hasTransaction } = useRoleImpersonationStateSnapshot() const [selectedValueForTextEdit, setSelectedValueForTextEdit] = useState() const [selectedValueForJsonEdit, setSelectedValueForJsonEdit] = useState() @@ -167,6 +170,11 @@ const RowEditor = ({ }`} onCancel={closePanel} > + {hasTransaction && ( +
+

You are in test mode. Any changes you make will be rolled back.

+
+ )}
onSaveChanges(e)} className="h-full">
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index f15551a8c81..1ec75c68a73 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -50,6 +50,7 @@ import { import SpreadsheetImport from './SpreadsheetImport/SpreadsheetImport' import TableEditor from './TableEditor/TableEditor' import type { ImportContent } from './TableEditor/TableEditor.types' +import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state' export interface SidePanelEditorProps { editable?: boolean @@ -74,7 +75,7 @@ const SidePanelEditor = ({ const queryClient = useQueryClient() const { project } = useProjectContext() - + const { hasTransaction } = useRoleImpersonationStateSnapshot() const [isEdited, setIsEdited] = useState(false) const [isClosingPanel, setIsClosingPanel] = useState(false) @@ -89,11 +90,7 @@ const SidePanelEditor = ({ toast.success('Successfully created row') }, }) - const { mutateAsync: updateTableRow } = useTableRowUpdateMutation({ - onSuccess() { - toast.success('Successfully updated row') - }, - }) + const { mutateAsync: updateTableRow } = useTableRowUpdateMutation() const { data: publications } = useDatabasePublicationsQuery({ projectRef: project?.ref, connectionString: project?.connectionString, @@ -158,6 +155,11 @@ const SidePanelEditor = ({ onComplete(saveRowError) if (!saveRowError) { setIsEdited(false) + if (hasTransaction) { + toast.success('Successfully updated row and rolled back') + } else { + toast.success('Successfully updated row') + } snap.closeSidePanel() } } diff --git a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx index 9f0da404d7f..c110bc86e55 100644 --- a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx +++ b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx @@ -8,6 +8,8 @@ import { ProjectLayoutWithAuth } from '../ProjectLayout/ProjectLayout' import { CollapseButton } from '../Tabs/CollapseButton' import { EditorTabs } from '../Tabs/Tabs' import { useEditorType } from './EditorsLayout.hooks' +import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state' +import { AlertTriangle } from 'lucide-react' export interface ExplorerLayoutProps extends ComponentProps { children: ReactNode @@ -21,6 +23,7 @@ export const EditorBaseLayout = ({ children, title, product, ...props }: Explore const pathname = usePathname() const editor = useEditorType() const tabs = useTabsStateSnapshot() + const { hasTransaction } = useRoleImpersonationStateSnapshot() const hasNoOpenTabs = editor === 'table' ? tabs.openTabs.filter((x) => !x.startsWith('sql')).length === 0 : false @@ -29,7 +32,19 @@ export const EditorBaseLayout = ({ children, title, product, ...props }: Explore return ( -
+
+ {hasTransaction && ( +
+

+ You are in test mode. Any changes to database records will be rolled back. +

+
+ )}
+export type RoleImpersonationState = Pick< + ValtioRoleImpersonationState, + 'role' | 'claims' | 'hasTransaction' +> -export function wrapWithRoleImpersonation(sql: string, state?: RoleImpersonationState) { - const { role, claims } = state ?? { role: undefined, claims: undefined } +export function wrapWithRoleImpersonation( + sql: string, + state?: RoleImpersonationState, + isMutation: boolean = false +) { + const { role, claims, hasTransaction } = state ?? { + role: undefined, + claims: undefined, + hasTransaction: false, + } + + const shouldTransaction = isMutation && hasTransaction if (role === undefined) { - return sql + return shouldTransaction ? wrapWithTransaction(sql) : sql } const impersonationSql = @@ -133,15 +146,20 @@ export function wrapWithRoleImpersonation(sql: string, state?: RoleImpersonation : '' : getCustomRoleImpersonationSql(role.role) - return /* SQL */ ` + const sqlWithImpersonation = ` ${impersonationSql} - -- If the users sql returns no rows, pg-meta will -- fallback to returning the result of the impersonation sql. select 1 as "${ROLE_IMPERSONATION_NO_RESULTS}"; ${sql} `.trim() + + return shouldTransaction ? wrapWithTransaction(sqlWithImpersonation) : sqlWithImpersonation +} + +function wrapWithTransaction(sql: string) { + return `begin;${sql}rollback;` } function encodeText(data: string) { diff --git a/apps/studio/state/role-impersonation-state.tsx b/apps/studio/state/role-impersonation-state.tsx index 2d969f8ed0b..a52905c3cf7 100644 --- a/apps/studio/state/role-impersonation-state.tsx +++ b/apps/studio/state/role-impersonation-state.tsx @@ -22,6 +22,7 @@ export function createRoleImpersonationState( projectRef, role: undefined as ImpersonationRole | undefined, claims: undefined as ReturnType | undefined, + hasTransaction: false, setRole: async ( role: ImpersonationRole | undefined, @@ -46,6 +47,9 @@ export function createRoleImpersonationState( roleImpersonationState.claims = claims } }, + setHasTransaction: (hasTransaction: boolean) => { + roleImpersonationState.hasTransaction = hasTransaction + }, }) return roleImpersonationState