From 6ff9a0e5f229c86a663df6e60538d2d3c823d6d5 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 1 Nov 2026 17:37:07 -0700 Subject: [PATCH] Show names and honest free/busy when the directory is private With directory queries off the server refuses Principal/query. Fall back to Principal/get, which still answers for the accounts the reader has a connection to, so the share dialog shows names and can offer those people, and free/busy works for colleagues who shared with the reader. The event editor no longer says a guest isn't on this server when it has no way of knowing. --- web/src/locales/de.ts | 1 + web/src/locales/es.ts | 1 + web/src/locales/fr.ts | 1 + web/src/locales/ja.ts | 1 + web/src/locales/nl.ts | 1 + web/src/locales/pt-BR.ts | 1 + web/src/locales/ru.ts | 1 + web/src/locales/tr.ts | 1 + web/src/locales/uk.ts | 1 + web/src/locales/zh-Hans.ts | 1 + .../store/__tests__/directory-private.test.ts | 74 +++++++++++++++++++ web/src/store/contacts.ts | 27 ++++++- web/src/views/calendar/EventEditor.tsx | 14 +++- web/src/views/settings/ShareDialog.tsx | 13 +++- 14 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 web/src/store/__tests__/directory-private.test.ts diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index f0aaa4e..6346972 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -1855,6 +1855,7 @@ export const catalog: Catalog = { "Move {n} items": { one: "{n} Element verschieben", other: "{n} Elemente verschieben" }, "Move {n} items…": { one: "{n} Element verschieben…", other: "{n} Elemente verschieben…" }, "The event runs {n} days longer than this shows.": { one: "Der Termin dauert {n} Tag länger, als hier angezeigt wird.", other: "Der Termin dauert {n} Tage länger, als hier angezeigt wird." }, + "No free/busy for {n} guests, because this server keeps its directory private.": { one: "Keine Frei/Gebucht-Informationen für {n} Gast, weil dieser Server sein Verzeichnis nicht freigibt.", other: "Keine Frei/Gebucht-Informationen für {n} Gäste, weil dieser Server sein Verzeichnis nicht freigibt." }, "{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} Gast ist nicht auf diesem Server, daher gibt es dafür keine Frei/Gebucht-Informationen.", other: "{n} Gäste sind nicht auf diesem Server, daher gibt es dafür keine Frei/Gebucht-Informationen." }, "{n} items selected": { one: "{n} Element ausgewählt", other: "{n} Elemente ausgewählt" }, // ── Third pass ───────────────────────────────────────────────────── diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 2b1af9a..466010e 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -1828,6 +1828,7 @@ export const catalog: Catalog = { "Move {n} items": { one: "Mover {n} elemento", other: "Mover {n} elementos" }, "Move {n} items…": { one: "Mover {n} elemento…", other: "Mover {n} elementos…" }, "The event runs {n} days longer than this shows.": { one: "El evento dura {n} día más de lo que se muestra aquí.", other: "El evento dura {n} días más de lo que se muestra aquí." }, + "No free/busy for {n} guests, because this server keeps its directory private.": { one: "No hay información de libre/ocupado para {n} invitado porque este servidor mantiene privado su directorio.", other: "No hay información de libre/ocupado para {n} invitados porque este servidor mantiene privado su directorio." }, "{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} invitado no está en este servidor, así que no hay información de libre/ocupado para él.", other: "{n} invitados no están en este servidor, así que no hay información de libre/ocupado para ellos." }, "{n} items selected": { one: "{n} elemento seleccionado", other: "{n} elementos seleccionados" }, // ── Third pass ───────────────────────────────────────────────────── diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 5f5b2ba..42d91f2 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -1833,6 +1833,7 @@ export const catalog: Catalog = { "Move {n} items": { one: "Déplacer {n} élément", other: "Déplacer {n} éléments" }, "Move {n} items…": { one: "Déplacer {n} élément…", other: "Déplacer {n} éléments…" }, "The event runs {n} days longer than this shows.": { one: "L'événement dure {n} jour de plus que ce qui est affiché ici.", other: "L'événement dure {n} jours de plus que ce qui est affiché ici." }, + "No free/busy for {n} guests, because this server keeps its directory private.": { one: "Pas de disponibilité pour {n} invité, car ce serveur garde son annuaire privé.", other: "Pas de disponibilité pour {n} invités, car ce serveur garde son annuaire privé." }, "{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} invité n'est pas sur ce serveur, il n'y a donc pas de disponibilité à lire pour lui.", other: "{n} invités ne sont pas sur ce serveur, il n'y a donc pas de disponibilité à lire pour eux." }, "{n} items selected": { one: "{n} élément sélectionné", other: "{n} éléments sélectionnés" }, // ── Third pass ───────────────────────────────────────────────────── diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 8bebd11..2d1d8a1 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -1836,6 +1836,7 @@ export const catalog: Catalog = { "Move {n} items": { other: "{n} 件を移動" }, "Move {n} items…": { other: "{n} 件を移動…" }, "The event runs {n} days longer than this shows.": { other: "この予定は表示よりも {n} 日長く続きます。" }, + "No free/busy for {n} guests, because this server keeps its directory private.": { other: "このサーバーはディレクトリを非公開にしているため、{n} 名の参加者の空き情報を取得できません。" }, "{n} guests are not on this server, so there is no free/busy to read for them.": { other: "{n} 名の参加者はこのサーバーにいないため、空き情報を取得できません。" }, "{n} items selected": { other: "{n} 件を選択中" }, // ── Third pass ───────────────────────────────────────────────────── diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 701c1d6..0e1cb55 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -1828,6 +1828,7 @@ export const catalog: Catalog = { "Move {n} items": { one: "{n} item verplaatsen", other: "{n} items verplaatsen" }, "Move {n} items…": { one: "{n} item verplaatsen…", other: "{n} items verplaatsen…" }, "The event runs {n} days longer than this shows.": { one: "De gebeurtenis duurt {n} dag langer dan hier wordt getoond.", other: "De gebeurtenis duurt {n} dagen langer dan hier wordt getoond." }, + "No free/busy for {n} guests, because this server keeps its directory private.": { one: "Geen vrij/bezet voor {n} gast, omdat deze server zijn adreslijst privé houdt.", other: "Geen vrij/bezet voor {n} gasten, omdat deze server zijn adreslijst privé houdt." }, "{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} gast zit niet op deze server, dus er is geen vrij/bezet voor die persoon te lezen.", other: "{n} gasten zitten niet op deze server, dus er is geen vrij/bezet voor hen te lezen." }, "{n} items selected": { one: "{n} item geselecteerd", other: "{n} items geselecteerd" }, // ── Third pass ───────────────────────────────────────────────────── diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index a4c4532..85bb6fd 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -1831,6 +1831,7 @@ export const catalog: Catalog = { "Move {n} items": { one: "Mover {n} item", other: "Mover {n} itens" }, "Move {n} items…": { one: "Mover {n} item…", other: "Mover {n} itens…" }, "The event runs {n} days longer than this shows.": { one: "O evento dura {n} dia a mais do que é exibido aqui.", other: "O evento dura {n} dias a mais do que é exibido aqui." }, + "No free/busy for {n} guests, because this server keeps its directory private.": { one: "Sem livre/ocupado para {n} convidado, porque este servidor mantém seu diretório privado.", other: "Sem livre/ocupado para {n} convidados, porque este servidor mantém seu diretório privado." }, "{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} convidado não está neste servidor, então não há livre/ocupado a consultar para ele.", other: "{n} convidados não estão neste servidor, então não há livre/ocupado a consultar para eles." }, "{n} items selected": { one: "{n} item selecionado", other: "{n} itens selecionados" }, // ── Third pass ───────────────────────────────────────────────────── diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index dee1f4c..186ed5f 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -1830,6 +1830,7 @@ export const catalog: Catalog = { "Move {n} items": { one: "Переместить {n} объект", few: "Переместить {n} объекта", many: "Переместить {n} объектов", other: "Переместить {n} объекта" }, "Move {n} items…": { one: "Переместить {n} объект…", few: "Переместить {n} объекта…", many: "Переместить {n} объектов…", other: "Переместить {n} объекта…" }, "The event runs {n} days longer than this shows.": { one: "Событие длится на {n} день дольше, чем показано здесь.", few: "Событие длится на {n} дня дольше, чем показано здесь.", many: "Событие длится на {n} дней дольше, чем показано здесь.", other: "Событие длится на {n} дня дольше, чем показано здесь." }, + "No free/busy for {n} guests, because this server keeps its directory private.": { one: "Нет сведений о занятости для {n} участника, потому что этот сервер не открывает свой каталог.", few: "Нет сведений о занятости для {n} участников, потому что этот сервер не открывает свой каталог.", many: "Нет сведений о занятости для {n} участников, потому что этот сервер не открывает свой каталог.", other: "Нет сведений о занятости для {n} участника, потому что этот сервер не открывает свой каталог." }, "{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} участник не на этом сервере, поэтому сведений о занятости для него нет.", few: "{n} участника не на этом сервере, поэтому сведений о занятости для них нет.", many: "{n} участников не на этом сервере, поэтому сведений о занятости для них нет.", other: "{n} участника не на этом сервере, поэтому сведений о занятости для них нет." }, "{n} items selected": { one: "Выбран {n} объект", few: "Выбрано {n} объекта", many: "Выбрано {n} объектов", other: "Выбрано {n} объекта" }, // ── Third pass ───────────────────────────────────────────────────── diff --git a/web/src/locales/tr.ts b/web/src/locales/tr.ts index 6ed6fff..e1ba2e3 100644 --- a/web/src/locales/tr.ts +++ b/web/src/locales/tr.ts @@ -1862,6 +1862,7 @@ export const catalog: Catalog = { one: "{n} grup", other: "{n} grup", }, + "No free/busy for {n} guests, because this server keeps its directory private.": { one: "Bu sunucu dizinini gizli tuttuğu için {n} konuğun uygunluk durumu okunamıyor.", other: "Bu sunucu dizinini gizli tuttuğu için {n} konuğun uygunluk durumu okunamıyor." }, "{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} konuk bu sunucuda değil, bu nedenle onun için uygunluk durumu okunamıyor.", other: "{n} konuk bu sunucuda değil, bu nedenle onlar için uygunluk durumu okunamıyor.", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 58078f0..f4419c0 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -1824,6 +1824,7 @@ export const catalog: Catalog = { "Move {n} items": { one: "Перемістити {n} об’єкт", few: "Перемістити {n} об’єкти", many: "Перемістити {n} об’єктів", other: "Перемістити {n} об’єкта" }, "Move {n} items…": { one: "Перемістити {n} об’єкт…", few: "Перемістити {n} об’єкти…", many: "Перемістити {n} об’єктів…", other: "Перемістити {n} об’єкта…" }, "The event runs {n} days longer than this shows.": { one: "Подія триває на {n} день довше, ніж показано тут.", few: "Подія триває на {n} дні довше, ніж показано тут.", many: "Подія триває на {n} днів довше, ніж показано тут.", other: "Подія триває на {n} дня довше, ніж показано тут." }, + "No free/busy for {n} guests, because this server keeps its directory private.": { one: "Немає відомостей про зайнятість для {n} гостя, бо цей сервер не відкриває свій каталог.", few: "Немає відомостей про зайнятість для {n} гостей, бо цей сервер не відкриває свій каталог.", many: "Немає відомостей про зайнятість для {n} гостей, бо цей сервер не відкриває свій каталог.", other: "Немає відомостей про зайнятість для {n} гостя, бо цей сервер не відкриває свій каталог." }, "{n} guests are not on this server, so there is no free/busy to read for them.": { one: "{n} гість не на цьому сервері, тому відомостей про зайнятість для нього немає.", few: "{n} гості не на цьому сервері, тому відомостей про зайнятість для них немає.", many: "{n} гостей не на цьому сервері, тому відомостей про зайнятість для них немає.", other: "{n} гостя не на цьому сервері, тому відомостей про зайнятість для них немає." }, "{n} items selected": { one: "Вибрано {n} об’єкт", few: "Вибрано {n} об’єкти", many: "Вибрано {n} об’єктів", other: "Вибрано {n} об’єкта" }, // ── Third pass ───────────────────────────────────────────────────── diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 1e5c0b3..c6badd0 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -1835,6 +1835,7 @@ export const catalog: Catalog = { "Move {n} items": { other: "移动 {n} 个项目" }, "Move {n} items…": { other: "移动 {n} 个项目…" }, "The event runs {n} days longer than this shows.": { other: "此活动比这里显示的时间长 {n} 天。" }, + "No free/busy for {n} guests, because this server keeps its directory private.": { other: "此服务器未公开其目录,因此无法读取 {n} 位与会者的空闲/忙碌信息。" }, "{n} guests are not on this server, so there is no free/busy to read for them.": { other: "有 {n} 位与会者不在此服务器上,因此无法读取他们的空闲/忙碌信息。" }, "{n} items selected": { other: "已选择 {n} 个项目" }, // ── Third pass ───────────────────────────────────────────────────── diff --git a/web/src/store/__tests__/directory-private.test.ts b/web/src/store/__tests__/directory-private.test.ts new file mode 100644 index 0000000..697c4d1 --- /dev/null +++ b/web/src/store/__tests__/directory-private.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useContacts } from "@/store/contacts"; +import { useSession } from "@/store/session"; +import { CAP, client, JmapMethodError } from "@/jmap/client"; +import type { JmapSession } from "@/jmap/types"; + +/* + * With directory queries off the server refuses Principal/query, but + * Principal/get still answers for the accounts the reader already has to do + * with. Those are what put a name on a share and find a sharer's free/busy. + */ + +const alice = { id: "c", type: "individual", name: "Alice Tester" }; +const carol = { id: "e", type: "individual", name: "Carol Sender" }; +const forbidden = () => new JmapMethodError("Principal/query", { type: "forbidden" }); + +const session = { + capabilities: { [CAP.core]: {}, [CAP.principals]: {} }, + accounts: { c: { name: "alice@example.com", isPersonal: true, accountCapabilities: { [CAP.principals]: {} } } }, + primaryAccounts: { [CAP.principals]: "c" }, + state: "s", +} as unknown as JmapSession; + +beforeEach(() => { + client.session = session; + useSession.setState({ status: "authenticated", session, accountId: "c" }); + useContacts.setState({ accountId: "c", principals: [], principalsLoaded: false, directoryPrivate: false }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("loadPrincipals", () => { + it("reads the whole directory when the server allows it", async () => { + vi.spyOn(client, "chain").mockResolvedValue(new Map([["g", [{ list: [alice, carol] }]]]) as never); + const call = vi.spyOn(client, "call"); + await useContacts.getState().loadPrincipals(); + const s = useContacts.getState(); + expect(s.principals).toEqual([alice, carol]); + expect(s.directoryPrivate).toBe(false); + expect(call).not.toHaveBeenCalled(); + }); + + it("falls back to the accounts the reader knows when the query is refused", async () => { + vi.spyOn(client, "chain").mockRejectedValue(forbidden()); + const call = vi.spyOn(client, "call").mockResolvedValue({ list: [alice, carol] } as never); + await useContacts.getState().loadPrincipals(); + const s = useContacts.getState(); + expect(call).toHaveBeenCalledWith("Principal/get", expect.objectContaining({ ids: null })); + expect(s.principals).toEqual([alice, carol]); + expect(s.directoryPrivate).toBe(true); + expect(s.principalsLoaded).toBe(true); + }); + + it("stays empty when the fallback is refused too", async () => { + vi.spyOn(client, "chain").mockRejectedValue(forbidden()); + vi.spyOn(client, "call").mockRejectedValue(new JmapMethodError("Principal/get", { type: "forbidden" })); + await useContacts.getState().loadPrincipals(); + const s = useContacts.getState(); + expect(s.principals).toEqual([]); + expect(s.directoryPrivate).toBe(true); + expect(s.principalsLoaded).toBe(true); + }); + + it("does not call a network failure a private directory", async () => { + vi.spyOn(client, "chain").mockRejectedValue(new Error("offline")); + const call = vi.spyOn(client, "call"); + await useContacts.getState().loadPrincipals(); + const s = useContacts.getState(); + expect(s.directoryPrivate).toBe(false); + expect(call).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/store/contacts.ts b/web/src/store/contacts.ts index cbbf338..ed74a17 100644 --- a/web/src/store/contacts.ts +++ b/web/src/store/contacts.ts @@ -182,6 +182,8 @@ interface ContactsState { error: string | null; principals: Principal[]; principalsLoaded: boolean; + /** The server keeps its directory private, so `principals` is only who the reader already has to do with. */ + directoryPrivate: boolean; recent: EmailAddress[]; /** Address books shared with the reader, from every non-personal account. */ sharedBooks: SharedBook[]; @@ -264,6 +266,7 @@ export const useContacts = create((set, get) => ({ error: null, principals: [], principalsLoaded: false, + directoryPrivate: false, recent: [], sharedBooks: [], sharedCards: {}, @@ -793,15 +796,31 @@ export const useContacts = create((set, get) => ({ set({ principalsLoaded: true }); return; } + const properties = ["id", "type", "name", "description", "email", "timeZone"]; try { const res = await client.chain([ ["Principal/query", { accountId, limit: 1000 }, "q"], - ["Principal/get", { accountId, "#ids": { resultOf: "q", name: "Principal/query", path: "/ids" }, properties: ["id", "type", "name", "description", "email", "timeZone"] }, "g"], + ["Principal/get", { accountId, "#ids": { resultOf: "q", name: "Principal/query", path: "/ids" }, properties }, "g"], ]); const g = res.get("g")?.[0] as unknown as GetResponse; set({ principals: g.list, principalsLoaded: true }); - } catch { - set({ principalsLoaded: true }); + } catch (err) { + if (!(err instanceof JmapMethodError) || err.type !== "forbidden") { + set({ principalsLoaded: true }); + return; + } + /* + * The administrator has turned directory queries off. Principal/get still + * answers for the accounts the reader already has to do with, which is + * what puts a name on a share and finds a colleague's free/busy. + */ + set({ directoryPrivate: true }); + try { + const g = await client.call>("Principal/get", { accountId, ids: null, properties }); + set({ principals: g.list, principalsLoaded: true }); + } catch { + set({ principalsLoaded: true }); + } } }, @@ -908,7 +927,7 @@ useSession.subscribe((s) => { } useContacts.setState({ recent }); } else { - useContacts.setState({ accountId: null, books: {}, cards: {}, loaded: false, principals: [], principalsLoaded: false }); + useContacts.setState({ accountId: null, books: {}, cards: {}, loaded: false, principals: [], principalsLoaded: false, directoryPrivate: false }); } }); diff --git a/web/src/views/calendar/EventEditor.tsx b/web/src/views/calendar/EventEditor.tsx index 6b070c5..806c171 100644 --- a/web/src/views/calendar/EventEditor.tsx +++ b/web/src/views/calendar/EventEditor.tsx @@ -536,10 +536,16 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
{known.length === 0 ? translate("Nobody here has free/busy on this server, so none of these rows can say whether anyone is free.") - : plural(unknown.length, { - one: "{n} guest is not on this server, so there is no free/busy to read for them.", - other: "{n} guests are not on this server, so there is no free/busy to read for them.", - })} + : contacts.directoryPrivate + /* We can't tell who is on this server, so don't say they aren't. */ + ? plural(unknown.length, { + one: "No free/busy for {n} guest, because this server keeps its directory private.", + other: "No free/busy for {n} guests, because this server keeps its directory private.", + }) + : plural(unknown.length, { + one: "{n} guest is not on this server, so there is no free/busy to read for them.", + other: "{n} guests are not on this server, so there is no free/busy to read for them.", + })}
)} diff --git a/web/src/views/settings/ShareDialog.tsx b/web/src/views/settings/ShareDialog.tsx index 4b81a6b..5f3e9d6 100644 --- a/web/src/views/settings/ShareDialog.tsx +++ b/web/src/views/settings/ShareDialog.tsx @@ -2,10 +2,11 @@ import { useEffect, useState } from "react"; import { Trash2 } from "lucide-react"; import { Dialog } from "@/ui/dialog"; import { useContacts } from "@/store/contacts"; +import { useSession } from "@/store/session"; import { useMail } from "@/store/mail"; import { useCalendar } from "@/store/calendar"; import { useFiles } from "@/store/files"; -import { client, setErrorMessage } from "@/jmap/client"; +import { CAP, client, setErrorMessage } from "@/jmap/client"; import { toast } from "@/ui/toast"; import type { Id, Principal } from "@/jmap/types"; import { t } from "@/lib/i18n"; @@ -64,7 +65,9 @@ const PRESETS: Record = { /** Share a mailbox / calendar / address book / file node with other principals (JMAP Sharing, RFC 9670). */ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind; id: Id; name: string; shareWith: Record | null; onClose: () => void }) { const principals = useContacts((s) => s.principals); + const directoryPrivate = useContacts((s) => s.directoryPrivate); const loadPrincipals = useContacts((s) => s.loadPrincipals); + const selfId = useSession((s) => s.accountFor(CAP.principals)); const [rights, setRights] = useState>>(() => ({ ...((shareWith ?? {}) as Record>) })); const [pick, setPick] = useState(""); const [busy, setBusy] = useState(false); @@ -72,7 +75,9 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind void loadPrincipals(); }, [loadPrincipals]); - const available = principals.filter((p) => !rights[p.id]); + /* A private directory lists the reader too, and sharing with yourself is not "someone to add". */ + const available = principals.filter((p) => !rights[p.id] && !(directoryPrivate && p.id === selfId)); + const canPick = directoryPrivate ? available.length > 0 : principals.length > 0; const add = (p: Principal, preset: "reader" | "editor") => { const r: Record = {}; for (const k of PRESETS[kind][preset]) r[k] = true; @@ -110,13 +115,13 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind with directory queries switched off -- which is the default, and which returns no principals -- showed nothing but the hint, and an existing share could not be seen, let alone removed. */} - {!principals.length && ( + {!canPick && (

{t("No other users found in the directory, so nobody new can be added. Sharing already in place is listed below and can still be removed.")}

)} - {principals.length > 0 && ( + {canPick && ( <>