-
Notifications
You must be signed in to change notification settings - Fork 432
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(sanity): request access flow #7248
Changes from all commits
9e6618e
a36f9a6
b04e2ab
f501c78
b154da1
f5ec9f6
28ae9b1
79c0f7a
40d7c04
1fe48a3
1d85967
5d22e48
641e8b6
c7944a1
884b5ac
a57c025
805adf3
05a879d
7785204
6db912e
6fddcce
8e50b81
3f97443
d3d4f16
d340bed
2ae0346
f4eef22
3d2f3fe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,262 @@ | ||
/* eslint-disable i18next/no-literal-string,@sanity/i18n/no-attribute-string-literals */ | ||
import {Box, Card, Flex, Stack, Text, TextInput, useToast} from '@sanity/ui' | ||
import {addWeeks, isAfter, isBefore} from 'date-fns' | ||
import {useCallback, useEffect, useState} from 'react' | ||
import { | ||
type CurrentUser, | ||
getProviderTitle, | ||
LoadingBlock, | ||
type SanityClient, | ||
useActiveWorkspace, | ||
} from 'sanity' | ||
|
||
import {Button, Dialog} from '../../../ui-components' | ||
import {NotAuthenticatedScreen} from './NotAuthenticatedScreen' | ||
|
||
interface AccessRequest { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure if this is where this type should go? I believe this will be the only screen to use it but would prefer to follow standard practice for codebase if applicable There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this seems sensible |
||
id: string | ||
status: 'pending' | 'accepted' | 'declined' | ||
resourceId: string | ||
resourceType: 'project' | ||
createdAt: string | ||
updatedAt: string | ||
updatedByUserId: string | ||
requestedByUserId: string | ||
note: string | ||
} | ||
|
||
const MAX_NOTE_LENGTH = 150 | ||
|
||
export function RequestAccessScreen() { | ||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null) | ||
const [client, setClient] = useState<SanityClient | undefined>() | ||
const [projectId, setProjectId] = useState<string | undefined>() | ||
const toast = useToast() | ||
|
||
const [error, setError] = useState<unknown>(null) | ||
const [msgError, setMsgError] = useState<string | undefined>() | ||
const [loading, setLoading] = useState(true) | ||
const [isSubmitting, setIsSubmitting] = useState(false) | ||
|
||
const [hasPendingRequest, setHasPendingRequest] = useState<boolean>(false) | ||
const [hasExpiredPendingRequest, setExpiredHasPendingRequest] = useState<boolean>(false) | ||
const [hasTooManyRequests, setHasTooManyRequests] = useState<boolean>(false) | ||
const [hasBeenDenied, setHasBeenDenied] = useState<boolean>(false) | ||
Comment on lines
+41
to
+44
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How would you feel about combining these into one state object instead of many different state booleans? Here is what claude came up with as an exampleimport React, {useCallback, useEffect, useState} from 'react'
import {Box, Card, Flex, Stack, Text, TextInput, useToast} from '@sanity/ui'
import {addWeeks, isAfter} from 'date-fns'
import {
type CurrentUser,
getProviderTitle,
LoadingBlock,
type SanityClient,
useActiveWorkspace,
} from 'sanity'
import {Button, Dialog} from '../../../ui-components'
import {NotAuthenticatedScreen} from './NotAuthenticatedScreen'
interface AccessRequest {
id: string
status: 'pending' | 'accepted' | 'declined'
resourceId: string
resourceType: 'project'
createdAt: string
updatedAt: string
updatedByUserId: string
requestedByUserId: string
note: string
}
type RequestStatusType =
| { type: 'initial' }
| { type: 'pending' }
| { type: 'expired' }
| { type: 'denied', message?: string }
| { type: 'too_many_requests', message?: string }
| { type: 'error', message: string }
const MAX_NOTE_LENGTH = 150
export function RequestAccessScreen() {
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null)
const [client, setClient] = useState<SanityClient | undefined>()
const [projectId, setProjectId] = useState<string | undefined>()
const toast = useToast()
const [loading, setLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
const [requestStatus, setRequestStatus] = useState<RequestStatusType>({ type: 'initial' })
const [note, setNote] = useState<string>('')
const {activeWorkspace} = useActiveWorkspace()
const handleLogout = useCallback(() => {
activeWorkspace.auth.logout?.()
}, [activeWorkspace])
useEffect(() => {
const subscription = activeWorkspace.auth.state.subscribe({
next: ({client: sanityClient, currentUser: user}) => {
setProjectId(sanityClient.config().projectId)
setClient(sanityClient.withConfig({apiVersion: '2024-07-01'}))
setCurrentUser(user)
},
error: (err) => setRequestStatus({ type: 'error', message: err.message || 'An error occurred' }),
})
return () => subscription.unsubscribe()
}, [activeWorkspace])
useEffect(() => {
if (!client || !projectId) return
client
.request<AccessRequest[] | null>({
url: `/access/requests/me`,
})
.then((requests) => {
if (requests?.length) {
const projectRequests = requests.filter((request) => request.resourceId === projectId)
const declinedRequest = projectRequests.find((request) => request.status === 'declined')
if (declinedRequest) {
setRequestStatus({ type: 'denied' })
return
}
const pendingRequest = projectRequests.find(
(request) =>
request.status === 'pending' &&
isAfter(addWeeks(new Date(request.createdAt), 2), new Date())
)
if (pendingRequest) {
setRequestStatus({ type: 'pending' })
return
}
const oldPendingRequest = projectRequests.find(
(request) =>
request.status === 'pending' &&
!isAfter(addWeeks(new Date(request.createdAt), 2), new Date())
)
if (oldPendingRequest) {
setRequestStatus({ type: 'expired' })
}
}
})
.catch((err) => {
setRequestStatus({ type: 'error', message: err.message || 'Failed to fetch access requests' })
})
.finally(() => {
setLoading(false)
})
}, [client, projectId])
const handleSubmitRequest = useCallback(() => {
if (!client || !projectId) return
setIsSubmitting(true)
client
.request<AccessRequest | null>({
url: `/access/project/${projectId}/requests`,
method: 'post',
body: {note, requestUrl: window?.location.href},
})
.then((request) => {
if (request) setRequestStatus({ type: 'pending' })
})
.catch((err) => {
const statusCode = err?.response?.statusCode
const errMessage = err?.response?.body?.message || 'An error occurred while submitting the request'
if (statusCode === 429) {
setRequestStatus({ type: 'too_many_requests', message: errMessage })
} else if (statusCode === 409) {
setRequestStatus({ type: 'denied', message: errMessage })
} else {
toast.push({
title: 'There was a problem submitting your request.',
status: 'error',
description: errMessage,
})
setRequestStatus({ type: 'error', message: errMessage })
}
})
.finally(() => {
setIsSubmitting(false)
})
}, [note, projectId, client, toast])
const providerTitle = getProviderTitle(currentUser?.provider)
const providerHelp = providerTitle ? ` through ${providerTitle}` : ''
if (loading) return <LoadingBlock />
if (requestStatus.type === 'error') return <NotAuthenticatedScreen />
return (
<Card height="fill">
<Dialog id="not-authorized-dialog" header="Not authorized" width={1}>
<Box>
<Stack space={4}>
<Text>
You are not authorized to access this studio (currently signed in as{' '}
<strong>
{currentUser?.name} ({currentUser?.email})
</strong>
{providerHelp}
).
</Text>
{requestStatus.type !== 'initial' && (
<Card
tone={requestStatus.type === 'pending' ? 'transparent' : 'caution'}
padding={3}
radius={2}
shadow={1}
>
<Text size={1}>
{requestStatus.type === 'too_many_requests' && (
<>
{requestStatus.message ??
"You've reached the limit for access requests across all projects. Please wait before submitting more requests or contact an admin for assistance."}
</>
)}
{requestStatus.type === 'pending' && (
<>Your request to access this project is pending approval.</>
)}
{requestStatus.type === 'denied' && (
<>{requestStatus.message ?? 'Your request to access this project has been declined.'}</>
)}
</Text>
</Card>
)}
{(requestStatus.type === 'initial' || requestStatus.type === 'expired') && (
<>
<Text>
{requestStatus.type === 'expired'
? 'Your previous request has expired. You may again request access below with an optional note.'
: 'You can request access below with an optional note.'}{' '}
The administrator(s) will receive an email letting them know that you are
requesting access.
</Text>
<Stack space={3} paddingBottom={0}>
<TextInput
maxLength={MAX_NOTE_LENGTH}
disabled={isSubmitting}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSubmitRequest()
}}
onChange={(e) => setNote(e.currentTarget.value)}
value={note}
placeholder="Add your note…"
/>
<Text align="right" muted size={1}>{`${note.length}/${MAX_NOTE_LENGTH}`}</Text>
</Stack>
</>
)}
</Stack>
<Flex align="center" justify="space-between" paddingTop={4}>
<Button
mode="bleed"
text="Sign out"
tone="default"
onClick={handleLogout}
size="large"
/>
{(requestStatus.type === 'initial' || requestStatus.type === 'expired') && (
<Button
mode="default"
text="Request access"
disabled={isSubmitting}
loading={isSubmitting}
tone="default"
onClick={handleSubmitRequest}
size="large"
/>
)}
</Flex>
</Box>
</Dialog>
</Card>
)
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this would be a good refactor on the next iteration when we add translations, but for now we'll get the functionality out as-is |
||
|
||
const [note, setNote] = useState<string | undefined>() | ||
const [noteLength, setNoteLength] = useState<number>(0) | ||
|
||
const {activeWorkspace} = useActiveWorkspace() | ||
|
||
const handleLogout = useCallback(() => { | ||
activeWorkspace.auth.logout?.() | ||
}, [activeWorkspace]) | ||
|
||
// Get config info from active workspace | ||
useEffect(() => { | ||
const subscription = activeWorkspace.auth.state.subscribe({ | ||
next: ({client: sanityClient, currentUser: user}) => { | ||
// Need to get the client, projectId, and user from workspace | ||
// because this screen is outside the SourceContext | ||
setProjectId(sanityClient.config().projectId) | ||
setClient(sanityClient.withConfig({apiVersion: '2024-07-01'})) | ||
setCurrentUser(user) | ||
}, | ||
error: setError, | ||
}) | ||
|
||
return () => { | ||
subscription.unsubscribe() | ||
} | ||
}, [activeWorkspace]) | ||
|
||
// Check if user has a pending | ||
// access request for this project | ||
useEffect(() => { | ||
if (!client || !projectId) return | ||
client | ||
.request<AccessRequest[] | null>({ | ||
url: `/access/requests/me`, | ||
}) | ||
.then((requests) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Q: Might it be worth checking for any declined requests? I would imagine we could just fallback to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yup, just added that in now! |
||
if (requests && requests?.length) { | ||
const projectRequests = requests.filter((request) => request.resourceId === projectId) | ||
const declinedRequest = projectRequests.find((request) => request.status === 'declined') | ||
if (declinedRequest) { | ||
setHasBeenDenied(true) | ||
return | ||
} | ||
const pendingRequest = projectRequests.find( | ||
(request) => | ||
request.status === 'pending' && | ||
// Access request is less than 2 weeks old | ||
isAfter(addWeeks(new Date(request.createdAt), 2), new Date()), | ||
) | ||
if (pendingRequest) { | ||
setHasPendingRequest(true) | ||
return | ||
} | ||
const oldPendingRequest = projectRequests.find( | ||
(request) => | ||
request.status === 'pending' && | ||
// Access request is more than 2 weeks old | ||
isBefore(addWeeks(new Date(request.createdAt), 2), new Date()), | ||
) | ||
if (oldPendingRequest) { | ||
setExpiredHasPendingRequest(true) | ||
} | ||
} | ||
}) | ||
.catch((err) => { | ||
console.error(err) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure if it's standard practice to send silent/handled errors to the console for the studio. In |
||
setError(true) | ||
}) | ||
.finally(() => { | ||
setLoading(false) | ||
}) | ||
}, [client, projectId]) | ||
|
||
const handleSubmitRequest = useCallback(() => { | ||
// If we haven't loaded the client or projectId from | ||
// current worspace, return early | ||
if (!client || !projectId) return | ||
|
||
setIsSubmitting(true) | ||
|
||
client | ||
.request<AccessRequest | null>({ | ||
url: `/access/project/${projectId}/requests`, | ||
method: 'post', | ||
body: {note, requestUrl: window?.location.href}, | ||
}) | ||
.then((request) => { | ||
if (request) setHasPendingRequest(true) | ||
}) | ||
.catch((err) => { | ||
const statusCode = err?.response?.statusCode | ||
const errMessage = err?.response?.body?.message | ||
if (statusCode === 429) { | ||
// User is over their cross-project request limit | ||
setHasTooManyRequests(true) | ||
setMsgError(errMessage) | ||
} | ||
if (statusCode === 409) { | ||
// If we get a 409, user has been denied on this project or has a valid pending request | ||
// valid pending request should be handled by GET request above | ||
setHasBeenDenied(true) | ||
setMsgError(errMessage) | ||
} else { | ||
toast.push({ | ||
title: 'There was a problem submitting your request.', | ||
status: errMessage, | ||
}) | ||
} | ||
}) | ||
.finally(() => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice use of |
||
setIsSubmitting(false) | ||
}) | ||
}, [note, projectId, client, toast]) | ||
|
||
const providerTitle = getProviderTitle(currentUser?.provider) | ||
const providerHelp = providerTitle ? ` through ${providerTitle}` : '' | ||
|
||
if (loading) return <LoadingBlock /> | ||
// Fallback to the old not authorized screen | ||
// if error communicating with Access API | ||
if (error) return <NotAuthenticatedScreen /> | ||
return ( | ||
<Card height="fill"> | ||
<Dialog id="not-authorized-dialog" header="Not authorized" width={1}> | ||
<Box> | ||
<Stack space={4}> | ||
<Text> | ||
You are not authorized to access this studio (currently signed in as{' '} | ||
<strong> | ||
{currentUser?.name} ({currentUser?.email}) | ||
</strong> | ||
{providerHelp} | ||
). | ||
</Text> | ||
{hasTooManyRequests || hasPendingRequest || hasBeenDenied ? ( | ||
<Card | ||
tone={hasPendingRequest ? 'transparent' : 'caution'} | ||
padding={3} | ||
radius={2} | ||
shadow={1} | ||
> | ||
<Text size={1}> | ||
{hasTooManyRequests && !hasPendingRequest && ( | ||
<> | ||
{msgError ?? | ||
`You've reached the limit for access requests across all projects. Please wait | ||
before submitting more requests or contact an admin for assistance.`} | ||
</> | ||
)} | ||
{hasPendingRequest && ( | ||
<>Your request to access this project is pending approval.</> | ||
)} | ||
{hasBeenDenied && ( | ||
<>{msgError ?? `Your request to access this project has been declined.`}</> | ||
)} | ||
</Text> | ||
</Card> | ||
) : ( | ||
<> | ||
<Text> | ||
{hasExpiredPendingRequest ? ( | ||
<> | ||
Your previous request has expired. You may again request access below with an | ||
optional note. The administrator(s) will receive an email letting them know | ||
that you are requesting access. | ||
</> | ||
) : ( | ||
<> | ||
You can request access below with an optional note. The administrator(s) will | ||
receive an email letting them know that you are requesting access. | ||
</> | ||
)} | ||
</Text> | ||
<Stack space={3} paddingBottom={0}> | ||
<TextInput | ||
maxLength={MAX_NOTE_LENGTH} | ||
disabled={isSubmitting} | ||
onKeyDown={(e) => { | ||
if (e.key === 'Enter') handleSubmitRequest() | ||
}} | ||
onChange={(e) => { | ||
setNote(e.currentTarget.value) | ||
setNoteLength(e.currentTarget.value.length) | ||
}} | ||
value={note} | ||
placeholder="Add your note…" | ||
/> | ||
<Text align="right" muted size={1}>{`${noteLength}/${MAX_NOTE_LENGTH}`}</Text> | ||
</Stack> | ||
</> | ||
)} | ||
</Stack> | ||
<Flex align={'center'} justify={'space-between'} paddingTop={4}> | ||
<Button | ||
mode="bleed" | ||
text={'Sign out'} | ||
tone="default" | ||
onClick={handleLogout} | ||
size="large" | ||
/> | ||
{!hasTooManyRequests && !hasBeenDenied && ( | ||
<Button | ||
mode="default" | ||
text={hasPendingRequest ? 'Request sent' : 'Request access'} | ||
disabled={hasPendingRequest || isSubmitting} | ||
loading={isSubmitting} | ||
tone="default" | ||
onClick={handleSubmitRequest} | ||
size="large" | ||
/> | ||
)} | ||
</Flex> | ||
</Box> | ||
</Dialog> | ||
</Card> | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
COULD: what do you think to localizing this and the RequestAccessScreen too in
packages/sanity/src/core/i18n/bundles/studio.ts
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We're hoping to get the functionality out in the next release and not add more to this PR, I'll make a ticket to follow up with adding translations to these two screens