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 }