RolesList.tsx305 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { partition, sortBy } from 'lodash'
3import { Plus, Search, X } from 'lucide-react'
4import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
5import { useEffect, useRef, useState } from 'react'
6import { toast } from 'sonner'
7import { Badge, Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
8import { Input } from 'ui-patterns/DataInputs/Input'
9import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
10
11import { CreateRolePanel } from './CreateRolePanel'
12import { RoleRow } from './RoleRow'
13import { RoleRowSkeleton } from './RoleRowSkeleton'
14import { BRIVEN_ROLES } from './Roles.constants'
15import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
16import { NoSearchResults } from '@/components/ui/NoSearchResults'
17import { Shortcut } from '@/components/ui/Shortcut'
18import { SparkBar } from '@/components/ui/SparkBar'
19import { useDatabaseRoleDeleteMutation } from '@/data/database-roles/database-role-delete-mutation'
20import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query'
21import { useMaxConnectionsQuery } from '@/data/database/max-connections-query'
22import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
23import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
24import { onSearchInputEscape } from '@/lib/keyboard'
25import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
26import { useShortcut } from '@/state/shortcuts/useShortcut'
27
28type BRIVEN_ROLE = (typeof BRIVEN_ROLES)[number]
29
30export const RolesList = () => {
31 const { data: project } = useSelectedProjectQuery()
32
33 const [filterString, setFilterString] = useState('')
34 const [filterType, setFilterType] = useState<'all' | 'active'>('all')
35 const searchInputRef = useRef<HTMLInputElement>(null)
36
37 const { can: canUpdateRoles } = useAsyncCheckPermissions(
38 PermissionAction.TENANT_SQL_ADMIN_WRITE,
39 'roles'
40 )
41
42 useShortcut(
43 SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
44 () => {
45 searchInputRef.current?.focus()
46 searchInputRef.current?.select()
47 },
48 { label: 'Search roles' }
49 )
50
51 useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
52 setFilterString('')
53 setFilterType('all')
54 })
55
56 const { data: maxConnData } = useMaxConnectionsQuery({
57 projectRef: project?.ref,
58 connectionString: project?.connectionString,
59 })
60 const maxConnectionLimit = maxConnData?.maxConnections
61
62 const {
63 data,
64 isPending: isLoading,
65 isSuccess,
66 } = useDatabaseRolesQuery({
67 projectRef: project?.ref,
68 connectionString: project?.connectionString,
69 })
70
71 const {
72 mutate: deleteDatabaseRole,
73 isPending: isDeleting,
74 isSuccess: isSuccessDelete,
75 } = useDatabaseRoleDeleteMutation({
76 onSuccess: () => {
77 toast.success(`Successfully deleted role`)
78 setSelectedRoleIdToDelete(null)
79 },
80 })
81
82 const [isCreatingRole, setIsCreatingRole] = useQueryState(
83 'new',
84 parseAsBoolean.withDefault(false)
85 )
86
87 const [selectedRoleIdToDelete, setSelectedRoleIdToDelete] = useQueryState('delete', parseAsString)
88 const roles = sortBy(data ?? [], (r) => r.name.toLocaleLowerCase())
89 const roleToDelete = roles?.find((role) => role.id.toString() === selectedRoleIdToDelete)
90
91 const filteredRoles = (
92 filterType === 'active' ? roles.filter((role) => role.activeConnections > 0) : roles
93 ).filter((role) => role.name.includes(filterString))
94 const [brivenRoles, otherRoles] = partition(filteredRoles, (role) =>
95 BRIVEN_ROLES.includes(role.name as BRIVEN_ROLE)
96 )
97
98 const totalActiveConnections = roles
99 .map((role) => role.activeConnections)
100 .reduce((a, b) => a + b, 0)
101 // order the roles with active connections by number of connections, most connections first
102 const rolesWithActiveConnections = sortBy(
103 roles.filter((role) => role.activeConnections > 0),
104 (r) => -r.activeConnections
105 )
106
107 const deleteRole = async () => {
108 if (!project) return console.error('Project is required')
109 if (!roleToDelete) return console.error('Failed to delete role: role is missing')
110 deleteDatabaseRole({
111 projectRef: project.ref,
112 connectionString: project.connectionString,
113 id: roleToDelete.id,
114 })
115 }
116
117 useEffect(() => {
118 if (isSuccess && !!selectedRoleIdToDelete && !roleToDelete && !isSuccessDelete) {
119 toast('Role cannot be found')
120 setSelectedRoleIdToDelete(null)
121 }
122 }, [isSuccess, selectedRoleIdToDelete, roleToDelete, isSuccessDelete, setSelectedRoleIdToDelete])
123
124 return (
125 <>
126 <div className="mb-4 flex items-center justify-between gap-2 flex-wrap">
127 <div className="flex items-center space-x-4">
128 <Input
129 ref={searchInputRef}
130 size="tiny"
131 className="w-52"
132 placeholder="Search for a role"
133 icon={<Search />}
134 value={filterString}
135 onChange={(event) => setFilterString(event.target.value)}
136 onKeyDown={onSearchInputEscape(filterString, setFilterString)}
137 actions={
138 filterString && (
139 <Button
140 size="tiny"
141 type="text"
142 onClick={() => setFilterString('')}
143 className="px-1 mr-1"
144 >
145 <X size={12} strokeWidth={2} />
146 </Button>
147 )
148 }
149 />
150 <div className="flex items-center border border-strong rounded-full w-min h-[26px]">
151 <button
152 className={cn(
153 'text-xs w-[80px] h-full text-center rounded-l-full flex items-center justify-center transition',
154 filterType === 'all'
155 ? 'bg-overlay-hover text-foreground'
156 : 'hover:bg-surface-200 text-foreground-light'
157 )}
158 onClick={() => setFilterType('all')}
159 >
160 All roles
161 </button>
162 <div className="h-full w-px border-r border-strong"></div>
163 <button
164 className={cn(
165 'text-xs w-[80px] h-full text-center rounded-r-full flex items-center justify-center transition',
166 filterType === 'active'
167 ? 'bg-overlay-hover text-foreground'
168 : 'hover:bg-surface-200 text-foreground-light'
169 )}
170 onClick={() => setFilterType('active')}
171 >
172 Active roles
173 </button>
174 </div>
175 </div>
176 <div className="flex items-center space-x-6">
177 <Tooltip>
178 <TooltipTrigger>
179 <div className="w-42">
180 <SparkBar
181 type="horizontal"
182 // if the maxConnectionLimit is undefined, set totalActiveConnections so that
183 // the width of the bar is set to 100%
184 max={maxConnectionLimit || totalActiveConnections}
185 value={totalActiveConnections}
186 barClass={
187 maxConnectionLimit === 0 || maxConnectionLimit === undefined
188 ? 'bg-foreground'
189 : totalActiveConnections > 0.9 * maxConnectionLimit
190 ? 'bg-destructive'
191 : totalActiveConnections > 0.75 * maxConnectionLimit
192 ? 'bg-warning'
193 : undefined
194 }
195 labelTop={
196 Number.isInteger(maxConnectionLimit)
197 ? `${totalActiveConnections}/${maxConnectionLimit}`
198 : `${totalActiveConnections}`
199 }
200 labelTopClass="text-xs"
201 labelBottom="Active connections"
202 labelBottomClass="text-xs"
203 />
204 </div>
205 </TooltipTrigger>
206 <TooltipContent align="start" side="bottom" className="space-y-1">
207 <p className="text-foreground-light pr-2">Connections by roles:</p>
208 {rolesWithActiveConnections.map((role) => (
209 <div key={role.id}>
210 {role.name}: {role.activeConnections}
211 </div>
212 ))}
213 </TooltipContent>
214 </Tooltip>
215 {canUpdateRoles ? (
216 <Shortcut
217 id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
218 label="Add new role"
219 onTrigger={() => setIsCreatingRole(true)}
220 side="bottom"
221 >
222 <Button
223 type="primary"
224 icon={<Plus size={12} />}
225 onClick={() => setIsCreatingRole(true)}
226 >
227 Add role
228 </Button>
229 </Shortcut>
230 ) : (
231 <ButtonTooltip
232 type="primary"
233 disabled
234 icon={<Plus size={12} />}
235 tooltip={{
236 content: {
237 side: 'bottom',
238 text: 'You need additional permissions to add a new role',
239 },
240 }}
241 >
242 Add role
243 </ButtonTooltip>
244 )}
245 </div>
246 </div>
247
248 <div className="space-y-4">
249 <div>
250 <div className="bg-surface-100 border border-default px-card py-3 rounded-t flex items-center space-x-4">
251 <p className="text-sm text-foreground-light">Roles managed by Briven</p>
252 <Badge variant="success">Protected</Badge>
253 </div>
254
255 {isLoading
256 ? Array.from({ length: 5 }).map((_, i) => <RoleRowSkeleton key={i} index={i} />)
257 : brivenRoles.map((role) => (
258 <RoleRow
259 disabled
260 key={role.id}
261 role={role}
262 onSelectDelete={setSelectedRoleIdToDelete}
263 />
264 ))}
265 </div>
266
267 <div>
268 <div className="bg-surface-100 border border-default px-card py-3 rounded-t">
269 <p className="text-sm text-foreground-light">Other database roles</p>
270 </div>
271
272 {isLoading
273 ? Array.from({ length: 3 }).map((_, i) => <RoleRowSkeleton key={i} index={i} />)
274 : otherRoles.map((role) => (
275 <RoleRow
276 key={role.id}
277 disabled={!canUpdateRoles}
278 role={role}
279 onSelectDelete={setSelectedRoleIdToDelete}
280 />
281 ))}
282 </div>
283 </div>
284
285 {filterString.length > 0 && filteredRoles.length === 0 && (
286 <NoSearchResults searchString={filterString} onResetFilter={() => setFilterString('')} />
287 )}
288
289 <CreateRolePanel visible={isCreatingRole} onClose={() => setIsCreatingRole(false)} />
290
291 <ConfirmationModal
292 visible={!!roleToDelete}
293 loading={isDeleting}
294 onCancel={() => setSelectedRoleIdToDelete(null)}
295 title={`Confirm to delete role "${roleToDelete?.name}"`}
296 onConfirm={deleteRole}
297 >
298 <p className="text-sm">
299 This will automatically revoke any membership of this role in other roles, and this action
300 cannot be undone.
301 </p>
302 </ConfirmationModal>
303 </>
304 )
305}