RoleRow.tsx235 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { ChevronUp, MoreVertical, Trash } from 'lucide-react'
3import { useEffect, useState } from 'react'
4import { useForm, type SubmitHandler } from 'react-hook-form'
5import { toast } from 'sonner'
6import {
7 Button,
8 cn,
9 Collapsible,
10 CollapsibleContent,
11 CollapsibleTrigger,
12 DropdownMenu,
13 DropdownMenuContent,
14 DropdownMenuItem,
15 DropdownMenuTrigger,
16 Form,
17 FormControl,
18 FormField,
19 Switch,
20} from 'ui'
21import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
22import * as z from 'zod'
23
24import { ROLE_PERMISSIONS } from './Roles.constants'
25import { useDatabaseRoleUpdateMutation } from '@/data/database-roles/database-role-update-mutation'
26import type { PgRole } from '@/data/database-roles/database-roles-query'
27import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
28
29interface RoleRowProps {
30 role: PgRole
31 disabled?: boolean
32 onSelectDelete: (role: string) => void
33}
34
35const permissionSchema = z.boolean().optional()
36const formSchema = z.object(
37 Object.keys(ROLE_PERMISSIONS).reduce(
38 (acc, key) => ({
39 ...acc,
40 [key]: permissionSchema,
41 }),
42 {} as Record<keyof typeof ROLE_PERMISSIONS, z.ZodBoolean>
43 )
44)
45
46export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps) => {
47 const { data: project } = useSelectedProjectQuery()
48 const [isExpanded, setIsExpanded] = useState(false)
49 const { mutate: updateDatabaseRole, isPending: isUpdating } = useDatabaseRoleUpdateMutation()
50 const form = useForm<z.infer<typeof formSchema>>({
51 resolver: zodResolver(formSchema as any),
52 defaultValues: role,
53 })
54
55 const { reset, formState } = form
56 const { isDirty } = formState
57
58 useEffect(() => {
59 reset(role)
60 }, [role, reset])
61
62 const onSaveChanges: SubmitHandler<z.infer<typeof formSchema>> = async (values) => {
63 if (!project) return console.error('Project is required')
64
65 const changed = Object.fromEntries(
66 Object.entries(values).filter(([k, v]) => {
67 const key = k as keyof PgRole
68 return v !== role[key]
69 })
70 )
71
72 if (Object.keys(changed).length === 0) {
73 // No actual changes to persist; avoid sending an empty update payload
74 reset(role)
75 return
76 }
77
78 updateDatabaseRole(
79 {
80 projectRef: project.ref,
81 connectionString: project.connectionString,
82 id: role.id,
83 payload: changed,
84 },
85 {
86 onSuccess: () => {
87 toast.success(`Successfully updated role "${role.name}"`)
88 reset(values)
89 },
90 }
91 )
92 }
93
94 const formId = `role-update-form-${role.id}`
95
96 return (
97 <Collapsible
98 open={isExpanded}
99 className={cn(
100 'bg-surface-100',
101 'hover:bg-overlay-hover',
102 'data-open:bg-selection',
103 'border-default hover:border-strong',
104 'data-open:border-strong',
105 'data-open:pb-px col-span-12 mx-auto',
106 '-space-y-px overflow-hidden',
107 'border border-t-0 first:border-t first:mt-0! hover:border-t hover:-mt-px shadow-sm transition hover:z-50',
108 'first:rounded-tl first:rounded-tr',
109 'last:rounded-bl last:rounded-br'
110 )}
111 >
112 <div className={cn('flex items-center relative', !disabled && 'pr-(--card-padding-x)')}>
113 <CollapsibleTrigger asChild>
114 <button
115 id={`collapsible-trigger-${role.id}`}
116 type="button"
117 className="group flex w-full items-center justify-between rounded-sm py-3 px-card text-foreground"
118 onClick={(event) => {
119 event.preventDefault()
120 event.stopPropagation()
121 setIsExpanded(!isExpanded)
122 }}
123 >
124 <div className="flex items-start space-x-3">
125 <ChevronUp
126 className="text-border-stronger transition data-open-parent:rotate-0 data-closed-parent:rotate-180"
127 strokeWidth={2}
128 width={14}
129 />
130 <div className="space-x-2 flex items-center">
131 <p className="text-left text-sm">{role.name}</p>
132 <p className="text-left text-sm text-foreground-light">(ID: {role.id})</p>
133 </div>
134 </div>
135 <div className="flex items-center space-x-4">
136 {role.activeConnections > 0 && (
137 <div className="relative h-2 w-2">
138 <span className="flex h-2 w-2">
139 <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand opacity-75"></span>
140 <span className="relative inline-flex h-2 w-2 rounded-full bg-brand opacity-75"></span>
141 </span>
142 </div>
143 )}
144 <p
145 className={cn(
146 `text-sm`,
147 role.activeConnections > 0 ? 'text-foreground' : 'text-foreground-light'
148 )}
149 >
150 {role.activeConnections} connections
151 </p>
152 </div>
153 </button>
154 </CollapsibleTrigger>
155 {!disabled && (
156 <DropdownMenu>
157 <DropdownMenuTrigger asChild>
158 <Button
159 type="default"
160 className="px-1"
161 icon={<MoreVertical />}
162 aria-label={`${role.name} actions`}
163 />
164 </DropdownMenuTrigger>
165 <DropdownMenuContent side="bottom" align="end" className="w-[120px]">
166 <DropdownMenuItem
167 className="space-x-2"
168 onClick={(event) => {
169 event.stopPropagation()
170 onSelectDelete(role.id.toString())
171 }}
172 >
173 <Trash className="text-red-800" size="14" strokeWidth={2} />
174 <p>Delete</p>
175 </DropdownMenuItem>
176 </DropdownMenuContent>
177 </DropdownMenu>
178 )}
179 </div>
180 <CollapsibleContent>
181 <Form {...form}>
182 <form
183 id={formId}
184 onSubmit={form.handleSubmit(onSaveChanges)}
185 className="group border-t border-default bg-surface-100 py-6 px-5 md:px-20 text-foreground"
186 >
187 <div className="py-4 space-y-[9px]">
188 {(Object.keys(ROLE_PERMISSIONS) as (keyof typeof ROLE_PERMISSIONS)[]).map(
189 (permission) => (
190 <FormField
191 key={permission}
192 control={form.control}
193 name={permission}
194 disabled={disabled || ROLE_PERMISSIONS[permission].disabled}
195 render={({ field }) => (
196 <FormItemLayout
197 id={`${role.id}-${permission}`}
198 layout="flex"
199 label={ROLE_PERMISSIONS[permission].description}
200 >
201 <FormControl>
202 <Switch
203 id={`${role.id}-${permission}`}
204 checked={field.value}
205 onCheckedChange={field.onChange}
206 disabled={disabled || ROLE_PERMISSIONS[permission].disabled}
207 />
208 </FormControl>
209 </FormItemLayout>
210 )}
211 />
212 )
213 )}
214 </div>
215 {!disabled && (
216 <div className="py-4 flex items-center space-x-2 justify-end">
217 <Button type="default" disabled={!isDirty || isUpdating} onClick={() => reset()}>
218 Cancel
219 </Button>
220 <Button
221 type="primary"
222 htmlType="submit"
223 disabled={!isDirty || isUpdating}
224 loading={isUpdating}
225 >
226 Save
227 </Button>
228 </div>
229 )}
230 </form>
231 </Form>
232 </CollapsibleContent>
233 </Collapsible>
234 )
235}