CreateAppSheet.tsx424 lines · main
| 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 | |
| 30 | import { usePrivateApps } from '../../PrivateAppsContext' |
| 31 | import { PERMISSIONS } from '../Apps.constants' |
| 32 | import { CreateAppSheetStep } from './CreateAppSheetStep' |
| 33 | import { usePlatformAppCreateMutation } from '@/data/platform-apps/platform-app-create-mutation' |
| 34 | import { usePlatformAppDeleteMutation } from '@/data/platform-apps/platform-app-delete-mutation' |
| 35 | import { usePlatformAppInstallationCreateMutation } from '@/data/platform-apps/platform-app-installation-create-mutation' |
| 36 | import { usePlatformAppSigningKeyCreateMutation } from '@/data/platform-apps/platform-app-signing-key-create-mutation' |
| 37 | import { useCopyToClipboard } from '@/hooks/ui/useCopyToClipboard' |
| 38 | |
| 39 | type CreatePlatformAppResponse = components['schemas']['CreatePlatformAppResponse'] |
| 40 | type CreatePlatformAppSigningKeyResponse = |
| 41 | components['schemas']['CreatePlatformAppSigningKeyResponse'] |
| 42 | |
| 43 | interface CreateAppSheetProps { |
| 44 | visible: boolean |
| 45 | onClose: () => void |
| 46 | onCreated: (app: CreatePlatformAppResponse) => void |
| 47 | } |
| 48 | |
| 49 | export 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 | } |