Skip to content

Commit

Permalink
feat(sanity): request access flow (#7248)
Browse files Browse the repository at this point in the history
* feat: create request access screen

* refactor: readablity edits

* feat: add submitting states and toasts

* refactor: merge useEffect hook and update some comments

* chore: add comment explaining the client access strategy

* feat: update pending state copy and style

* fix: remove eslint disable from AuthBoundary

* revert: not authenticated screen text

* fix: typo in comment

* fix: bug in date check

* fix: authboundary provider error, client config bug, and enter key to submit

* fix: update text copy for NotAuthenticated

* fix: remove toast for success message

* fix: couple small ui tweaks

* fix: revert muted text color

* feat: add requestURL to project request payload

* fix: copy changes, check for declined invite, error handling

* fix: use date-fns, change copy if resubmitting a request

* use ui-components for button/dialog

* pnpm-lock

* maxLength for text input for note

* feat: limit note length, show char count

* hide submit button when declined

* remove dialog padding

* use error responses for submission errors

* pnpm-lock.yaml

* pnpm-lock.yaml

* optional chaining on err response object

Co-authored-by: Rico Kahler <ricokahler@gmail.com>

---------

Co-authored-by: svirs <shur@sanity.io>
Co-authored-by: Rico Kahler <ricokahler@gmail.com>
  • Loading branch information
3 people committed Aug 20, 2024
1 parent 49083dd commit 5ab35a5
Show file tree
Hide file tree
Showing 5 changed files with 2,092 additions and 1,140 deletions.
13 changes: 10 additions & 3 deletions packages/sanity/src/core/studio/AuthBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {type ComponentType, type ReactNode, useEffect, useState} from 'react'

import {LoadingBlock} from '../components/loadingBlock'
import {useActiveWorkspace} from './activeWorkspaceMatcher'
import {AuthenticateScreen, NotAuthenticatedScreen} from './screens'
import {AuthenticateScreen, NotAuthenticatedScreen, RequestAccessScreen} from './screens'

interface AuthBoundaryProps {
children: ReactNode
Expand All @@ -23,6 +23,7 @@ export function AuthBoundary({
const [loggedIn, setLoggedIn] = useState<'logged-in' | 'logged-out' | 'loading' | 'unauthorized'>(
'loading',
)
const [loginProvider, setLoginProvider] = useState<string | undefined>()
const {activeWorkspace} = useActiveWorkspace()

useEffect(() => {
Expand All @@ -34,7 +35,7 @@ export function AuthBoundary({
next: ({authenticated, currentUser}) => {
if (currentUser?.roles?.length === 0) {
setLoggedIn('unauthorized')

if (currentUser?.provider) setLoginProvider(currentUser.provider)
return
}

Expand All @@ -50,7 +51,13 @@ export function AuthBoundary({

if (loggedIn === 'loading') return <LoadingComponent />

if (loggedIn === 'unauthorized') return <NotAuthenticatedComponent />
if (loggedIn === 'unauthorized') {
// If using unverified `sanity` login provider, send them
// to basic NotAuthorized component.
if (!loginProvider || loginProvider === 'sanity') return <NotAuthenticatedComponent />
// Otherwise, send user to request access screen
return <RequestAccessScreen />
}

// NOTE: there is currently a bug where the `AuthenticateComponent` will
// flash after the first login with cookieless mode. See `createAuthStore`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ export function NotAuthenticatedScreen() {
>
<Stack space={4}>
<Text>
You are not authorized to access this studio. Maybe you could ask someone to invite you
to collaborate on this project?
You are not authorized to access this studio. Please contact someone with access to
invite you to this project.
</Text>

<Text>
Expand Down
262 changes: 262 additions & 0 deletions packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx
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 {
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)

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) => {
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)
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(() => {
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>
)
}
1 change: 1 addition & 0 deletions packages/sanity/src/core/studio/screens/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export * from './ConfigErrorsScreen'
export * from './CorsOriginErrorScreen'
export * from './NotAuthenticatedScreen'
export * from './NotFoundScreen'
export * from './RequestAccessScreen'
export * from './schemaErrors'
Loading

0 comments on commit 5ab35a5

Please sign in to comment.