diff --git a/packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx b/packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx index fd1db0fc60f..24d08692a88 100644 --- a/packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx +++ b/packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx @@ -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 @@ -22,6 +23,8 @@ interface AccessRequest { updatedAt: string updatedByUserId: string requestedByUserId: string + requestedRole: string + type: 'access' | 'role' note: string } @@ -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 } @@ -127,7 +133,7 @@ export function RequestAccessScreen() { .request({ 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) @@ -148,7 +154,7 @@ export function RequestAccessScreen() { } else { toast.push({ title: 'There was a problem submitting your request.', - status: errMessage, + status: 'error', }) } }) diff --git a/packages/sanity/src/structure/components/requestPermissionDialog/RequestPermissionDialog.tsx b/packages/sanity/src/structure/components/requestPermissionDialog/RequestPermissionDialog.tsx new file mode 100644 index 00000000000..715e1fa30b2 --- /dev/null +++ b/packages/sanity/src/structure/components/requestPermissionDialog/RequestPermissionDialog.tsx @@ -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(0) + + const [msgError, setMsgError] = useState() + const [hasTooManyRequests, setHasTooManyRequests] = useState(false) + const [hasBeenDenied, setHasBeenDenied] = useState(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({ + 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 ( + + + + + {t('request-permission-dialog.description.text')} + {hasTooManyRequests || hasBeenDenied ? ( + + + {hasTooManyRequests && ( + <>{msgError ?? t('request-permission-dialog.warning.limit-reached.text')} + )} + {hasBeenDenied && ( + <>{msgError ?? t('request-permission-dialog.warning.denied.text')} + )} + + + ) : ( + + { + if (e.key === 'Enter') onSubmit() + }} + maxLength={MAX_NOTE_LENGTH} + value={note} + onChange={(e) => { + setNote(e.currentTarget.value) + setNoteLength(e.currentTarget.value.length) + }} + /> + + {`${noteLength}/${MAX_NOTE_LENGTH}`} + + )} + + + + + ) +} diff --git a/packages/sanity/src/structure/components/requestPermissionDialog/__telemetry__/RequestPermissionDialog.telemetry.ts b/packages/sanity/src/structure/components/requestPermissionDialog/__telemetry__/RequestPermissionDialog.telemetry.ts new file mode 100644 index 00000000000..a3e03104bb6 --- /dev/null +++ b/packages/sanity/src/structure/components/requestPermissionDialog/__telemetry__/RequestPermissionDialog.telemetry.ts @@ -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', +}) diff --git a/packages/sanity/src/structure/components/requestPermissionDialog/index.ts b/packages/sanity/src/structure/components/requestPermissionDialog/index.ts new file mode 100644 index 00000000000..32288078ca6 --- /dev/null +++ b/packages/sanity/src/structure/components/requestPermissionDialog/index.ts @@ -0,0 +1,2 @@ +export * from './RequestPermissionDialog' +export * from './useRoleRequestsStatus' diff --git a/packages/sanity/src/structure/components/requestPermissionDialog/useRoleRequestsStatus.tsx b/packages/sanity/src/structure/components/requestPermissionDialog/useRoleRequestsStatus.tsx new file mode 100644 index 00000000000..6c43d6d887b --- /dev/null +++ b/packages/sanity/src/structure/components/requestPermissionDialog/useRoleRequestsStatus.tsx @@ -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({ + 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} +} diff --git a/packages/sanity/src/structure/i18n/resources.ts b/packages/sanity/src/structure/i18n/resources.ts index 6001d69fc54..6bbfa4b3099 100644 --- a/packages/sanity/src/structure/i18n/resources.ts +++ b/packages/sanity/src/structure/i18n/resources.ts @@ -102,18 +102,22 @@ const structureLocaleStrings = defineLocalesResources('structure', { /** The text content for the live edit document when it's a draft */ 'banners.live-edit-draft-banner.text': 'The type {{schemaType}} has liveEdit enabled, but a draft version of this document exists. Publish or discard the draft in order to continue live editing it.', - /** The text for the permission check banner if the user only has one role, and it does not allow updating this document */ + /** The text for the permission check banner if the user only has one role, and it does not allow publishing this document */ 'banners.permission-check-banner.missing-permission_create_one': - 'Your role does not have permissions to create this document.', - /** The text for the permission check banner if the user only has multiple roles, but they do not allow updating this document */ + 'Your role does not have permission to publish this document.', + /** The text for the permission check banner if the user only has multiple roles, but they do not allow publishing this document */ 'banners.permission-check-banner.missing-permission_create_other': - 'Your roles do not have permissions to create this document.', - /** The text for the permission check banner if the user only has one role, and it does not allow updating this document */ + 'Your roles do not have permission to publish this document.', + /** The text for the permission check banner if the user only has one role, and it does not allow editing this document */ 'banners.permission-check-banner.missing-permission_update_one': - 'Your role does not have permissions to update this document.', - /** The text for the permission check banner if the user only has multiple roles, but they do not allow updating this document */ + 'Your role does not have permission to edit this document.', + /** The text for the permission check banner if the user only has multiple roles, but they do not allow editing this document */ 'banners.permission-check-banner.missing-permission_update_other': - 'Your roles do not have permissions to update this document.', + 'Your roles do not have permission to edit this document.', + /** The pending text for the request permission button that appears for viewer roles */ + 'banners.permission-check-banner.request-permission-button.sent': 'Editor request sent', + /** The text for the request permission button that appears for viewer roles */ + 'banners.permission-check-banner.request-permission-button.text': 'Ask to edit', /** The text for the reload button */ 'banners.reference-changed-banner.reason-changed.reload-button.text': 'Reload reference', /** The text for the reference change banner if the reason is that the reference has been changed */ @@ -411,6 +415,24 @@ const structureLocaleStrings = defineLocalesResources('structure', { /** The text for the "Open preview" action for a document */ 'production-preview.menu-item.title': 'Open preview', + /** The text for the confirm button in the request permission dialog used in the permissions banner */ + 'request-permission-dialog.confirm-button.text': 'Send request', + /** The description text for the request permission dialog used in the permissions banner */ + 'request-permission-dialog.description.text': + "Your request will be sent to the project administrator(s). If you'd like, you can also include a note", + /** The header/title for the request permission dialog used in the permissions banner */ + 'request-permission-dialog.header.text': 'Ask for edit access', + /** The text describing the note input for the request permission dialog used in the permissions banner */ + 'request-permission-dialog.note-input.description.text': "If you'd like, you can add a note", + /** The placeholder for the note input in the request permission dialog used in the permissions banner */ + 'request-permission-dialog.note-input.placeholder.text': 'Add note...', + /** The error/warning text in the request permission dialog when the user's request has been declined */ + 'request-permission-dialog.warning.denied.text': + 'Your request to access this project has been declined.', + /** The error/warning text in the request permission dialog when the user's request has been denied due to too many outstanding requests */ + 'request-permission-dialog.warning.limit-reached.text': + "You've reached the limit for role requests across all projects. Please wait before submitting more requests or contact an administrator for assistance.", + /** Label for button when status is saved */ 'status-bar.document-status-pulse.status.saved.text': 'Saved', /** Label for button when status is syncing */ diff --git a/packages/sanity/src/structure/panes/document/documentPanel/banners/Banner.tsx b/packages/sanity/src/structure/panes/document/documentPanel/banners/Banner.tsx index 11f66145c90..f6b8a70f1fe 100644 --- a/packages/sanity/src/structure/panes/document/documentPanel/banners/Banner.tsx +++ b/packages/sanity/src/structure/panes/document/documentPanel/banners/Banner.tsx @@ -1,8 +1,7 @@ -import {type ButtonTone, Card, type CardTone, Flex, Text} from '@sanity/ui' +import {type ButtonMode, type ButtonTone, Card, type CardTone, Flex, Text} from '@sanity/ui' import {type ComponentType, type ElementType, type JSX, type ReactNode} from 'react' import {Button} from '../../../../../ui-components' -import {SpacerButton} from '../../../../components/spacerButton' interface BannerProps { action?: { @@ -11,6 +10,8 @@ interface BannerProps { onClick?: () => void text: string tone?: ButtonTone + disabled?: boolean + mode?: ButtonMode } content: ReactNode icon?: ComponentType @@ -33,16 +34,8 @@ export function Banner(props: BannerProps) { {content} - - {action && ( -