CustomAuthProvidersList.tsx558 lines · main
1import { useQueryClient } from '@tanstack/react-query'
2import { useParams } from 'common'
3import { Edit, MoreVertical, Plus, Power, PowerOff, Search, Trash, X } from 'lucide-react'
4import { parseAsBoolean, parseAsStringLiteral, useQueryState } from 'nuqs'
5import { useEffect, useMemo, useRef, useState } from 'react'
6import { toast } from 'sonner'
7import {
8 Badge,
9 Button,
10 Card,
11 DropdownMenu,
12 DropdownMenuContent,
13 DropdownMenuItem,
14 DropdownMenuTrigger,
15 HoverCard,
16 HoverCardContent,
17 HoverCardTrigger,
18 InputGroup,
19 InputGroupAddon,
20 InputGroupInput,
21 Table,
22 TableBody,
23 TableCell,
24 TableHead,
25 TableHeader,
26 TableHeadSort,
27 TableRow,
28} from 'ui'
29import { Admonition } from 'ui-patterns/admonition'
30import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
31
32import { CreateOrUpdateCustomProviderSheet } from './CreateOrUpdateCustomProviderSheet'
33import {
34 CUSTOM_PROVIDER_ENABLED_OPTIONS,
35 CUSTOM_PROVIDER_TYPE_OPTIONS,
36 filterCustomProviders,
37 getNextPlanForCustomProviders,
38} from './customProviders.utils'
39import { DeleteCustomProviderModal } from './DeleteCustomProviderModal'
40import { DisableCustomProviderModal } from './DisableCustomProviderModal'
41import AlertError from '@/components/ui/AlertError'
42import { FilterPopover } from '@/components/ui/FilterPopover'
43import { Shortcut } from '@/components/ui/Shortcut'
44import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
45import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
46import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
47import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
48import { useOAuthCustomProviderUpdateMutation } from '@/data/oauth-custom-providers/oauth-custom-provider-update-mutation'
49import { useOAuthCustomProvidersQuery } from '@/data/oauth-custom-providers/oauth-custom-providers-query'
50import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
51import { onSearchInputEscape } from '@/lib/keyboard'
52import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
53import { useShortcut } from '@/state/shortcuts/useShortcut'
54
55const CUSTOM_PROVIDERS_SORT_VALUES = [
56 'name:asc',
57 'name:desc',
58 'identifier:asc',
59 'identifier:desc',
60 'provider_type:asc',
61 'provider_type:desc',
62 'created_at:asc',
63 'created_at:desc',
64] as const
65
66type CustomProvidersSort = (typeof CUSTOM_PROVIDERS_SORT_VALUES)[number]
67type CustomProvidersSortColumn = CustomProvidersSort extends `${infer Column}:${string}`
68 ? Column
69 : unknown
70type CustomProvidersSortOrder = CustomProvidersSort extends `${string}:${infer Order}`
71 ? Order
72 : unknown
73
74const NewProviderButton = ({
75 canCreateProvider,
76 setShowCreateSheet,
77}: {
78 canCreateProvider: boolean
79 setShowCreateSheet: (show: boolean) => void
80}) => {
81 return (
82 <Button
83 type="primary"
84 disabled={!canCreateProvider}
85 icon={<Plus />}
86 onClick={() => setShowCreateSheet(true)}
87 className="grow"
88 >
89 New Provider
90 </Button>
91 )
92}
93
94export const CustomAuthProvidersList = () => {
95 const { ref: projectRef } = useParams()
96
97 const { data: organization } = useSelectedOrganizationQuery()
98 const { data: authConfig, isPending: isAuthConfigLoading } = useAuthConfigQuery({ projectRef })
99 const { hostEndpoint: clientEndpoint } = useProjectApiUrl({ projectRef })
100 const nextPlan = getNextPlanForCustomProviders(organization?.plan?.id)
101 const isCustomProvidersEnabled = !!authConfig?.CUSTOM_OAUTH_ENABLED
102 const providerLimit = authConfig?.CUSTOM_OAUTH_MAX_PROVIDERS || 0
103
104 const queryClient = useQueryClient()
105 const { mutate: updateAuthConfig, isPending: isEnabling } = useAuthConfigUpdateMutation({
106 onSuccess: () => {
107 toast.success('Custom providers have been enabled')
108 // Invalidate and refetch custom providers query so it retries after enabling
109 queryClient.invalidateQueries({
110 queryKey: ['projects', projectRef, 'oauth-custom-providers'],
111 })
112 },
113 })
114
115 const { mutate: updateCustomProvider } = useOAuthCustomProviderUpdateMutation({
116 onSuccess: () => {
117 toast.success('Custom provider enabled')
118 },
119 })
120
121 const handleEnableCustomProviders = () => {
122 if (projectRef) {
123 updateAuthConfig({ projectRef, config: { CUSTOM_OAUTH_ENABLED: true } })
124 }
125 }
126
127 const [selectedProviderToEdit, setSelectedProviderToEdit] = useState<string | null>(null)
128 const [selectedProviderToDelete, setSelectedProviderToDelete] = useState<string | null>(null)
129 const [selectedProviderToDisable, setSelectedProviderToDisable] = useState<string | null>(null)
130 const [filteredProviderTypes, setFilteredProviderTypes] = useState<string[]>([])
131 const [filteredEnabledStatuses, setFilteredEnabledStatuses] = useState<string[]>([])
132 const searchInputRef = useRef<HTMLInputElement>(null)
133
134 const {
135 data: customProviders,
136 isLoading: isPending,
137 isError,
138 error,
139 } = useOAuthCustomProvidersQuery({ projectRef })
140 const providerCount = customProviders?.length ?? 0
141 const atProviderLimit = providerLimit !== Infinity && providerCount >= providerLimit
142
143 const [showCreateSheet, setShowCreateSheet] = useQueryState(
144 'new',
145 parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
146 )
147
148 // Prevent opening the create sheet if custom providers are disabled or at plan limit
149 useEffect(() => {
150 if (showCreateSheet && (!isCustomProvidersEnabled || atProviderLimit)) {
151 setShowCreateSheet(false)
152 }
153 }, [showCreateSheet, atProviderLimit, isCustomProvidersEnabled, setShowCreateSheet])
154
155 const [filterString, setFilterString] = useState<string>('')
156
157 const [sort, setSort] = useQueryState(
158 'sort',
159 parseAsStringLiteral<CustomProvidersSort>(CUSTOM_PROVIDERS_SORT_VALUES).withDefault('name:asc')
160 )
161
162 const providerToEdit = useMemo(
163 () => customProviders?.find((p) => p.id === selectedProviderToEdit),
164 [customProviders, selectedProviderToEdit]
165 )
166
167 const providerToDelete = useMemo(
168 () => customProviders?.find((p) => p.id === selectedProviderToDelete),
169 [customProviders, selectedProviderToDelete]
170 )
171
172 const providerToDisable = useMemo(
173 () => customProviders?.find((p) => p.id === selectedProviderToDisable),
174 [customProviders, selectedProviderToDisable]
175 )
176
177 const filteredAndSortedCustomProviders = useMemo(() => {
178 const filtered = filterCustomProviders({
179 providers: customProviders ?? [],
180 searchString: filterString,
181 providerTypes: filteredProviderTypes,
182 enabledStatuses: filteredEnabledStatuses,
183 })
184
185 const [sortCol, sortOrder] = sort.split(':') as [
186 CustomProvidersSortColumn,
187 CustomProvidersSortOrder,
188 ]
189 const orderMultiplier = sortOrder === 'asc' ? 1 : -1
190
191 return filtered.sort((a, b) => {
192 if (sortCol === 'name') {
193 return a.name.localeCompare(b.name) * orderMultiplier
194 }
195 if (sortCol === 'identifier') {
196 return a.identifier.localeCompare(b.identifier) * orderMultiplier
197 }
198 if (sortCol === 'provider_type') {
199 return a.provider_type.localeCompare(b.provider_type) * orderMultiplier
200 }
201 if (sortCol === 'created_at') {
202 return (
203 (new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * orderMultiplier
204 )
205 }
206 return 0
207 })
208 }, [customProviders, filterString, filteredProviderTypes, filteredEnabledStatuses, sort])
209
210 const hasActiveFilters =
211 filterString.length > 0 ||
212 filteredProviderTypes.length > 0 ||
213 filteredEnabledStatuses.length > 0
214
215 const handleResetFilters = () => {
216 setFilterString('')
217 setFilteredProviderTypes([])
218 setFilteredEnabledStatuses([])
219 }
220
221 useShortcut(
222 SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
223 () => {
224 searchInputRef.current?.focus()
225 searchInputRef.current?.select()
226 },
227 { label: 'Search custom providers' }
228 )
229
230 useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, handleResetFilters)
231
232 const handleSortChange = (column: CustomProvidersSortColumn) => {
233 const [currentCol, currentOrder] = sort.split(':') as [
234 CustomProvidersSortColumn,
235 CustomProvidersSortOrder,
236 ]
237 if (currentCol === column) {
238 // Cycle through: asc -> desc -> no sort (default)
239 if (currentOrder === 'asc') {
240 setSort(`${column}:desc` as CustomProvidersSort)
241 } else {
242 // Reset to default sort (name:asc)
243 setSort('name:asc')
244 }
245 } else {
246 // New column, start with asc
247 setSort(`${column}:asc` as CustomProvidersSort)
248 }
249 }
250
251 const isCreateOrUpdateSheetVisible =
252 isCustomProvidersEnabled && (showCreateSheet || !!providerToEdit)
253 const canCreateProvider = isCustomProvidersEnabled && !atProviderLimit
254
255 if (isAuthConfigLoading || (isCustomProvidersEnabled && isPending)) {
256 return <GenericSkeletonLoader />
257 }
258
259 if (!isCustomProvidersEnabled) {
260 return (
261 <Admonition
262 type="default"
263 title="Custom providers are not enabled"
264 description="Enable custom OAuth/OIDC providers to configure your own identity providers for authentication."
265 >
266 <Button
267 type="primary"
268 loading={isEnabling}
269 disabled={isEnabling}
270 onClick={handleEnableCustomProviders}
271 >
272 Enable Custom Providers
273 </Button>
274 </Admonition>
275 )
276 }
277
278 if (isError) {
279 if (error?.message?.includes('Custom providers are not enabled')) {
280 return (
281 <Admonition
282 type="default"
283 title="Custom providers are not enabled"
284 description="Enable custom OAuth/OIDC providers to configure your own identity providers for authentication."
285 >
286 <Button
287 type="primary"
288 loading={isEnabling}
289 disabled={isEnabling}
290 onClick={handleEnableCustomProviders}
291 >
292 Enable Custom Providers
293 </Button>
294 </Admonition>
295 )
296 }
297 return <AlertError error={error} subject="Failed to retrieve Custom Auth Providers" />
298 }
299
300 return (
301 <>
302 <div className="flex flex-col gap-y-4">
303 <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
304 <div className="flex flex-col lg:flex-row lg:items-center gap-2">
305 <InputGroup className="w-full lg:w-52">
306 <InputGroupInput
307 ref={searchInputRef}
308 size="tiny"
309 placeholder="Search custom providers"
310 value={filterString}
311 onChange={(e) => setFilterString(e.target.value)}
312 onKeyDown={onSearchInputEscape(filterString, setFilterString)}
313 />
314 <InputGroupAddon>
315 <Search />
316 </InputGroupAddon>
317 </InputGroup>
318 <FilterPopover
319 name="Provider Type"
320 options={CUSTOM_PROVIDER_TYPE_OPTIONS}
321 labelKey="name"
322 valueKey="value"
323 iconKey="icon"
324 activeOptions={filteredProviderTypes}
325 labelClass="text-xs text-foreground-light"
326 maxHeightClass="h-[200px]"
327 className="w-52"
328 onSaveFilters={setFilteredProviderTypes}
329 />
330 <FilterPopover
331 name="Status"
332 options={CUSTOM_PROVIDER_ENABLED_OPTIONS}
333 labelKey="name"
334 valueKey="value"
335 iconKey="icon"
336 activeOptions={filteredEnabledStatuses}
337 labelClass="text-xs text-foreground-light"
338 maxHeightClass="h-[190px]"
339 className="w-52"
340 onSaveFilters={setFilteredEnabledStatuses}
341 />
342 {hasActiveFilters && (
343 <Button
344 type="default"
345 size="tiny"
346 className="px-1"
347 icon={<X />}
348 onClick={handleResetFilters}
349 />
350 )}
351 </div>
352 <div className="flex items-center gap-x-2">
353 {!isCustomProvidersEnabled || atProviderLimit ? (
354 <HoverCard openDelay={0}>
355 <HoverCardTrigger>
356 <NewProviderButton
357 canCreateProvider={canCreateProvider}
358 setShowCreateSheet={setShowCreateSheet}
359 />
360 </HoverCardTrigger>
361 <HoverCardContent
362 side="bottom"
363 align="end"
364 className="text-xs flex flex-col gap-y-2 bg-alternative items-start"
365 >
366 {!isCustomProvidersEnabled ? (
367 <div className="flex flex-col gap-y-2">
368 <p>Custom providers are not enabled for this project.</p>
369 <Button
370 type="primary"
371 size="tiny"
372 loading={isEnabling}
373 disabled={isEnabling}
374 onClick={handleEnableCustomProviders}
375 >
376 Enable Custom Providers
377 </Button>
378 </div>
379 ) : (
380 <>
381 <p>You've reached the limit of {providerLimit} providers for your plan.</p>
382 <UpgradePlanButton
383 source={`customAuthProviders-${organization?.plan.id}`}
384 plan={nextPlan ?? 'Pro'}
385 />
386 </>
387 )}
388 </HoverCardContent>
389 </HoverCard>
390 ) : (
391 <Shortcut
392 id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
393 label="Create new provider"
394 onTrigger={() => setShowCreateSheet(true)}
395 side="bottom"
396 >
397 <Button
398 type="primary"
399 disabled={!canCreateProvider}
400 icon={<Plus />}
401 onClick={() => setShowCreateSheet(true)}
402 className="grow"
403 >
404 New Provider
405 </Button>
406 </Shortcut>
407 )}
408 </div>
409 </div>
410
411 <div className="w-full overflow-hidden overflow-x-auto">
412 <Card className="@container">
413 <Table containerProps={{ stickyLastColumn: true }}>
414 <TableHeader>
415 <TableRow>
416 <TableHead>
417 <TableHeadSort column="name" currentSort={sort} onSortChange={handleSortChange}>
418 Name
419 </TableHeadSort>
420 </TableHead>
421 <TableHead>
422 <TableHeadSort
423 column="identifier"
424 currentSort={sort}
425 onSortChange={handleSortChange}
426 >
427 Identifier
428 </TableHeadSort>
429 </TableHead>
430 <TableHead>
431 <TableHeadSort
432 column="provider_type"
433 currentSort={sort}
434 onSortChange={handleSortChange}
435 >
436 Type
437 </TableHeadSort>
438 </TableHead>
439 <TableHead>Enabled</TableHead>
440 <TableHead className="w-8 px-0">
441 <div className="bg-200! px-4 w-full h-full flex items-center border-l @[944px]:border-l-0" />
442 </TableHead>
443 </TableRow>
444 </TableHeader>
445 <TableBody>
446 {filteredAndSortedCustomProviders.length === 0 && (
447 <TableRow>
448 <TableCell colSpan={5}>
449 <p className="text-foreground-lighter">No custom providers found</p>
450 </TableCell>
451 </TableRow>
452 )}
453 {filteredAndSortedCustomProviders.length > 0 &&
454 filteredAndSortedCustomProviders.map((provider) => (
455 <TableRow key={provider.id} className="w-full">
456 <TableCell className="flex" title={provider.name}>
457 <Button
458 type="text"
459 className="text-link-table-cell text-sm p-0 hover:bg-transparent title [&>span]:w-full!"
460 onClick={() => setSelectedProviderToEdit(provider.id)}
461 title={provider.name}
462 >
463 {provider.name}
464 </Button>
465 </TableCell>
466 <TableCell title={provider.identifier}>
467 <Badge className="font-mono">{provider.identifier}</Badge>
468 </TableCell>
469 <TableCell className="text-xs text-foreground-light max-w-28 uppercase">
470 {provider.provider_type}
471 </TableCell>
472 <TableCell className="text-xs text-foreground-light max-w-28">
473 <Badge variant={provider.enabled ? 'success' : 'default'}>
474 {provider.enabled ? 'Enabled' : 'Disabled'}
475 </Badge>
476 </TableCell>
477 <TableCell className="max-w-20 bg-surface-100 @[944px]:hover:bg-surface-200 px-6">
478 <div className="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center border-l @[944px]:border-l-0">
479 <DropdownMenu>
480 <DropdownMenuTrigger asChild>
481 <Button type="default" className="px-1" icon={<MoreVertical />} />
482 </DropdownMenuTrigger>
483 <DropdownMenuContent side="bottom" align="end" className="w-48">
484 <DropdownMenuItem
485 className="space-x-2"
486 onClick={() => {
487 setSelectedProviderToEdit(provider.id)
488 }}
489 >
490 <Edit size={12} />
491 <p>Update</p>
492 </DropdownMenuItem>
493 {provider.enabled ? (
494 <DropdownMenuItem
495 className="space-x-2"
496 onClick={() => setSelectedProviderToDisable(provider.id)}
497 >
498 <PowerOff size={12} />
499 <p>Disable</p>
500 </DropdownMenuItem>
501 ) : (
502 <DropdownMenuItem
503 className="space-x-2"
504 onClick={() =>
505 updateCustomProvider({
506 identifier: provider.identifier,
507 projectRef,
508 clientEndpoint,
509 enabled: true,
510 })
511 }
512 >
513 <Power size={12} />
514 <p>Enable</p>
515 </DropdownMenuItem>
516 )}
517 <DropdownMenuItem
518 className="space-x-2"
519 onClick={() => setSelectedProviderToDelete(provider.id)}
520 >
521 <Trash size={12} />
522 <p>Delete</p>
523 </DropdownMenuItem>
524 </DropdownMenuContent>
525 </DropdownMenu>
526 </div>
527 </TableCell>
528 </TableRow>
529 ))}
530 </TableBody>
531 </Table>
532 </Card>
533 </div>
534 </div>
535
536 <CreateOrUpdateCustomProviderSheet
537 visible={isCreateOrUpdateSheetVisible}
538 providerToEdit={providerToEdit}
539 onClose={() => {
540 setShowCreateSheet(false)
541 setSelectedProviderToEdit(null)
542 }}
543 />
544
545 <DeleteCustomProviderModal
546 visible={!!providerToDelete}
547 selectedProvider={providerToDelete}
548 onClose={() => setSelectedProviderToDelete(null)}
549 />
550
551 <DisableCustomProviderModal
552 visible={!!providerToDisable}
553 selectedProvider={providerToDisable}
554 onClose={() => setSelectedProviderToDisable(null)}
555 />
556 </>
557 )
558}