diff --git a/editor/src/components/editor/persistence/generic/persistence-machine.ts b/editor/src/components/editor/persistence/generic/persistence-machine.ts index bfcb86410646..e2617df22b55 100644 --- a/editor/src/components/editor/persistence/generic/persistence-machine.ts +++ b/editor/src/components/editor/persistence/generic/persistence-machine.ts @@ -94,6 +94,18 @@ function loadFailedEvent(): LoadFailedEvent { } } +interface LoadFailedNotAuthorizedEvent { + type: 'LOAD_FAILED_NOT_AUTHORIZED' + projectId: string +} + +function loadFailedNotAuthorizedEvent(projectId: string): LoadFailedNotAuthorizedEvent { + return { + type: 'LOAD_FAILED_NOT_AUTHORIZED', + projectId: projectId, + } +} + export interface SaveEvent { type: 'SAVE' projectModel: ProjectModel @@ -180,6 +192,7 @@ type CoreEvent = | LoadEvent | LoadCompleteEvent | LoadFailedEvent + | LoadFailedNotAuthorizedEvent | CheckOwnershipCompleteEvent | SaveEvent | SaveCompleteEvent @@ -525,6 +538,8 @@ export function createPersistenceMachine( send((_, event: DoneInvokeEvent>) => { if (event.data.type === 'PROJECT_LOAD_SUCCESS') { return loadCompleteEvent(event.data.projectId, event.data.projectModel) + } else if (event.data.type === 'PROJECT_NOT_AUTHORIZED') { + return loadFailedNotAuthorizedEvent(event.data.projectId) } else { return loadFailedEvent() } @@ -703,6 +718,20 @@ export function createPersistenceMachine( } }), }, + LOAD_FAILED_NOT_AUTHORIZED: { + target: Empty, + actions: assign((_context, _event) => { + return { + projectId: undefined, + project: undefined, + queuedSave: undefined, + projectOwnership: { + ownerId: null, + isOwner: false, + }, + } + }), + }, }, }, [Saving]: { diff --git a/editor/src/components/editor/persistence/generic/persistence-types.ts b/editor/src/components/editor/persistence/generic/persistence-types.ts index c48303196d04..68366b29e06e 100644 --- a/editor/src/components/editor/persistence/generic/persistence-types.ts +++ b/editor/src/components/editor/persistence/generic/persistence-types.ts @@ -32,11 +32,19 @@ export interface ProjectLoadSuccess extends ProjectModelWithId = ProjectLoadSuccess | ProjectNotFount +export interface ProjectNotAuthorized { + type: 'PROJECT_NOT_AUTHORIZED' + projectId: string +} + +export type ProjectLoadResult = + | ProjectLoadSuccess + | ProjectNotFound + | ProjectNotAuthorized export interface FileWithFileName { fileName: string diff --git a/editor/src/components/editor/persistence/persistence-backend.ts b/editor/src/components/editor/persistence/persistence-backend.ts index cd7f7f8b4033..e68c81b019ce 100644 --- a/editor/src/components/editor/persistence/persistence-backend.ts +++ b/editor/src/components/editor/persistence/persistence-backend.ts @@ -92,6 +92,12 @@ async function loadProject(projectId: string): Promise, projectNotFound: false, + projectNotAuthorized: false, createdOrLoadedProject: undefined as PersistentModel | undefined, latestContext: { projectOwnership: { ownerId: null, isOwner: false }, @@ -192,6 +193,7 @@ function setupTest(saveThrottle: number = 0) { }) } const onProjectNotFound = () => (capturedData.projectNotFound = true) + const onProjectNotAuthorized = () => (capturedData.projectNotFound = true) const onCreatedOrLoadedProject = ( _projectId: string, _projectName: string, @@ -206,6 +208,7 @@ function setupTest(saveThrottle: number = 0) { PersistenceBackend, testDispatch, onProjectNotFound, + onProjectNotAuthorized, onCreatedOrLoadedProject, onContextChange, saveThrottle, diff --git a/editor/src/components/editor/persistence/persistence.test-utils.ts b/editor/src/components/editor/persistence/persistence.test-utils.ts index 344ddb987408..d3ae9367c053 100644 --- a/editor/src/components/editor/persistence/persistence.test-utils.ts +++ b/editor/src/components/editor/persistence/persistence.test-utils.ts @@ -13,4 +13,5 @@ export const DummyPersistenceMachine: PersistenceMachine = new PersistenceMachin NO_OP, NO_OP, NO_OP, + NO_OP, ) diff --git a/editor/src/components/editor/persistence/persistence.ts b/editor/src/components/editor/persistence/persistence.ts index 13b1a62a35b5..c5453018e1df 100644 --- a/editor/src/components/editor/persistence/persistence.ts +++ b/editor/src/components/editor/persistence/persistence.ts @@ -47,6 +47,7 @@ export class PersistenceMachine { backendAPI: PersistenceBackendAPI, dispatch: EditorDispatch, onProjectNotFound: () => void, + onProjectNotAuthorized: (projectId: string) => void, onCreatedOrLoadedProject: ( projectId: string, projectName: string, @@ -85,6 +86,9 @@ export class PersistenceMachine { case 'LOAD_FAILED': onProjectNotFound() break + case 'LOAD_FAILED_NOT_AUTHORIZED': + onProjectNotAuthorized(event.projectId) + break case 'DOWNLOAD_ASSETS_COMPLETE': { if (state.matches({ core: { [Forking]: CreatingProjectId } })) { this.queuedActions.push(setForkedFromProjectID(state.context.projectId!)) diff --git a/editor/src/components/editor/server.ts b/editor/src/components/editor/server.ts index b24648e302b4..1763140f8477 100644 --- a/editor/src/components/editor/server.ts +++ b/editor/src/components/editor/server.ts @@ -66,7 +66,15 @@ interface ProjectNotFound { type: 'ProjectNotFound' } -export type LoadProjectResponse = ProjectLoaded | ProjectUnchanged | ProjectNotFound +interface ProjectNotAuthorized { + type: 'ProjectNotAuthorized' +} + +export type LoadProjectResponse = + | ProjectLoaded + | ProjectUnchanged + | ProjectNotFound + | ProjectNotAuthorized interface SaveAssetResponse { id: string @@ -176,6 +184,8 @@ export async function loadProject( return response.json() } else if (response.status === 404) { return { type: 'ProjectNotFound' } + } else if (response.status === 403) { + return { type: 'ProjectNotAuthorized' } } else { // FIXME Client should show an error if server requests fail throw new Error(`server responded with ${response.status} ${response.statusText}`) @@ -548,3 +558,17 @@ async function getCollaboratorsFromLiveblocks(projectId: string): Promise u.toObject()) } + +export async function requestProjectAccess(projectId: string): Promise { + if (!isBackendBFF()) { + return + } + const response = await fetch(`/internal/projects/${projectId}/access/request`, { + method: 'POST', + credentials: 'include', + mode: MODE, + }) + if (!response.ok) { + throw new Error(`Request project access failed (${response.status}): ${response.statusText}`) + } +} diff --git a/editor/src/templates/editor.tsx b/editor/src/templates/editor.tsx index 6a0190f8d326..c4bf8517a868 100644 --- a/editor/src/templates/editor.tsx +++ b/editor/src/templates/editor.tsx @@ -273,6 +273,7 @@ export class Editor { PersistenceBackend, this.boundDispatch, renderProjectNotFound, + renderProjectNotAuthorized, onCreatedOrLoadedProject, ), builtInDependencies: builtInDependencies, @@ -826,3 +827,11 @@ async function renderProjectNotFound(): Promise { root.render() } } + +async function renderProjectNotAuthorized(projectId: string): Promise { + const rootElement = document.getElementById(EditorID) + if (rootElement != null) { + const root = createRoot(rootElement) + root.render() + } +} diff --git a/editor/src/templates/project-not-found/ProjectNotFound.tsx b/editor/src/templates/project-not-found/ProjectNotFound.tsx index cf162bff7cac..a00876f34ebf 100644 --- a/editor/src/templates/project-not-found/ProjectNotFound.tsx +++ b/editor/src/templates/project-not-found/ProjectNotFound.tsx @@ -4,10 +4,19 @@ import React from 'react' import { jsx } from '@emotion/react' import { Button } from '../../uuiui/button' import { UTOPIA_BACKEND_BASE_URL } from '../../common/env-vars' +import { when } from '../../utils/react-conditionals' +import { requestProjectAccess } from '../../components/editor/server' const PyramidLight404 = `${process.env.UTOPIA_DOMAIN}/editor/404_pyramid_light.png?hash=${process.env.UTOPIA_SHA}` -export default function ProjectNotFound() { +export default function ProjectNotFound({ projectId }: { projectId?: string }) { + const [accessRequested, setAccessRequested] = React.useState(false) + const requestAccess = React.useCallback(async () => { + if (projectId != null) { + void requestProjectAccess(projectId) + setAccessRequested(true) + } + }, [projectId]) return (
Either this project does not exist, or you do not have access to it.
- - - + + + {when( + projectId != null, + , + )} + ) } + +function ActionButton({ + text, + onClick, + disabled, +}: { + text: string + onClick?: () => void + disabled?: boolean +}) { + return ( + + ) +} diff --git a/utopia-remix/__mocks__/@openfga/sdk.ts b/utopia-remix/__mocks__/@openfga/sdk.ts index aa8e6396af47..7c1024f80dd3 100644 --- a/utopia-remix/__mocks__/@openfga/sdk.ts +++ b/utopia-remix/__mocks__/@openfga/sdk.ts @@ -7,9 +7,9 @@ export function OpenFgaClient() { export const CredentialsMethod = {} export const ErrorCode = {} -export type ClientRequestOptsWithAuthZModelId = {} -export type ClientWriteRequest = {} -export type ClientWriteRequestOpts = {} -export type ClientWriteResponse = {} -export type FgaApiValidationError = {} -export type ClientConfiguration = {} +export type ClientRequestOptsWithAuthZModelId = '' +export type ClientWriteRequest = '' +export type ClientWriteRequestOpts = '' +export type ClientWriteResponse = '' +export type FgaApiValidationError = '' +export type ClientConfiguration = '' diff --git a/utopia-remix/app/components/SharePopup.tsx b/utopia-remix/app/components/SharePopup.tsx new file mode 100644 index 000000000000..55e3bc43ea32 --- /dev/null +++ b/utopia-remix/app/components/SharePopup.tsx @@ -0,0 +1,199 @@ +import { Dialog, Flex, IconButton, Text, Button, DropdownMenu, Separator } from '@radix-ui/themes' +import { + CaretDownIcon, + Cross2Icon, + GlobeIcon, + LockClosedIcon, + CookieIcon, +} from '@radix-ui/react-icons' +import { + asAccessLevel, + operationApproveAccessRequest, + operationChangeAccess, + type ProjectWithoutContent, + AccessRequestStatus, + type ProjectAccessRequestWithUserDetails, +} from '../types' +import { AccessLevel } from '../types' +import { useFetcherWithOperation } from '../hooks/useFetcherWithOperation' +import React from 'react' +import { when } from '../util/react-conditionals' +import moment from 'moment' + +export function SharePopup({ + project, + accessRequests, +}: { + project: ProjectWithoutContent + accessRequests: ProjectAccessRequestWithUserDetails[] +}) { + const accessLevel = asAccessLevel(project.ProjectAccess?.access_level) ?? AccessLevel.PRIVATE + + const changeAccessFetcher = useFetcherWithOperation(project.proj_id, 'changeAccess') + const approveAccessRequestFetcher = useFetcherWithOperation( + project.proj_id, + 'approveAccessRequest', + ) + + const changeAccessLevel = React.useCallback( + (projectId: string, newAccessLevel: AccessLevel) => { + changeAccessFetcher.submit( + operationChangeAccess(project, newAccessLevel), + { accessLevel: newAccessLevel.toString() }, + { method: 'POST', action: `/internal/projects/${projectId}/access` }, + ) + }, + [changeAccessFetcher, project], + ) + + const changeProjectAccessLevel = React.useCallback( + (newAccessLevel: AccessLevel) => { + changeAccessLevel(project.proj_id, newAccessLevel) + }, + [changeAccessLevel, project], + ) + + const approveAccessRequest = React.useCallback( + (projectId: string, tokenId: string) => { + approveAccessRequestFetcher.submit( + operationApproveAccessRequest(project, tokenId), + { tokenId: tokenId }, + { + method: 'POST', + action: `/internal/projects/${projectId}/access/request/${tokenId}/approve`, + }, + ) + }, + [approveAccessRequestFetcher, project], + ) + + return ( + <> + + + Project Sharing + + + + + + + + Project Visibility + + + {when( + accessRequests.length > 0, + <> + + + , + )} + + + ) +} + +function AccessRequests({ + project, + approveAccessRequest, + accessRequests, +}: { + project: ProjectWithoutContent + approveAccessRequest: (projectId: string, tokenId: string) => void + accessRequests: ProjectAccessRequestWithUserDetails[] +}) { + return accessRequests + .sort((a, b) => { + if (a.status !== b.status) { + return a.status - b.status + } + return moment(a.updated_at).unix() - moment(b.updated_at).unix() + }) + .map((request) => { + function onApprove() { + approveAccessRequest(project.proj_id, request.token) + } + const status = request.status + return ( + + {request.User?.name ?? request.User?.email ?? request.user_id} + {status === AccessRequestStatus.PENDING ? ( + + ) : ( + + {status === AccessRequestStatus.APPROVED ? 'Approved' : 'Rejected'} + + )} + + ) + }) +} + +const VisibilityUIComponents = { + [AccessLevel.PUBLIC]: { text: 'Public', icon: }, + [AccessLevel.PRIVATE]: { text: 'Private', icon: }, + [AccessLevel.COLLABORATIVE]: { + text: 'Collaborative', + icon: , + }, + [AccessLevel.WITH_LINK]: { + text: 'With Link', + icon: , + }, +} + +function VisibilityDropdown({ + accessLevel, + changeProjectAccessLevel, +}: { + accessLevel: AccessLevel + changeProjectAccessLevel: (newAccessLevel: AccessLevel) => void +}) { + return ( + + + + + + {[AccessLevel.PUBLIC, AccessLevel.PRIVATE, AccessLevel.COLLABORATIVE].map((level) => { + function onCheckedChange() { + if (accessLevel === level) { + return + } + changeProjectAccessLevel(level) + } + return ( + + {VisibilityUIComponents[level].text} + + ) + })} + + + ) +} diff --git a/utopia-remix/app/components/projectActionContextMenu.tsx b/utopia-remix/app/components/projectActionContextMenu.tsx index 2c51fab88f3c..222fb3af99f5 100644 --- a/utopia-remix/app/components/projectActionContextMenu.tsx +++ b/utopia-remix/app/components/projectActionContextMenu.tsx @@ -3,20 +3,22 @@ import React from 'react' import { useProjectsStore } from '../store' import { contextMenuDropdown, contextMenuItem } from '../styles/contextMenu.css' import { sprinkles } from '../styles/sprinkles.css' -import type { ProjectWithoutContent } from '../types' +import type { ProjectAccessRequestWithUserDetails, ProjectWithoutContent } from '../types' import { - operationChangeAccess, + AccessRequestStatus, operationDelete, operationDestroy, operationRename, operationRestore, } from '../types' import { assertNever } from '../util/assertNever' -import { AccessLevel } from '../types' import { useProjectEditorLink } from '../util/links' import { useFetcherWithOperation } from '../hooks/useFetcherWithOperation' import slugify from 'slugify' import { SLUGIFY_OPTIONS } from '../routes/internal.projects.$id.rename' +import { SharePopup } from './SharePopup' +import { ContextMenu, Dialog, Flex, Text } from '@radix-ui/themes' +import { DotFilledIcon } from '@radix-ui/react-icons' type ContextMenuEntry = | { @@ -24,182 +26,206 @@ type ContextMenuEntry = onClick: (project: ProjectWithoutContent) => void } | 'separator' + | 'sharing-dialog' -export const ProjectContextMenu = React.memo(({ project }: { project: ProjectWithoutContent }) => { - const deleteFetcher = useFetcherWithOperation(project.proj_id, 'delete') - const destroyFetcher = useFetcherWithOperation(project.proj_id, 'destroy') - const restoreFetcher = useFetcherWithOperation(project.proj_id, 'restore') - const renameFetcher = useFetcherWithOperation(project.proj_id, 'rename') - const changeAccessFetcher = useFetcherWithOperation(project.proj_id, 'changeAccess') +export const ProjectContextMenu = React.memo( + ({ + project, + accessRequests, + }: { + project: ProjectWithoutContent + accessRequests: ProjectAccessRequestWithUserDetails[] + }) => { + const deleteFetcher = useFetcherWithOperation(project.proj_id, 'delete') + const destroyFetcher = useFetcherWithOperation(project.proj_id, 'destroy') + const restoreFetcher = useFetcherWithOperation(project.proj_id, 'restore') + const renameFetcher = useFetcherWithOperation(project.proj_id, 'rename') - const selectedCategory = useProjectsStore((store) => store.selectedCategory) - let accessLevel = project.ProjectAccess?.access_level ?? AccessLevel.PRIVATE + const selectedCategory = useProjectsStore((store) => store.selectedCategory) - const deleteProject = React.useCallback( - (projectId: string) => { - deleteFetcher.submit( - operationDelete(project), - {}, - { method: 'POST', action: `/internal/projects/${projectId}/delete` }, - ) - }, - [deleteFetcher, project], - ) - - const destroyProject = React.useCallback( - (projectId: string) => { - const ok = window.confirm('Are you sure? The project contents will be deleted permanently.') - if (ok) { - destroyFetcher.submit( - operationDestroy(project), + const deleteProject = React.useCallback( + (projectId: string) => { + deleteFetcher.submit( + operationDelete(project), {}, - { method: 'POST', action: `/internal/projects/${projectId}/destroy` }, + { method: 'POST', action: `/internal/projects/${projectId}/delete` }, ) - } - }, - [destroyFetcher, project], - ) + }, + [deleteFetcher, project], + ) - const restoreProject = React.useCallback( - (projectId: string) => { - restoreFetcher.submit( - operationRestore(project), - {}, - { method: 'POST', action: `/internal/projects/${projectId}/restore` }, - ) - }, - [restoreFetcher, project], - ) + const destroyProject = React.useCallback( + (projectId: string) => { + const ok = window.confirm('Are you sure? The project contents will be deleted permanently.') + if (ok) { + destroyFetcher.submit( + operationDestroy(project), + {}, + { method: 'POST', action: `/internal/projects/${projectId}/destroy` }, + ) + } + }, + [destroyFetcher, project], + ) - const renameProject = React.useCallback( - (projectId: string, newTitle: string) => { - renameFetcher.submit( - operationRename(project, slugify(newTitle, SLUGIFY_OPTIONS)), - { title: newTitle }, - { method: 'POST', action: `/internal/projects/${projectId}/rename` }, - ) - }, - [renameFetcher, project], - ) + const restoreProject = React.useCallback( + (projectId: string) => { + restoreFetcher.submit( + operationRestore(project), + {}, + { method: 'POST', action: `/internal/projects/${projectId}/restore` }, + ) + }, + [restoreFetcher, project], + ) - const changeAccessLevel = React.useCallback( - (projectId: string, newAccessLevel: AccessLevel) => { - changeAccessFetcher.submit( - operationChangeAccess(project, newAccessLevel), - { accessLevel: newAccessLevel.toString() }, - { method: 'POST', action: `/internal/projects/${projectId}/access` }, - ) - }, - [changeAccessFetcher, project], - ) - const projectEditorLink = useProjectEditorLink() + const renameProject = React.useCallback( + (projectId: string, newTitle: string) => { + renameFetcher.submit( + operationRename(project, slugify(newTitle, SLUGIFY_OPTIONS)), + { title: newTitle }, + { method: 'POST', action: `/internal/projects/${projectId}/rename` }, + ) + }, + [renameFetcher, project], + ) - const menuEntries = React.useMemo((): ContextMenuEntry[] => { - switch (selectedCategory) { - case 'allProjects': - return [ - { - text: 'Open', - onClick: (selectedProject) => { - window.open(projectEditorLink(selectedProject.proj_id), '_blank') - }, - }, - 'separator', - { - text: 'Copy Link', - onClick: (selectedProject) => { - window.navigator.clipboard.writeText(projectEditorLink(selectedProject.proj_id)) - // TODO notification toast + const projectEditorLink = useProjectEditorLink() + + const menuEntries = React.useMemo((): ContextMenuEntry[] => { + switch (selectedCategory) { + case 'allProjects': + return [ + { + text: 'Open', + onClick: (selectedProject) => { + window.open(projectEditorLink(selectedProject.proj_id), '_blank') + }, }, - }, - { - text: 'Fork', - onClick: (selectedProject) => { - window.open(projectEditorLink(selectedProject.proj_id) + '/?fork=true', '_blank') + 'separator', + { + text: 'Copy Link', + onClick: (selectedProject) => { + window.navigator.clipboard.writeText(projectEditorLink(selectedProject.proj_id)) + // TODO notification toast + }, }, - }, - 'separator', - { - text: 'Rename', - onClick: (selectedProject) => { - const newTitle = window.prompt('New title:', selectedProject.title) - if (newTitle != null) { - renameProject(selectedProject.proj_id, newTitle) - } + { + text: 'Fork', + onClick: (selectedProject) => { + window.open(projectEditorLink(selectedProject.proj_id) + '/?fork=true', '_blank') + }, }, - }, - { - text: 'Delete', - onClick: (selectedProject) => { - deleteProject(selectedProject.proj_id) + 'separator', + { + text: 'Rename', + onClick: (selectedProject) => { + const newTitle = window.prompt('New title:', selectedProject.title) + if (newTitle != null) { + renameProject(selectedProject.proj_id, newTitle) + } + }, }, - }, - { - text: accessLevel === AccessLevel.PUBLIC ? 'Make Private' : 'Make Public', - onClick: (selectedProject) => { - changeAccessLevel( - selectedProject.proj_id, - accessLevel === AccessLevel.PUBLIC ? AccessLevel.PRIVATE : AccessLevel.PUBLIC, - ) + { + text: 'Delete', + onClick: (selectedProject) => { + deleteProject(selectedProject.proj_id) + }, }, - }, - ] - case 'trash': - return [ - { - text: 'Restore', - onClick: (selectedProject) => { - restoreProject(selectedProject.proj_id) + 'separator', + 'sharing-dialog', + ] + case 'trash': + return [ + { + text: 'Restore', + onClick: (selectedProject) => { + restoreProject(selectedProject.proj_id) + }, }, - }, - 'separator', - { - text: 'Delete permanently', - onClick: (selectedProject) => { - destroyProject(selectedProject.proj_id) + 'separator', + { + text: 'Delete permanently', + onClick: (selectedProject) => { + destroyProject(selectedProject.proj_id) + }, }, - }, - ] - default: - assertNever(selectedCategory) - } - }, [ - selectedCategory, - accessLevel, - projectEditorLink, - changeAccessLevel, - deleteProject, - destroyProject, - renameProject, - restoreProject, - ]) + ] + default: + assertNever(selectedCategory) + } + }, [ + selectedCategory, + projectEditorLink, + deleteProject, + destroyProject, + renameProject, + restoreProject, + ]) + + const preventDefault = React.useCallback((event: Event) => { + event.preventDefault() + }, []) + + const pendingAccessRequests = React.useMemo( + () => accessRequests.filter((r) => r.status === AccessRequestStatus.PENDING), + [accessRequests], + ) - return ( - - - {menuEntries.map((entry, index) => { - if (entry === 'separator') { + return ( + + + {menuEntries.map((entry, index) => { + if (entry === 'separator') { + return ( + + ) + } + if (entry === 'sharing-dialog') { + return ( + + + + + Share + {pendingAccessRequests.length > 0 ? ( + + ) : null} + + + + + + + + ) + } + function onClick() { + if (entry != null && typeof entry !== 'string') { + entry.onClick(project) + } + } return ( - + + {entry.text} + ) - } - return ( - entry.onClick(project)} - className={contextMenuItem()} - > - {entry.text} - - ) - })} - - - ) -}) + })} + + + ) + }, +) ProjectContextMenu.displayName = 'ProjectContextMenu' diff --git a/utopia-remix/app/handlers/validators.ts b/utopia-remix/app/handlers/validators.ts index 959f6174c886..83e7d1c587db 100644 --- a/utopia-remix/app/handlers/validators.ts +++ b/utopia-remix/app/handlers/validators.ts @@ -1,6 +1,6 @@ import type { AccessValidator } from '../util/api.server' import { ensure, getUser } from '../util/api.server' -import type { UserProjectPermission } from '../types' +import { UserProjectPermission } from '../types' import type { Params } from '@remix-run/react' import { Status } from '../util/statusCodes' import { hasUserProjectPermission } from '../services/permissionsService.server' @@ -13,11 +13,13 @@ export function validateProjectAccess( status, getProjectId, includeDeleted = false, + canRequestAccess = false, }: { errorMessage?: string status?: number getProjectId: (params: Params) => string | null | undefined includeDeleted?: boolean + canRequestAccess?: boolean }, ): AccessValidator { return async function (req: Request, params: Params) { @@ -32,7 +34,20 @@ export function validateProjectAccess( const isCreator = userId ? ownerId === userId : false const allowed = isCreator || (await hasUserProjectPermission(projectId, userId, permission)) - ensure(allowed, errorMessage ?? 'Unauthorized Access', status ?? Status.UNAUTHORIZED) + let errorMessageToUse = errorMessage ?? 'Unauthorized Access' + let statusToUse = status ?? Status.FORBIDDEN + if (!allowed && canRequestAccess === true) { + const hasRequestAccessPermission = await hasUserProjectPermission( + projectId, + userId, + UserProjectPermission.CAN_REQUEST_ACCESS, + ) + if (hasRequestAccessPermission) { + errorMessageToUse = 'Request access to this project' + statusToUse = Status.FORBIDDEN + } + } + ensure(allowed, errorMessageToUse, statusToUse) } } diff --git a/utopia-remix/app/models/projectAccessRequest.server.spec.ts b/utopia-remix/app/models/projectAccessRequest.server.spec.ts index 38375d8b4f04..1edec1944de9 100644 --- a/utopia-remix/app/models/projectAccessRequest.server.spec.ts +++ b/utopia-remix/app/models/projectAccessRequest.server.spec.ts @@ -6,7 +6,11 @@ import { truncateTables, } from '../test-util' import { AccessRequestStatus } from '../types' -import { createAccessRequest, updateAccessRequestStatus } from './projectAccessRequest.server' +import { + createAccessRequest, + listProjectAccessRequests, + updateAccessRequestStatus, +} from './projectAccessRequest.server' describe('projectAccessRequest', () => { describe('createAccessRequest', () => { @@ -144,4 +148,114 @@ describe('projectAccessRequest', () => { expect(requests[0].status).toBe(AccessRequestStatus.APPROVED) }) }) + + describe('listProjectAccessRequests', () => { + afterEach(async () => { + await truncateTables([ + prisma.projectID, + prisma.projectAccessRequest, + prisma.project, + prisma.userDetails, + ]) + }) + beforeEach(async () => { + await createTestUser(prisma, { id: 'bob' }) + await createTestUser(prisma, { id: 'alice' }) + await createTestUser(prisma, { id: 'p1', name: 'person1' }) + await createTestUser(prisma, { id: 'p2', name: 'person2' }) + await createTestUser(prisma, { id: 'p3', name: 'person3' }) + await createTestUser(prisma, { id: 'p4', name: 'person4' }) + await createTestUser(prisma, { id: 'p5', name: 'person5' }) + await createTestUser(prisma, { id: 'p6', name: 'person5' }) + await createTestUser(prisma, { id: 'p7', name: 'person5' }) + await createTestProject(prisma, { id: 'one', ownerId: 'bob' }) + await createTestProject(prisma, { id: 'two', ownerId: 'alice' }) + await createTestProject(prisma, { id: 'three', ownerId: 'alice' }) + await createTestProjectAccessRequest(prisma, { + userId: 'p1', + projectId: 'two', + status: AccessRequestStatus.APPROVED, + token: 'test1', + }) + await createTestProjectAccessRequest(prisma, { + userId: 'p2', + projectId: 'two', + status: AccessRequestStatus.PENDING, + token: 'test2', + }) + await createTestProjectAccessRequest(prisma, { + userId: 'p3', + projectId: 'two', + status: AccessRequestStatus.PENDING, + token: 'test3', + }) + await createTestProjectAccessRequest(prisma, { + userId: 'p4', + projectId: 'two', + status: AccessRequestStatus.REJECTED, + token: 'test4', + }) + await createTestProjectAccessRequest(prisma, { + userId: 'p5', + projectId: 'three', + status: AccessRequestStatus.PENDING, + token: 'test5', + }) + await createTestProjectAccessRequest(prisma, { + userId: 'p6', + projectId: 'three', + status: AccessRequestStatus.PENDING, + token: 'test6', + }) + await createTestProjectAccessRequest(prisma, { + userId: 'p7', + projectId: 'three', + status: AccessRequestStatus.REJECTED, + token: 'test7', + }) + }) + + it('requires an existing project', async () => { + const fn = async () => + listProjectAccessRequests({ + projectId: 'unknown', + userId: 'bob', + }) + await expect(fn).rejects.toThrow('project not found') + }) + it('requires the user to own the project', async () => { + const fn = async () => + listProjectAccessRequests({ + projectId: 'two', + userId: 'bob', + }) + await expect(fn).rejects.toThrow('project not found') + }) + it('returns an empty list if there are no requests', async () => { + const got = await listProjectAccessRequests({ + projectId: 'one', + userId: 'bob', + }) + expect(got.length).toBe(0) + }) + it('returns the list of requests including their user details', async () => { + const got = await listProjectAccessRequests({ + projectId: 'two', + userId: 'alice', + }) + expect(got.length).toBe(4) + expect(got[0].token).toBe('test1') + expect(got[0].user_id).toBe('p1') + expect(got[0].User?.name).toBe('person1') + expect(got[1].token).toBe('test2') + expect(got[1].user_id).toBe('p2') + expect(got[1].User?.name).toBe('person2') + expect(got[2].token).toBe('test3') + expect(got[2].user_id).toBe('p3') + expect(got[2].User?.name).toBe('person3') + expect(got[3].token).toBe('test4') + expect(got[3].user_id).toBe('p4') + expect(got[3].User?.name).toBe('person4') + }) + }) }) diff --git a/utopia-remix/app/models/projectAccessRequest.server.ts b/utopia-remix/app/models/projectAccessRequest.server.ts index 48cd02cc75fa..bee9745ccc16 100644 --- a/utopia-remix/app/models/projectAccessRequest.server.ts +++ b/utopia-remix/app/models/projectAccessRequest.server.ts @@ -1,8 +1,10 @@ -import { prisma } from '../db.server' -import { AccessRequestStatus } from '../types' import * as uuid from 'uuid' +import { prisma } from '../db.server' +import * as permissionsService from '../services/permissionsService.server' import { ensure } from '../util/api.server' import { Status } from '../util/statusCodes' +import type { ProjectAccessRequestWithUserDetails } from '../types' +import { AccessRequestStatus, UserProjectRole } from '../types' function makeRequestToken(): string { return uuid.v4() @@ -61,13 +63,14 @@ export async function updateAccessRequestStatus(params: { status: AccessRequestStatus }) { await prisma.$transaction(async (tx) => { - // check that the project exists + // check that the project exists… const projectCount = await tx.project.count({ where: { proj_id: params.projectId, owner_id: params.ownerId }, }) ensure(projectCount === 1, 'project not found', Status.NOT_FOUND) - await tx.projectAccessRequest.update({ + // …update the request with the given status… + const request = await tx.projectAccessRequest.update({ where: { project_id_token: { project_id: params.projectId, @@ -79,5 +82,43 @@ export async function updateAccessRequestStatus(params: { updated_at: new Date(), }, }) + + // …finally, grant the role. + if (params.status === AccessRequestStatus.APPROVED) { + await permissionsService.grantProjectRoleToUser( + params.projectId, + request.user_id, + UserProjectRole.VIEWER, + ) + } + // NOTE (ruggi): there should be a way to revoke the permission if the request is REJECTED (TODO) }) } + +export async function listProjectAccessRequests(params: { + projectId: string + userId: string +}): Promise { + const data = await prisma.project.findFirst({ + where: { + proj_id: params.projectId, + owner_id: params.userId, + OR: [{ deleted: null }, { deleted: false }], + }, + select: { + ProjectAccessRequest: true, + }, + }) + ensure(data != null, 'project not found', Status.NOT_FOUND) + + // VERY unfortunately we cannot include the User in the query above, because it will always return the last value :( + const users = await prisma.userDetails.findMany({ + where: { user_id: { in: data.ProjectAccessRequest.map((r) => r.user_id) } }, + }) + + // merge data + return data.ProjectAccessRequest.map((r) => ({ + ...r, + User: users.find((u) => u.user_id === r.user_id) ?? null, + })) +} diff --git a/utopia-remix/app/routes-test/internal.projects.$id.access.spec.ts b/utopia-remix/app/routes-test/internal.projects.$id.access.spec.ts index 28e174cc87de..29ecdc0ea114 100644 --- a/utopia-remix/app/routes-test/internal.projects.$id.access.spec.ts +++ b/utopia-remix/app/routes-test/internal.projects.$id.access.spec.ts @@ -94,7 +94,7 @@ describe('handleChangeAccess', () => { const error = await getActionResult('two', AccessLevel.PRIVATE) expect(error).toEqual({ message: 'Unauthorized Access', - status: Status.UNAUTHORIZED, + status: Status.FORBIDDEN, error: 'Error', }) }) diff --git a/utopia-remix/app/routes/internal.projects.$id.access.request.tsx b/utopia-remix/app/routes/internal.projects.$id.access.request.tsx index c48fc7d0123e..8f328e14d367 100644 --- a/utopia-remix/app/routes/internal.projects.$id.access.request.tsx +++ b/utopia-remix/app/routes/internal.projects.$id.access.request.tsx @@ -2,14 +2,17 @@ import type { ActionFunctionArgs } from '@remix-run/node' import type { Params } from '@remix-run/react' import { ensure, handle, requireUser } from '../util/api.server' import { Status } from '../util/statusCodes' -import { ALLOW } from '../handlers/validators' +import { validateProjectAccess } from '../handlers/validators' +import { UserProjectPermission } from '../types' import { createAccessRequest } from '../models/projectAccessRequest.server' export async function action(args: ActionFunctionArgs) { return handle(args, { POST: { handler: handleRequestAccess, - validator: ALLOW, + validator: validateProjectAccess(UserProjectPermission.CAN_REQUEST_ACCESS, { + getProjectId: (params) => params.id, + }), }, }) } diff --git a/utopia-remix/app/routes/internal.projects.$id.access.requests.tsx b/utopia-remix/app/routes/internal.projects.$id.access.requests.tsx new file mode 100644 index 000000000000..f98bdaa865b5 --- /dev/null +++ b/utopia-remix/app/routes/internal.projects.$id.access.requests.tsx @@ -0,0 +1,32 @@ +import type { LoaderFunctionArgs } from '@remix-run/node' +import { json } from '@remix-run/node' +import type { Params } from '@remix-run/react' +import { validateProjectAccess } from '../handlers/validators' +import { listProjectAccessRequests } from '../models/projectAccessRequest.server' +import { UserProjectPermission } from '../types' +import { ensure, handle, requireUser } from '../util/api.server' +import { Status } from '../util/statusCodes' + +export async function loader(args: LoaderFunctionArgs) { + return handle(args, { + GET: { + handler: handleListAccessRequests, + validator: validateProjectAccess(UserProjectPermission.CAN_MANAGE_PROJECT, { + getProjectId: (params) => params.id, + }), + }, + }) +} + +export async function handleListAccessRequests(req: Request, params: Params) { + const user = await requireUser(req) + + const projectId = params.id + ensure(projectId != null, 'project id is null', Status.BAD_REQUEST) + + const requests = await listProjectAccessRequests({ + projectId: projectId, + userId: user.user_id, + }) + return json(requests) +} diff --git a/utopia-remix/app/routes/internal.projects.$id.role.tsx b/utopia-remix/app/routes/internal.projects.$id.role.tsx new file mode 100644 index 000000000000..531c29f1b865 --- /dev/null +++ b/utopia-remix/app/routes/internal.projects.$id.role.tsx @@ -0,0 +1,45 @@ +import type { ActionFunctionArgs } from '@remix-run/node' +import { validateProjectAccess } from '../handlers/validators' +import { ensure, handle, requireUser } from '../util/api.server' +import { UserProjectPermission, UserProjectRole, asUserProjectRole } from '../types' +import type { Params } from '@remix-run/react' +import { Status } from '../util/statusCodes' +import { asNumber } from '../util/common' +import * as permissionService from '../services/permissionsService.server' +import { getUserDetails } from '../models/userDetails.server' + +export async function action(args: ActionFunctionArgs) { + return handle(args, { + POST: { + handler: changeProjectUserRole, + validator: validateProjectAccess(UserProjectPermission.CAN_MANAGE_PROJECT, { + getProjectId: (params) => params.id, + }), + }, + }) +} + +async function changeProjectUserRole(req: Request, params: Params) { + await requireUser(req) + + const { id } = params + ensure(id != null, 'id is null', Status.BAD_REQUEST) + + const formData = await req.formData() + + const userId = formData.get('userId') + ensure(userId != null && typeof userId === 'string', 'invalid user id', Status.BAD_REQUEST) + + const user = await getUserDetails(userId) + ensure(user != null, 'user not found', Status.BAD_REQUEST) + + const userRoleStr = formData.get('userRole') + const userRoleNumber = asNumber(userRoleStr) + ensure(!isNaN(userRoleNumber), 'invalid user role', Status.BAD_REQUEST) + + const userRole = asUserProjectRole(userRoleNumber) + ensure(userRole != null, 'invalid user role', Status.BAD_REQUEST) + await permissionService.grantProjectRoleToUser(id, user.user_id, userRole) + + return {} +} diff --git a/utopia-remix/app/routes/projects.tsx b/utopia-remix/app/routes/projects.tsx index 85d7d0f7385c..a166e686ff3d 100644 --- a/utopia-remix/app/routes/projects.tsx +++ b/utopia-remix/app/routes/projects.tsx @@ -26,7 +26,6 @@ import { getCollaborators } from '../models/projectCollaborators.server' import type { OperationWithKey } from '../store' import { useProjectsStore } from '../store' import { button } from '../styles/button.css' -import { newProjectButton } from '../styles/newProjectButton.css' import { projectCategoryButton, userName } from '../styles/sidebarComponents.css' import { projectCards, projectRows } from '../styles/projects.css' import { sprinkles } from '../styles/sprinkles.css' @@ -36,7 +35,13 @@ import type { Operation, ProjectWithoutContent, } from '../types' -import { AccessLevel, getOperationDescription, asAccessLevel } from '../types' +import { + AccessLevel, + getOperationDescription, + asAccessLevel, + isProjectAccessRequestWithUserDetailsArray, +} from '../types' +import type { ProjectAccessRequestWithUserDetails } from '../types' import { requireUser } from '../util/api.server' import { assertNever } from '../util/assertNever' import { auth0LoginURL } from '../util/auth0.server' @@ -888,10 +893,26 @@ const ProjectRow = React.memo( ProjectRow.displayName = 'ProjectRow' const ProjectCardActions = React.memo(({ project }: { project: ProjectWithoutContent }) => { + const accessRequestsFetcher = useFetcher() + const [sortMenuOpen, setSortMenuOpen] = React.useState(false) + const [accessRequests, setAccessRequests] = React.useState( + [], + ) + const handleSortMenuOpenChange = React.useCallback(() => { + const action = `/internal/projects/${project.proj_id}/access/requests` + accessRequestsFetcher.submit({}, { method: 'GET', action: action }) setSortMenuOpen((prevSortMenuOpen) => !prevSortMenuOpen) - }, []) + }, [accessRequestsFetcher, project]) + + React.useEffect(() => { + if (accessRequestsFetcher.state === 'idle' && accessRequestsFetcher.data != null) { + if (isProjectAccessRequestWithUserDetailsArray(accessRequestsFetcher.data)) { + setAccessRequests(accessRequestsFetcher.data) + } + } + }, [accessRequestsFetcher]) return (
@@ -902,7 +923,7 @@ const ProjectCardActions = React.memo(({ project }: { project: ProjectWithoutCon style={{ background: sortMenuOpen ? '#a4a4a430' : 'inherit' }} /> - +
) @@ -918,6 +939,8 @@ const ProjectBadge = React.memo(({ accessLevel }: { accessLevel: AccessLevel }) return ['rgb(0 130 77)', 'rgb(0 155 0 / 9%)'] case AccessLevel.WITH_LINK: return ['rgb(0 114 222)', 'rgb(0 132 241 / 9%)'] + case AccessLevel.COLLABORATIVE: + return ['rgb(0 114 222)', 'rgb(0 132 241 / 9%)'] default: return ['gray', 'lightgray'] } @@ -931,6 +954,8 @@ const ProjectBadge = React.memo(({ accessLevel }: { accessLevel: AccessLevel }) return 'Public' case AccessLevel.WITH_LINK: return 'With Link' + case AccessLevel.COLLABORATIVE: + return 'Collaborative' default: return 'Unknown' } diff --git a/utopia-remix/app/routes/v1.project.$id.tsx b/utopia-remix/app/routes/v1.project.$id.tsx index badb78f93698..2706e8b7805a 100644 --- a/utopia-remix/app/routes/v1.project.$id.tsx +++ b/utopia-remix/app/routes/v1.project.$id.tsx @@ -13,6 +13,7 @@ export async function loader(args: LoaderFunctionArgs) { validator: validateProjectAccess(UserProjectPermission.CAN_VIEW_PROJECT, { errorMessage: 'Project not found', status: Status.NOT_FOUND, + canRequestAccess: true, getProjectId: (params) => params.id, }), }, diff --git a/utopia-remix/app/services/fgaClient.server.ts b/utopia-remix/app/services/fgaClient.server.ts index adedbc6dca5d..5f67adc4f7f7 100644 --- a/utopia-remix/app/services/fgaClient.server.ts +++ b/utopia-remix/app/services/fgaClient.server.ts @@ -29,7 +29,10 @@ class WriteSafeOpenFgaClient extends OpenFgaClient { } catch (err) { if ((err as FgaApiValidationError).apiErrorCode === ErrorCode.WriteFailedDueToInvalidInput) { // FGA throws a hard error on that, but we want to ignore it (since it's a no-op) - console.error('Failed writing an existing tuple, or deleting a non-existing tuple', err) + console.error( + 'Failed writing an existing tuple, or deleting a non-existing tuple', + (err as FgaApiValidationError).requestData, + ) } else { throw err } diff --git a/utopia-remix/app/services/fgaService.server.ts b/utopia-remix/app/services/fgaService.server.ts index 694220168bb5..2b77edf57f7a 100644 --- a/utopia-remix/app/services/fgaService.server.ts +++ b/utopia-remix/app/services/fgaService.server.ts @@ -1,31 +1,11 @@ +import type { ClientWriteRequest } from '@openfga/sdk' import { AccessLevel } from '../types' import { fgaClient } from './fgaClient.server' +import { assertNever } from '../util/assertNever' export async function updateAccessLevel(projectId: string, accessLevel: AccessLevel) { - switch (accessLevel) { - case AccessLevel.PUBLIC: - await fgaClient.write({ - writes: [ - { - user: 'user:*', - relation: 'viewer', - object: `project:${projectId}`, - }, - ], - }) - break - case AccessLevel.PRIVATE: - await fgaClient.write({ - deletes: [ - { - user: 'user:*', - relation: 'viewer', - object: `project:${projectId}`, - }, - ], - }) - break - } + const writes = accessLevelToFgaWrites(projectId, accessLevel) + return await Promise.all(writes.map((write) => fgaClient.write(write))) } const userProjectPermission = [ @@ -90,3 +70,72 @@ export async function canSeeLiveChanges(projectId: string, userId: string): Prom export async function canManageProject(projectId: string, userId: string): Promise { return checkUserProjectPermission(projectId, userId, 'can_manage') } + +// + +export async function makeUserViewer(projectId: string, userId: string) { + return fgaClient.write({ + writes: [{ user: `user:${userId}`, relation: 'viewer', object: `project:${projectId}` }], + }) +} + +export async function makeUserCollaborator(projectId: string, userId: string) { + return fgaClient.write({ + writes: [{ user: `user:${userId}`, relation: 'collaborator', object: `project:${projectId}` }], + }) +} + +export async function makeUserEditor(projectId: string, userId: string) { + return fgaClient.write({ + writes: [{ user: `user:${userId}`, relation: 'editor', object: `project:${projectId}` }], + }) +} + +export async function makeUserAdmin(projectId: string, userId: string) { + return fgaClient.write({ + writes: [{ user: `user:${userId}`, relation: 'admin', object: `project:${projectId}` }], + }) +} + +// + +function generalRelation(projectId: string, relation: string) { + return { + user: 'user:*', + relation: relation, + object: `project:${projectId}`, + } +} + +function accessLevelToFgaWrites(projectId: string, accessLevel: AccessLevel): ClientWriteRequest[] { + switch (accessLevel) { + case AccessLevel.PUBLIC: + return [ + { writes: [generalRelation(projectId, 'viewer')] }, + { deletes: [generalRelation(projectId, 'collaborator')] }, + ] + + case AccessLevel.PRIVATE: + return [ + { deletes: [generalRelation(projectId, 'viewer')] }, + { deletes: [generalRelation(projectId, 'collaborator')] }, + { deletes: [generalRelation(projectId, 'can_request_access')] }, + ] + + case AccessLevel.WITH_LINK: + return [ + { writes: [generalRelation(projectId, 'viewer')] }, + { writes: [generalRelation(projectId, 'collaborator')] }, + ] + + case AccessLevel.COLLABORATIVE: + return [ + { writes: [generalRelation(projectId, 'can_request_access')] }, + { deletes: [generalRelation(projectId, 'viewer')] }, + { deletes: [generalRelation(projectId, 'collaborator')] }, + ] + + default: + assertNever(accessLevel) + } +} diff --git a/utopia-remix/app/services/permissionsService.server.ts b/utopia-remix/app/services/permissionsService.server.ts index 19e9cb3f3f4f..1e7b268934b1 100644 --- a/utopia-remix/app/services/permissionsService.server.ts +++ b/utopia-remix/app/services/permissionsService.server.ts @@ -1,6 +1,6 @@ import { assertNever } from '../util/assertNever' import type { AccessLevel } from '../types' -import { UserProjectPermission } from '../types' +import { UserProjectPermission, UserProjectRole } from '../types' import * as fgaService from './fgaService.server' const ANONYMOUS_USER_ID = '__ANON__' @@ -38,3 +38,26 @@ export async function hasUserProjectPermission( export async function setProjectAccess(projectId: string, accessLevel: AccessLevel) { await fgaService.updateAccessLevel(projectId, accessLevel) } + +export async function grantProjectRoleToUser( + projectId: string, + userId: string, + role: UserProjectRole, +) { + switch (role) { + case UserProjectRole.VIEWER: + return await Promise.all([ + fgaService.makeUserViewer(projectId, userId), + // we're keeping collaborator role separate from viewer, to be able to grant it separately in the future + fgaService.makeUserCollaborator(projectId, userId), + ]) + case UserProjectRole.COLLABORATOR: + return await fgaService.makeUserCollaborator(projectId, userId) + case UserProjectRole.EDITOR: + return await fgaService.makeUserEditor(projectId, userId) + case UserProjectRole.ADMIN: + return await fgaService.makeUserAdmin(projectId, userId) + default: + assertNever(role) + } +} diff --git a/utopia-remix/app/types.ts b/utopia-remix/app/types.ts index ae747080fe07..a69814830a99 100644 --- a/utopia-remix/app/types.ts +++ b/utopia-remix/app/types.ts @@ -1,4 +1,4 @@ -import type { UserDetails } from 'prisma-client' +import type { ProjectAccessRequest, UserDetails } from 'prisma-client' import { Prisma } from 'prisma-client' import { assertNever } from './util/assertNever' @@ -46,6 +46,7 @@ export const AccessLevel = { PRIVATE: 0, PUBLIC: 1, WITH_LINK: 2, + COLLABORATIVE: 3, } as const export type AccessLevel = (typeof AccessLevel)[keyof typeof AccessLevel] @@ -58,6 +59,8 @@ export function asAccessLevel(accessLevel: number | undefined | null): AccessLev return AccessLevel.PUBLIC case AccessLevel.WITH_LINK: return AccessLevel.WITH_LINK + case AccessLevel.COLLABORATIVE: + return AccessLevel.COLLABORATIVE default: return null } @@ -77,6 +80,30 @@ export const UserProjectPermission = { export type UserProjectPermission = (typeof UserProjectPermission)[keyof typeof UserProjectPermission] + +export const UserProjectRole = { + VIEWER: 0, + COLLABORATOR: 1, + EDITOR: 2, + ADMIN: 3, +} as const + +export type UserProjectRole = (typeof UserProjectRole)[keyof typeof UserProjectRole] + +export function asUserProjectRole(role: number | undefined | null): UserProjectRole | null { + switch (role) { + case UserProjectRole.VIEWER: + return UserProjectRole.VIEWER + case UserProjectRole.COLLABORATOR: + return UserProjectRole.COLLABORATOR + case UserProjectRole.EDITOR: + return UserProjectRole.EDITOR + case UserProjectRole.ADMIN: + return UserProjectRole.ADMIN + default: + return null + } +} interface BaseOperation { projectId: string } @@ -133,7 +160,19 @@ export function operationChangeAccess( project: ProjectWithoutContent, newAccessLevel: AccessLevel, ): OperationChangeAccess { - return { type: 'changeAccess', ...baseOperation(project), newAccessLevel } + return { type: 'changeAccess', ...baseOperation(project), newAccessLevel: newAccessLevel } +} + +type OperationApproveAccessRequest = BaseOperation & { + type: 'approveAccessRequest' + tokenId: string +} + +export function operationApproveAccessRequest( + project: ProjectWithoutContent, + tokenId: string, +): OperationApproveAccessRequest { + return { type: 'approveAccessRequest', ...baseOperation(project), tokenId: tokenId } } export type Operation = @@ -142,8 +181,15 @@ export type Operation = | OperationDestroy | OperationRestore | OperationChangeAccess + | OperationApproveAccessRequest -export type OperationType = 'rename' | 'delete' | 'destroy' | 'restore' | 'changeAccess' +export type OperationType = + | 'rename' + | 'delete' + | 'destroy' + | 'restore' + | 'changeAccess' + | 'approveAccessRequest' export function areBaseOperationsEquivalent(a: Operation, b: Operation): boolean { return a.projectId === b.projectId && a.type === b.type @@ -161,6 +207,8 @@ export function getOperationDescription(op: Operation, project: ProjectWithoutCo return `Restoring project ${project.title}` case 'changeAccess': return `Changing access level of project ${project.title}` + case 'approveAccessRequest': + return `Granting access request to project ${project.title}` default: assertNever(op) } @@ -171,3 +219,33 @@ export enum AccessRequestStatus { APPROVED, REJECTED, } + +export type ProjectAccessRequestWithUserDetails = ProjectAccessRequest & { + User: UserDetails | null +} + +export function isProjectAccessRequestWithUserDetails( + u: unknown, +): u is ProjectAccessRequestWithUserDetails { + const maybe = u as ProjectAccessRequestWithUserDetails + return ( + u != null && + typeof u === 'object' && + maybe.id != null && + maybe.status != null && + maybe.user_id != null && + maybe.project_id != null + ) +} + +export function isProjectAccessRequestWithUserDetailsArray( + u: unknown, +): u is ProjectAccessRequestWithUserDetails[] { + const maybe = u as ProjectAccessRequestWithUserDetails[] + return ( + u != null && + typeof u === 'object' && + Array.isArray(u) && + maybe.every(isProjectAccessRequestWithUserDetails) + ) +} diff --git a/utopia-remix/package.json b/utopia-remix/package.json index 4656e073524d..97a3f28b1930 100644 --- a/utopia-remix/package.json +++ b/utopia-remix/package.json @@ -17,9 +17,14 @@ "@headlessui/react": "1.7.18", "@openfga/sdk": "0.3.2", "@prisma/client": "5.9.0", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-context-menu": "2.1.5", + "@radix-ui/react-dialog": "1.0.5", "@radix-ui/react-direction": "1.0.1", "@radix-ui/react-dropdown-menu": "2.0.6", "@radix-ui/react-icons": "1.3.0", + "@radix-ui/react-scroll-area": "1.0.5", + "@radix-ui/react-separator": "1.0.3", "@radix-ui/react-slot": "1.0.2", "@radix-ui/react-tooltip": "1.0.7", "@radix-ui/themes": "2.0.3", diff --git a/utopia-remix/pnpm-lock.yaml b/utopia-remix/pnpm-lock.yaml index 9cc08e4ff02a..f1e056661219 100644 --- a/utopia-remix/pnpm-lock.yaml +++ b/utopia-remix/pnpm-lock.yaml @@ -6,9 +6,14 @@ specifiers: '@headlessui/react': 1.7.18 '@openfga/sdk': 0.3.2 '@prisma/client': 5.9.0 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-context-menu': 2.1.5 + '@radix-ui/react-dialog': 1.0.5 '@radix-ui/react-direction': 1.0.1 '@radix-ui/react-dropdown-menu': 2.0.6 '@radix-ui/react-icons': 1.3.0 + '@radix-ui/react-scroll-area': 1.0.5 + '@radix-ui/react-separator': 1.0.3 '@radix-ui/react-slot': 1.0.2 '@radix-ui/react-tooltip': 1.0.7 '@radix-ui/themes': 2.0.3 @@ -70,9 +75,14 @@ dependencies: '@headlessui/react': 1.7.18_biqbaboplfbrettd7655fr4n2y '@openfga/sdk': 0.3.2 '@prisma/client': 5.9.0_prisma@5.9.0 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-context-menu': 2.1.5_gltvt74xzh7f5lvw2hzxriz5bu + '@radix-ui/react-dialog': 1.0.5_gltvt74xzh7f5lvw2hzxriz5bu '@radix-ui/react-direction': 1.0.1_j3ahe22lw6ac2w6qvqp4kjqnqy '@radix-ui/react-dropdown-menu': 2.0.6_gltvt74xzh7f5lvw2hzxriz5bu '@radix-ui/react-icons': 1.3.0_react@18.2.0 + '@radix-ui/react-scroll-area': 1.0.5_gltvt74xzh7f5lvw2hzxriz5bu + '@radix-ui/react-separator': 1.0.3_gltvt74xzh7f5lvw2hzxriz5bu '@radix-ui/react-slot': 1.0.2_j3ahe22lw6ac2w6qvqp4kjqnqy '@radix-ui/react-tooltip': 1.0.7_gltvt74xzh7f5lvw2hzxriz5bu '@radix-ui/themes': 2.0.3_gltvt74xzh7f5lvw2hzxriz5bu