-
Notifications
You must be signed in to change notification settings - Fork 440
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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]>
- Loading branch information
Showing
8 changed files
with
405 additions
and
39 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
184 changes: 184 additions & 0 deletions
184
packages/sanity/src/structure/components/requestPermissionDialog/RequestPermissionDialog.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
18 changes: 18 additions & 0 deletions
18
...ure/components/requestPermissionDialog/__telemetry__/RequestPermissionDialog.telemetry.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
}) |
2 changes: 2 additions & 0 deletions
2
packages/sanity/src/structure/components/requestPermissionDialog/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export * from './RequestPermissionDialog' | ||
export * from './useRoleRequestsStatus' |
89 changes: 89 additions & 0 deletions
89
packages/sanity/src/structure/components/requestPermissionDialog/useRoleRequestsStatus.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} | ||
} |
Oops, something went wrong.