Skip to content

Commit

Permalink
WIP collaborators integration
Browse files Browse the repository at this point in the history
  • Loading branch information
ericvicenti committed Aug 14, 2024
1 parent 5ffb3d0 commit 48e0182
Show file tree
Hide file tree
Showing 8 changed files with 226 additions and 8 deletions.
2 changes: 1 addition & 1 deletion frontend/apps/desktop/src/components/accessory-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ export function AccessoryContainer({
{title ? (
<SizableText
userSelect="none"
f={1}
size="$3"
fontWeight="600"
marginHorizontal="$4"
paddingBottom="$4"
>
{title}
</SizableText>
Expand Down
126 changes: 126 additions & 0 deletions frontend/apps/desktop/src/components/collaborators-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import {
useAddCapabilities,
useAllDocumentCapabilities,
useMyCapability,
} from '@/models/access-control'
import {useSearch} from '@/models/search'
import {DocumentRoute} from '@/utils/routes'
import {Role, UnpackedHypermediaId, unpackHmId} from '@shm/shared'
import {Button, Input, Label, SizableText, Text} from '@shm/ui'
import {useState} from 'react'
import {AccessoryContainer} from './accessory-sidebar'

export function CollaboratorsPanel({
route,
onClose,
}: {
route: DocumentRoute
onClose: () => void
}) {
return (
<AccessoryContainer title="Collaborators" onClose={onClose}>
<AddCollaboratorForm id={route.id} />
<Label>Collaborators</Label>
<CollaboratorsList id={route.id} />
</AccessoryContainer>
)
}

type SearchResult = {
id: UnpackedHypermediaId
label: string
}

function AddCollaboratorForm({id}: {id: UnpackedHypermediaId}) {
const myCapability = useMyCapability(id, 'admin')
const addCapabilities = useAddCapabilities(id)
const [selectedCollaborators, setSelectedCollaborators] = useState<
SearchResult[]
>([])
const [search, setSearch] = useState('')
const searchResults = useSearch(search, {})
if (!myCapability) return null
return (
<>
<Label>Add Collaborator</Label>
{selectedCollaborators.map((collab) => (
<SizableText key={collab.id.id}>{collab.label}</SizableText>
))}
<Input
value={search}
onChangeText={(searchText: string) => {
console.log(searchText)
setSearch(searchText)
}}
/>
{searchResults.data
?.map((result) => {
const id = unpackHmId(result.id)
if (!id) return null
return {id, label: result.title}
})
.filter((result) => {
if (!result) return false // probably id was not parsed correctly
if (result.id.path?.length) return false // this is a directory document, not an account
if (result.id.uid === id.uid) return false // this account is already the owner, cannot be added
if (
selectedCollaborators.find(
(collab) => collab.id.id === result.id.id,
)
)
return false // already added
return true
})
.map(
(result) =>
result && (
<SearchResultItem
key={result.id.id}
result={result}
onSelect={() => {
setSelectedCollaborators((collabs) => [...collabs, result])
}}
/>
),
)}
{/* <SelectDropdown options={RoleOptions} onSelect={() = > {}} /> // not relevant yet because we can only add writers
*/}
<Text>{JSON.stringify(myCapability)}</Text>
<Button
onPress={() => {
addCapabilities.mutate({
myCapability: myCapability,
collaboratorAccountIds: selectedCollaborators.map(
(collab) => collab.id.uid,
),
role: Role.WRITER,
})
}}
>
Add Writers
</Button>
</>
)
}

function SearchResultItem({
result,
onSelect,
}: {
result: SearchResult
onSelect: () => void
}) {
return <Button onPress={onSelect}>{result.label}</Button>
}

function CollaboratorsList({id}: {id: UnpackedHypermediaId}) {
const capabilities = useAllDocumentCapabilities(id)
return capabilities.data?.map((capability) => {
return (
<SizableText>
{capability.account} - {capability.role}
</SizableText>
)
})
return null
}
8 changes: 5 additions & 3 deletions frontend/apps/desktop/src/components/titlebar-common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import {ContactsPrompt} from '@/components/contacts-prompt'
import {useCopyGatewayReference} from '@/components/copy-gateway-reference'
import {useDeleteDialog} from '@/components/delete-dialog'
import {MenuItemType, OptionsDropdown} from '@/components/options-dropdown'
import {useMyCapability} from '@/models/access-control'
import {useDraft} from '@/models/accounts'
import {useMyAccountIds} from '@/models/daemon'
import {usePushPublication} from '@/models/documents'
import {useEntity} from '@/models/entities'
import {useGatewayHost, useGatewayUrl} from '@/models/gateway-settings'
Expand Down Expand Up @@ -152,10 +152,12 @@ function EditDocButton() {
const route = useNavRoute()
if (route.key !== 'document')
throw new Error('EditDocButton can only be rendered on document route')
const myAccountIds = useMyAccountIds()
const capability = useMyCapability(route.id, 'writer')
// const myAccountIds = useMyAccountIds() // TODO, enable when API is fixed
const navigate = useNavigate()
const draft = useDraft(route.id.id)
const hasExistingDraft = !!draft.data
// if (!capability) return null // TODO, enable when API is fixed
return (
<>
<Tooltip content={hasExistingDraft ? 'Resume Editing' : 'Edit'}>
Expand Down Expand Up @@ -196,7 +198,7 @@ export function useDocumentUrl({
const gwUrl = useGatewayUrl()
const [copyDialogContent, onCopyPublic] = useCopyGatewayReference()
const hostname = gwUrl.data

if (!docId) return null
return {
url: createPublicWebHmUrl('d', docId.uid, {
version: pub.data?.document?.version,
Expand Down
85 changes: 85 additions & 0 deletions frontend/apps/desktop/src/models/access-control.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {useGRPCClient, useQueryInvalidator} from '@/app-context'
import {toPlainMessage} from '@bufbuild/protobuf'
import {Role, UnpackedHypermediaId} from '@shm/shared'
import {useMutation, useQuery} from '@tanstack/react-query'
import {useMyAccountIds} from './daemon'
import {getParentPaths, hmIdPathToEntityQueryPath} from './entities'
import {queryKeys} from './query-keys'

export function useDocumentCollaborators(id: UnpackedHypermediaId) {
//
getParentPaths()
}

export function useAddCapabilities(id: UnpackedHypermediaId) {
const grpcClient = useGRPCClient()
const invalidate = useQueryInvalidator()
return useMutation({
mutationFn: async ({
myCapability,
collaboratorAccountIds,
role,
}: {
myCapability: HMCapability
collaboratorAccountIds: string[]
role: Role
}) => {
await Promise.all(
collaboratorAccountIds.map(
async (collaboratorAccountId) =>
await grpcClient.accessControl.createCapability({
account: collaboratorAccountId,
delegate: id.uid,
role,
path: hmIdPathToEntityQueryPath(id.path),
signingKeyName: myCapability.accountUid,
// noRecursive, // ?
}),
),
)
},
onSuccess: () => {
invalidate([queryKeys.CAPABILITIES, id.uid, ...(id.path || [])])
},
})
}

type CapabilityType = 'admin' | 'owner' | 'writer'

type HMCapability = {
accountUid: string
role: CapabilityType
}

const CapabilityInheritance: Readonly<CapabilityType[]> =
// used to determine when one capability can be used in place of another. all owners are writers, for example
['owner', 'admin', 'writer']

export function useMyCapability(
id: UnpackedHypermediaId,
capability: 'admin' | 'owner' | 'writer',
): HMCapability | null {
const myAccounts = useMyAccountIds()
const capabilities = useAllDocumentCapabilities(id)
// todo!
if (myAccounts.data?.indexOf(id.uid) !== -1) {
// owner. no capability needed
return {accountUid: id.uid, role: 'owner'}
}
return null
}

export function useAllDocumentCapabilities(id: UnpackedHypermediaId) {
const grpcClient = useGRPCClient()
return useQuery({
queryKey: [queryKeys.CAPABILITIES, id.uid, ...(id.path || [])],
queryFn: async () => {
const result = await grpcClient.accessControl.listCapabilities({
account: id.uid,
path: hmIdPathToEntityQueryPath(id.path),
})
const capabilities = result.capabilities.map(toPlainMessage)
return capabilities
},
})
}
2 changes: 2 additions & 0 deletions frontend/apps/desktop/src/models/query-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export const queryKeys = {

ENTITY: 'ENTITY',

CAPABILITIES: 'CAPABILITIES', //, id.uid: string, ...id.path

// comments
COMMENT: 'COMMENT', //, commentId: string
PUBLICATION_COMMENTS: 'PUBLICATION_COMMENTS', //, docUid: string
Expand Down
5 changes: 2 additions & 3 deletions frontend/apps/desktop/src/pages/document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
AccessoryLayout,
} from '@/components/accessory-sidebar'
import {AvatarForm} from '@/components/avatar-form'
import {CollaboratorsPanel} from '@/components/collaborators-panel'
import {useCopyGatewayReference} from '@/components/copy-gateway-reference'
import {Directory} from '@/components/directory'
import {LinkNameComponent} from '@/components/document-name'
Expand Down Expand Up @@ -81,9 +82,7 @@ export default function DocumentPage() {
} else if (accessoryKey === 'versions') {
accessory = <AccessoryContainer title="Versions" onClose={handleClose} />
} else if (accessoryKey === 'collaborators') {
accessory = (
<AccessoryContainer title="Collaborators" onClose={handleClose} />
)
accessory = <CollaboratorsPanel route={route} onClose={handleClose} />
} else if (accessoryKey === 'suggested-changes') {
accessory = (
<AccessoryContainer title="Suggested Changes" onClose={handleClose} />
Expand Down
2 changes: 2 additions & 0 deletions frontend/packages/shared/src/client/grpc-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export * from './.generated/activity/v1alpha/activity_connect'
export * from './.generated/activity/v1alpha/activity_pb'
export * from './.generated/daemon/v1alpha/daemon_connect'
export * from './.generated/daemon/v1alpha/daemon_pb'
export * from './.generated/documents/v3alpha/access_control_connect'
export * from './.generated/documents/v3alpha/access_control_pb'
export * from './.generated/documents/v3alpha/documents_connect'
export * from './.generated/documents/v3alpha/documents_pb'
export * from './.generated/entities/v1alpha/entities_connect'
Expand Down
4 changes: 3 additions & 1 deletion frontend/packages/shared/src/grpc-client.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {createPromiseClient, PromiseClient} from '@connectrpc/connect'
import {Daemon, Documents, Entities, Networking} from './client'
import {AccessControl, Daemon, Documents, Entities, Networking} from './client'

export type GRPCClient = {
daemon: PromiseClient<typeof Daemon>
documents: PromiseClient<typeof Documents>
entities: PromiseClient<typeof Entities>
networking: PromiseClient<typeof Networking>
accessControl: PromiseClient<typeof AccessControl>
}

export function createGRPCClient(transport: any): GRPCClient {
Expand All @@ -14,5 +15,6 @@ export function createGRPCClient(transport: any): GRPCClient {
documents: createPromiseClient(Documents, transport),
entities: createPromiseClient(Entities, transport),
networking: createPromiseClient(Networking, transport),
accessControl: createPromiseClient(AccessControl, transport),
} as const
}

0 comments on commit 48e0182

Please sign in to comment.