Skip to content
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

Merged
merged 28 commits into from
Aug 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9e6618e
feat: create request access screen
drewlyton Jul 24, 2024
a36f9a6
refactor: readablity edits
drewlyton Jul 24, 2024
b04e2ab
feat: add submitting states and toasts
drewlyton Jul 25, 2024
f501c78
refactor: merge useEffect hook and update some comments
drewlyton Jul 25, 2024
b154da1
chore: add comment explaining the client access strategy
drewlyton Jul 25, 2024
f5ec9f6
feat: update pending state copy and style
drewlyton Jul 25, 2024
28ae9b1
fix: remove eslint disable from AuthBoundary
drewlyton Jul 25, 2024
79c0f7a
revert: not authenticated screen text
drewlyton Jul 25, 2024
40d7c04
fix: typo in comment
drewlyton Jul 25, 2024
1fe48a3
fix: bug in date check
drewlyton Jul 25, 2024
1d85967
fix: authboundary provider error, client config bug, and enter key to…
drewlyton Jul 30, 2024
5d22e48
fix: update text copy for NotAuthenticated
drewlyton Aug 1, 2024
641e8b6
fix: remove toast for success message
drewlyton Aug 1, 2024
c7944a1
fix: couple small ui tweaks
drewlyton Aug 1, 2024
884b5ac
fix: revert muted text color
drewlyton Aug 1, 2024
a57c025
feat: add requestURL to project request payload
svirs Aug 16, 2024
805adf3
fix: copy changes, check for declined invite, error handling
svirs Aug 16, 2024
05a879d
fix: use date-fns, change copy if resubmitting a request
svirs Aug 16, 2024
7785204
use ui-components for button/dialog
svirs Aug 16, 2024
6db912e
pnpm-lock
svirs Aug 16, 2024
6fddcce
maxLength for text input for note
svirs Aug 17, 2024
8e50b81
feat: limit note length, show char count
svirs Aug 17, 2024
3f97443
hide submit button when declined
svirs Aug 17, 2024
d3d4f16
remove dialog padding
svirs Aug 19, 2024
d340bed
use error responses for submission errors
svirs Aug 19, 2024
2ae0346
pnpm-lock.yaml
svirs Aug 19, 2024
f4eef22
pnpm-lock.yaml
svirs Aug 20, 2024
3d2f3fe
optional chaining on err response object
svirs Aug 20, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Member

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?

Copy link
Contributor

@svirs svirs Aug 16, 2024

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

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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Member

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The 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 example
import 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>
  )
}

Copy link
Contributor

Choose a reason for hiding this comment

The 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) => {
Copy link
Member

Choose a reason for hiding this comment

The 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 NotAuthenticatedScreen

Copy link
Contributor

Choose a reason for hiding this comment

The 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)
Copy link
Contributor Author

@drewlyton drewlyton Jul 25, 2024

Choose a reason for hiding this comment

The 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 NotAuthenticatedScreen it seemed like we just threw an error and crashed the studio if we saw one

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(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice use of 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
Loading