From 9e6618e4e21d991d8b26d8837e49eda71928fae3 Mon Sep 17 00:00:00 2001 From: Drew Lyton Date: Wed, 24 Jul 2024 14:52:51 -0400 Subject: [PATCH 01/28] feat: create request access screen --- .../sanity/src/core/studio/AuthBoundary.tsx | 13 +- .../studio/screens/NotAuthenticatedScreen.tsx | 12 +- .../studio/screens/RequestAccessScreen.tsx | 193 ++++++++++++++++++ .../sanity/src/core/studio/screens/index.ts | 1 + 4 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx diff --git a/packages/sanity/src/core/studio/AuthBoundary.tsx b/packages/sanity/src/core/studio/AuthBoundary.tsx index 8bd967507e5..ac07a2ace77 100644 --- a/packages/sanity/src/core/studio/AuthBoundary.tsx +++ b/packages/sanity/src/core/studio/AuthBoundary.tsx @@ -1,8 +1,9 @@ +/* eslint-disable i18next/no-literal-string,@sanity/i18n/no-attribute-string-literals */ import {type ComponentType, type ReactNode, useEffect, useState} from 'react' import {LoadingBlock} from '../components/loadingBlock' import {useActiveWorkspace} from './activeWorkspaceMatcher' -import {AuthenticateScreen, NotAuthenticatedScreen} from './screens' +import {AuthenticateScreen, NotAuthenticatedScreen, RequestAccessScreen} from './screens' interface AuthBoundaryProps { children: ReactNode @@ -23,6 +24,7 @@ export function AuthBoundary({ const [loggedIn, setLoggedIn] = useState<'logged-in' | 'logged-out' | 'loading' | 'unauthorized'>( 'loading', ) + const [loginProvider, setLoginProvider] = useState() const {activeWorkspace} = useActiveWorkspace() useEffect(() => { @@ -39,6 +41,7 @@ export function AuthBoundary({ } setLoggedIn(authenticated ? 'logged-in' : 'logged-out') + if (currentUser?.provider) setLoginProvider(currentUser.provider) }, error: handleError, }) @@ -50,7 +53,13 @@ export function AuthBoundary({ if (loggedIn === 'loading') return - if (loggedIn === 'unauthorized') return + if (loggedIn === 'unauthorized') { + // If using unverified `sanity` login provider, send them + // to basic NotAuthorized component. + if (loginProvider === 'sanity') return + // Otherwise, send use to RequestAccessDialog + return + } // NOTE: there is currently a bug where the `AuthenticateComponent` will // flash after the first login with cookieless mode. See `createAuthStore` diff --git a/packages/sanity/src/core/studio/screens/NotAuthenticatedScreen.tsx b/packages/sanity/src/core/studio/screens/NotAuthenticatedScreen.tsx index f9038de2ce2..3256e701beb 100644 --- a/packages/sanity/src/core/studio/screens/NotAuthenticatedScreen.tsx +++ b/packages/sanity/src/core/studio/screens/NotAuthenticatedScreen.tsx @@ -51,18 +51,16 @@ export function NotAuthenticatedScreen() { > - You are not authorized to access this studio. Maybe you could ask someone to invite you - to collaborate on this project? - - - - If you think this is an error, verify that you are signed in with the correct account. - You are currently signed in as{' '} + You are not authorized to access this studio. You are currently signed in as{' '} {currentUser?.name} ({currentUser?.email}) {providerHelp}. + + + If you think this is an error, verify that you are signed in with the correct account. + diff --git a/packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx b/packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx new file mode 100644 index 00000000000..dbccd8b2208 --- /dev/null +++ b/packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx @@ -0,0 +1,193 @@ +/* eslint-disable i18next/no-literal-string,@sanity/i18n/no-attribute-string-literals */ +import {Box, Button, Card, Dialog, Flex, Stack, Text, TextInput} from '@sanity/ui' +import {useCallback, useEffect, useState} from 'react' +import { + type CurrentUser, + getProviderTitle, + LoadingBlock, + type SanityClient, + useActiveWorkspace, +} from 'sanity' + +import {NotAuthenticatedScreen} from './NotAuthenticatedScreen' + +interface AccessRequest { + id: string + status: 'pending' | 'accepted' | 'declined' + resourceId: string + resourceType: 'project' + createdAt: string + updatedAt: string + updatedByUserId: string + requestedByUserId: string + note: string +} + +export function RequestAccessScreen() { + const [currentUser, setCurrentUser] = useState(null) + const [error, setError] = useState(null) + + const [projectId, setProjectId] = useState() + const [loading, setLoading] = useState(true) + const [hasPendingRequest, setHasPendingRequest] = useState() + const [hasTooManyRequests, setHasTooManyRequests] = useState() + const [client, setClient] = useState() + const [note, setNote] = useState('') + + const {activeWorkspace} = useActiveWorkspace() + + const handleLogout = useCallback(() => { + activeWorkspace.auth.logout?.() + }, [activeWorkspace]) + + const handleSubmitRequest = useCallback(() => { + if (!client || !projectId) return + client + .request({ + url: `/access/project/${projectId}/requests`, + method: 'post', + body: {note}, + }) + .then((request) => { + if (request) setHasPendingRequest(true) + }) + .catch((err) => { + const statusCode = err && err.response && err.response.statusCode + // If we get a 403, that means the user + // is over their cross-project request limit + if (statusCode === 403) { + setHasTooManyRequests(true) + } else { + setError(true) + } + }) + .finally(() => { + setLoading(false) + }) + }, [note, projectId, client]) + + // Get the active workspace client + useEffect(() => { + const subscription = activeWorkspace.auth.state.subscribe({ + next: ({client: sanityClient}) => { + setProjectId(sanityClient.config().projectId) + setClient(sanityClient) + }, + error: setError, + }) + + return () => { + subscription.unsubscribe() + } + }, [activeWorkspace]) + + // Check if user currently has a pending access request + // for this project + useEffect(() => { + if (!client || !projectId) return + client + .request({ + url: `/access/requests/me`, + }) + .then((requests) => { + if (requests && requests?.length) { + const pendingRequests = requests.filter( + (request) => + // Access request is for this project + request.resourceId === projectId && + // Access request is still pending + request.status === 'pending' && + // Access request is less than 2 weeks old + new Date(request.createdAt).getTime() < Date.now() - 2 * 1000 * 60 * 60 * 24 * 7, + ) + if (pendingRequests.length) setHasPendingRequest(true) + } + }) + .catch((err) => { + console.error(err) + setError(true) + }) + .finally(() => { + setLoading(false) + }) + }, [client, projectId]) + + useEffect(() => { + const subscription = activeWorkspace.auth.state.subscribe({ + next: ({currentUser: user}) => { + setCurrentUser(user) + }, + error: setError, + }) + + return () => { + subscription.unsubscribe() + } + }, [activeWorkspace]) + + const providerTitle = getProviderTitle(currentUser?.provider) + const providerHelp = providerTitle ? ` through ${providerTitle}` : '' + + if (loading) return + if (error) return + return ( + + + + + + You are not authorized to access this studio. You are currently signed in as{' '} + + {currentUser?.name} ({currentUser?.email}) + + {providerHelp}. + + {hasTooManyRequests || hasPendingRequest ? ( + + + {hasTooManyRequests && !hasPendingRequest && ( + <> + You've reached the limit for access requests across all projects. Please wait + before submitting more requests or contact an admin for assistance. + + )} + {hasPendingRequest && ( + <> + Your request to access this project is pending. We'll send you an email when + your request has been approved. + + )} + + + ) : ( + <> + + You can request access to collaborate on this project. If you'd like, you can + include a note. + + setNote(e.currentTarget.value)} value={note} /> + + )} + + + + + ) +} diff --git a/packages/sanity/src/core/studio/screens/index.ts b/packages/sanity/src/core/studio/screens/index.ts index fe2a3623fc3..bba0a03fa48 100644 --- a/packages/sanity/src/core/studio/screens/index.ts +++ b/packages/sanity/src/core/studio/screens/index.ts @@ -3,4 +3,5 @@ export * from './ConfigErrorsScreen' export * from './CorsOriginErrorScreen' export * from './NotAuthenticatedScreen' export * from './NotFoundScreen' +export * from './RequestAccessScreen' export * from './schemaErrors' From a36f9a6bffe0ab8b0fd11442fd0f40d93951a5d5 Mon Sep 17 00:00:00 2001 From: Drew Lyton Date: Wed, 24 Jul 2024 15:40:58 -0400 Subject: [PATCH 02/28] refactor: readablity edits --- .../studio/screens/RequestAccessScreen.tsx | 82 ++++++++++--------- 1 file changed, 42 insertions(+), 40 deletions(-) diff --git a/packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx b/packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx index dbccd8b2208..9bd2d1b63c0 100644 --- a/packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx +++ b/packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx @@ -25,13 +25,15 @@ interface AccessRequest { export function RequestAccessScreen() { const [currentUser, setCurrentUser] = useState(null) - const [error, setError] = useState(null) - + const [client, setClient] = useState() const [projectId, setProjectId] = useState() + + const [error, setError] = useState(null) const [loading, setLoading] = useState(true) + const [hasPendingRequest, setHasPendingRequest] = useState() const [hasTooManyRequests, setHasTooManyRequests] = useState() - const [client, setClient] = useState() + const [note, setNote] = useState('') const {activeWorkspace} = useActiveWorkspace() @@ -40,31 +42,18 @@ export function RequestAccessScreen() { activeWorkspace.auth.logout?.() }, [activeWorkspace]) - const handleSubmitRequest = useCallback(() => { - if (!client || !projectId) return - client - .request({ - url: `/access/project/${projectId}/requests`, - method: 'post', - body: {note}, - }) - .then((request) => { - if (request) setHasPendingRequest(true) - }) - .catch((err) => { - const statusCode = err && err.response && err.response.statusCode - // If we get a 403, that means the user - // is over their cross-project request limit - if (statusCode === 403) { - setHasTooManyRequests(true) - } else { - setError(true) - } - }) - .finally(() => { - setLoading(false) - }) - }, [note, projectId, client]) + useEffect(() => { + const subscription = activeWorkspace.auth.state.subscribe({ + next: ({currentUser: user}) => { + setCurrentUser(user) + }, + error: setError, + }) + + return () => { + subscription.unsubscribe() + } + }, [activeWorkspace]) // Get the active workspace client useEffect(() => { @@ -112,18 +101,31 @@ export function RequestAccessScreen() { }) }, [client, projectId]) - useEffect(() => { - const subscription = activeWorkspace.auth.state.subscribe({ - next: ({currentUser: user}) => { - setCurrentUser(user) - }, - error: setError, - }) - - return () => { - subscription.unsubscribe() - } - }, [activeWorkspace]) + const handleSubmitRequest = useCallback(() => { + if (!client || !projectId) return + client + .request({ + url: `/access/project/${projectId}/requests`, + method: 'post', + body: {note}, + }) + .then((request) => { + if (request) setHasPendingRequest(true) + }) + .catch((err) => { + const statusCode = err && err.response && err.response.statusCode + // If we get a 403, that means the user + // is over their cross-project request limit + if (statusCode === 403) { + setHasTooManyRequests(true) + } else { + setError(true) + } + }) + .finally(() => { + setLoading(false) + }) + }, [note, projectId, client]) const providerTitle = getProviderTitle(currentUser?.provider) const providerHelp = providerTitle ? ` through ${providerTitle}` : '' From b04e2abc8a76a8e07de67a8fd73858985a49ca64 Mon Sep 17 00:00:00 2001 From: Drew Lyton Date: Thu, 25 Jul 2024 10:31:59 -0400 Subject: [PATCH 03/28] feat: add submitting states and toasts --- .../studio/screens/RequestAccessScreen.tsx | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx b/packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx index 9bd2d1b63c0..7d26a9a5791 100644 --- a/packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx +++ b/packages/sanity/src/core/studio/screens/RequestAccessScreen.tsx @@ -1,5 +1,5 @@ /* eslint-disable i18next/no-literal-string,@sanity/i18n/no-attribute-string-literals */ -import {Box, Button, Card, Dialog, Flex, Stack, Text, TextInput} from '@sanity/ui' +import {Box, Button, Card, Dialog, Flex, Stack, Text, TextInput, useToast} from '@sanity/ui' import {useCallback, useEffect, useState} from 'react' import { type CurrentUser, @@ -27,9 +27,11 @@ export function RequestAccessScreen() { const [currentUser, setCurrentUser] = useState(null) const [client, setClient] = useState() const [projectId, setProjectId] = useState() + const toast = useToast() const [error, setError] = useState(null) const [loading, setLoading] = useState(true) + const [isSubmitting, setIsSubmitting] = useState(false) const [hasPendingRequest, setHasPendingRequest] = useState() const [hasTooManyRequests, setHasTooManyRequests] = useState() @@ -55,7 +57,7 @@ export function RequestAccessScreen() { } }, [activeWorkspace]) - // Get the active workspace client + // Get the active workspace client to use `request` config useEffect(() => { const subscription = activeWorkspace.auth.state.subscribe({ next: ({client: sanityClient}) => { @@ -103,6 +105,7 @@ export function RequestAccessScreen() { const handleSubmitRequest = useCallback(() => { if (!client || !projectId) return + setIsSubmitting(true) client .request({ url: `/access/project/${projectId}/requests`, @@ -110,6 +113,7 @@ export function RequestAccessScreen() { body: {note}, }) .then((request) => { + toast.push({title: 'Access requested', status: 'success'}) if (request) setHasPendingRequest(true) }) .catch((err) => { @@ -119,18 +123,22 @@ export function RequestAccessScreen() { if (statusCode === 403) { setHasTooManyRequests(true) } else { - setError(true) + toast.push({ + title: 'There was a problem submitting your request. Please try again later.', + status: 'error', + }) } }) .finally(() => { - setLoading(false) + setIsSubmitting(false) }) - }, [note, projectId, client]) + }, [note, projectId, client, toast]) const providerTitle = getProviderTitle(currentUser?.provider) const providerHelp = providerTitle ? ` through ${providerTitle}` : '' if (loading) return + // Fallback to the old not authorized screen if error if (error) return return ( @@ -167,11 +175,15 @@ export function RequestAccessScreen() { You can request access to collaborate on this project. If you'd like, you can include a note. - setNote(e.currentTarget.value)} value={note} /> + setNote(e.currentTarget.value)} + value={note} + /> )} - +