CreateAppSheet.tsx424 lines · main
1import type { components } from 'api-types'
2import { Copy, Download, Key, Plus, RotateCcw, X } from 'lucide-react'
3import { useEffect, useRef, useState } from 'react'
4import { toast } from 'sonner'
5import {
6 Button,
7 Checkbox,
8 Command,
9 CommandEmpty,
10 CommandGroup,
11 CommandInput,
12 CommandItem,
13 CommandList,
14 Input,
15 Popover,
16 PopoverContent,
17 PopoverTrigger,
18 ScrollArea,
19 Sheet,
20 SheetContent,
21 SheetDescription,
22 SheetFooter,
23 SheetHeader,
24 SheetTitle,
25 WarningIcon,
26} from 'ui'
27import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
28import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
29
30import { usePrivateApps } from '../../PrivateAppsContext'
31import { PERMISSIONS } from '../Apps.constants'
32import { CreateAppSheetStep } from './CreateAppSheetStep'
33import { usePlatformAppCreateMutation } from '@/data/platform-apps/platform-app-create-mutation'
34import { usePlatformAppDeleteMutation } from '@/data/platform-apps/platform-app-delete-mutation'
35import { usePlatformAppInstallationCreateMutation } from '@/data/platform-apps/platform-app-installation-create-mutation'
36import { usePlatformAppSigningKeyCreateMutation } from '@/data/platform-apps/platform-app-signing-key-create-mutation'
37import { useCopyToClipboard } from '@/hooks/ui/useCopyToClipboard'
38
39type CreatePlatformAppResponse = components['schemas']['CreatePlatformAppResponse']
40type CreatePlatformAppSigningKeyResponse =
41 components['schemas']['CreatePlatformAppSigningKeyResponse']
42
43interface CreateAppSheetProps {
44 visible: boolean
45 onClose: () => void
46 onCreated: (app: CreatePlatformAppResponse) => void
47}
48
49export function CreateAppSheet({ visible, onClose, onCreated }: CreateAppSheetProps) {
50 const { slug, installations, addInstallation } = usePrivateApps()
51 const { copy } = useCopyToClipboard()
52
53 const [name, setName] = useState('')
54 const [selectedPermissions, setSelectedPermissions] = useState<string[]>([])
55 const [permissionSearchOpen, setPermissionSearchOpen] = useState(false)
56 const [createdApp, setCreatedApp] = useState<CreatePlatformAppResponse | null>(null)
57 const [generatedKey, setGeneratedKey] = useState<CreatePlatformAppSigningKeyResponse | null>(null)
58 const [keyCopied, setKeyCopied] = useState(false)
59 const [showCloseConfirm, setShowCloseConfirm] = useState(false)
60 const step3Ref = useRef<HTMLDivElement>(null)
61
62 const { mutate: deleteApp, isPending: isDeleting } = usePlatformAppDeleteMutation({
63 onSuccess: () => handleClose(),
64 })
65
66 const { mutate: installApp } = usePlatformAppInstallationCreateMutation({
67 onSuccess: (data) => {
68 if (data) addInstallation(data, 'all')
69 },
70 })
71
72 const { mutate: createSigningKey, isPending: isCreatingKey } =
73 usePlatformAppSigningKeyCreateMutation({
74 onSuccess: (keyData, vars) => {
75 if (keyData) setGeneratedKey(keyData)
76 if (installations.length === 0 && slug) {
77 installApp({ slug, app_id: vars.appId })
78 }
79 },
80 })
81
82 const { mutate: createApp, isPending: isCreatingApp } = usePlatformAppCreateMutation({
83 onSuccess: (data) => {
84 toast.success(`App "${data.name}" created`)
85 setCreatedApp(data)
86 createSigningKey({ slug: slug!, appId: data.id })
87 },
88 })
89
90 function reset() {
91 setName('')
92 setSelectedPermissions([])
93 setPermissionSearchOpen(false)
94 setCreatedApp(null)
95 setGeneratedKey(null)
96 setKeyCopied(false)
97 }
98
99 function handleClose() {
100 reset()
101 onClose()
102 }
103
104 function handleRequestClose() {
105 setShowCloseConfirm(true)
106 }
107
108 function handleCreate() {
109 if (!slug) return
110 createApp({
111 slug,
112 name: name.trim(),
113 permissions:
114 selectedPermissions as components['schemas']['CreatePlatformAppBody']['permissions'],
115 })
116 }
117
118 function toggle(id: string) {
119 setSelectedPermissions((prev) =>
120 prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id]
121 )
122 }
123
124 const canCreate = name.trim().length > 0 && selectedPermissions.length > 0
125 const isLoading = isCreatingApp || isCreatingKey || isDeleting
126 const keyRevealed = generatedKey !== null
127
128 useEffect(() => {
129 if (generatedKey) {
130 step3Ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
131 }
132 }, [generatedKey])
133
134 return (
135 <>
136 <Sheet
137 open={visible}
138 onOpenChange={(open) => {
139 if (!open) handleRequestClose()
140 }}
141 >
142 <SheetContent
143 showClose={false}
144 size="default"
145 className="min-w-[600px]! flex flex-col h-full gap-0"
146 >
147 <SheetHeader>
148 <SheetTitle>Create private app</SheetTitle>
149 <SheetDescription className="sr-only">
150 Create a private app to generate scoped access tokens for your organization.
151 </SheetDescription>
152 </SheetHeader>
153
154 <ScrollArea className="flex-1 max-h-[calc(100vh-116px)]">
155 <div className="px-5 sm:px-6 py-6">
156 <CreateAppSheetStep
157 number={1}
158 title="App details"
159 description="The first app you create will be automatically installed."
160 disabled={keyRevealed}
161 >
162 <FormLayout label="Name" id="app-name">
163 <Input
164 id="app-name"
165 placeholder="My integration"
166 value={name}
167 onChange={(e) => setName(e.target.value)}
168 disabled={isLoading}
169 />
170 </FormLayout>
171 </CreateAppSheetStep>
172
173 <CreateAppSheetStep
174 number={2}
175 title="Permissions"
176 description="Select the permissions this app requires."
177 disabled={keyRevealed}
178 >
179 <div className="space-y-4">
180 <div className="flex justify-between items-center">
181 <span className="text-sm">Configure permissions</span>
182 <div className="flex items-center gap-2">
183 {selectedPermissions.length > 0 && (
184 <Button
185 type="default"
186 size="tiny"
187 className="p-1"
188 icon={<RotateCcw size={16} />}
189 onClick={() => setSelectedPermissions([])}
190 disabled={isLoading}
191 />
192 )}
193 <Popover
194 open={permissionSearchOpen}
195 onOpenChange={setPermissionSearchOpen}
196 modal
197 >
198 <PopoverTrigger asChild>
199 <Button
200 type="default"
201 size="tiny"
202 icon={<Plus size={14} />}
203 disabled={isLoading}
204 >
205 Add permission
206 </Button>
207 </PopoverTrigger>
208 <PopoverContent className="w-[400px] p-0" align="end">
209 <Command>
210 <CommandInput placeholder="Search permissions..." />
211 <CommandList>
212 <CommandEmpty>No permissions found.</CommandEmpty>
213 <CommandGroup className="[&>div]:text-left">
214 <div className="max-h-[210px] overflow-y-auto">
215 {PERMISSIONS.map((perm) => (
216 <CommandItem
217 key={perm.id}
218 value={`${perm.id} ${perm.label}`}
219 onSelect={() => toggle(perm.id)}
220 className="text-foreground"
221 >
222 <div className="flex items-center gap-3 w-full">
223 <Checkbox
224 checked={selectedPermissions.includes(perm.id)}
225 onCheckedChange={() => toggle(perm.id)}
226 onClick={(e) => e.stopPropagation()}
227 />
228 <Key size={12} className="text-foreground-lighter" />
229 <span className="font-medium text-foreground">
230 {perm.label}
231 </span>
232 </div>
233 </CommandItem>
234 ))}
235 </div>
236 </CommandGroup>
237 </CommandList>
238 </Command>
239 </PopoverContent>
240 </Popover>
241 </div>
242 </div>
243
244 {selectedPermissions.length === 0 ? (
245 <div className="text-center py-8 border border-dashed border-border rounded-lg">
246 <p className="text-sm text-foreground-light">
247 No permissions configured yet.
248 </p>
249 </div>
250 ) : (
251 <div className="border border-border rounded-lg">
252 {selectedPermissions.map((id, index) => {
253 const perm = PERMISSIONS.find((p) => p.id === id)
254 return (
255 <div key={id}>
256 <div className="flex items-center gap-3 p-3">
257 <div className="flex-1">
258 <p className="text-sm font-medium">{perm?.label}</p>
259 {perm?.description && (
260 <p className="text-xs text-foreground-lighter">
261 {perm.description}
262 </p>
263 )}
264 </div>
265 <Button
266 type="text"
267 size="tiny"
268 className="p-1"
269 icon={<X size={16} />}
270 disabled={isLoading}
271 onClick={() =>
272 setSelectedPermissions((prev) => prev.filter((p) => p !== id))
273 }
274 />
275 </div>
276 {index < selectedPermissions.length - 1 && (
277 <div className="border-t border-border" />
278 )}
279 </div>
280 )
281 })}
282 </div>
283 )}
284
285 <div className="w-full flex gap-x-2 items-center">
286 <WarningIcon />
287 <span className="text-xs text-foreground-lighter">
288 Once you've set these permissions, you cannot edit them.
289 </span>
290 </div>
291 </div>
292 </CreateAppSheetStep>
293
294 <div ref={step3Ref}>
295 <CreateAppSheetStep
296 number={3}
297 title="Signing key"
298 description={
299 isCreatingKey
300 ? 'Generating your signing key...'
301 : keyRevealed
302 ? 'This is the only time you can view this key. Copy or download it and store it securely.'
303 : "A signing key will be generated automatically when you create the app. You'll only be able to view it once."
304 }
305 isLast
306 disabled={createdApp === null && !isLoading}
307 >
308 {isCreatingKey && (
309 <p className="text-sm text-foreground-light animate-pulse">
310 Generating signing key...
311 </p>
312 )}
313 {keyRevealed && (
314 <div className="space-y-2">
315 <div className="flex items-center justify-between">
316 <h3 className="text-sm font-medium">Private key</h3>
317 <div className="flex items-center gap-2">
318 <Button
319 type="default"
320 size="tiny"
321 icon={<Copy size={12} />}
322 onClick={() => copy(generatedKey.private_key, { withToast: true })}
323 >
324 Copy
325 </Button>
326 <Button
327 type="default"
328 size="tiny"
329 icon={<Download size={12} />}
330 onClick={() => {
331 const blob = new Blob([generatedKey.private_key], {
332 type: 'text/plain',
333 })
334 const url = URL.createObjectURL(blob)
335 const a = document.createElement('a')
336 a.href = url
337 a.download = `${createdApp?.name.toLowerCase().replace(/\s+/g, '-')}-private-key.pem`
338 a.click()
339 URL.revokeObjectURL(url)
340 }}
341 >
342 Download
343 </Button>
344 </div>
345 </div>
346 <textarea
347 readOnly
348 value={generatedKey.private_key}
349 rows={8}
350 className="w-full rounded-md border border-control bg-surface-200 px-3 py-2 text-xs font-mono resize-none focus:outline-hidden"
351 />
352 <label className="flex items-center gap-3 cursor-pointer bg-warning-200 border border-warning-400 rounded-md px-3 py-2">
353 <Checkbox
354 id="key-copied"
355 checked={keyCopied}
356 onCheckedChange={(v) => setKeyCopied(Boolean(v))}
357 />
358 <span className="text-sm text-warning cursor-pointer select-none">
359 I have copied the key and stored it securely
360 </span>
361 </label>
362 </div>
363 )}
364 </CreateAppSheetStep>
365 </div>
366 </div>
367 </ScrollArea>
368
369 <SheetFooter className="justify-end! w-full mt-auto py-4 border-t">
370 <div className="flex gap-2">
371 <Button type="default" onClick={handleRequestClose} disabled={isLoading}>
372 Cancel
373 </Button>
374 {keyRevealed ? (
375 <Button
376 type="primary"
377 disabled={!keyCopied}
378 onClick={() => {
379 onCreated(createdApp!)
380 handleClose()
381 }}
382 >
383 Done
384 </Button>
385 ) : (
386 <Button
387 type="primary"
388 disabled={!canCreate}
389 loading={isLoading}
390 onClick={handleCreate}
391 >
392 Create app
393 </Button>
394 )}
395 </div>
396 </SheetFooter>
397 </SheetContent>
398 </Sheet>
399
400 <ConfirmationModal
401 variant="destructive"
402 visible={showCloseConfirm}
403 title="Close without saving?"
404 confirmLabel="Close"
405 confirmLabelLoading="Closing..."
406 onCancel={() => setShowCloseConfirm(false)}
407 onConfirm={() => {
408 setShowCloseConfirm(false)
409 if (createdApp && slug) {
410 deleteApp({ slug, appId: createdApp.id })
411 } else {
412 handleClose()
413 }
414 }}
415 >
416 <p className="text-sm text-foreground-light py-2">
417 {createdApp
418 ? 'The app will be deleted and your signing key will be permanently lost.'
419 : 'Any progress you have made will be lost.'}
420 </p>
421 </ConfirmationModal>
422 </>
423 )
424}