column-privileges.tsx374 lines · main
1import { LOCAL_STORAGE_KEYS, useParams } from 'common'
2import { AlertCircle, XIcon } from 'lucide-react'
3import Link from 'next/link'
4import { useCallback, useEffect, useMemo, useState } from 'react'
5import { toast } from 'sonner'
6import { Alert, AlertDescription, AlertTitle, Button } from 'ui'
7import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
8
9import {
10 useFeaturePreviewModal,
11 useIsColumnLevelPrivilegesEnabled,
12} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
13import {
14 getDefaultColumnCheckedStates,
15 getDefaultTableCheckedStates,
16 useApplyPrivilegeOperations,
17 usePrivilegesState,
18} from '@/components/interfaces/Database/Privileges/Privileges.utils'
19import PrivilegesHead from '@/components/interfaces/Database/Privileges/PrivilegesHead'
20import PrivilegesTable from '@/components/interfaces/Database/Privileges/PrivilegesTable'
21import { ProtectedSchemaWarning } from '@/components/interfaces/Database/ProtectedSchemaWarning'
22import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout'
23import DefaultLayout from '@/components/layouts/DefaultLayout'
24import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold'
25import AlertError from '@/components/ui/AlertError'
26import { DocsButton } from '@/components/ui/DocsButton'
27import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge'
28import { PgRole, useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query'
29import { useColumnPrivilegesQuery } from '@/data/privileges/column-privileges-query'
30import { useTablePrivilegesQuery } from '@/data/privileges/table-privileges-query'
31import { useTablesQuery } from '@/data/tables/tables-query'
32import { useLocalStorage } from '@/hooks/misc/useLocalStorage'
33import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
34import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
35import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
36import { DOCS_URL } from '@/lib/constants'
37import type { NextPageWithLayout } from '@/types'
38
39const EDITABLE_ROLES = ['authenticated', 'anon', 'service_role']
40
41const 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
368PrivilegesPage.getLayout = (page) => (
369 <DefaultLayout>
370 <DatabaseLayout title="Column Privileges">{page}</DatabaseLayout>
371 </DefaultLayout>
372)
373
374export default PrivilegesPage