SessionsAuthSettingsForm.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { PermissionAction } from '@supabase/shared-types/out/constants'
  3. import { useParams } from 'common'
  4. import { useEffect, useState } from 'react'
  5. import { useForm } from 'react-hook-form'
  6. import { toast } from 'sonner'
  7. import {
  8. Button,
  9. Card,
  10. CardContent,
  11. CardFooter,
  12. Form,
  13. FormControl,
  14. FormField,
  15. FormInputGroupInput,
  16. InputGroup,
  17. InputGroupAddon,
  18. InputGroupText,
  19. Switch,
  20. } from 'ui'
  21. import { GenericSkeletonLoader } from 'ui-patterns'
  22. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  23. import {
  24. PageSection,
  25. PageSectionContent,
  26. PageSectionMeta,
  27. PageSectionSummary,
  28. PageSectionTitle,
  29. } from 'ui-patterns/PageSection'
  30. import * as z from 'zod'
  31. import AlertError from '@/components/ui/AlertError'
  32. import NoPermission from '@/components/ui/NoPermission'
  33. import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
  34. import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
  35. import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
  36. import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
  37. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  38. import { IS_PLATFORM } from '@/lib/constants'
  39. function HoursOrNeverText({ value }: { value: number }) {
  40. if (value === 0) {
  41. return 'never'
  42. } else if (value === 1) {
  43. return 'hour'
  44. } else {
  45. return 'hours'
  46. }
  47. }
  48. const RefreshTokenSchema = z.object({
  49. REFRESH_TOKEN_ROTATION_ENABLED: z.boolean(),
  50. SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: z.coerce.number().min(0, 'Must be a value more than 0'),
  51. })
  52. const UserSessionsSchema = z.object({
  53. SESSIONS_TIMEBOX: z.coerce.number().min(0, 'Must be a positive number'),
  54. SESSIONS_INACTIVITY_TIMEOUT: z.coerce
  55. .number()
  56. .multipleOf(0.1)
  57. .min(0, 'Must be a positive number'),
  58. SESSIONS_SINGLE_PER_USER: z.boolean(),
  59. })
  60. export const SessionsAuthSettingsForm = () => {
  61. const { ref: projectRef } = useParams()
  62. const {
  63. data: authConfig,
  64. error: authConfigError,
  65. isError,
  66. isPending: isLoading,
  67. } = useAuthConfigQuery({ projectRef })
  68. const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation()
  69. // Separate loading states for each form
  70. const [isUpdatingRefreshTokens, setIsUpdatingRefreshTokens] = useState(false)
  71. const [isUpdatingUserSessions, setIsUpdatingUserSessions] = useState(false)
  72. const { can: canReadConfig } = useAsyncCheckPermissions(
  73. PermissionAction.READ,
  74. 'custom_config_gotrue'
  75. )
  76. const { can: canUpdateConfig } = useAsyncCheckPermissions(
  77. PermissionAction.UPDATE,
  78. 'custom_config_gotrue'
  79. )
  80. const { hasAccess: hasUserSessionsEntitlement, isLoading: isLoadingEntitlements } =
  81. useCheckEntitlements('auth.user_sessions')
  82. const promptProPlanUpgrade = IS_PLATFORM && !hasUserSessionsEntitlement
  83. const refreshTokenForm = useForm<z.infer<typeof RefreshTokenSchema>>({
  84. resolver: zodResolver(RefreshTokenSchema as any),
  85. defaultValues: {
  86. REFRESH_TOKEN_ROTATION_ENABLED: false,
  87. SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 0,
  88. },
  89. })
  90. const userSessionsForm = useForm({
  91. resolver: zodResolver(UserSessionsSchema as any),
  92. defaultValues: {
  93. SESSIONS_TIMEBOX: 0,
  94. SESSIONS_INACTIVITY_TIMEOUT: 0,
  95. SESSIONS_SINGLE_PER_USER: false,
  96. },
  97. })
  98. useEffect(() => {
  99. if (authConfig) {
  100. // Only reset forms if they're not currently being updated
  101. if (!isUpdatingRefreshTokens) {
  102. refreshTokenForm.reset({
  103. REFRESH_TOKEN_ROTATION_ENABLED: authConfig.REFRESH_TOKEN_ROTATION_ENABLED || false,
  104. SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: authConfig.SECURITY_REFRESH_TOKEN_REUSE_INTERVAL,
  105. })
  106. }
  107. if (!isUpdatingUserSessions) {
  108. userSessionsForm.reset({
  109. SESSIONS_TIMEBOX: authConfig.SESSIONS_TIMEBOX || 0,
  110. SESSIONS_INACTIVITY_TIMEOUT: authConfig.SESSIONS_INACTIVITY_TIMEOUT || 0,
  111. SESSIONS_SINGLE_PER_USER: authConfig.SESSIONS_SINGLE_PER_USER || false,
  112. })
  113. }
  114. }
  115. }, [authConfig, isUpdatingRefreshTokens, isUpdatingUserSessions])
  116. const onSubmitRefreshTokens = (values: any) => {
  117. const payload = { ...values }
  118. setIsUpdatingRefreshTokens(true)
  119. updateAuthConfig(
  120. { projectRef: projectRef!, config: payload },
  121. {
  122. onError: (error) => {
  123. toast.error(`Failed to update refresh token settings: ${error?.message}`)
  124. setIsUpdatingRefreshTokens(false)
  125. },
  126. onSuccess: () => {
  127. toast.success('Successfully updated refresh token settings')
  128. setIsUpdatingRefreshTokens(false)
  129. },
  130. }
  131. )
  132. }
  133. const onSubmitUserSessions = (values: any) => {
  134. const payload = { ...values }
  135. setIsUpdatingUserSessions(true)
  136. updateAuthConfig(
  137. { projectRef: projectRef!, config: payload },
  138. {
  139. onError: (error) => {
  140. toast.error(`Failed to update user session settings: ${error?.message}`)
  141. setIsUpdatingUserSessions(false)
  142. },
  143. onSuccess: () => {
  144. toast.success('Successfully updated user session settings')
  145. setIsUpdatingUserSessions(false)
  146. },
  147. }
  148. )
  149. }
  150. if (isError) {
  151. return (
  152. <PageSection>
  153. <PageSectionContent>
  154. <AlertError error={authConfigError} subject="Failed to retrieve auth configuration" />
  155. </PageSectionContent>
  156. </PageSection>
  157. )
  158. }
  159. if (!canReadConfig) {
  160. return (
  161. <PageSection>
  162. <PageSectionContent>
  163. <NoPermission resourceText="view auth configuration settings" />
  164. </PageSectionContent>
  165. </PageSection>
  166. )
  167. }
  168. if (isLoading || isLoadingEntitlements) {
  169. return (
  170. <PageSection>
  171. <PageSectionContent>
  172. <GenericSkeletonLoader />
  173. </PageSectionContent>
  174. </PageSection>
  175. )
  176. }
  177. return (
  178. <>
  179. <PageSection>
  180. <PageSectionMeta>
  181. <PageSectionSummary>
  182. <PageSectionTitle>Refresh Tokens</PageSectionTitle>
  183. </PageSectionSummary>
  184. </PageSectionMeta>
  185. <PageSectionContent>
  186. <Form {...refreshTokenForm}>
  187. <form
  188. onSubmit={refreshTokenForm.handleSubmit(onSubmitRefreshTokens)}
  189. className="space-y-4"
  190. >
  191. <Card>
  192. <CardContent>
  193. <FormField
  194. control={refreshTokenForm.control}
  195. name="REFRESH_TOKEN_ROTATION_ENABLED"
  196. render={({ field }) => (
  197. <FormItemLayout
  198. layout="flex-row-reverse"
  199. label="Detect and revoke potentially compromised refresh tokens"
  200. description="Prevent replay attacks from potentially compromised refresh tokens."
  201. >
  202. <FormControl>
  203. <Switch
  204. checked={field.value}
  205. onCheckedChange={field.onChange}
  206. disabled={!canUpdateConfig}
  207. />
  208. </FormControl>
  209. </FormItemLayout>
  210. )}
  211. />
  212. </CardContent>
  213. <CardContent>
  214. <FormField
  215. control={refreshTokenForm.control}
  216. name="SECURITY_REFRESH_TOKEN_REUSE_INTERVAL"
  217. render={({ field }) => (
  218. <FormItemLayout
  219. layout="flex-row-reverse"
  220. label="Refresh token reuse interval"
  221. description="Time interval where the same refresh token can be used multiple times to request for an access token. Recommendation: 10 seconds."
  222. >
  223. <FormControl className="w-full">
  224. <InputGroup>
  225. <FormInputGroupInput
  226. type="number"
  227. min={0}
  228. {...field}
  229. disabled={!canUpdateConfig}
  230. />
  231. <InputGroupAddon align="inline-end">
  232. <InputGroupText>seconds</InputGroupText>
  233. </InputGroupAddon>
  234. </InputGroup>
  235. </FormControl>
  236. </FormItemLayout>
  237. )}
  238. />
  239. </CardContent>
  240. <CardFooter className="justify-end space-x-2">
  241. {refreshTokenForm.formState.isDirty && (
  242. <Button type="default" onClick={() => refreshTokenForm.reset()}>
  243. Cancel
  244. </Button>
  245. )}
  246. <Button
  247. type="primary"
  248. htmlType="submit"
  249. disabled={
  250. !canUpdateConfig ||
  251. isUpdatingRefreshTokens ||
  252. !refreshTokenForm.formState.isDirty
  253. }
  254. loading={isUpdatingRefreshTokens}
  255. >
  256. Save changes
  257. </Button>
  258. </CardFooter>
  259. </Card>
  260. </form>
  261. </Form>
  262. </PageSectionContent>
  263. </PageSection>
  264. <PageSection>
  265. <PageSectionMeta>
  266. <PageSectionSummary>
  267. <PageSectionTitle>User Sessions</PageSectionTitle>
  268. </PageSectionSummary>
  269. </PageSectionMeta>
  270. <PageSectionContent>
  271. <Form {...userSessionsForm}>
  272. <form
  273. onSubmit={userSessionsForm.handleSubmit(onSubmitUserSessions)}
  274. className="space-y-4"
  275. >
  276. <Card>
  277. <CardContent>
  278. <FormField
  279. control={userSessionsForm.control}
  280. name="SESSIONS_SINGLE_PER_USER"
  281. render={({ field }) => (
  282. <FormItemLayout
  283. layout="flex-row-reverse"
  284. label="Enforce single session per user"
  285. description="If enabled, all but a user's most recently active session will be terminated."
  286. >
  287. <FormControl>
  288. <Switch
  289. checked={field.value}
  290. onCheckedChange={field.onChange}
  291. disabled={!canUpdateConfig || !hasUserSessionsEntitlement}
  292. />
  293. </FormControl>
  294. </FormItemLayout>
  295. )}
  296. />
  297. </CardContent>
  298. <CardContent>
  299. <FormField
  300. control={userSessionsForm.control}
  301. name="SESSIONS_TIMEBOX"
  302. render={({ field }) => (
  303. <FormItemLayout
  304. layout="flex-row-reverse"
  305. label="Time-box user sessions"
  306. description="The amount of time before a user is forced to sign in again. Use 0 for never."
  307. >
  308. <FormControl className="w-full">
  309. <InputGroup>
  310. <FormInputGroupInput
  311. type="number"
  312. min={0}
  313. {...field}
  314. disabled={!canUpdateConfig || !hasUserSessionsEntitlement}
  315. />
  316. <InputGroupAddon align="inline-end">
  317. <InputGroupText>
  318. <HoursOrNeverText value={field.value || 0} />
  319. </InputGroupText>
  320. </InputGroupAddon>
  321. </InputGroup>
  322. </FormControl>
  323. </FormItemLayout>
  324. )}
  325. />
  326. </CardContent>
  327. <CardContent>
  328. <FormField
  329. control={userSessionsForm.control}
  330. name="SESSIONS_INACTIVITY_TIMEOUT"
  331. render={({ field }) => (
  332. <FormItemLayout
  333. layout="flex-row-reverse"
  334. label="Inactivity timeout"
  335. description="The amount of time a user needs to be inactive to be forced to sign in again. Use 0 for never."
  336. >
  337. <FormControl className="w-full">
  338. <InputGroup>
  339. <FormInputGroupInput
  340. type="number"
  341. {...field}
  342. className="flex-1"
  343. disabled={!canUpdateConfig || !hasUserSessionsEntitlement}
  344. />
  345. <InputGroupAddon align="inline-end">
  346. <InputGroupText>
  347. <HoursOrNeverText value={field.value || 0} />
  348. </InputGroupText>
  349. </InputGroupAddon>
  350. </InputGroup>
  351. </FormControl>
  352. </FormItemLayout>
  353. )}
  354. />
  355. </CardContent>
  356. {promptProPlanUpgrade && (
  357. <UpgradeToPro
  358. fullWidth
  359. source="authSessions"
  360. featureProposition="configure user sessions"
  361. primaryText="Configuring user sessions is only available on the Pro Plan and above"
  362. secondaryText="Upgrade to Pro Plan to configure settings for user sessions."
  363. />
  364. )}
  365. <CardFooter className="justify-end space-x-2">
  366. {userSessionsForm.formState.isDirty && (
  367. <Button type="default" onClick={() => userSessionsForm.reset()}>
  368. Cancel
  369. </Button>
  370. )}
  371. <Button
  372. type={promptProPlanUpgrade ? 'default' : 'primary'}
  373. htmlType="submit"
  374. disabled={
  375. !canUpdateConfig ||
  376. isUpdatingUserSessions ||
  377. !userSessionsForm.formState.isDirty
  378. }
  379. loading={isUpdatingUserSessions}
  380. >
  381. Save changes
  382. </Button>
  383. </CardFooter>
  384. </Card>
  385. </form>
  386. </Form>
  387. </PageSectionContent>
  388. </PageSection>
  389. </>
  390. )
  391. }