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