Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Collaborative projects #5033

Merged
merged 63 commits into from
Mar 14, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
9f6490f
initial commit
liady Mar 12, 2024
184deda
fix order
liady Mar 12, 2024
b1df39e
Merge branch 'master' into refactor/local-fga
liady Mar 12, 2024
63f2b06
fix lint issue
liady Mar 12, 2024
af1540b
fix CR
liady Mar 13, 2024
0c62f8c
fix CR
liady Mar 13, 2024
efcfe7a
fix
liady Mar 13, 2024
7b086d4
Merge branch 'master' into refactor/local-fga
liady Mar 13, 2024
ee07423
log FGA client mode
liady Mar 13, 2024
63a5956
add more relations
liady Mar 13, 2024
4aa647c
refactor
liady Mar 13, 2024
5cae347
return 403 when unauthorized
liady Mar 13, 2024
9003463
Merge branch 'master' into feat/collaborative-project
liady Mar 13, 2024
cf76657
fix eslint
liady Mar 13, 2024
7cffb48
grant role
liady Mar 13, 2024
df05c66
fix eslint
liady Mar 13, 2024
af1626f
Update utopia-remix/app/handlers/validators.ts
liady Mar 13, 2024
1621d51
use info
liady Mar 13, 2024
31260b6
remove filter Boolean
liady Mar 13, 2024
555ea30
add requests table
ruggi Mar 13, 2024
0d68f59
add uuid
ruggi Mar 13, 2024
8493636
create/update access requests
ruggi Mar 13, 2024
fc2961d
routes
ruggi Mar 13, 2024
8fbecc9
add status type
ruggi Mar 13, 2024
cf6b9f4
test db operations
ruggi Mar 13, 2024
29d77fd
update schema
ruggi Mar 13, 2024
a9edbfd
comment
ruggi Mar 13, 2024
bd90c9d
Merge branch 'master' into feat/collaborative-project
liady Mar 13, 2024
f9f894b
Merge branch 'master' into feat/project-access-request
liady Mar 13, 2024
1ede489
Merge branch 'feat/project-access-request' into feat/collaborative-pr…
liady Mar 13, 2024
65c4eba
access approval flow
liady Mar 14, 2024
028ba57
remove unneeded function
liady Mar 14, 2024
212600f
add popup
liady Mar 14, 2024
d490497
Merge branch 'master' into feat/collaborative-project
ruggi Mar 14, 2024
aab7c8a
fix updateAccessRequestStatus
ruggi Mar 14, 2024
e5e5570
popup
liady Mar 14, 2024
5f41a70
Merge branch 'master' into feat/collaborative-project
liady Mar 14, 2024
fdec37b
Merge branch 'feat/collaborative-project' into feat/share-popup
liady Mar 14, 2024
bac91c8
use 403
ruggi Mar 14, 2024
21631a8
fix components
ruggi Mar 14, 2024
7944af7
popup
liady Mar 14, 2024
1474a6f
do not actually throw on NaN array access (#5041)
balazsbajorics Mar 14, 2024
955d4a1
sub-cartouches (#5040)
balazsbajorics Mar 14, 2024
f142273
Merge branch 'master' into feat/collaborative-project
liady Mar 14, 2024
d074e34
add a dot for pending
liady Mar 14, 2024
b218be1
Merge branch 'master' into feat/collaborative-project
liady Mar 14, 2024
b6cda6e
Merge branch 'feat/collaborative-project' into feat/share-popup
liady Mar 14, 2024
c3ee65a
lazy load requests
ruggi Mar 14, 2024
412c444
fix changeProjectUserRole
ruggi Mar 14, 2024
881750f
Merge branch 'feat/collaborative-project' into feat/share-popup
liady Mar 14, 2024
cd01f24
remove uneeded code
liady Mar 14, 2024
66068da
fix cr
liady Mar 14, 2024
5805f6c
remove arrow functions
liady Mar 14, 2024
84a0129
remove unneeded null
liady Mar 14, 2024
173e438
Merge branch 'feat/collaborative-project' into feat/share-popup
liady Mar 14, 2024
fb2a8ad
include 'rejected' status
liady Mar 14, 2024
b09abb7
fix click handler
liady Mar 14, 2024
6db73a5
Merge branch 'feat/collaborative-project' into feat/share-popup
liady Mar 14, 2024
0f09e2e
return all requests, because needed by the ui
ruggi Mar 14, 2024
110d8cc
Merge branch 'feat/collaborative-project' into feat/share-popup
liady Mar 14, 2024
5809125
Update utopia-remix/app/components/SharePopup.tsx
liady Mar 14, 2024
68e9a4c
fix when
liady Mar 14, 2024
ad784ad
sort requests
ruggi Mar 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions utopia-remix/__mocks__/@openfga/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ''
16 changes: 14 additions & 2 deletions utopia-remix/app/components/projectActionContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type ContextMenuEntry =
onClick: (project: ProjectWithoutContent) => void
}
| 'separator'
| null

export const ProjectContextMenu = React.memo(({ project }: { project: ProjectWithoutContent }) => {
const deleteFetcher = useFetcherWithOperation(project.proj_id, 'delete')
Expand Down Expand Up @@ -94,7 +95,7 @@ export const ProjectContextMenu = React.memo(({ project }: { project: ProjectWit
)
const projectEditorLink = useProjectEditorLink()

const menuEntries = React.useMemo((): ContextMenuEntry[] => {
const menuEntries = React.useMemo((): (ContextMenuEntry | null)[] => {
switch (selectedCategory) {
case 'allProjects':
return [
Expand Down Expand Up @@ -134,6 +135,14 @@ export const ProjectContextMenu = React.memo(({ project }: { project: ProjectWit
deleteProject(selectedProject.proj_id)
},
},
accessLevel === AccessLevel.PRIVATE
? {
text: 'Make Collaborative',
onClick: (selectedProject) => {
changeAccessLevel(selectedProject.proj_id, AccessLevel.COLLABORATIVE)
},
}
: null,
{
text: accessLevel === AccessLevel.PUBLIC ? 'Make Private' : 'Make Public',
onClick: (selectedProject) => {
Expand Down Expand Up @@ -177,7 +186,10 @@ export const ProjectContextMenu = React.memo(({ project }: { project: ProjectWit
return (
<DropdownMenu.Portal>
<DropdownMenu.Content className={contextMenuDropdown()} align='end' sideOffset={5}>
{menuEntries.map((entry, index) => {
{menuEntries.filter(Boolean).map((entry, index) => {
liady marked this conversation as resolved.
Show resolved Hide resolved
if (entry == null) {
return null
}
if (entry === 'separator') {
return (
<DropdownMenu.Separator
Expand Down
19 changes: 17 additions & 2 deletions utopia-remix/app/handlers/validators.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -13,11 +13,13 @@ export function validateProjectAccess(
status,
getProjectId,
includeDeleted = false,
canRequestAccess = false,
}: {
errorMessage?: string
status?: number
getProjectId: (params: Params<string>) => string | null | undefined
includeDeleted?: boolean
canRequestAccess?: boolean
},
): AccessValidator {
return async function (req: Request, params: Params<string>) {
Expand All @@ -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.UNAUTHORIZED
if (!allowed && canRequestAccess) {
ruggi marked this conversation as resolved.
Show resolved Hide resolved
const hasRequestAccessPermission = await hasUserProjectPermission(
projectId,
userId,
UserProjectPermission.CAN_REQUEST_ACCESS,
)
if (hasRequestAccessPermission) {
errorMessageToUse = 'Request access to this project'
statusToUse = Status.UNAUTHORIZED
}
}
ensure(allowed, errorMessageToUse, statusToUse)
}
}

Expand Down
39 changes: 39 additions & 0 deletions utopia-remix/app/routes/internal.projects.$id.role.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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'

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<string>) {
const user = await requireUser(req)
const { id } = params
ensure(id != null, 'id is null', Status.BAD_REQUEST)

const formData = await req.formData()
const userRoleStr = formData.get('userRole')
const userRoleNumber = asNumber(userRoleStr)
ensure(!isNaN(userRoleNumber), 'userRole is not a number', Status.BAD_REQUEST)
const userRole = asUserProjectRole(userRoleNumber)
ensure(
userRole != null && Object.values(UserProjectRole).includes(userRole),
'userRole is not a valid UserProjectRole',
Status.BAD_REQUEST,
)
await permissionService.grantProjectRoleToUser(id, user.user_id, userRole)

return {}
}
4 changes: 4 additions & 0 deletions utopia-remix/app/routes/projects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,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']
}
Expand All @@ -931,6 +933,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'
}
Expand Down
1 change: 1 addition & 0 deletions utopia-remix/app/routes/v1.project.$id.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
},
Expand Down
8 changes: 7 additions & 1 deletion utopia-remix/app/services/fgaClient.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -87,14 +90,17 @@ function getCredentials(fgaClientMode: 'local' | 'remote'): CredentialsConfig {
function getFgaClientMode(): 'local_mock' | 'local' | 'remote' {
if (ServerEnvironment.environment === 'local') {
if (ServerEnvironment.FGA_API_HOST == '') {
// eslint-disable-next-line no-console
liady marked this conversation as resolved.
Show resolved Hide resolved
console.log('Using mock FGA client')
return 'local_mock'
}
if (ServerEnvironment.FGA_API_HOST.includes('localhost')) {
// eslint-disable-next-line no-console
console.log('Using local FGA client')
return 'local'
}
}
// eslint-disable-next-line no-console
console.log('Using remote FGA client')
return 'remote'
}
97 changes: 73 additions & 24 deletions utopia-remix/app/services/fgaService.server.ts
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -90,3 +70,72 @@ export async function canSeeLiveChanges(projectId: string, userId: string): Prom
export async function canManageProject(projectId: string, userId: string): Promise<boolean> {
return checkUserProjectPermission(projectId, userId, 'can_manage')
}

//
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this deliberate?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just as a visual separation between related groups of functions (I can remove it though of course :) )

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please


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)
}
}
25 changes: 24 additions & 1 deletion utopia-remix/app/services/permissionsService.server.ts
Original file line number Diff line number Diff line change
@@ -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__'
Expand Down Expand Up @@ -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)
}
}
27 changes: 27 additions & 0 deletions utopia-remix/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down
Loading