From f7deb92d160e41cf7bfc529a419a4b6c8acde1a4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:07:46 +0200 Subject: [PATCH] fix: explain a closed directory and refused free/busy in the right words With directory queries off on Stalwart 1.0, the free/busy list under the attendees disappeared without a word. The share dialog explained every `forbidden` from Principal/query with the 1.0 text: "only your groups and people who have shared with you are listed", and a hint at Stalwart's sharing settings. On 0.16 that refusal only means the role lacks the permission, Principal/get then lists everyone, and the setting has no effect over JMAP. - When the server refuses free/busy for the user (`mayGetAvailability` false on the user's own principal, or a lookup refused with `forbidden`), one muted line says "Free/busy isn't available on this server". The block stays empty only when the server offers no free/busy at all. - The share dialog keeps the 1.0 wording for Stalwart 1.0's own refusal ("The administrator has disabled directory queries.") and otherwise says "Your server doesn't let you browse its user directory.", still listing whoever Principal/get returned. --- .../participant-availability.test.ts | 32 +++++++++++++++---- .../calendar/participant-availability.tsx | 18 +++++++++-- .../share-collection-dialog.test.tsx | 23 +++++++++++++ .../settings/share-collection-dialog.tsx | 13 ++++++-- lib/__tests__/jmap-principals-paging.test.ts | 20 ++++++++++++ lib/jmap/client.ts | 16 ++++++++-- lib/jmap/types.ts | 6 ++++ locales/ar/common.json | 4 ++- locales/ca/common.json | 4 ++- locales/cs/common.json | 4 ++- locales/da/common.json | 4 ++- locales/de/common.json | 4 ++- locales/en/common.json | 4 ++- locales/es/common.json | 4 ++- locales/fa/common.json | 4 ++- locales/fr/common.json | 4 ++- locales/he/common.json | 4 ++- locales/hu/common.json | 4 ++- locales/it/common.json | 4 ++- locales/ja/common.json | 4 ++- locales/ko/common.json | 4 ++- locales/lv/common.json | 4 ++- locales/mn/common.json | 4 ++- locales/nb/common.json | 4 ++- locales/nl/common.json | 4 ++- locales/pl/common.json | 4 ++- locales/pt/common.json | 4 ++- locales/ro/common.json | 4 ++- locales/ru/common.json | 4 ++- locales/sk/common.json | 4 ++- locales/tr/common.json | 4 ++- locales/uk/common.json | 4 ++- locales/zh-TW/common.json | 4 ++- locales/zh/common.json | 4 ++- 34 files changed, 194 insertions(+), 42 deletions(-) diff --git a/components/calendar/__tests__/participant-availability.test.ts b/components/calendar/__tests__/participant-availability.test.ts index 9b2ffd750..99cdfe212 100644 --- a/components/calendar/__tests__/participant-availability.test.ts +++ b/components/calendar/__tests__/participant-availability.test.ts @@ -148,20 +148,36 @@ describe('ParticipantAvailability', () => { vi.restoreAllMocks(); }); - it('stays hidden on Stalwart 1.0 with directory queries off, without looking anyone up', async () => { + it('says free/busy is not available on Stalwart 1.0 with directory queries off, without looking anyone up', async () => { const { client, requests } = makeClient({ ownPrincipal: OWN_PRINCIPAL_1_0_DIRECTORY_OFF }); renderFor(client); - // Nothing shows while the server is asked, either. + // Nothing shows while the server is asked. expect(screen.queryByTestId('participant-availability')).toBeNull(); + expect(screen.queryByTestId('participant-availability-unavailable')).toBeNull(); - await waitFor(() => expect(requests).toHaveLength(1)); + // The i18n test mock renders the translation key. + await waitFor(() => expect(screen.getByText('unavailable_on_server')).toBeInTheDocument()); await settle(); expect(screen.queryByTestId('participant-availability')).toBeNull(); + expect(screen.getAllByTestId('participant-availability-unavailable')).toHaveLength(1); expect(methodsOf(requests)).toEqual(['Principal/get']); expect(requests[0][0]).toEqual(['Principal/get', { accountId: 'me', ids: ['me'], properties: ['id', 'capabilities'] }, '0']); }); + it('renders nothing when the server offers no free/busy at all', async () => { + const { client, requests } = makeClient({ ownPrincipal: OWN_PRINCIPAL_0_16 }); + // No urn:ietf:params:jmap:principals:availability in the session. + const capabilities = (client as unknown as { capabilities: Record }).capabilities; + delete capabilities['urn:ietf:params:jmap:principals:availability']; + const { container } = renderFor(client); + + await settle(); + + expect(container).toBeEmptyDOMElement(); + expect(requests).toHaveLength(0); + }); + it('checks attendees on Stalwart 0.16, whose principal capabilities are empty', async () => { const { client } = makeClient({ ownPrincipal: OWN_PRINCIPAL_0_16, @@ -177,28 +193,30 @@ describe('ParticipantAvailability', () => { expect(screen.getByText('unknown')).toBeInTheDocument(); }); - it('hides the block once getAvailability is refused with forbidden, and stays hidden', async () => { + it('says free/busy is not available once getAvailability is refused with forbidden, and asks no more', async () => { const { client, requests } = makeClient({ ownPrincipal: OWN_PRINCIPAL_0_16, availability: () => DIRECTORY_QUERIES_OFF, }); const first = renderFor(client); await waitFor(() => expect(methodsOf(requests)).toContain('Principal/getAvailability'), { timeout: 2000 }); - await waitFor(() => expect(screen.queryByTestId('participant-availability')).toBeNull()); + await waitFor(() => expect(screen.getByText('unavailable_on_server')).toBeInTheDocument()); + expect(screen.queryByTestId('participant-availability')).toBeNull(); first.unmount(); const calls = requests.length; renderFor(client); await settle(); + expect(screen.getByText('unavailable_on_server')).toBeInTheDocument(); expect(screen.queryByTestId('participant-availability')).toBeNull(); expect(requests).toHaveLength(calls); }); - it('stays hidden when the role may not read principals', async () => { + it('says free/busy is not available when the role may not read principals (0.16 and 1.0)', async () => { const { client, requests } = makeClient({ ownPrincipal: NOT_AUTHORIZED }); renderFor(client); - await waitFor(() => expect(requests).toHaveLength(1)); + await waitFor(() => expect(screen.getByText('unavailable_on_server')).toBeInTheDocument()); await settle(); expect(screen.queryByTestId('participant-availability')).toBeNull(); diff --git a/components/calendar/participant-availability.tsx b/components/calendar/participant-availability.tsx index b8096918f..15f2507fa 100644 --- a/components/calendar/participant-availability.tsx +++ b/components/calendar/participant-availability.tsx @@ -93,8 +93,9 @@ async function lookUpStatuses( /** * Free/busy of the invited attendees for the event window * (Principal/getAvailability). Only attendees that are principals on this - * server can be checked; external addresses show as unknown. Nothing is shown - * when the server won't answer free/busy for this user. + * server can be checked; external addresses show as unknown. When the server + * won't answer free/busy for this user, one line says so; nothing is shown + * when it doesn't offer free/busy at all. */ export function ParticipantAvailability({ attendees, start, end }: ParticipantAvailabilityProps) { const t = useTranslations("calendar.participants.availability"); @@ -153,7 +154,18 @@ export function ParticipantAvailability({ attendees, start, end }: ParticipantAv // eslint-disable-next-line react-hooks/exhaustive-deps }, [client, wanted, allowed, emailsKey, startMs, endMs]); - if (!wanted || allowed !== true) return null; + if (!wanted) return null; + if (allowed === false) { + // Stalwart 1.0 with directory queries off (its default), or a role without + // the principal permissions: the lookups are refused, so explain the gap + // instead of leaving the attendees without a word. + return ( +

+ {t("unavailable_on_server")} +

+ ); + } + if (allowed !== true) return null; return (