| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424 |
- import type { components } from 'api-types'
- import { Copy, Download, Key, Plus, RotateCcw, X } from 'lucide-react'
- import { useEffect, useRef, useState } from 'react'
- import { toast } from 'sonner'
- import {
- Button,
- Checkbox,
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
- Input,
- Popover,
- PopoverContent,
- PopoverTrigger,
- ScrollArea,
- Sheet,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
- WarningIcon,
- } from 'ui'
- import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
- import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
- import { usePrivateApps } from '../../PrivateAppsContext'
- import { PERMISSIONS } from '../Apps.constants'
- import { CreateAppSheetStep } from './CreateAppSheetStep'
- import { usePlatformAppCreateMutation } from '@/data/platform-apps/platform-app-create-mutation'
- import { usePlatformAppDeleteMutation } from '@/data/platform-apps/platform-app-delete-mutation'
- import { usePlatformAppInstallationCreateMutation } from '@/data/platform-apps/platform-app-installation-create-mutation'
- import { usePlatformAppSigningKeyCreateMutation } from '@/data/platform-apps/platform-app-signing-key-create-mutation'
- import { useCopyToClipboard } from '@/hooks/ui/useCopyToClipboard'
- type CreatePlatformAppResponse = components['schemas']['CreatePlatformAppResponse']
- type CreatePlatformAppSigningKeyResponse =
- components['schemas']['CreatePlatformAppSigningKeyResponse']
- interface CreateAppSheetProps {
- visible: boolean
- onClose: () => void
- onCreated: (app: CreatePlatformAppResponse) => void
- }
- export function CreateAppSheet({ visible, onClose, onCreated }: CreateAppSheetProps) {
- const { slug, installations, addInstallation } = usePrivateApps()
- const { copy } = useCopyToClipboard()
- const [name, setName] = useState('')
- const [selectedPermissions, setSelectedPermissions] = useState<string[]>([])
- const [permissionSearchOpen, setPermissionSearchOpen] = useState(false)
- const [createdApp, setCreatedApp] = useState<CreatePlatformAppResponse | null>(null)
- const [generatedKey, setGeneratedKey] = useState<CreatePlatformAppSigningKeyResponse | null>(null)
- const [keyCopied, setKeyCopied] = useState(false)
- const [showCloseConfirm, setShowCloseConfirm] = useState(false)
- const step3Ref = useRef<HTMLDivElement>(null)
- const { mutate: deleteApp, isPending: isDeleting } = usePlatformAppDeleteMutation({
- onSuccess: () => handleClose(),
- })
- const { mutate: installApp } = usePlatformAppInstallationCreateMutation({
- onSuccess: (data) => {
- if (data) addInstallation(data, 'all')
- },
- })
- const { mutate: createSigningKey, isPending: isCreatingKey } =
- usePlatformAppSigningKeyCreateMutation({
- onSuccess: (keyData, vars) => {
- if (keyData) setGeneratedKey(keyData)
- if (installations.length === 0 && slug) {
- installApp({ slug, app_id: vars.appId })
- }
- },
- })
- const { mutate: createApp, isPending: isCreatingApp } = usePlatformAppCreateMutation({
- onSuccess: (data) => {
- toast.success(`App "${data.name}" created`)
- setCreatedApp(data)
- createSigningKey({ slug: slug!, appId: data.id })
- },
- })
- function reset() {
- setName('')
- setSelectedPermissions([])
- setPermissionSearchOpen(false)
- setCreatedApp(null)
- setGeneratedKey(null)
- setKeyCopied(false)
- }
- function handleClose() {
- reset()
- onClose()
- }
- function handleRequestClose() {
- setShowCloseConfirm(true)
- }
- function handleCreate() {
- if (!slug) return
- createApp({
- slug,
- name: name.trim(),
- permissions:
- selectedPermissions as components['schemas']['CreatePlatformAppBody']['permissions'],
- })
- }
- function toggle(id: string) {
- setSelectedPermissions((prev) =>
- prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id]
- )
- }
- const canCreate = name.trim().length > 0 && selectedPermissions.length > 0
- const isLoading = isCreatingApp || isCreatingKey || isDeleting
- const keyRevealed = generatedKey !== null
- useEffect(() => {
- if (generatedKey) {
- step3Ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
- }
- }, [generatedKey])
- return (
- <>
- <Sheet
- open={visible}
- onOpenChange={(open) => {
- if (!open) handleRequestClose()
- }}
- >
- <SheetContent
- showClose={false}
- size="default"
- className="min-w-[600px]! flex flex-col h-full gap-0"
- >
- <SheetHeader>
- <SheetTitle>Create private app</SheetTitle>
- <SheetDescription className="sr-only">
- Create a private app to generate scoped access tokens for your organization.
- </SheetDescription>
- </SheetHeader>
- <ScrollArea className="flex-1 max-h-[calc(100vh-116px)]">
- <div className="px-5 sm:px-6 py-6">
- <CreateAppSheetStep
- number={1}
- title="App details"
- description="The first app you create will be automatically installed."
- disabled={keyRevealed}
- >
- <FormLayout label="Name" id="app-name">
- <Input
- id="app-name"
- placeholder="My integration"
- value={name}
- onChange={(e) => setName(e.target.value)}
- disabled={isLoading}
- />
- </FormLayout>
- </CreateAppSheetStep>
- <CreateAppSheetStep
- number={2}
- title="Permissions"
- description="Select the permissions this app requires."
- disabled={keyRevealed}
- >
- <div className="space-y-4">
- <div className="flex justify-between items-center">
- <span className="text-sm">Configure permissions</span>
- <div className="flex items-center gap-2">
- {selectedPermissions.length > 0 && (
- <Button
- type="default"
- size="tiny"
- className="p-1"
- icon={<RotateCcw size={16} />}
- onClick={() => setSelectedPermissions([])}
- disabled={isLoading}
- />
- )}
- <Popover
- open={permissionSearchOpen}
- onOpenChange={setPermissionSearchOpen}
- modal
- >
- <PopoverTrigger asChild>
- <Button
- type="default"
- size="tiny"
- icon={<Plus size={14} />}
- disabled={isLoading}
- >
- Add permission
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-[400px] p-0" align="end">
- <Command>
- <CommandInput placeholder="Search permissions..." />
- <CommandList>
- <CommandEmpty>No permissions found.</CommandEmpty>
- <CommandGroup className="[&>div]:text-left">
- <div className="max-h-[210px] overflow-y-auto">
- {PERMISSIONS.map((perm) => (
- <CommandItem
- key={perm.id}
- value={`${perm.id} ${perm.label}`}
- onSelect={() => toggle(perm.id)}
- className="text-foreground"
- >
- <div className="flex items-center gap-3 w-full">
- <Checkbox
- checked={selectedPermissions.includes(perm.id)}
- onCheckedChange={() => toggle(perm.id)}
- onClick={(e) => e.stopPropagation()}
- />
- <Key size={12} className="text-foreground-lighter" />
- <span className="font-medium text-foreground">
- {perm.label}
- </span>
- </div>
- </CommandItem>
- ))}
- </div>
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- </div>
- </div>
- {selectedPermissions.length === 0 ? (
- <div className="text-center py-8 border border-dashed border-border rounded-lg">
- <p className="text-sm text-foreground-light">
- No permissions configured yet.
- </p>
- </div>
- ) : (
- <div className="border border-border rounded-lg">
- {selectedPermissions.map((id, index) => {
- const perm = PERMISSIONS.find((p) => p.id === id)
- return (
- <div key={id}>
- <div className="flex items-center gap-3 p-3">
- <div className="flex-1">
- <p className="text-sm font-medium">{perm?.label}</p>
- {perm?.description && (
- <p className="text-xs text-foreground-lighter">
- {perm.description}
- </p>
- )}
- </div>
- <Button
- type="text"
- size="tiny"
- className="p-1"
- icon={<X size={16} />}
- disabled={isLoading}
- onClick={() =>
- setSelectedPermissions((prev) => prev.filter((p) => p !== id))
- }
- />
- </div>
- {index < selectedPermissions.length - 1 && (
- <div className="border-t border-border" />
- )}
- </div>
- )
- })}
- </div>
- )}
- <div className="w-full flex gap-x-2 items-center">
- <WarningIcon />
- <span className="text-xs text-foreground-lighter">
- Once you've set these permissions, you cannot edit them.
- </span>
- </div>
- </div>
- </CreateAppSheetStep>
- <div ref={step3Ref}>
- <CreateAppSheetStep
- number={3}
- title="Signing key"
- description={
- isCreatingKey
- ? 'Generating your signing key...'
- : keyRevealed
- ? 'This is the only time you can view this key. Copy or download it and store it securely.'
- : "A signing key will be generated automatically when you create the app. You'll only be able to view it once."
- }
- isLast
- disabled={createdApp === null && !isLoading}
- >
- {isCreatingKey && (
- <p className="text-sm text-foreground-light animate-pulse">
- Generating signing key...
- </p>
- )}
- {keyRevealed && (
- <div className="space-y-2">
- <div className="flex items-center justify-between">
- <h3 className="text-sm font-medium">Private key</h3>
- <div className="flex items-center gap-2">
- <Button
- type="default"
- size="tiny"
- icon={<Copy size={12} />}
- onClick={() => copy(generatedKey.private_key, { withToast: true })}
- >
- Copy
- </Button>
- <Button
- type="default"
- size="tiny"
- icon={<Download size={12} />}
- onClick={() => {
- const blob = new Blob([generatedKey.private_key], {
- type: 'text/plain',
- })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = `${createdApp?.name.toLowerCase().replace(/\s+/g, '-')}-private-key.pem`
- a.click()
- URL.revokeObjectURL(url)
- }}
- >
- Download
- </Button>
- </div>
- </div>
- <textarea
- readOnly
- value={generatedKey.private_key}
- rows={8}
- className="w-full rounded-md border border-control bg-surface-200 px-3 py-2 text-xs font-mono resize-none focus:outline-hidden"
- />
- <label className="flex items-center gap-3 cursor-pointer bg-warning-200 border border-warning-400 rounded-md px-3 py-2">
- <Checkbox
- id="key-copied"
- checked={keyCopied}
- onCheckedChange={(v) => setKeyCopied(Boolean(v))}
- />
- <span className="text-sm text-warning cursor-pointer select-none">
- I have copied the key and stored it securely
- </span>
- </label>
- </div>
- )}
- </CreateAppSheetStep>
- </div>
- </div>
- </ScrollArea>
- <SheetFooter className="justify-end! w-full mt-auto py-4 border-t">
- <div className="flex gap-2">
- <Button type="default" onClick={handleRequestClose} disabled={isLoading}>
- Cancel
- </Button>
- {keyRevealed ? (
- <Button
- type="primary"
- disabled={!keyCopied}
- onClick={() => {
- onCreated(createdApp!)
- handleClose()
- }}
- >
- Done
- </Button>
- ) : (
- <Button
- type="primary"
- disabled={!canCreate}
- loading={isLoading}
- onClick={handleCreate}
- >
- Create app
- </Button>
- )}
- </div>
- </SheetFooter>
- </SheetContent>
- </Sheet>
- <ConfirmationModal
- variant="destructive"
- visible={showCloseConfirm}
- title="Close without saving?"
- confirmLabel="Close"
- confirmLabelLoading="Closing..."
- onCancel={() => setShowCloseConfirm(false)}
- onConfirm={() => {
- setShowCloseConfirm(false)
- if (createdApp && slug) {
- deleteApp({ slug, appId: createdApp.id })
- } else {
- handleClose()
- }
- }}
- >
- <p className="text-sm text-foreground-light py-2">
- {createdApp
- ? 'The app will be deleted and your signing key will be permanently lost.'
- : 'Any progress you have made will be lost.'}
- </p>
- </ConfirmationModal>
- </>
- )
- }
|