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: Request Edit Access flow #7546

Merged
merged 33 commits into from
Oct 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
cdb48af
feat: add stub of request permission dialog
drewlyton Sep 24, 2024
b1103ec
feat: add i18n strings
drewlyton Sep 26, 2024
ed52d94
feat: add permission dialog to banner
drewlyton Sep 26, 2024
859fe55
feat: update i18n strings for banner copy
drewlyton Sep 26, 2024
2f93b09
fix: default to false for all error
drewlyton Sep 26, 2024
ab9fdd0
feat: set banner CTA to primary
drewlyton Sep 26, 2024
8547697
feat: update i18n strings
drewlyton Sep 26, 2024
929907d
feat: update zOffsets
drewlyton Sep 26, 2024
e869f06
fix: type not requestType
drewlyton Sep 26, 2024
a2b4253
fix: update onRequestSubmitted handler
drewlyton Sep 26, 2024
05a05ba
chore: merge types
drewlyton Sep 26, 2024
fa20161
feat: update premissions banner copy and add center prop to banner
drewlyton Sep 26, 2024
f09b0dc
chore: update banner props to take all button props
drewlyton Sep 26, 2024
01f08a3
fix: bring back old Roles copy
drewlyton Sep 27, 2024
e1b0244
fix: update to roleName
drewlyton Sep 27, 2024
93f3969
back to requestedRole
drewlyton Sep 27, 2024
ff49266
fix: use RXjs and other PR changes
drewlyton Sep 27, 2024
d9e356f
fix: copy typo
drewlyton Sep 27, 2024
56c7578
feat: add pending state to banner button
drewlyton Sep 29, 2024
66ead7d
feat: update pending state logic
drewlyton Sep 29, 2024
588485a
chore: update comment
drewlyton Sep 29, 2024
a580e47
fix: specify api version
drewlyton Sep 30, 2024
eee7cb3
feat: add button mode to banner
drewlyton Sep 30, 2024
e3f4a0d
feat: set pending state on submit
drewlyton Sep 30, 2024
7a27651
feat: update to useObservable
drewlyton Sep 30, 2024
d43421c
feat: remove center
drewlyton Sep 30, 2024
2524e47
fix: submit requestedRole as name not title
drewlyton Sep 30, 2024
41f965d
fix: add back in submit handler
drewlyton Sep 30, 2024
695dc05
feat: add tracking
drewlyton Sep 30, 2024
8a8fbe2
feat: update declined request logic and copy for pending state
drewlyton Oct 1, 2024
3117cb9
feat: update requestedRole filter
drewlyton Oct 1, 2024
1978272
fix: type import linting error
drewlyton Oct 1, 2024
c7fa19d
fix: apply suggestions from code review
rexxars Oct 1, 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
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 {
rexxars marked this conversation as resolved.
Show resolved Hide resolved
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'},
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Send type to API - @jwoods02 do you know if this is required or will the API just default to type: 'access'?

Choose a reason for hiding this comment

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

The API will default to access but it's good to send it and be explicit

})
.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.
Comment on lines +37 to +39
Copy link
Member

Choose a reason for hiding this comment

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

Copy pasta?

*
* @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)
Copy link
Member

Choose a reason for hiding this comment

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

Nitpick, but couldn't this be derived from note.length?


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

const requestedRole$: Observable<'administrator' | 'editor'> = useMemo(() => {
Copy link
Member

Choose a reason for hiding this comment

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

Is there always an administrator role, even when using custom roles/role mapping? Or do we assume that if the customer has a viewer role they are using default roles and it shouldn't matter?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Second one is our inference

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
Copy link
Member

Choose a reason for hiding this comment

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

What?

* @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({
rexxars marked this conversation as resolved.
Show resolved Hide resolved
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 {
rexxars marked this conversation as resolved.
Show resolved Hide resolved
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 = () => {
rexxars marked this conversation as resolved.
Show resolved Hide resolved
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
Loading