TransferProjectButton.tsx302 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { useQueryClient } from '@tanstack/react-query'
3import { useFlag } from 'common'
4import { Loader, Shield, Wrench } from 'lucide-react'
5import { useEffect, useState } from 'react'
6import { toast } from 'sonner'
7import {
8 Button,
9 InfoIcon,
10 Loading,
11 Modal,
12 Select,
13 SelectContent,
14 SelectItem,
15 SelectTrigger,
16 SelectValue,
17 WarningIcon,
18} from 'ui'
19import { Admonition } from 'ui-patterns'
20import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
21
22import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
23import { DocsButton } from '@/components/ui/DocsButton'
24import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
25import { projectKeys } from '@/data/projects/keys'
26import { useProjectTransferMutation } from '@/data/projects/project-transfer-mutation'
27import { useProjectTransferPreviewQuery } from '@/data/projects/project-transfer-preview-query'
28import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
29import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
30import { DOCS_URL } from '@/lib/constants'
31
32export const TransferProjectButton = () => {
33 const { data: project } = useSelectedProjectQuery()
34 const projectRef = project?.ref
35 const projectOrgId = project?.organization_id
36 const [isOpen, setIsOpen] = useState(false)
37
38 const { data: allOrganizations } = useOrganizationsQuery({ enabled: isOpen })
39 const disableProjectTransfer = useFlag('disableProjectTransfer')
40
41 const organizations = (allOrganizations || []).filter((it) => it.id !== projectOrgId)
42
43 const [selectedOrg, setSelectedOrg] = useState<string>()
44
45 const {
46 mutate: transferProject,
47 error: transferError,
48 isPending: isTransferring,
49 } = useProjectTransferMutation({
50 onSuccess: () => {
51 toast.success(`Successfully transferred project ${project?.name}.`)
52 setIsOpen(false)
53 },
54 })
55
56 const {
57 data: transferPreviewData,
58 error: transferPreviewError,
59 isPending: transferPreviewIsLoading,
60 } = useProjectTransferPreviewQuery(
61 { projectRef, targetOrganizationSlug: selectedOrg },
62 { enabled: !isTransferring && isOpen }
63 )
64 const queryClient = useQueryClient()
65
66 useEffect(() => {
67 if (isOpen) {
68 // reset state
69 setSelectedOrg(undefined)
70 } else {
71 // Invalidate cache
72 queryClient.removeQueries({
73 queryKey: projectKeys.projectTransferPreview(projectRef, selectedOrg),
74 })
75 }
76 // eslint-disable-next-line react-hooks/exhaustive-deps
77 }, [isOpen])
78
79 const { can: canTransferProject } = useAsyncCheckPermissions(
80 PermissionAction.UPDATE,
81 'organizations'
82 )
83
84 const toggle = () => {
85 setIsOpen(!isOpen)
86 }
87
88 async function handleTransferProject() {
89 if (project === undefined) return
90 if (selectedOrg === undefined) return
91 transferProject({ projectRef, targetOrganizationSlug: selectedOrg })
92 }
93
94 return (
95 <>
96 <ButtonTooltip
97 type="default"
98 onClick={toggle}
99 disabled={!canTransferProject || disableProjectTransfer}
100 tooltip={{
101 content: {
102 side: 'bottom',
103 text: !canTransferProject
104 ? 'You need additional permissions to transfer this project'
105 : disableProjectTransfer
106 ? 'Project transfers are temporarily disabled, please try again later.'
107 : undefined,
108 },
109 }}
110 >
111 Transfer project
112 </ButtonTooltip>
113
114 <Modal
115 onCancel={() => toggle()}
116 visible={isOpen}
117 loading={isTransferring}
118 size={'xlarge'}
119 header={`Transfer project ${project?.name}`}
120 customFooter={
121 <div className="flex items-center space-x-2 justify-end">
122 <Button type="default" onClick={() => setIsOpen(false)}>
123 Cancel
124 </Button>
125 <Button
126 onClick={() => handleTransferProject()}
127 disabled={
128 !transferPreviewData || !transferPreviewData.valid || isTransferring || !selectedOrg
129 }
130 >
131 Transfer Project
132 </Button>
133 </div>
134 }
135 >
136 <Modal.Content className="text-foreground-light">
137 <p className="text-sm">
138 To transfer projects, the owner must be a member of both the source and target
139 organizations. Consider the following before transferring your project:
140 </p>
141
142 <ul className="mt-4 space-y-5 text-sm">
143 <li className="flex gap-4">
144 <span className="shrink-0 mt-1">
145 <Loader />
146 </span>
147 <div>
148 <p className="font-bold">Possible downtime</p>
149 <p>
150 There might be a short downtime when transferring projects from a paid to a free
151 organization.
152 </p>
153 </div>
154 </li>
155
156 <li className="flex gap-4">
157 <span className="shrink-0 mt-1">
158 <Shield />
159 </span>
160 <div>
161 <p className="font-bold">Permissions</p>
162 <p>
163 Depending on your role in the target organization, your level of permissions may
164 change after transfer.
165 </p>
166 </div>
167 </li>
168
169 <li className="flex gap-4">
170 <span className="shrink-0 mt-1">
171 <Wrench size={24} className="shrink-0" />
172 </span>
173 <div>
174 <p className="font-bold">Features</p>
175 <p>
176 Moving your project to an organization with a smaller subscription plan may result
177 in the loss of certain features (i.e. image transformations).
178 </p>
179 </div>
180 </li>
181 </ul>
182
183 <DocsButton
184 abbrev={false}
185 className="mt-6"
186 href={`${DOCS_URL}/guides/platform/project-transfer`}
187 />
188 </Modal.Content>
189
190 <Modal.Separator />
191
192 <Modal.Content>
193 {organizations && (
194 <div className="space-y-2">
195 {organizations.length === 0 ? (
196 <div className="flex items-center gap-3 bg-surface-200 p-3 text-sm rounded-md border">
197 <InfoIcon /> You do not have any organizations you can transfer your project to.
198 </div>
199 ) : (
200 <FormItemLayout
201 id="organization"
202 isReactForm={false}
203 layout="vertical"
204 label="Select Target Organization"
205 className="gap-[2px]"
206 size="tiny"
207 >
208 <Select onValueChange={(slug) => setSelectedOrg(slug)} value={selectedOrg}>
209 <SelectTrigger id="organization">
210 <SelectValue placeholder="Select Organization" />
211 </SelectTrigger>
212 <SelectContent>
213 {organizations.map((x) => (
214 <SelectItem key={x.id} value={x.slug}>
215 {x.name}
216 </SelectItem>
217 ))}
218 </SelectContent>
219 </Select>
220 </FormItemLayout>
221 )}
222 </div>
223 )}
224 </Modal.Content>
225
226 {selectedOrg !== undefined && (
227 <Loading active={selectedOrg !== undefined && transferPreviewIsLoading}>
228 <Modal.Content>
229 <div className="space-y-2">
230 {transferPreviewData && transferPreviewData.errors.length > 0 && (
231 <Admonition type="danger" title="Project cannot be transferred">
232 <div className="space-y-1">
233 {transferPreviewData.errors.map((error) => (
234 <p key={error.key}>{error.message}</p>
235 ))}
236 </div>
237 {transferPreviewData.members_exceeding_free_project_limit.length > 0 && (
238 <div className="space-y-2">
239 <p className="text-sm text-foreground-light">
240 These members have reached their maximum limits for the number of active
241 Free plan projects within organizations where they are an administrator or
242 owner:
243 </p>
244 <ul className="pl-5 text-sm list-disc text-foreground-light">
245 {(transferPreviewData.members_exceeding_free_project_limit || []).map(
246 (member, idx: number) => (
247 <li key={`member-${idx}`}>
248 {member.name} (Limit: {member.limit} free projects)
249 </li>
250 )
251 )}
252 </ul>
253 <p className="text-sm text-foreground-light">
254 These members will need to either delete, pause, or upgrade one or more of
255 their projects before you can transfer this project.
256 </p>
257 </div>
258 )}
259 </Admonition>
260 )}
261 {transferPreviewData &&
262 (transferPreviewData.warnings.length > 0 ||
263 transferPreviewData.info.length > 0) && (
264 <Admonition type="caution" showIcon={false} className="mt-3">
265 <div className="flex flex-col gap-y-2">
266 {transferPreviewData.warnings.map((warning) => (
267 <div key={warning.key} className="flex items-center gap-2">
268 <WarningIcon className="shrink-0" />
269 <p className="mb-0.5">{warning.message}</p>
270 </div>
271 ))}
272 {transferPreviewData.info.map((info) => (
273 <div key={info.key} className="flex items-start gap-2">
274 <InfoIcon className="shrink-0 mt-0.5" />
275 <p className="mb-0.5">{info.message}</p>
276 </div>
277 ))}
278 </div>
279 </Admonition>
280 )}
281 {transferPreviewError && !transferError && (
282 <Admonition
283 type="danger"
284 title="Project cannot be transferred"
285 description={transferPreviewError.message}
286 />
287 )}
288 {transferError && (
289 <Admonition
290 type="danger"
291 title="Project cannot be transferred"
292 description={transferError.message}
293 />
294 )}
295 </div>
296 </Modal.Content>
297 </Loading>
298 )}
299 </Modal>
300 </>
301 )
302}