CreateAppSheet.tsx 16 KB

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