Skip to content

Commit

Permalink
feat: add ability to request edit access from viewer role (#7546)
Browse files Browse the repository at this point in the history
* feat: add stub of request permission dialog

* feat: add i18n strings

* feat: add permission dialog to banner

* feat: update i18n strings for banner copy

* fix: default to false for all error

* feat: set banner CTA to primary

* feat: update i18n strings

* feat: update zOffsets

* fix: type not requestType

* fix: update onRequestSubmitted handler

* chore: merge types

* feat: update premissions banner copy and add center prop to banner

* chore: update banner props to take all button props

* fix: bring back old Roles copy

* fix: update to roleName

* back to requestedRole

* fix: use RXjs and other PR changes

* fix: copy typo

* feat: add pending state to banner button

* feat: update pending state logic

* chore: update comment

* fix: specify api version

* feat: add button mode to banner

* feat: set pending state  on submit

* feat: update to useObservable

* feat: remove center

* fix: submit requestedRole as name not title

* fix: add back in submit handler

* feat: add tracking

* feat: update declined request logic and copy for pending state

* feat: update requestedRole filter

* fix: type import linting error

* fix: apply suggestions from code review

---------

Co-authored-by: Espen Hovlandsdal <[email protected]>
  • Loading branch information
drewlyton and rexxars authored Oct 1, 2024
1 parent 85941d2 commit 59f4fda
Show file tree
Hide file tree
Showing 8 changed files with 405 additions and 39 deletions.
14 changes: 10 additions & 4 deletions packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
import {Button, Dialog} from '../../../ui-components'
import {NotAuthenticatedScreen} from './NotAuthenticatedScreen'

interface AccessRequest {
/** @internal */
export interface AccessRequest {
id: string
status: 'pending' | 'accepted' | 'declined'
resourceId: string
Expand All @@ -22,6 +23,8 @@ interface AccessRequest {
updatedAt: string
updatedByUserId: string
requestedByUserId: string
requestedRole: string
type: 'access' | 'role'
note: string
}

Expand Down Expand Up @@ -82,7 +85,10 @@ export function RequestAccessScreen() {
if (requests && requests?.length) {
const projectRequests = requests.filter((request) => request.resourceId === projectId)
const declinedRequest = projectRequests.find((request) => request.status === 'declined')
if (declinedRequest) {
if (
declinedRequest &&
isAfter(addWeeks(new Date(declinedRequest.createdAt), 2), new Date())
) {
setHasBeenDenied(true)
return
}
Expand Down Expand Up @@ -127,7 +133,7 @@ export function RequestAccessScreen() {
.request<AccessRequest | null>({
url: `/access/project/${projectId}/requests`,
method: 'post',
body: {note, requestUrl: window?.location.href},
body: {note, requestUrl: window?.location.href, type: 'access'},
})
.then((request) => {
if (request) setHasPendingRequest(true)
Expand All @@ -148,7 +154,7 @@ export function RequestAccessScreen() {
} else {
toast.push({
title: 'There was a problem submitting your request.',
status: errMessage,
status: 'error',
})
}
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import {useTelemetry} from '@sanity/telemetry/react'
import {Box, Card, DialogProvider, Flex, Stack, Text, TextInput, useToast} from '@sanity/ui'
import {useId, useMemo, useState} from 'react'
import {useObservable} from 'react-rx'
import {catchError, map, type Observable, of, startWith} from 'rxjs'
import {type Role, useClient, useProjectId, useTranslation, useZIndex} from 'sanity'
import {styled} from 'styled-components'

import {Dialog} from '../../../ui-components'
import {structureLocaleNamespace} from '../../i18n'
import {AskToEditRequestSent} from './__telemetry__/RequestPermissionDialog.telemetry'
import {type AccessRequest} from './useRoleRequestsStatus'

const MAX_NOTE_LENGTH = 150

/** @internal */
export const DialogBody = styled(Box)`
box-sizing: border-box;
`

/** @internal */
export const LoadingContainer = styled(Flex).attrs({
align: 'center',
direction: 'column',
justify: 'center',
})`
height: 110px;
`

/** @internal */
export interface RequestPermissionDialogProps {
onClose?: () => void
onRequestSubmitted?: () => void
}

/**
* A confirmation dialog used to prevent unwanted document deletes. Loads all
* the referencing internal and cross-data references prior to showing the
* delete button.
*
* @internal
*/
export function RequestPermissionDialog({
onClose,
onRequestSubmitted,
}: RequestPermissionDialogProps) {
const {t} = useTranslation(structureLocaleNamespace)
const telemtry = useTelemetry()
const dialogId = `request-permissions-${useId()}`
const projectId = useProjectId()
const client = useClient({apiVersion: '2024-09-26'})
const toast = useToast()
const zOffset = useZIndex()

const [isSubmitting, setIsSubmitting] = useState(false)

const [note, setNote] = useState('')
const [noteLength, setNoteLength] = useState<number>(0)

const [msgError, setMsgError] = useState<string | undefined>()
const [hasTooManyRequests, setHasTooManyRequests] = useState<boolean>(false)
const [hasBeenDenied, setHasBeenDenied] = useState<boolean>(false)

const requestedRole$: Observable<'administrator' | 'editor'> = useMemo(() => {
const adminRole = 'administrator' as const
if (!projectId || !client) return of(adminRole)
return client.observable
.request<(Role & {appliesToUsers?: boolean})[]>({url: `/projects/${projectId}/roles`})
.pipe(
map((roles) => {
const hasEditor = roles
.filter((role) => role?.appliesToUsers)
.find((role) => role.name === 'editor')
return hasEditor ? 'editor' : adminRole
}),
startWith(adminRole),
catchError(() => of(adminRole)),
)
}, [projectId, client])

const requestedRole = useObservable(requestedRole$)

const onSubmit = () => {
setIsSubmitting(true)
client
.request<AccessRequest | null>({
url: `/access/project/${projectId}/requests`,
method: 'post',
body: {note, requestUrl: window?.location.href, requestedRole, type: 'role'},
})
.then((request) => {
if (request) {
if (onRequestSubmitted) onRequestSubmitted()
telemtry.log(AskToEditRequestSent)
toast.push({title: 'Edit access requested'})
}
})
.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: 'error',
})
}
})
.finally(() => {
setIsSubmitting(false)
})
}

return (
<DialogProvider position={'fixed'} zOffset={zOffset.fullscreen}>
<Dialog
width={1}
id={dialogId}
header={t('request-permission-dialog.header.text')}
footer={{
cancelButton: {
onClick: onClose,
text: t('confirm-dialog.cancel-button.fallback-text'),
},
confirmButton: {
onClick: onSubmit,
loading: isSubmitting,
disabled: hasTooManyRequests || hasBeenDenied,
text: t('request-permission-dialog.confirm-button.text'),
tone: 'primary',
type: 'submit',
},
}}
onClose={onClose}
onClickOutside={onClose}
>
<DialogBody>
<Stack space={4}>
<Text>{t('request-permission-dialog.description.text')}</Text>
{hasTooManyRequests || hasBeenDenied ? (
<Card tone={'caution'} padding={3} radius={2} shadow={1}>
<Text size={1}>
{hasTooManyRequests && (
<>{msgError ?? t('request-permission-dialog.warning.limit-reached.text')}</>
)}
{hasBeenDenied && (
<>{msgError ?? t('request-permission-dialog.warning.denied.text')}</>
)}
</Text>
</Card>
) : (
<Stack space={3} paddingBottom={0}>
<TextInput
placeholder={t('request-permission-dialog.note-input.placeholder.text')}
disabled={isSubmitting}
onKeyDown={(e) => {
if (e.key === 'Enter') onSubmit()
}}
maxLength={MAX_NOTE_LENGTH}
value={note}
onChange={(e) => {
setNote(e.currentTarget.value)
setNoteLength(e.currentTarget.value.length)
}}
/>

<Text align="right" muted size={1}>{`${noteLength}/${MAX_NOTE_LENGTH}`}</Text>
</Stack>
)}
</Stack>
</DialogBody>
</Dialog>
</DialogProvider>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {defineEvent} from '@sanity/telemetry'

/**
* When a draft in a live edit document is published
* @internal
*/
export const AskToEditDialogOpened = defineEvent({
name: 'Ask To Edit Dialog Opened',
version: 1,
description: 'User clicked the "Ask to edit" button in the document permissions banner',
})

/** @internal */
export const AskToEditRequestSent = defineEvent({
name: 'Ask To Edit Request Sent',
version: 1,
description: 'User sent a role change request from the dialog',
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './RequestPermissionDialog'
export * from './useRoleRequestsStatus'
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {addWeeks, isAfter, isBefore} from 'date-fns'
import {useMemo} from 'react'
import {useObservable} from 'react-rx'
import {from, of} from 'rxjs'
import {catchError, map, startWith} from 'rxjs/operators'
import {useClient, useProjectId} from 'sanity'

/** @internal */
export interface AccessRequest {
id: string
status: 'pending' | 'accepted' | 'declined'
resourceId: string
resourceType: 'project'
createdAt: string
updatedAt: string
updatedByUserId: string
requestedByUserId: string
requestedRole: string
type: 'access' | 'role'
note: string
}

/** @internal */
export const useRoleRequestsStatus = () => {
const client = useClient({apiVersion: '2024-07-01'})
const projectId = useProjectId()

const checkRoleRequests = useMemo(() => {
if (!client || !projectId) {
return of({loading: false, error: false, status: 'none'})
}

return from(
client.request<AccessRequest[] | null>({
url: `/access/requests/me`,
}),
).pipe(
map((requests) => {
if (requests && requests.length) {
// Filter requests for the specific project and where type is 'role'
const projectRequests = requests.filter(
(request) => request.resourceId === projectId && request.type === 'role',
)

const declinedRequest = projectRequests.find((request) => request.status === 'declined')
if (
declinedRequest &&
isAfter(addWeeks(new Date(declinedRequest.createdAt), 2), new Date())
) {
return {loading: false, error: false, status: 'declined'}
}

const pendingRequest = projectRequests.find(
(request) =>
request.status === 'pending' &&
isAfter(addWeeks(new Date(request.createdAt), 2), new Date()),
)
if (pendingRequest) {
return {loading: false, error: false, status: 'pending'}
}

const oldPendingRequest = projectRequests.find(
(request) =>
request.status === 'pending' &&
isBefore(addWeeks(new Date(request.createdAt), 2), new Date()),
)
if (oldPendingRequest) {
return {loading: false, error: false, status: 'expired'}
}
}
return {loading: false, error: false, status: 'none'}
}),
catchError((err) => {
console.error('Failed to fetch access requests', err)
return of({loading: false, error: true, status: undefined})
}),
startWith({loading: true, error: false, status: undefined}), // Start with loading state
)
}, [client, projectId])

// Use useObservable to subscribe to the checkRoleRequests observable
const {loading, error, status} = useObservable(checkRoleRequests, {
loading: true,
error: false,
status: undefined,
})

return {data: status, loading, error}
}
Loading

0 comments on commit 59f4fda

Please sign in to comment.