OAuthAppsList.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. import type { OAuthClient } from '@supabase/supabase-js'
  2. import { useParams } from 'common'
  3. import { Edit, MoreVertical, Plus, RotateCw, Search, Trash, X } from 'lucide-react'
  4. import Link from 'next/link'
  5. import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs'
  6. import { useEffect, useMemo, useRef, useState } from 'react'
  7. import { toast } from 'sonner'
  8. import {
  9. Button,
  10. Card,
  11. DropdownMenu,
  12. DropdownMenuContent,
  13. DropdownMenuItem,
  14. DropdownMenuSeparator,
  15. DropdownMenuTrigger,
  16. InputGroup,
  17. InputGroupAddon,
  18. InputGroupInput,
  19. Table,
  20. TableBody,
  21. TableCell,
  22. TableHead,
  23. TableHeader,
  24. TableHeadSort,
  25. TableRow,
  26. } from 'ui'
  27. import { Admonition } from 'ui-patterns/admonition'
  28. import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
  29. import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
  30. import { TimestampInfo } from 'ui-patterns/TimestampInfo'
  31. import { CreateOrUpdateOAuthAppSheet } from './CreateOrUpdateOAuthAppSheet'
  32. import { DeleteOAuthAppModal } from './DeleteOAuthAppModal'
  33. import { NewOAuthAppBanner } from './NewOAuthAppBanner'
  34. import {
  35. filterOAuthApps,
  36. OAUTH_APP_CLIENT_TYPE_OPTIONS,
  37. OAUTH_APP_REGISTRATION_TYPE_OPTIONS,
  38. } from './oauthApps.utils'
  39. import AlertError from '@/components/ui/AlertError'
  40. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  41. import { FilterPopover } from '@/components/ui/FilterPopover'
  42. import { Shortcut } from '@/components/ui/Shortcut'
  43. import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
  44. import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
  45. import { useOAuthServerAppDeleteMutation } from '@/data/oauth-server-apps/oauth-server-app-delete-mutation'
  46. import { useOAuthServerAppRegenerateSecretMutation } from '@/data/oauth-server-apps/oauth-server-app-regenerate-secret-mutation'
  47. import { useOAuthServerAppsQuery } from '@/data/oauth-server-apps/oauth-server-apps-query'
  48. import { onSearchInputEscape } from '@/lib/keyboard'
  49. import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
  50. import { useShortcut } from '@/state/shortcuts/useShortcut'
  51. const OAUTH_APPS_SORT_VALUES = [
  52. 'name:asc',
  53. 'name:desc',
  54. 'client_type:asc',
  55. 'client_type:desc',
  56. 'registration_type:asc',
  57. 'registration_type:desc',
  58. 'created_at:asc',
  59. 'created_at:desc',
  60. ] as const
  61. type OAuthAppsSort = (typeof OAUTH_APPS_SORT_VALUES)[number]
  62. type OAuthAppsSortColumn = OAuthAppsSort extends `${infer Column}:${string}` ? Column : unknown
  63. type OAuthAppsSortOrder = OAuthAppsSort extends `${string}:${infer Order}` ? Order : unknown
  64. export const OAuthAppsList = () => {
  65. const { ref: projectRef } = useParams()
  66. const {
  67. data: authConfig,
  68. isPending: isAuthConfigLoading,
  69. isSuccess: isSuccessAuthConfig,
  70. } = useAuthConfigQuery({ projectRef })
  71. const isOAuthServerEnabled = !!authConfig?.OAUTH_SERVER_ENABLED
  72. const [newOAuthApp, setNewOAuthApp] = useState<OAuthClient | undefined>(undefined)
  73. const [showRegenerateDialog, setShowRegenerateDialog] = useState(false)
  74. const [selectedApp, setSelectedApp] = useState<OAuthClient>()
  75. const [filteredRegistrationTypes, setFilteredRegistrationTypes] = useState<string[]>([])
  76. const [filteredClientTypes, setFilteredClientTypes] = useState<string[]>([])
  77. const [filterString, setFilterString] = useState<string>('')
  78. const searchInputRef = useRef<HTMLInputElement>(null)
  79. const { hostEndpoint: clientEndpoint } = useProjectApiUrl({ projectRef })
  80. const {
  81. data,
  82. error,
  83. isPending: isLoading,
  84. isSuccess,
  85. isError,
  86. } = useOAuthServerAppsQuery({ projectRef })
  87. const oAuthApps = useMemo(() => data?.clients || [], [data])
  88. const { mutateAsync: regenerateSecret, isPending: isRegenerating } =
  89. useOAuthServerAppRegenerateSecretMutation({
  90. onSuccess: (data) => {
  91. if (data) setNewOAuthApp(data)
  92. },
  93. })
  94. const [sort, setSort] = useQueryState(
  95. 'sort',
  96. parseAsStringLiteral<OAuthAppsSort>(OAUTH_APPS_SORT_VALUES).withDefault('name:asc')
  97. )
  98. const [showCreateSheet, setShowCreateSheet] = useQueryState(
  99. 'new',
  100. parseAsBoolean.withDefault(false)
  101. )
  102. const [selectedAppToEdit, setSelectedAppToEdit] = useQueryState('edit', parseAsString)
  103. const appToEdit = oAuthApps?.find((app) => app.client_id === selectedAppToEdit)
  104. const [selectedAppToDelete, setSelectedAppToDelete] = useQueryState('delete', parseAsString)
  105. const appToDelete = oAuthApps?.find((app) => app.client_id === selectedAppToDelete)
  106. const {
  107. mutate: deleteOAuthApp,
  108. isPending: isDeletingApp,
  109. isSuccess: isSuccessDelete,
  110. } = useOAuthServerAppDeleteMutation({
  111. onSuccess: () => {
  112. toast.success(`Successfully deleted OAuth app`)
  113. setSelectedAppToDelete(null)
  114. },
  115. })
  116. const filteredAndSortedOAuthApps = useMemo(() => {
  117. const filtered = filterOAuthApps({
  118. apps: oAuthApps,
  119. searchString: filterString,
  120. registrationTypes: filteredRegistrationTypes,
  121. clientTypes: filteredClientTypes,
  122. })
  123. const [sortCol, sortOrder] = sort.split(':') as [OAuthAppsSortColumn, OAuthAppsSortOrder]
  124. const orderMultiplier = sortOrder === 'asc' ? 1 : -1
  125. return filtered.sort((a, b) => {
  126. if (sortCol === 'name') {
  127. return (a.client_name || '').localeCompare(b.client_name || '') * orderMultiplier
  128. }
  129. if (sortCol === 'client_type') {
  130. return a.client_type.localeCompare(b.client_type) * orderMultiplier
  131. }
  132. if (sortCol === 'registration_type') {
  133. return a.registration_type.localeCompare(b.registration_type) * orderMultiplier
  134. }
  135. if (sortCol === 'created_at') {
  136. return (
  137. (new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * orderMultiplier
  138. )
  139. }
  140. return 0
  141. })
  142. }, [oAuthApps, filterString, filteredRegistrationTypes, filteredClientTypes, sort])
  143. const hasActiveFilters =
  144. filterString.length > 0 ||
  145. filteredRegistrationTypes.length > 0 ||
  146. filteredClientTypes.length > 0
  147. const handleResetFilters = () => {
  148. setFilterString('')
  149. setFilteredRegistrationTypes([])
  150. setFilteredClientTypes([])
  151. }
  152. useShortcut(
  153. SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
  154. () => {
  155. searchInputRef.current?.focus()
  156. searchInputRef.current?.select()
  157. },
  158. { label: 'Search OAuth apps' }
  159. )
  160. useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, handleResetFilters)
  161. const handleSortChange = (column: OAuthAppsSortColumn) => {
  162. const [currentCol, currentOrder] = sort.split(':') as [OAuthAppsSortColumn, OAuthAppsSortOrder]
  163. if (currentCol === column) {
  164. // Cycle through: asc -> desc -> no sort (default)
  165. if (currentOrder === 'asc') {
  166. setSort(`${column}:desc` as OAuthAppsSort)
  167. } else {
  168. // Reset to default sort (name:asc)
  169. setSort('name:asc')
  170. }
  171. } else {
  172. // New column, start with asc
  173. setSort(`${column}:asc` as OAuthAppsSort)
  174. }
  175. }
  176. const isCreateMode = showCreateSheet && isOAuthServerEnabled
  177. const isEditMode = !!appToEdit
  178. const isCreateOrUpdateSheetVisible = isCreateMode || isEditMode
  179. // Prevent opening the create sheet if OAuth Server is disabled
  180. useEffect(() => {
  181. if (isSuccessAuthConfig && !isOAuthServerEnabled && showCreateSheet) {
  182. setShowCreateSheet(false)
  183. }
  184. }, [isSuccessAuthConfig, isOAuthServerEnabled, showCreateSheet, setShowCreateSheet])
  185. useEffect(() => {
  186. if (isSuccess && !!selectedAppToEdit && !appToEdit) {
  187. toast('App not found')
  188. setSelectedAppToEdit(null)
  189. }
  190. }, [appToEdit, isSuccess, selectedAppToEdit, setSelectedAppToEdit])
  191. useEffect(() => {
  192. if (isSuccess && !!selectedAppToDelete && !appToDelete && !isSuccessDelete) {
  193. toast('App not found')
  194. setSelectedAppToDelete(null)
  195. }
  196. }, [appToDelete, isSuccess, isSuccessDelete, selectedAppToDelete, setSelectedAppToDelete])
  197. if (isAuthConfigLoading || (isOAuthServerEnabled && isLoading)) {
  198. return <GenericSkeletonLoader />
  199. }
  200. if (isError) {
  201. return <AlertError error={error} subject="Failed to retrieve OAuth Server apps" />
  202. }
  203. return (
  204. <>
  205. <div className="flex flex-col gap-y-4">
  206. {newOAuthApp?.client_secret && (
  207. <NewOAuthAppBanner oauthApp={newOAuthApp} onClose={() => setNewOAuthApp(undefined)} />
  208. )}
  209. {!isOAuthServerEnabled && (
  210. <Admonition
  211. type="default"
  212. layout="horizontal"
  213. className="mb-8"
  214. title="OAuth Server is disabled"
  215. description="Enable OAuth Server to make your project act as an identity provider for third-party applications."
  216. actions={
  217. <Button asChild type="default">
  218. <Link href={`/project/${projectRef}/auth/oauth-server`}>OAuth Server Settings</Link>
  219. </Button>
  220. }
  221. />
  222. )}
  223. <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
  224. <div className="flex flex-col lg:flex-row lg:items-center gap-2">
  225. <InputGroup className="w-full lg:w-52">
  226. <InputGroupInput
  227. ref={searchInputRef}
  228. size="tiny"
  229. placeholder="Search OAuth apps"
  230. value={filterString}
  231. onChange={(e) => setFilterString(e.target.value)}
  232. onKeyDown={onSearchInputEscape(filterString, setFilterString)}
  233. />
  234. <InputGroupAddon>
  235. <Search />
  236. </InputGroupAddon>
  237. </InputGroup>
  238. <FilterPopover
  239. name="Registration Type"
  240. options={OAUTH_APP_REGISTRATION_TYPE_OPTIONS}
  241. labelKey="name"
  242. valueKey="value"
  243. iconKey="icon"
  244. activeOptions={filteredRegistrationTypes}
  245. labelClass="text-xs text-foreground-light"
  246. maxHeightClass="h-[190px]"
  247. className="w-52"
  248. onSaveFilters={setFilteredRegistrationTypes}
  249. />
  250. <FilterPopover
  251. name="Client Type"
  252. options={OAUTH_APP_CLIENT_TYPE_OPTIONS}
  253. labelKey="name"
  254. valueKey="value"
  255. iconKey="icon"
  256. activeOptions={filteredClientTypes}
  257. labelClass="text-xs text-foreground-light"
  258. maxHeightClass="h-[190px]"
  259. className="w-52"
  260. onSaveFilters={setFilteredClientTypes}
  261. />
  262. {hasActiveFilters && (
  263. <Button
  264. type="default"
  265. size="tiny"
  266. className="px-1"
  267. icon={<X />}
  268. onClick={handleResetFilters}
  269. />
  270. )}
  271. </div>
  272. <div className="flex items-center gap-x-2">
  273. {isOAuthServerEnabled ? (
  274. <Shortcut
  275. id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
  276. label="Create new OAuth app"
  277. onTrigger={() => setShowCreateSheet(true)}
  278. side="bottom"
  279. >
  280. <Button
  281. type="primary"
  282. icon={<Plus />}
  283. onClick={() => setShowCreateSheet(true)}
  284. className="grow"
  285. >
  286. New OAuth App
  287. </Button>
  288. </Shortcut>
  289. ) : (
  290. <ButtonTooltip
  291. disabled
  292. icon={<Plus />}
  293. onClick={() => setShowCreateSheet(true)}
  294. className="grow"
  295. tooltip={{
  296. content: {
  297. side: 'bottom',
  298. text: 'OAuth server must be enabled in settings',
  299. },
  300. }}
  301. >
  302. New OAuth App
  303. </ButtonTooltip>
  304. )}
  305. </div>
  306. </div>
  307. <div className="w-full overflow-hidden overflow-x-auto">
  308. <Card className="@container">
  309. <Table containerProps={{ stickyLastColumn: true }}>
  310. <TableHeader>
  311. <TableRow>
  312. <TableHead className="w-48 max-w-48 flex">
  313. <TableHeadSort column="name" currentSort={sort} onSortChange={handleSortChange}>
  314. Name
  315. </TableHeadSort>
  316. </TableHead>
  317. <TableHead>Client ID</TableHead>
  318. <TableHead>
  319. <TableHeadSort
  320. column="client_type"
  321. currentSort={sort}
  322. onSortChange={handleSortChange}
  323. >
  324. Client Type
  325. </TableHeadSort>
  326. </TableHead>
  327. <TableHead>
  328. <TableHeadSort
  329. column="registration_type"
  330. currentSort={sort}
  331. onSortChange={handleSortChange}
  332. >
  333. Registration Type
  334. </TableHeadSort>
  335. </TableHead>
  336. <TableHead>
  337. <TableHeadSort
  338. column="created_at"
  339. currentSort={sort}
  340. onSortChange={handleSortChange}
  341. >
  342. Created
  343. </TableHeadSort>
  344. </TableHead>
  345. <TableHead className="w-8 px-0">
  346. <div className="bg-200! px-4 w-full h-full flex items-center border-l @[944px]:border-l-0" />
  347. </TableHead>
  348. </TableRow>
  349. </TableHeader>
  350. <TableBody>
  351. {filteredAndSortedOAuthApps.length === 0 && (
  352. <TableRow>
  353. <TableCell colSpan={6}>
  354. <p className="text-foreground-lighter">No OAuth apps found</p>
  355. </TableCell>
  356. </TableRow>
  357. )}
  358. {filteredAndSortedOAuthApps.length > 0 &&
  359. filteredAndSortedOAuthApps.map((app) => (
  360. <TableRow key={app.client_id} className="w-full">
  361. <TableCell title={app.client_name}>
  362. <Button
  363. type="text"
  364. className="text-link-table-cell text-sm p-0 hover:bg-transparent title [&>span]:w-full!"
  365. onClick={() => setSelectedAppToEdit(app.client_id)}
  366. title={app.client_name}
  367. >
  368. {app.client_name}
  369. </Button>
  370. </TableCell>
  371. <TableCell title={app.client_id}>
  372. <code className="text-code-inline">{app.client_id}</code>
  373. </TableCell>
  374. <TableCell className="max-w-28 capitalize">{app.client_type}</TableCell>
  375. <TableCell className="max-w-28 capitalize">{app.registration_type}</TableCell>
  376. <TableCell className="min-w-28 max-w-40 w-1/6">
  377. <TimestampInfo
  378. className="text-sm"
  379. utcTimestamp={app.created_at}
  380. labelFormat="D MMM, YYYY"
  381. />
  382. </TableCell>
  383. <TableCell className="max-w-20 bg-surface-100 @[944px]:hover:bg-surface-200 px-6">
  384. <div className="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center border-l @[944px]:border-l-0">
  385. <DropdownMenu>
  386. <DropdownMenuTrigger asChild>
  387. <Button type="default" className="px-1" icon={<MoreVertical />} />
  388. </DropdownMenuTrigger>
  389. <DropdownMenuContent side="bottom" align="end" className="w-48">
  390. <DropdownMenuItem
  391. className="space-x-2"
  392. onClick={() => {
  393. setSelectedAppToEdit(app.client_id)
  394. }}
  395. >
  396. <Edit size={12} />
  397. <p>Edit OAuth app</p>
  398. </DropdownMenuItem>
  399. {app.client_type === 'confidential' && (
  400. <DropdownMenuItem
  401. className="space-x-2"
  402. onClick={() => {
  403. setSelectedApp(app)
  404. setShowRegenerateDialog(true)
  405. }}
  406. >
  407. <RotateCw size={12} />
  408. <p>Regenerate client secret</p>
  409. </DropdownMenuItem>
  410. )}
  411. <DropdownMenuSeparator />
  412. <DropdownMenuItem
  413. className="space-x-2"
  414. onClick={() => setSelectedAppToDelete(app.client_id)}
  415. >
  416. <Trash size={12} />
  417. <p>Delete OAuth app</p>
  418. </DropdownMenuItem>
  419. </DropdownMenuContent>
  420. </DropdownMenu>
  421. </div>
  422. </TableCell>
  423. </TableRow>
  424. ))}
  425. </TableBody>
  426. </Table>
  427. </Card>
  428. </div>
  429. </div>
  430. <CreateOrUpdateOAuthAppSheet
  431. visible={isCreateOrUpdateSheetVisible}
  432. appToEdit={appToEdit}
  433. onSuccess={(app) => {
  434. const isCreating = !appToEdit
  435. setShowCreateSheet(false)
  436. setSelectedAppToEdit(null)
  437. setSelectedApp(undefined)
  438. // Only show banner for new apps or regenerated secrets, not for simple edits
  439. if (isCreating || app.client_secret) {
  440. setNewOAuthApp(app)
  441. }
  442. }}
  443. onCancel={() => {
  444. setShowCreateSheet(false)
  445. setSelectedAppToEdit(null)
  446. setSelectedApp(undefined)
  447. }}
  448. />
  449. <DeleteOAuthAppModal
  450. visible={!!appToDelete}
  451. selectedApp={appToDelete}
  452. setVisible={setSelectedAppToDelete}
  453. onDelete={(params: Parameters<typeof deleteOAuthApp>[0]) => {
  454. deleteOAuthApp(params)
  455. }}
  456. isLoading={isDeletingApp}
  457. />
  458. <ConfirmationModal
  459. variant="warning"
  460. visible={showRegenerateDialog}
  461. loading={isRegenerating}
  462. title="Confirm regenerating client secret"
  463. confirmLabel="Confirm"
  464. onCancel={() => setShowRegenerateDialog(false)}
  465. onConfirm={() => {
  466. regenerateSecret({
  467. projectRef,
  468. clientEndpoint,
  469. clientId: selectedApp?.client_id,
  470. })
  471. setShowRegenerateDialog(false)
  472. }}
  473. >
  474. <p className="text-sm text-foreground-light">
  475. Are you sure you wish to regenerate the client secret for "{selectedApp?.client_name}"?
  476. You'll need to update it in all applications that use it. This action cannot be undone.
  477. </p>
  478. </ConfirmationModal>
  479. </>
  480. )
  481. }