Skip to content

Commit 59f4fda

Browse files
drewlytonrexxars
andauthored
feat: add ability to request edit access from viewer role (#7546)
* 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]>
1 parent 85941d2 commit 59f4fda

File tree

8 files changed

+405
-39
lines changed

8 files changed

+405
-39
lines changed

packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx

+10-4
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import {
1313
import {Button, Dialog} from '../../../ui-components'
1414
import {NotAuthenticatedScreen} from './NotAuthenticatedScreen'
1515

16-
interface AccessRequest {
16+
/** @internal */
17+
export interface AccessRequest {
1718
id: string
1819
status: 'pending' | 'accepted' | 'declined'
1920
resourceId: string
@@ -22,6 +23,8 @@ interface AccessRequest {
2223
updatedAt: string
2324
updatedByUserId: string
2425
requestedByUserId: string
26+
requestedRole: string
27+
type: 'access' | 'role'
2528
note: string
2629
}
2730

@@ -82,7 +85,10 @@ export function RequestAccessScreen() {
8285
if (requests && requests?.length) {
8386
const projectRequests = requests.filter((request) => request.resourceId === projectId)
8487
const declinedRequest = projectRequests.find((request) => request.status === 'declined')
85-
if (declinedRequest) {
88+
if (
89+
declinedRequest &&
90+
isAfter(addWeeks(new Date(declinedRequest.createdAt), 2), new Date())
91+
) {
8692
setHasBeenDenied(true)
8793
return
8894
}
@@ -127,7 +133,7 @@ export function RequestAccessScreen() {
127133
.request<AccessRequest | null>({
128134
url: `/access/project/${projectId}/requests`,
129135
method: 'post',
130-
body: {note, requestUrl: window?.location.href},
136+
body: {note, requestUrl: window?.location.href, type: 'access'},
131137
})
132138
.then((request) => {
133139
if (request) setHasPendingRequest(true)
@@ -148,7 +154,7 @@ export function RequestAccessScreen() {
148154
} else {
149155
toast.push({
150156
title: 'There was a problem submitting your request.',
151-
status: errMessage,
157+
status: 'error',
152158
})
153159
}
154160
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import {useTelemetry} from '@sanity/telemetry/react'
2+
import {Box, Card, DialogProvider, Flex, Stack, Text, TextInput, useToast} from '@sanity/ui'
3+
import {useId, useMemo, useState} from 'react'
4+
import {useObservable} from 'react-rx'
5+
import {catchError, map, type Observable, of, startWith} from 'rxjs'
6+
import {type Role, useClient, useProjectId, useTranslation, useZIndex} from 'sanity'
7+
import {styled} from 'styled-components'
8+
9+
import {Dialog} from '../../../ui-components'
10+
import {structureLocaleNamespace} from '../../i18n'
11+
import {AskToEditRequestSent} from './__telemetry__/RequestPermissionDialog.telemetry'
12+
import {type AccessRequest} from './useRoleRequestsStatus'
13+
14+
const MAX_NOTE_LENGTH = 150
15+
16+
/** @internal */
17+
export const DialogBody = styled(Box)`
18+
box-sizing: border-box;
19+
`
20+
21+
/** @internal */
22+
export const LoadingContainer = styled(Flex).attrs({
23+
align: 'center',
24+
direction: 'column',
25+
justify: 'center',
26+
})`
27+
height: 110px;
28+
`
29+
30+
/** @internal */
31+
export interface RequestPermissionDialogProps {
32+
onClose?: () => void
33+
onRequestSubmitted?: () => void
34+
}
35+
36+
/**
37+
* A confirmation dialog used to prevent unwanted document deletes. Loads all
38+
* the referencing internal and cross-data references prior to showing the
39+
* delete button.
40+
*
41+
* @internal
42+
*/
43+
export function RequestPermissionDialog({
44+
onClose,
45+
onRequestSubmitted,
46+
}: RequestPermissionDialogProps) {
47+
const {t} = useTranslation(structureLocaleNamespace)
48+
const telemtry = useTelemetry()
49+
const dialogId = `request-permissions-${useId()}`
50+
const projectId = useProjectId()
51+
const client = useClient({apiVersion: '2024-09-26'})
52+
const toast = useToast()
53+
const zOffset = useZIndex()
54+
55+
const [isSubmitting, setIsSubmitting] = useState(false)
56+
57+
const [note, setNote] = useState('')
58+
const [noteLength, setNoteLength] = useState<number>(0)
59+
60+
const [msgError, setMsgError] = useState<string | undefined>()
61+
const [hasTooManyRequests, setHasTooManyRequests] = useState<boolean>(false)
62+
const [hasBeenDenied, setHasBeenDenied] = useState<boolean>(false)
63+
64+
const requestedRole$: Observable<'administrator' | 'editor'> = useMemo(() => {
65+
const adminRole = 'administrator' as const
66+
if (!projectId || !client) return of(adminRole)
67+
return client.observable
68+
.request<(Role & {appliesToUsers?: boolean})[]>({url: `/projects/${projectId}/roles`})
69+
.pipe(
70+
map((roles) => {
71+
const hasEditor = roles
72+
.filter((role) => role?.appliesToUsers)
73+
.find((role) => role.name === 'editor')
74+
return hasEditor ? 'editor' : adminRole
75+
}),
76+
startWith(adminRole),
77+
catchError(() => of(adminRole)),
78+
)
79+
}, [projectId, client])
80+
81+
const requestedRole = useObservable(requestedRole$)
82+
83+
const onSubmit = () => {
84+
setIsSubmitting(true)
85+
client
86+
.request<AccessRequest | null>({
87+
url: `/access/project/${projectId}/requests`,
88+
method: 'post',
89+
body: {note, requestUrl: window?.location.href, requestedRole, type: 'role'},
90+
})
91+
.then((request) => {
92+
if (request) {
93+
if (onRequestSubmitted) onRequestSubmitted()
94+
telemtry.log(AskToEditRequestSent)
95+
toast.push({title: 'Edit access requested'})
96+
}
97+
})
98+
.catch((err) => {
99+
const statusCode = err?.response?.statusCode
100+
const errMessage = err?.response?.body?.message
101+
if (statusCode === 429) {
102+
// User is over their cross-project request limit
103+
setHasTooManyRequests(true)
104+
setMsgError(errMessage)
105+
}
106+
if (statusCode === 409) {
107+
// If we get a 409, user has been denied on this project or has a valid pending request
108+
// valid pending request should be handled by GET request above
109+
setHasBeenDenied(true)
110+
setMsgError(errMessage)
111+
} else {
112+
toast.push({
113+
title: 'There was a problem submitting your request.',
114+
status: 'error',
115+
})
116+
}
117+
})
118+
.finally(() => {
119+
setIsSubmitting(false)
120+
})
121+
}
122+
123+
return (
124+
<DialogProvider position={'fixed'} zOffset={zOffset.fullscreen}>
125+
<Dialog
126+
width={1}
127+
id={dialogId}
128+
header={t('request-permission-dialog.header.text')}
129+
footer={{
130+
cancelButton: {
131+
onClick: onClose,
132+
text: t('confirm-dialog.cancel-button.fallback-text'),
133+
},
134+
confirmButton: {
135+
onClick: onSubmit,
136+
loading: isSubmitting,
137+
disabled: hasTooManyRequests || hasBeenDenied,
138+
text: t('request-permission-dialog.confirm-button.text'),
139+
tone: 'primary',
140+
type: 'submit',
141+
},
142+
}}
143+
onClose={onClose}
144+
onClickOutside={onClose}
145+
>
146+
<DialogBody>
147+
<Stack space={4}>
148+
<Text>{t('request-permission-dialog.description.text')}</Text>
149+
{hasTooManyRequests || hasBeenDenied ? (
150+
<Card tone={'caution'} padding={3} radius={2} shadow={1}>
151+
<Text size={1}>
152+
{hasTooManyRequests && (
153+
<>{msgError ?? t('request-permission-dialog.warning.limit-reached.text')}</>
154+
)}
155+
{hasBeenDenied && (
156+
<>{msgError ?? t('request-permission-dialog.warning.denied.text')}</>
157+
)}
158+
</Text>
159+
</Card>
160+
) : (
161+
<Stack space={3} paddingBottom={0}>
162+
<TextInput
163+
placeholder={t('request-permission-dialog.note-input.placeholder.text')}
164+
disabled={isSubmitting}
165+
onKeyDown={(e) => {
166+
if (e.key === 'Enter') onSubmit()
167+
}}
168+
maxLength={MAX_NOTE_LENGTH}
169+
value={note}
170+
onChange={(e) => {
171+
setNote(e.currentTarget.value)
172+
setNoteLength(e.currentTarget.value.length)
173+
}}
174+
/>
175+
176+
<Text align="right" muted size={1}>{`${noteLength}/${MAX_NOTE_LENGTH}`}</Text>
177+
</Stack>
178+
)}
179+
</Stack>
180+
</DialogBody>
181+
</Dialog>
182+
</DialogProvider>
183+
)
184+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import {defineEvent} from '@sanity/telemetry'
2+
3+
/**
4+
* When a draft in a live edit document is published
5+
* @internal
6+
*/
7+
export const AskToEditDialogOpened = defineEvent({
8+
name: 'Ask To Edit Dialog Opened',
9+
version: 1,
10+
description: 'User clicked the "Ask to edit" button in the document permissions banner',
11+
})
12+
13+
/** @internal */
14+
export const AskToEditRequestSent = defineEvent({
15+
name: 'Ask To Edit Request Sent',
16+
version: 1,
17+
description: 'User sent a role change request from the dialog',
18+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './RequestPermissionDialog'
2+
export * from './useRoleRequestsStatus'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import {addWeeks, isAfter, isBefore} from 'date-fns'
2+
import {useMemo} from 'react'
3+
import {useObservable} from 'react-rx'
4+
import {from, of} from 'rxjs'
5+
import {catchError, map, startWith} from 'rxjs/operators'
6+
import {useClient, useProjectId} from 'sanity'
7+
8+
/** @internal */
9+
export interface AccessRequest {
10+
id: string
11+
status: 'pending' | 'accepted' | 'declined'
12+
resourceId: string
13+
resourceType: 'project'
14+
createdAt: string
15+
updatedAt: string
16+
updatedByUserId: string
17+
requestedByUserId: string
18+
requestedRole: string
19+
type: 'access' | 'role'
20+
note: string
21+
}
22+
23+
/** @internal */
24+
export const useRoleRequestsStatus = () => {
25+
const client = useClient({apiVersion: '2024-07-01'})
26+
const projectId = useProjectId()
27+
28+
const checkRoleRequests = useMemo(() => {
29+
if (!client || !projectId) {
30+
return of({loading: false, error: false, status: 'none'})
31+
}
32+
33+
return from(
34+
client.request<AccessRequest[] | null>({
35+
url: `/access/requests/me`,
36+
}),
37+
).pipe(
38+
map((requests) => {
39+
if (requests && requests.length) {
40+
// Filter requests for the specific project and where type is 'role'
41+
const projectRequests = requests.filter(
42+
(request) => request.resourceId === projectId && request.type === 'role',
43+
)
44+
45+
const declinedRequest = projectRequests.find((request) => request.status === 'declined')
46+
if (
47+
declinedRequest &&
48+
isAfter(addWeeks(new Date(declinedRequest.createdAt), 2), new Date())
49+
) {
50+
return {loading: false, error: false, status: 'declined'}
51+
}
52+
53+
const pendingRequest = projectRequests.find(
54+
(request) =>
55+
request.status === 'pending' &&
56+
isAfter(addWeeks(new Date(request.createdAt), 2), new Date()),
57+
)
58+
if (pendingRequest) {
59+
return {loading: false, error: false, status: 'pending'}
60+
}
61+
62+
const oldPendingRequest = projectRequests.find(
63+
(request) =>
64+
request.status === 'pending' &&
65+
isBefore(addWeeks(new Date(request.createdAt), 2), new Date()),
66+
)
67+
if (oldPendingRequest) {
68+
return {loading: false, error: false, status: 'expired'}
69+
}
70+
}
71+
return {loading: false, error: false, status: 'none'}
72+
}),
73+
catchError((err) => {
74+
console.error('Failed to fetch access requests', err)
75+
return of({loading: false, error: true, status: undefined})
76+
}),
77+
startWith({loading: true, error: false, status: undefined}), // Start with loading state
78+
)
79+
}, [client, projectId])
80+
81+
// Use useObservable to subscribe to the checkRoleRequests observable
82+
const {loading, error, status} = useObservable(checkRoleRequests, {
83+
loading: true,
84+
error: false,
85+
status: undefined,
86+
})
87+
88+
return {data: status, loading, error}
89+
}

0 commit comments

Comments
 (0)