column-privileges.tsx374 lines · main
| 1 | import { LOCAL_STORAGE_KEYS, useParams } from 'common' |
| 2 | import { AlertCircle, XIcon } from 'lucide-react' |
| 3 | import Link from 'next/link' |
| 4 | import { useCallback, useEffect, useMemo, useState } from 'react' |
| 5 | import { toast } from 'sonner' |
| 6 | import { Alert, AlertDescription, AlertTitle, Button } from 'ui' |
| 7 | import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' |
| 8 | |
| 9 | import { |
| 10 | useFeaturePreviewModal, |
| 11 | useIsColumnLevelPrivilegesEnabled, |
| 12 | } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' |
| 13 | import { |
| 14 | getDefaultColumnCheckedStates, |
| 15 | getDefaultTableCheckedStates, |
| 16 | useApplyPrivilegeOperations, |
| 17 | usePrivilegesState, |
| 18 | } from '@/components/interfaces/Database/Privileges/Privileges.utils' |
| 19 | import PrivilegesHead from '@/components/interfaces/Database/Privileges/PrivilegesHead' |
| 20 | import PrivilegesTable from '@/components/interfaces/Database/Privileges/PrivilegesTable' |
| 21 | import { ProtectedSchemaWarning } from '@/components/interfaces/Database/ProtectedSchemaWarning' |
| 22 | import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' |
| 23 | import DefaultLayout from '@/components/layouts/DefaultLayout' |
| 24 | import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' |
| 25 | import AlertError from '@/components/ui/AlertError' |
| 26 | import { DocsButton } from '@/components/ui/DocsButton' |
| 27 | import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge' |
| 28 | import { PgRole, useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query' |
| 29 | import { useColumnPrivilegesQuery } from '@/data/privileges/column-privileges-query' |
| 30 | import { useTablePrivilegesQuery } from '@/data/privileges/table-privileges-query' |
| 31 | import { useTablesQuery } from '@/data/tables/tables-query' |
| 32 | import { useLocalStorage } from '@/hooks/misc/useLocalStorage' |
| 33 | import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState' |
| 34 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 35 | import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas' |
| 36 | import { DOCS_URL } from '@/lib/constants' |
| 37 | import type { NextPageWithLayout } from '@/types' |
| 38 | |
| 39 | const EDITABLE_ROLES = ['authenticated', 'anon', 'service_role'] |
| 40 | |
| 41 | const PrivilegesPage: NextPageWithLayout = () => { |
| 42 | const { ref, table: paramTable } = useParams() |
| 43 | const { data: project } = useSelectedProjectQuery() |
| 44 | const { toggleFeaturePreviewModal } = useFeaturePreviewModal() |
| 45 | const isEnabled = useIsColumnLevelPrivilegesEnabled() |
| 46 | |
| 47 | const { selectedSchema, setSelectedSchema } = useQuerySchemaState() |
| 48 | const [selectedTable, setSelectedTable] = useState<string | undefined>(paramTable) |
| 49 | const [selectedRole, setSelectedRole] = useState<string>('authenticated') |
| 50 | |
| 51 | const { |
| 52 | data: tableList, |
| 53 | isPending: isLoadingTables, |
| 54 | isSuccess: isSuccessTables, |
| 55 | } = useTablesQuery({ |
| 56 | projectRef: project?.ref, |
| 57 | connectionString: project?.connectionString, |
| 58 | }) |
| 59 | |
| 60 | useEffect(() => { |
| 61 | if (!isSuccessTables) return |
| 62 | const tables = tableList |
| 63 | .filter((table) => table.schema === selectedSchema) |
| 64 | .map((table) => table.name) |
| 65 | if (tables[0] && selectedTable === undefined) { |
| 66 | setSelectedTable(tables[0]) |
| 67 | } |
| 68 | }, [isSuccessTables, tableList, selectedSchema, selectedTable]) |
| 69 | |
| 70 | const { data: allRoles, isPending: isLoadingRoles } = useDatabaseRolesQuery({ |
| 71 | projectRef: project?.ref, |
| 72 | connectionString: project?.connectionString, |
| 73 | }) |
| 74 | |
| 75 | const tables = tableList |
| 76 | ?.filter((table) => table.schema === selectedSchema) |
| 77 | .map((table) => table.name) |
| 78 | |
| 79 | const { |
| 80 | data: allTablePrivileges, |
| 81 | isPending: isLoadingTablePrivileges, |
| 82 | isError: isErrorTablePrivileges, |
| 83 | error: errorTablePrivileges, |
| 84 | } = useTablePrivilegesQuery({ |
| 85 | projectRef: project?.ref, |
| 86 | connectionString: project?.connectionString, |
| 87 | }) |
| 88 | |
| 89 | const tablePrivilege = useMemo(() => { |
| 90 | const tablePrivilege = allTablePrivileges?.find( |
| 91 | (tablePrivilege) => |
| 92 | tablePrivilege.schema === selectedSchema && tablePrivilege.name === selectedTable |
| 93 | ) |
| 94 | |
| 95 | if (tablePrivilege) { |
| 96 | return { |
| 97 | ...tablePrivilege, |
| 98 | privileges: tablePrivilege.privileges.filter( |
| 99 | (privilege) => privilege.grantee === selectedRole |
| 100 | ), |
| 101 | } |
| 102 | } |
| 103 | }, [allTablePrivileges, selectedRole, selectedSchema, selectedTable]) |
| 104 | |
| 105 | const { |
| 106 | data: allColumnPrivileges, |
| 107 | isPending: isLoadingColumnPrivileges, |
| 108 | isError: isErrorColumnPrivileges, |
| 109 | error: errorColumnPrivileges, |
| 110 | } = useColumnPrivilegesQuery({ |
| 111 | projectRef: project?.ref, |
| 112 | connectionString: project?.connectionString, |
| 113 | }) |
| 114 | |
| 115 | const columnPrivileges = useMemo( |
| 116 | () => |
| 117 | allColumnPrivileges |
| 118 | ?.filter( |
| 119 | (privilege) => |
| 120 | privilege.relation_schema === selectedSchema && |
| 121 | privilege.relation_name === selectedTable |
| 122 | ) |
| 123 | .map((privilege) => ({ |
| 124 | ...privilege, |
| 125 | privileges: privilege.privileges.filter( |
| 126 | (privilege) => privilege.grantee === selectedRole |
| 127 | ), |
| 128 | })) ?? [], |
| 129 | [allColumnPrivileges, selectedRole, selectedSchema, selectedTable] |
| 130 | ) |
| 131 | |
| 132 | const rolesList = allRoles?.filter((role: PgRole) => EDITABLE_ROLES.includes(role.name)) ?? [] |
| 133 | const roles = rolesList.map((role: PgRole) => role.name) |
| 134 | |
| 135 | const table = tableList?.find( |
| 136 | (table) => table.schema === selectedSchema && table.name === selectedTable |
| 137 | ) |
| 138 | const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema }) |
| 139 | |
| 140 | const { |
| 141 | tableCheckedStates, |
| 142 | columnCheckedStates, |
| 143 | toggleTablePrivilege, |
| 144 | toggleColumnPrivilege, |
| 145 | operations, |
| 146 | resetOperations, |
| 147 | } = usePrivilegesState( |
| 148 | useMemo( |
| 149 | () => ({ |
| 150 | tableId: table?.id ?? -1, |
| 151 | role: selectedRole, |
| 152 | defaultTableCheckedStates: tablePrivilege |
| 153 | ? getDefaultTableCheckedStates(tablePrivilege) |
| 154 | : {}, |
| 155 | defaultColumnCheckedStates: getDefaultColumnCheckedStates(columnPrivileges), |
| 156 | }), |
| 157 | [columnPrivileges, selectedRole, table?.id, tablePrivilege] |
| 158 | ) |
| 159 | ) |
| 160 | |
| 161 | const hasChanges = operations.length > 0 |
| 162 | |
| 163 | const handleChangeSchema = (schema: string) => { |
| 164 | if (hasChanges) { |
| 165 | if (window.confirm('You will lose your changes. Are you sure?')) { |
| 166 | resetOperations() |
| 167 | } else { |
| 168 | return |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | const newTable = tableList?.find((table) => table.schema === schema)?.name |
| 173 | setSelectedSchema(schema) |
| 174 | setSelectedTable(newTable) |
| 175 | } |
| 176 | |
| 177 | const handleChangeTable = (table: string) => { |
| 178 | if (hasChanges) { |
| 179 | if (window.confirm('You will lose your changes. Are you sure?')) { |
| 180 | resetOperations() |
| 181 | } else { |
| 182 | return |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | setSelectedTable(table) |
| 187 | } |
| 188 | |
| 189 | const handleChangeRole = (role: string) => { |
| 190 | setSelectedRole(role) |
| 191 | } |
| 192 | |
| 193 | const { apply: applyColumnPrivileges, isLoading: isApplyingChanges } = |
| 194 | useApplyPrivilegeOperations( |
| 195 | useCallback(() => { |
| 196 | toast.success( |
| 197 | `Successfully updated privileges on ${selectedSchema}.${selectedTable} for ${selectedRole}`, |
| 198 | { duration: 6000 } |
| 199 | ) |
| 200 | resetOperations() |
| 201 | }, [resetOperations, selectedRole, selectedSchema, selectedTable]) |
| 202 | ) |
| 203 | |
| 204 | function applyChanges() { |
| 205 | applyColumnPrivileges(operations) |
| 206 | } |
| 207 | |
| 208 | const [diffWarningDismissed, setDiffWarningDismissed] = useLocalStorage( |
| 209 | LOCAL_STORAGE_KEYS.CLS_DIFF_WARNING, |
| 210 | false |
| 211 | ) |
| 212 | const [selectStarWarningDismissed, setSelectStarWarningDismissed] = useLocalStorage( |
| 213 | LOCAL_STORAGE_KEYS.CLS_SELECT_STAR_WARNING, |
| 214 | false |
| 215 | ) |
| 216 | |
| 217 | const isLoading = |
| 218 | isLoadingTablePrivileges || isLoadingColumnPrivileges || isLoadingTables || isLoadingRoles |
| 219 | |
| 220 | const isError = isErrorTablePrivileges || isErrorColumnPrivileges |
| 221 | |
| 222 | return ( |
| 223 | <ScaffoldContainer className="h-full"> |
| 224 | <ScaffoldSection className="h-full"> |
| 225 | <div className="col-span-12 flex flex-col pb-4 gap-y-4"> |
| 226 | <div className="flex items-center justify-between"> |
| 227 | <div> |
| 228 | <div className="flex items-center gap-x-4 mb-1"> |
| 229 | <h3 className="text-xl">Column-level privileges</h3> |
| 230 | {isEnabled && ( |
| 231 | <FeaturePreviewBadge featureKey={LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS} /> |
| 232 | )} |
| 233 | </div> |
| 234 | <p className="text-sm text-lighter"> |
| 235 | Grant or revoke privileges on a column based on user role. |
| 236 | </p> |
| 237 | </div> |
| 238 | <DocsButton href={`${DOCS_URL}/guides/auth/column-level-security`} /> |
| 239 | </div> |
| 240 | |
| 241 | {isEnabled ? ( |
| 242 | <> |
| 243 | {!diffWarningDismissed && ( |
| 244 | <Alert variant="warning"> |
| 245 | <AlertCircle strokeWidth={2} /> |
| 246 | <AlertTitle> |
| 247 | Changes to column privileges will not be reflected in migrations when running{' '} |
| 248 | <code className="text-code-inline">briven db diff</code>. |
| 249 | </AlertTitle> |
| 250 | <AlertDescription> |
| 251 | Column privileges are not supported in the current version of the Briven CLI. |
| 252 | <br /> |
| 253 | You will need to manually apply these changes to your database. |
| 254 | </AlertDescription> |
| 255 | <Button |
| 256 | type="outline" |
| 257 | aria-label="Dismiss" |
| 258 | className="absolute top-2 right-2 p-1 pl-1!" |
| 259 | onClick={() => { |
| 260 | setDiffWarningDismissed(true) |
| 261 | }} |
| 262 | > |
| 263 | <XIcon width={14} height={14} /> |
| 264 | </Button> |
| 265 | </Alert> |
| 266 | )} |
| 267 | |
| 268 | {!selectStarWarningDismissed && ( |
| 269 | <Alert variant="warning"> |
| 270 | <AlertCircle strokeWidth={2} /> |
| 271 | <AlertTitle>Changing column privileges can break existing queries.</AlertTitle> |
| 272 | <AlertDescription> |
| 273 | If you remove a column privilege for a role, that role will lose all access to |
| 274 | that column. |
| 275 | <br /> |
| 276 | All operations selecting <code className="text-code-inline"> |
| 277 | * |
| 278 | </code> (including <code className="text-code-inline">returning *</code> for{' '} |
| 279 | <code className="text-code-inline">insert</code>,{' '} |
| 280 | <code className="text-code-inline">update</code>, and{' '} |
| 281 | <code className="text-code-inline">delete</code>) will fail. |
| 282 | </AlertDescription> |
| 283 | <Button |
| 284 | type="outline" |
| 285 | aria-label="Dismiss" |
| 286 | className="absolute top-2 right-2 p-1 pl-1!" |
| 287 | onClick={() => { |
| 288 | setSelectStarWarningDismissed(true) |
| 289 | }} |
| 290 | > |
| 291 | <XIcon width={14} height={14} /> |
| 292 | </Button> |
| 293 | </Alert> |
| 294 | )} |
| 295 | |
| 296 | <PrivilegesHead |
| 297 | disabled={isSchemaLocked} |
| 298 | selectedSchema={selectedSchema} |
| 299 | selectedRole={selectedRole} |
| 300 | selectedTable={table} |
| 301 | tables={tables ?? []} |
| 302 | roles={roles} |
| 303 | onChangeSchema={handleChangeSchema} |
| 304 | onChangeRole={handleChangeRole} |
| 305 | onChangeTable={handleChangeTable} |
| 306 | applyChanges={applyChanges} |
| 307 | resetChanges={resetOperations} |
| 308 | hasChanges={hasChanges} |
| 309 | isApplyingChanges={isApplyingChanges} |
| 310 | /> |
| 311 | {isSchemaLocked && ( |
| 312 | <ProtectedSchemaWarning schema={selectedSchema} entity="column privileges" /> |
| 313 | )} |
| 314 | {isLoading ? ( |
| 315 | <GenericSkeletonLoader /> |
| 316 | ) : isError ? ( |
| 317 | <AlertError error={errorTablePrivileges || errorColumnPrivileges} /> |
| 318 | ) : table && tablePrivilege ? ( |
| 319 | <div> |
| 320 | <PrivilegesTable |
| 321 | disabled={isSchemaLocked} |
| 322 | columnPrivileges={columnPrivileges} |
| 323 | tableCheckedStates={tableCheckedStates} |
| 324 | columnCheckedStates={columnCheckedStates} |
| 325 | toggleTablePrivilege={toggleTablePrivilege} |
| 326 | toggleColumnPrivilege={toggleColumnPrivilege} |
| 327 | isApplyingChanges={isApplyingChanges} |
| 328 | /> |
| 329 | </div> |
| 330 | ) : (tables ?? []).length === 0 ? ( |
| 331 | <div className="grow flex flex-col items-center justify-center w-[600px] mx-auto"> |
| 332 | <p className="text-center">There are no tables in the {selectedSchema} schema</p> |
| 333 | <p className="text-sm text-foreground-light text-center"> |
| 334 | Once a table is available in the schema, you may manage it's column-level |
| 335 | privileges here |
| 336 | </p> |
| 337 | {selectedSchema === 'public' && ( |
| 338 | <Button asChild className="mt-4"> |
| 339 | <Link href={`/project/${ref}/editor`}>Create a new table</Link> |
| 340 | </Button> |
| 341 | )} |
| 342 | </div> |
| 343 | ) : ( |
| 344 | <div className="flex flex-col items-center justify-center h-64 "> |
| 345 | <p className="text-foreground-light">Select a table to edit privileges</p> |
| 346 | </div> |
| 347 | )} |
| 348 | </> |
| 349 | ) : ( |
| 350 | <Alert> |
| 351 | <AlertTitle>Column-level privileges is a dashboard feature preview</AlertTitle> |
| 352 | <AlertDescription> |
| 353 | You may access this feature by enabling it under dashboard feature previews. |
| 354 | </AlertDescription> |
| 355 | <div className="mt-4"> |
| 356 | <Button type="default" onClick={() => toggleFeaturePreviewModal(true)}> |
| 357 | View feature previews |
| 358 | </Button> |
| 359 | </div> |
| 360 | </Alert> |
| 361 | )} |
| 362 | </div> |
| 363 | </ScaffoldSection> |
| 364 | </ScaffoldContainer> |
| 365 | ) |
| 366 | } |
| 367 | |
| 368 | PrivilegesPage.getLayout = (page) => ( |
| 369 | <DefaultLayout> |
| 370 | <DatabaseLayout title="Column Privileges">{page}</DatabaseLayout> |
| 371 | </DefaultLayout> |
| 372 | ) |
| 373 | |
| 374 | export default PrivilegesPage |