From 8dfaa396077676bd555cd9be69e23a18317b9ffe Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:32:24 +0100 Subject: [PATCH] fix: leave Edit, Move and Delete out for contacts in a read-only address book MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two "Add to group" tests click the entry by its new key, which ends in "…" because it opens a group picker. --- .../contacts/__tests__/contacts-app.test.tsx | 20 ++++++++++-- components/contacts/contact-context-menu.tsx | 31 ++++++++++++------- components/contacts/contact-detail.tsx | 7 +++-- components/contacts/contact-list.tsx | 4 +++ components/contacts/contacts-app.tsx | 5 +++ 5 files changed, 52 insertions(+), 15 deletions(-) diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx index cd153f9e8..802c8f994 100644 --- a/components/contacts/__tests__/contacts-app.test.tsx +++ b/components/contacts/__tests__/contacts-app.test.tsx @@ -182,7 +182,7 @@ describe('ContactsApp', () => { it('starts a new group with the contact when "Add to group" finds no group to add to', () => { renderContacts(); fireEvent.contextMenu(screen.getByText('Alice Smith')); - fireEvent.click(screen.getByText('context_menu.add_to_group')); + fireEvent.click(screen.getByText('contacts_add_to_group')); expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument(); }); @@ -190,11 +190,27 @@ describe('ContactsApp', () => { const team: ContactCard = { id: 'g-team', kind: 'group', addressBookIds: {}, name: { full: 'Team' }, members: {} }; renderContacts([alice, bob, team]); fireEvent.contextMenu(screen.getByText('Bob Jones')); - fireEvent.click(screen.getByText('context_menu.add_to_group')); + fireEvent.click(screen.getByText('contacts_add_to_group')); fireEvent.click(screen.getByText('bulk.new_group')); expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument(); }); + it('offers no Edit, Move or Delete for a contact in an address book shared read-only', () => { + const shared = { ...alice, addressBookIds: { 'ab-shared': true } }; + renderContacts([shared, bob]); + act(() => { + useContactStore.setState({ addressBooks: [{ id: 'ab-shared', name: 'Team', myRights: { mayRead: true, mayWrite: false } }] as never }); + }); + fireEvent.contextMenu(screen.getByText('Alice Smith')); + expect(screen.queryByRole('menuitem', { name: 'context_menu.edit' })).toBeNull(); + expect(screen.queryByRole('menuitem', { name: /context_menu.delete/ })).toBeNull(); + expect(screen.getByRole('menuitem', { name: 'context_menu.export_vcard' })).toBeInTheDocument(); + fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' }); + + fireEvent.contextMenu(screen.getByText('Bob Jones')); + expect(screen.getByRole('menuitem', { name: 'context_menu.edit' })).toBeInTheDocument(); + }); + it('imports in a real dialog that Escape closes', () => { renderContacts(); fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]); diff --git a/components/contacts/contact-context-menu.tsx b/components/contacts/contact-context-menu.tsx index 9b6eea288..6281846ca 100644 --- a/components/contacts/contact-context-menu.tsx +++ b/components/contacts/contact-context-menu.tsx @@ -65,6 +65,8 @@ interface ContactContextMenuProps { onBatchExport?: () => void; onBatchAddToGroup?: () => void; onBatchDelete?: () => void; + /** The contact is in an address book shared read-only: no Edit, Move or Delete. */ + readOnly?: boolean; } export function ContactContextMenu({ @@ -88,8 +90,10 @@ export function ContactContextMenu({ onBatchExport, onBatchAddToGroup, onBatchDelete, + readOnly = false, }: ContactContextMenuProps) { const t = useTranslations("contacts"); + const tMenu = useTranslations("menuAudit"); const printOff = useStrictestDevices().noPrint; const email = getContactPrimaryEmail(contact); const phone = getContactPrimaryPhone(contact); @@ -134,7 +138,7 @@ export function ContactContextMenu({ onBatchAddToGroup?.())} disabled={!onBatchAddToGroup} /> @@ -179,12 +183,12 @@ export function ContactContextMenu({ onClick={handle(handleCall)} /> )} - {onEdit && } + {onEdit && !readOnly && } {!inDetail && } {onAddToGroup && ( )} @@ -195,7 +199,7 @@ export function ContactContextMenu({ onClick={handle(onDuplicate)} /> )} - {onMoveTo && ( + {onMoveTo && !readOnly && ( )} {email && ( @@ -225,13 +229,18 @@ export function ContactContextMenu({ onClick={handle(handlePrint)} /> )} - - + {!readOnly && ( + <> + + + + )} ); } diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 77edb2cdc..66a3969e2 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -35,6 +35,8 @@ interface ContactDetailProps { onPrint?: () => void; isMobile?: boolean; className?: string; + /** In an address book shared read-only: no Edit, Move or Delete. */ + readOnly?: boolean; } // Contexts and phone features the contact form offers, shown with the same @@ -74,7 +76,7 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null { } -export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className }: ContactDetailProps) { +export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className, readOnly = false }: ContactDetailProps) { const t = useTranslations("contacts"); const tCommon = useTranslations("common"); const locale = useLocale(); @@ -173,7 +175,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli )} {/* A phone's row has no room for this label: the ⋮ fell onto a line of its own. */} - {isMobile ? ( + {readOnly ? null : isMobile ? ( @@ -207,6 +209,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli onClose={moreMenu.closeContextMenu} menuRef={moreMenu.menuRef} inDetail + readOnly={readOnly} onCompose={onCompose} onDelete={onDelete} onAddToGroup={onAddToGroup} diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 6a11b7031..c3eef9c7a 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -105,6 +105,8 @@ function ToggleChip({ icon, label, value, onClick }: { icon: React.ReactNode; la interface ContactListProps { contacts: ContactCard[]; + /** A contact in an address book shared read-only: its menu has no Edit, Move or Delete. */ + isContactReadOnly?: (contact: ContactCard) => boolean; selectedContactId: string | null; searchQuery: string; onSearchChange: (query: string) => void; @@ -144,6 +146,7 @@ interface ContactListProps { export function ContactList({ contacts, + isContactReadOnly, selectedContactId, searchQuery, onSearchChange, @@ -788,6 +791,7 @@ export function ContactList({ onBatchExport={onBulkExport} onBatchAddToGroup={onBulkAddToGroup} onBatchDelete={onBulkDelete} + readOnly={isContactReadOnly?.(contextMenu.data) ?? false} /> )} diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index a28c84ea1..83e2619d7 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -487,6 +487,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = // Address books contacts can be moved to; "Move to…" needs a second one. const writableBooks = useMemo(() => addressBooks.filter((b) => b.myRights?.mayWrite !== false), [addressBooks]); const canMoveContacts = !!client && supportsSync && writableBooks.length > 1; + // A contact in an address book shared read-only can't be changed: its menus leave out Edit, Move and Delete. + const isContactReadOnly = useCallback((contact: ContactCard) => Object.entries(contact.addressBookIds ?? {}) + .some(([id, inBook]) => inBook && addressBooks.find((b) => b.id === id)?.myRights?.mayWrite === false), [addressBooks]); const handleMoveContacts = useCallback((ids: string[]) => { if (ids.length > 0) setMovingContactIds(ids); }, []); @@ -1144,6 +1147,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = onMoveTo={selectedContact && canMoveContacts ? () => handleMoveContacts([selectedContact.id]) : undefined} onPrint={selectedContact && !printOff ? () => { void printContact(selectedContact); } : undefined} isMobile={isMobile} + readOnly={selectedContact ? isContactReadOnly(selectedContact) : false} /> ); } @@ -1398,6 +1402,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = ) : (