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