CustomAuthProvidersList.tsx 20 KB

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