ApiAuthorization.Form.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. import dayjs from 'dayjs'
  2. import Link from 'next/link'
  3. import { useMemo, type ReactNode } from 'react'
  4. import type { UseFormReturn } from 'react-hook-form'
  5. import {
  6. Alert,
  7. AlertDescription,
  8. AlertTitle,
  9. Button,
  10. Card,
  11. CardContent,
  12. CardFooter,
  13. CardHeader,
  14. Form,
  15. FormControl,
  16. FormField,
  17. FormItem,
  18. Select,
  19. SelectContent,
  20. SelectItem,
  21. SelectTrigger,
  22. SelectValue,
  23. WarningIcon,
  24. } from 'ui'
  25. import { ShimmeringLoader } from 'ui-patterns'
  26. import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
  27. import type { ApprovalState, IApprovalFormSchema } from './ApiAuthorization.Schema'
  28. import { AuthorizeRequesterDetails } from '@/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails'
  29. import type { ApiAuthorizationResponse } from '@/data/api-authorization/api-authorization-query'
  30. import { BASE_PATH } from '@/lib/constants'
  31. import type { Organization, ResponseError } from '@/types'
  32. type OrganizationsState_Loading = {
  33. _tag: 'loading'
  34. }
  35. type OrganizationsState_Error = {
  36. _tag: 'error'
  37. error: ResponseError | null
  38. }
  39. type OrganizationsState_Empty = {
  40. _tag: 'empty'
  41. }
  42. type OrganizationsState_NotMember = {
  43. _tag: 'not_member'
  44. }
  45. type OrganizationsState_Success = {
  46. _tag: 'success'
  47. organizations: Array<Organization>
  48. }
  49. type OrganizationsState =
  50. | OrganizationsState_Loading
  51. | OrganizationsState_Error
  52. | OrganizationsState_Empty
  53. | OrganizationsState_NotMember
  54. | OrganizationsState_Success
  55. export interface ApiAuthorizationMainViewProps {
  56. approvalState: ApprovalState
  57. form: UseFormReturn<IApprovalFormSchema>
  58. requester: ApiAuthorizationResponse
  59. organizations: OrganizationsState
  60. requestedOrganizationSlug: string | undefined
  61. onApprove: () => void
  62. onDecline: () => void
  63. }
  64. export function ApiAuthorizationMainView({
  65. approvalState,
  66. form,
  67. requester,
  68. organizations,
  69. requestedOrganizationSlug,
  70. onApprove,
  71. onDecline,
  72. }: ApiAuthorizationMainViewProps): ReactNode {
  73. const isMcpClient = requester.registration_type === 'dynamic'
  74. const isExpired = dayjs().isAfter(dayjs(requester.expires_at))
  75. return (
  76. <FormShell title={`Authorize API access for ${requester.name}`}>
  77. {isMcpClient && <McpNotice />}
  78. <AuthorizeRequesterDetails
  79. icon={requester.icon}
  80. name={requester.name}
  81. domain={requester.domain}
  82. scopes={requester.scopes}
  83. />
  84. {isExpired && <ExpiredNotice />}
  85. {organizations._tag === 'loading' && <OrganizationsLoader />}
  86. {organizations._tag === 'error' && <OrganizationsErrorNotice error={organizations.error} />}
  87. {organizations._tag === 'empty' && <OrganizationsEmptyState />}
  88. {organizations._tag === 'not_member' && <NotMemberOfOrganizationNotice />}
  89. {organizations._tag === 'success' && (
  90. <OrganizationSelector
  91. form={form}
  92. disabled={isExpired || !!requestedOrganizationSlug}
  93. requester={requester}
  94. organizations={organizations.organizations}
  95. requestedOrganizationSlug={requestedOrganizationSlug}
  96. />
  97. )}
  98. <FormFooter
  99. disabled={isExpired || organizations._tag !== 'success'}
  100. approvalState={approvalState}
  101. requester={requester}
  102. organizations={organizations}
  103. onApprove={onApprove}
  104. onDecline={onDecline}
  105. />
  106. </FormShell>
  107. )
  108. }
  109. interface FormShellProps {
  110. title: string
  111. children: ReactNode
  112. }
  113. function FormShell({ title, children }: FormShellProps): ReactNode {
  114. return (
  115. <Card>
  116. <CardHeader>{title}</CardHeader>
  117. <CardContent className="space-y-8">{children}</CardContent>
  118. </Card>
  119. )
  120. }
  121. function McpNotice(): ReactNode {
  122. return (
  123. <Alert variant="warning">
  124. <WarningIcon />
  125. <AlertTitle>MCP Client Connection</AlertTitle>
  126. <AlertDescription>
  127. This is an MCP (Model Context Protocol) client designed to connect with AI applications.
  128. Please ensure you trust this application before granting access to your organization's data.
  129. </AlertDescription>
  130. </Alert>
  131. )
  132. }
  133. function ExpiredNotice(): ReactNode {
  134. return (
  135. <Alert variant="warning">
  136. <WarningIcon />
  137. <AlertTitle>This authorization request is expired</AlertTitle>
  138. <AlertDescription>
  139. Please retry your authorization request from the requesting app
  140. </AlertDescription>
  141. </Alert>
  142. )
  143. }
  144. function OrganizationsLoader(): ReactNode {
  145. return (
  146. <div className="py-4 space-y-2">
  147. <ShimmeringLoader />
  148. <ShimmeringLoader className="w-3/4" />
  149. </div>
  150. )
  151. }
  152. interface OrganizationsErrorNoticeProps {
  153. error: ResponseError | null
  154. }
  155. function OrganizationsErrorNotice({ error }: OrganizationsErrorNoticeProps): ReactNode {
  156. return (
  157. <Alert variant="warning">
  158. <WarningIcon />
  159. <AlertTitle>There was an error loading your organizations</AlertTitle>
  160. <AlertDescription>
  161. Please try again. If the problem persists, contact support.
  162. {error && <p className="mt-2">Error: {error.message}</p>}
  163. </AlertDescription>
  164. </Alert>
  165. )
  166. }
  167. function OrganizationsEmptyState(): ReactNode {
  168. return (
  169. <Alert variant="warning">
  170. <WarningIcon />
  171. <AlertTitle>Organization is needed for installing an integration</AlertTitle>
  172. <AlertDescription>
  173. Your account isn't associated with any organizations. To use this integration, it must be
  174. installed within an organization. You'll be redirected to create an organization first.
  175. </AlertDescription>
  176. </Alert>
  177. )
  178. }
  179. function NotMemberOfOrganizationNotice(): ReactNode {
  180. return (
  181. <Alert variant="warning">
  182. <WarningIcon />
  183. <AlertTitle>Organization is needed for installing an integration</AlertTitle>
  184. <AlertDescription>
  185. Your account is not a member of the pre-selected organization. To use this integration, it
  186. must be installed within an organization your account is associated with.
  187. </AlertDescription>
  188. </Alert>
  189. )
  190. }
  191. interface OrganizationSelectorProps {
  192. form: UseFormReturn<IApprovalFormSchema>
  193. requester: ApiAuthorizationResponse
  194. requestedOrganizationSlug: string | undefined
  195. organizations: Array<Organization>
  196. disabled?: boolean
  197. }
  198. function OrganizationSelector({
  199. form,
  200. requester,
  201. requestedOrganizationSlug,
  202. organizations,
  203. disabled = false,
  204. }: OrganizationSelectorProps): ReactNode {
  205. return (
  206. <Form {...form}>
  207. <FormField
  208. control={form.control}
  209. name="selectedOrgSlug"
  210. render={({ field }) => (
  211. <FormItem>
  212. <FormLayout
  213. label="Organization to grant API access to"
  214. description={
  215. requestedOrganizationSlug
  216. ? `This organization has been pre-selected by ${requester.name}.`
  217. : undefined
  218. }
  219. isReactForm
  220. >
  221. <FormControl>
  222. <Select
  223. value={field.value || undefined}
  224. disabled={disabled}
  225. onValueChange={field.onChange}
  226. >
  227. <SelectTrigger size="small">
  228. <SelectValue placeholder="Select an organization" />
  229. </SelectTrigger>
  230. <SelectContent>
  231. {organizations.map((organization) => (
  232. <SelectItem
  233. key={organization.slug}
  234. value={organization.slug}
  235. className="text-xs"
  236. >
  237. {organization.name}
  238. </SelectItem>
  239. ))}
  240. </SelectContent>
  241. </Select>
  242. </FormControl>
  243. </FormLayout>
  244. </FormItem>
  245. )}
  246. />
  247. </Form>
  248. )
  249. }
  250. interface FormFooterProps {
  251. disabled?: boolean
  252. approvalState: ApprovalState
  253. requester: ApiAuthorizationResponse
  254. organizations: OrganizationsState
  255. onDecline: () => void
  256. onApprove: () => void
  257. }
  258. function FormFooter({
  259. disabled = false,
  260. approvalState,
  261. requester,
  262. organizations,
  263. onDecline,
  264. onApprove,
  265. }: FormFooterProps): ReactNode {
  266. const showApprovalButton = organizations._tag === 'success' || organizations._tag === 'not_member'
  267. return (
  268. <CardFooter className="justify-end space-x-2">
  269. <Button
  270. type="default"
  271. loading={approvalState === 'declining'}
  272. disabled={disabled || approvalState !== 'indeterminate'}
  273. onClick={onDecline}
  274. >
  275. Decline
  276. </Button>
  277. {organizations._tag === 'loading' && (
  278. <LoadingApprovalButton>Authorize {requester.name}</LoadingApprovalButton>
  279. )}
  280. {organizations._tag === 'empty' && <CreateOrganizationLink />}
  281. {showApprovalButton && (
  282. <ApprovalButton
  283. disabled={disabled || approvalState !== 'indeterminate'}
  284. approvalState={approvalState}
  285. requester={requester}
  286. onApprove={onApprove}
  287. />
  288. )}
  289. </CardFooter>
  290. )
  291. }
  292. interface LoadingApprovalButtonProps {
  293. children: ReactNode
  294. }
  295. function LoadingApprovalButton({ children }: LoadingApprovalButtonProps): ReactNode {
  296. return <Button loading={true}>{children}</Button>
  297. }
  298. function createReturnToSearchParam(): string | null {
  299. if (typeof window === 'undefined') {
  300. return null
  301. }
  302. const basePath = BASE_PATH
  303. let pathname = basePath ? location.pathname.replace(basePath, '') : location.pathname
  304. if (location.search) {
  305. pathname += location.search
  306. }
  307. return pathname
  308. }
  309. function CreateOrganizationLink(): ReactNode {
  310. const searchParamString = useMemo(function createSearchParams() {
  311. const searchParams = new URLSearchParams()
  312. const returnTo = createReturnToSearchParam()
  313. if (returnTo) {
  314. searchParams.set('returnTo', returnTo)
  315. }
  316. return searchParams.toString()
  317. }, [])
  318. return (
  319. <Button asChild>
  320. <Link href={`/new?${searchParamString}`}>Create an organization</Link>
  321. </Button>
  322. )
  323. }
  324. interface ApprovalButtonProps {
  325. disabled?: boolean
  326. approvalState: ApprovalState
  327. requester: ApiAuthorizationResponse
  328. onApprove: () => void
  329. }
  330. function ApprovalButton({
  331. disabled,
  332. approvalState,
  333. requester,
  334. onApprove,
  335. }: ApprovalButtonProps): ReactNode {
  336. return (
  337. <Button loading={approvalState === 'approving'} disabled={disabled} onClick={onApprove}>
  338. Authorize {requester.name}
  339. </Button>
  340. )
  341. }