diff --git a/frontend/apps/desktop/src/components/accessory-sidebar.tsx b/frontend/apps/desktop/src/components/accessory-sidebar.tsx
index da4b6654..5b35f4b2 100644
--- a/frontend/apps/desktop/src/components/accessory-sidebar.tsx
+++ b/frontend/apps/desktop/src/components/accessory-sidebar.tsx
@@ -25,10 +25,10 @@ export function AccessoryContainer({
{title ? (
{title}
diff --git a/frontend/apps/desktop/src/components/collaborators-panel.tsx b/frontend/apps/desktop/src/components/collaborators-panel.tsx
new file mode 100644
index 00000000..c11c4f99
--- /dev/null
+++ b/frontend/apps/desktop/src/components/collaborators-panel.tsx
@@ -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 (
+
+
+
+
+
+ )
+}
+
+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 (
+ <>
+
+ {selectedCollaborators.map((collab) => (
+ {collab.label}
+ ))}
+ {
+ 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 && (
+ {
+ setSelectedCollaborators((collabs) => [...collabs, result])
+ }}
+ />
+ ),
+ )}
+ {/* {}} /> // not relevant yet because we can only add writers
+ */}
+ {JSON.stringify(myCapability)}
+
+ >
+ )
+}
+
+function SearchResultItem({
+ result,
+ onSelect,
+}: {
+ result: SearchResult
+ onSelect: () => void
+}) {
+ return
+}
+
+function CollaboratorsList({id}: {id: UnpackedHypermediaId}) {
+ const capabilities = useAllDocumentCapabilities(id)
+ return capabilities.data?.map((capability) => {
+ return (
+
+ {capability.account} - {capability.role}
+
+ )
+ })
+ return null
+}
diff --git a/frontend/apps/desktop/src/components/titlebar-common.tsx b/frontend/apps/desktop/src/components/titlebar-common.tsx
index 981b0850..5579e615 100644
--- a/frontend/apps/desktop/src/components/titlebar-common.tsx
+++ b/frontend/apps/desktop/src/components/titlebar-common.tsx
@@ -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'
@@ -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 (
<>
@@ -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,
diff --git a/frontend/apps/desktop/src/models/access-control.ts b/frontend/apps/desktop/src/models/access-control.ts
new file mode 100644
index 00000000..77bd5763
--- /dev/null
+++ b/frontend/apps/desktop/src/models/access-control.ts
@@ -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 =
+ // 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
+ },
+ })
+}
diff --git a/frontend/apps/desktop/src/models/query-keys.ts b/frontend/apps/desktop/src/models/query-keys.ts
index 2af0cbbe..28185d7a 100644
--- a/frontend/apps/desktop/src/models/query-keys.ts
+++ b/frontend/apps/desktop/src/models/query-keys.ts
@@ -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
diff --git a/frontend/apps/desktop/src/pages/document.tsx b/frontend/apps/desktop/src/pages/document.tsx
index e75b5414..8bdd7cd1 100644
--- a/frontend/apps/desktop/src/pages/document.tsx
+++ b/frontend/apps/desktop/src/pages/document.tsx
@@ -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'
@@ -81,9 +82,7 @@ export default function DocumentPage() {
} else if (accessoryKey === 'versions') {
accessory =
} else if (accessoryKey === 'collaborators') {
- accessory = (
-
- )
+ accessory =
} else if (accessoryKey === 'suggested-changes') {
accessory = (
diff --git a/frontend/packages/shared/src/client/grpc-types.ts b/frontend/packages/shared/src/client/grpc-types.ts
index a7a05b5e..965c6ff2 100644
--- a/frontend/packages/shared/src/client/grpc-types.ts
+++ b/frontend/packages/shared/src/client/grpc-types.ts
@@ -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'
diff --git a/frontend/packages/shared/src/grpc-client.ts b/frontend/packages/shared/src/grpc-client.ts
index 7d307cbe..afd83e63 100644
--- a/frontend/packages/shared/src/grpc-client.ts
+++ b/frontend/packages/shared/src/grpc-client.ts
@@ -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
documents: PromiseClient
entities: PromiseClient
networking: PromiseClient
+ accessControl: PromiseClient
}
export function createGRPCClient(transport: any): GRPCClient {
@@ -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
}