From 6eeee8df541b1126becc25badad6d0130bcaf323 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:42:07 +0200 Subject: [PATCH] fix: explain sharing when Stalwart 1.0 keeps its directory closed Stalwart 1.0 refuses Principal/query with `forbidden` unless the administrator allows directory queries, which is off by default. The app took the missing Principal/get answer for an empty directory: the calendar share sheet said "Sharing is not available on this server.", the file share sheet said "No other users found", and existing shares showed bare ids. - `listPrincipals` says whether the listing is complete, `restricted` or failed. On `forbidden` it falls back to Principal/get without ids, which 1.0 still answers with the user's own account, their groups and the accounts that shared something with them. - Principal/get asks for `id`, `type`, `name`, `description` and `email` only; 1.0's default list adds `capabilities` and `accounts`. - Both share sheets explain a restricted directory under the search box. Only 1.0's own refusal ("The administrator has disabled directory queries.") gets the wording that points at Stalwart's sharing settings; any other refusal gets a generic one, and a failed listing says so. - A sharee the app can't name shows as "Unknown user" with its id, and can still be changed or removed. On 0.16 with the default roles the listing is complete and the sheets behave as before. --- locales/rn/ar.json | 6 + locales/rn/ca.json | 6 + locales/rn/cs.json | 6 + locales/rn/da.json | 6 + locales/rn/de.json | 6 + locales/rn/en.json | 6 + locales/rn/es.json | 6 + locales/rn/fa.json | 6 + locales/rn/fr.json | 6 + locales/rn/he.json | 6 + locales/rn/hu.json | 6 + locales/rn/it.json | 6 + locales/rn/ja.json | 6 + locales/rn/ko.json | 6 + locales/rn/lv.json | 6 + locales/rn/mn.json | 6 + locales/rn/nb.json | 6 + locales/rn/nl.json | 6 + locales/rn/pl.json | 6 + locales/rn/pt.json | 6 + locales/rn/ro.json | 6 + locales/rn/ru.json | 6 + locales/rn/sk.json | 6 + locales/rn/tr.json | 6 + locales/rn/uk.json | 6 + locales/rn/zh-TW.json | 6 + locales/rn/zh.json | 6 + src/api/__tests__/files.test.ts | 84 +++++++++++ src/api/files.ts | 134 ++++++++++++++---- .../calendar/CalendarShareSheet.tsx | 58 ++++++-- src/components/files/ShareSheet.tsx | 58 ++++++-- src/lib/principal-directory.ts | 18 +++ 32 files changed, 461 insertions(+), 53 deletions(-) create mode 100644 src/lib/principal-directory.ts diff --git a/locales/rn/ar.json b/locales/rn/ar.json index 46e4b7f8..ec7c075d 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "تعذّر تحميل قائمة المستخدمين والمجموعات.", + "directory_not_browsable": "لا يسمح لك خادمك بتصفح دليل المستخدمين.", + "directory_restricted": "لا يسمح خادمك بتصفح دليل المستخدمين، لذا لا تظهر إلا مجموعاتك والأشخاص الذين شاركوا شيئًا معك. يمكن للمسؤول تفعيل استعلامات الدليل من إعدادات المشاركة في Stalwart.", + "unknown_user": "مستخدم غير معروف" + }, "sidebar": { "account_error": "يحتاج إلى انتباه", "remove_account_hint": "اضغط مطولًا لإزالة هذا الحساب", diff --git a/locales/rn/ca.json b/locales/rn/ca.json index b426d009..eea7c99c 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "No s'ha pogut carregar la llista d'usuaris i grups.", + "directory_not_browsable": "El vostre servidor no us permet explorar el directori d'usuaris.", + "directory_restricted": "El vostre servidor no permet explorar el directori d'usuaris, així que només es mostren els vostres grups i les persones que han compartit alguna cosa amb vós. Un administrador pot activar les consultes al directori a la configuració de compartició de Stalwart.", + "unknown_user": "Usuari desconegut" + }, "sidebar": { "account_error": "Cal atenció", "remove_account_hint": "Manteniu premut per eliminar aquest compte", diff --git a/locales/rn/cs.json b/locales/rn/cs.json index 6aa90bb3..2ed2f8b8 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "Seznam uživatelů a skupin se nepodařilo načíst.", + "directory_not_browsable": "Váš server vám neumožňuje procházet adresář uživatelů.", + "directory_restricted": "Váš server neumožňuje procházet adresář uživatelů, proto se zobrazují jen vaše skupiny a lidé, kteří s vámi něco sdíleli. Správce může dotazy na adresář zapnout v nastavení sdílení serveru Stalwart.", + "unknown_user": "Neznámý uživatel" + }, "sidebar": { "account_error": "Vyžaduje pozornost", "remove_account_hint": "Dlouhým stisknutím tento účet odeberete", diff --git a/locales/rn/da.json b/locales/rn/da.json index 5e2990b5..00376269 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "Kunne ikke indlæse listen over brugere og grupper.", + "directory_not_browsable": "Din server lader dig ikke gennemse brugerkataloget.", + "directory_restricted": "Din server tillader ikke, at brugerkataloget gennemses, så kun dine grupper og personer, der har delt noget med dig, vises. En administrator kan slå katalogforespørgsler til i Stalwarts delingsindstillinger.", + "unknown_user": "Ukendt bruger" + }, "sidebar": { "account_error": "Kræver handling", "remove_account_hint": "Tryk længe for at fjerne denne konto", diff --git a/locales/rn/de.json b/locales/rn/de.json index 4a67bb86..a47d08dc 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "Die Liste der Benutzer und Gruppen konnte nicht geladen werden.", + "directory_not_browsable": "Ihr Server erlaubt Ihnen nicht, sein Benutzerverzeichnis zu durchsuchen.", + "directory_restricted": "Ihr Server erlaubt kein Durchsuchen seines Benutzerverzeichnisses, daher werden nur Ihre Gruppen und Personen aufgeführt, die etwas mit Ihnen geteilt haben. Ein Administrator kann Verzeichnisabfragen in den Freigabeeinstellungen von Stalwart aktivieren.", + "unknown_user": "Unbekannter Benutzer" + }, "sidebar": { "account_error": "Aktion erforderlich", "remove_account_hint": "Lange drücken, um dieses Konto zu entfernen", diff --git a/locales/rn/en.json b/locales/rn/en.json index 2b4c3947..6bcfb6c9 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -984,6 +984,12 @@ } } }, + "sharing": { + "directory_failed": "Couldn't load the list of users and groups.", + "directory_not_browsable": "Your server doesn't let you browse its user directory.", + "directory_restricted": "Your server doesn't allow browsing its user directory, so only your groups and people who have shared with you are listed. An administrator can turn on directory queries in Stalwart's sharing settings.", + "unknown_user": "Unknown user" + }, "sidebar": { "account_error": "Needs attention", "remove_account_hint": "Long-press to remove this account", diff --git a/locales/rn/es.json b/locales/rn/es.json index 91dc3e26..ea3a5596 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "No se pudo cargar la lista de usuarios y grupos.", + "directory_not_browsable": "Tu servidor no te permite explorar su directorio de usuarios.", + "directory_restricted": "Tu servidor no permite explorar su directorio de usuarios, así que solo se muestran tus grupos y las personas que han compartido algo contigo. Un administrador puede activar las consultas al directorio en los ajustes de uso compartido de Stalwart.", + "unknown_user": "Usuario desconocido" + }, "sidebar": { "account_error": "Requiere atención", "remove_account_hint": "Mantén pulsado para quitar esta cuenta", diff --git a/locales/rn/fa.json b/locales/rn/fa.json index 63d521de..cfe6e435 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "بارگذاری فهرست کاربران و گروه‌ها ممکن نشد.", + "directory_not_browsable": "سرور شما به شما اجازهٔ مرور فهرست کاربرانش را نمی‌دهد.", + "directory_restricted": "سرور شما اجازهٔ مرور فهرست کاربرانش را نمی‌دهد، بنابراین فقط گروه‌های شما و افرادی که چیزی را با شما به اشتراک گذاشته‌اند نمایش داده می‌شوند. مدیر می‌تواند پرس‌وجوهای فهرست را در تنظیمات اشتراک‌گذاری Stalwart فعال کند.", + "unknown_user": "کاربر ناشناس" + }, "sidebar": { "account_error": "نیاز به توجه دارد", "remove_account_hint": "برای حذف این حساب، انگشت خود را نگه دارید", diff --git a/locales/rn/fr.json b/locales/rn/fr.json index 7978e240..e4290ca3 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "Impossible de charger la liste des utilisateurs et des groupes.", + "directory_not_browsable": "Votre serveur ne vous permet pas de parcourir son annuaire d'utilisateurs.", + "directory_restricted": "Votre serveur ne permet pas de parcourir son annuaire d'utilisateurs, seuls vos groupes et les personnes ayant partagé quelque chose avec vous sont donc listés. Un administrateur peut activer les requêtes d'annuaire dans les paramètres de partage de Stalwart.", + "unknown_user": "Utilisateur inconnu" + }, "sidebar": { "account_error": "Action requise", "remove_account_hint": "Appuyez longuement pour supprimer ce compte", diff --git a/locales/rn/he.json b/locales/rn/he.json index 91536bed..91bbc004 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "לא ניתן היה לטעון את רשימת המשתמשים והקבוצות.", + "directory_not_browsable": "השרת שלך אינו מאפשר לך לעיין בספריית המשתמשים שלו.", + "directory_restricted": "השרת שלך אינו מאפשר לעיין בספריית המשתמשים שלו, ולכן מוצגות רק הקבוצות שלך ואנשים ששיתפו איתך משהו. מנהל מערכת יכול להפעיל שאילתות ספרייה בהגדרות השיתוף של Stalwart.", + "unknown_user": "משתמש לא ידוע" + }, "sidebar": { "account_error": "דורש תשומת לב", "remove_account_hint": "לחיצה ארוכה להסרת החשבון הזה", diff --git a/locales/rn/hu.json b/locales/rn/hu.json index cb36b3f5..591e0273 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "Nem sikerült betölteni a felhasználók és csoportok listáját.", + "directory_not_browsable": "A szervered nem engedi, hogy böngészd a felhasználói címtárát.", + "directory_restricted": "A szervered nem engedi a felhasználói címtár böngészését, ezért csak a csoportjaid és azok jelennek meg, akik megosztottak veled valamit. Egy rendszergazda a Stalwart megosztási beállításaiban engedélyezheti a címtár-lekérdezéseket.", + "unknown_user": "Ismeretlen felhasználó" + }, "sidebar": { "account_error": "Figyelmet igényel", "remove_account_hint": "Nyomd hosszan a fiók eltávolításához", diff --git a/locales/rn/it.json b/locales/rn/it.json index 942d1a70..499e0063 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "Impossibile caricare l'elenco di utenti e gruppi.", + "directory_not_browsable": "Il tuo server non ti consente di sfogliare la directory degli utenti.", + "directory_restricted": "Il tuo server non consente di sfogliare la directory degli utenti, quindi vengono elencati solo i tuoi gruppi e le persone che hanno condiviso qualcosa con te. Un amministratore può attivare le query sulla directory nelle impostazioni di condivisione di Stalwart.", + "unknown_user": "Utente sconosciuto" + }, "sidebar": { "account_error": "Richiede attenzione", "remove_account_hint": "Tieni premuto per rimuovere questo account", diff --git a/locales/rn/ja.json b/locales/rn/ja.json index 5898eeb3..6dd22501 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "ユーザーとグループの一覧を読み込めませんでした。", + "directory_not_browsable": "お使いのサーバーでは、ユーザーディレクトリを参照できません。", + "directory_restricted": "お使いのサーバーではユーザーディレクトリの参照が許可されていないため、所属グループと、あなたと共有しているユーザーのみが表示されます。管理者は Stalwart の共有設定でディレクトリクエリを有効にできます。", + "unknown_user": "不明なユーザー" + }, "sidebar": { "account_error": "要確認", "remove_account_hint": "長押しするとこのアカウントを削除できます", diff --git a/locales/rn/ko.json b/locales/rn/ko.json index 4b96aa32..1583ba51 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "사용자 및 그룹 목록을 불러오지 못했습니다.", + "directory_not_browsable": "서버에서 사용자 디렉터리를 탐색할 수 없습니다.", + "directory_restricted": "서버에서 사용자 디렉터리 탐색을 허용하지 않아 내 그룹과 나와 공유한 사람만 표시됩니다. 관리자는 Stalwart의 공유 설정에서 디렉터리 쿼리를 켤 수 있습니다.", + "unknown_user": "알 수 없는 사용자" + }, "sidebar": { "account_error": "확인 필요", "remove_account_hint": "길게 눌러 이 계정 삭제", diff --git a/locales/rn/lv.json b/locales/rn/lv.json index 68baca3c..26b46099 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "Neizdevās ielādēt lietotāju un grupu sarakstu.", + "directory_not_browsable": "Jūsu serveris neļauj jums pārlūkot tā lietotāju direktoriju.", + "directory_restricted": "Jūsu serveris neļauj pārlūkot tā lietotāju direktoriju, tāpēc tiek parādītas tikai jūsu grupas un personas, kas ar jums kaut ko kopīgojušas. Administrators var ieslēgt direktorija vaicājumus Stalwart kopīgošanas iestatījumos.", + "unknown_user": "Nezināms lietotājs" + }, "sidebar": { "account_error": "Nepieciešama uzmanība", "remove_account_hint": "Turiet nospiestu, lai noņemtu šo kontu", diff --git a/locales/rn/mn.json b/locales/rn/mn.json index 4e2a0ccc..a6982fee 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "Хэрэглэгч болон бүлгийн жагсаалтыг ачаалж чадсангүй.", + "directory_not_browsable": "Таны сервер танд хэрэглэгчийн лавлахаа үзэхийг зөвшөөрдөггүй.", + "directory_restricted": "Таны сервер хэрэглэгчийн лавлахаа үзэхийг зөвшөөрдөггүй тул зөвхөн таны бүлгүүд болон тантай ямар нэг зүйл хуваалцсан хүмүүс харагдана. Админ Stalwart-ын хуваалцах тохиргоонд лавлахын хайлтыг идэвхжүүлж болно.", + "unknown_user": "Үл мэдэгдэх хэрэглэгч" + }, "sidebar": { "account_error": "Анхаарал шаардлагатай", "remove_account_hint": "Энэ бүртгэлийг хасахын тулд удаан дарна уу", diff --git a/locales/rn/nb.json b/locales/rn/nb.json index 0cacd3f2..2cfc5254 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "Kunne ikke laste inn listen over brukere og grupper.", + "directory_not_browsable": "Serveren din lar deg ikke bla gjennom brukerkatalogen.", + "directory_restricted": "Serveren din tillater ikke å bla gjennom brukerkatalogen, så bare gruppene dine og personer som har delt noe med deg, vises. En administrator kan slå på katalogspørringer i delingsinnstillingene i Stalwart.", + "unknown_user": "Ukjent bruker" + }, "sidebar": { "account_error": "Krever handling", "remove_account_hint": "Trykk lenge for å fjerne denne kontoen", diff --git a/locales/rn/nl.json b/locales/rn/nl.json index b8bb98e0..4a2d637c 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "Kan de lijst met gebruikers en groepen niet laden.", + "directory_not_browsable": "Uw server staat u niet toe de gebruikersdirectory te doorbladeren.", + "directory_restricted": "Uw server staat het doorbladeren van de gebruikersdirectory niet toe, daarom worden alleen uw groepen en personen die iets met u hebben gedeeld weergegeven. Een beheerder kan directoryquery's inschakelen in de instellingen voor delen van Stalwart.", + "unknown_user": "Onbekende gebruiker" + }, "sidebar": { "account_error": "Actie vereist", "remove_account_hint": "Lang drukken om dit account te verwijderen", diff --git a/locales/rn/pl.json b/locales/rn/pl.json index 564faf7d..d4b04ff2 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "Nie udało się wczytać listy użytkowników i grup.", + "directory_not_browsable": "Twój serwer nie pozwala Ci przeglądać katalogu użytkowników.", + "directory_restricted": "Twój serwer nie pozwala przeglądać katalogu użytkowników, dlatego widoczne są tylko Twoje grupy i osoby, które coś Ci udostępniły. Administrator może włączyć zapytania do katalogu w ustawieniach udostępniania Stalwart.", + "unknown_user": "Nieznany użytkownik" + }, "sidebar": { "account_error": "Wymaga uwagi", "remove_account_hint": "Przytrzymaj, aby usunąć to konto", diff --git a/locales/rn/pt.json b/locales/rn/pt.json index fdada61c..65560307 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "Não foi possível carregar a lista de usuários e grupos.", + "directory_not_browsable": "Seu servidor não permite que você navegue no diretório de usuários.", + "directory_restricted": "Seu servidor não permite navegar no diretório de usuários, então apenas seus grupos e as pessoas que compartilharam algo com você são listados. Um administrador pode ativar as consultas ao diretório nas configurações de compartilhamento do Stalwart.", + "unknown_user": "Usuário desconhecido" + }, "sidebar": { "account_error": "Requer atenção", "remove_account_hint": "Toque e segure para remover esta conta", diff --git a/locales/rn/ro.json b/locales/rn/ro.json index 6f0f8d9c..7190f4b7 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "Lista de utilizatori și grupuri nu a putut fi încărcată.", + "directory_not_browsable": "Serverul dvs. nu vă permite să răsfoiți directorul de utilizatori.", + "directory_restricted": "Serverul dvs. nu permite răsfoirea directorului de utilizatori, așa că sunt afișate doar grupurile dvs. și persoanele care au partajat ceva cu dvs. Un administrator poate activa interogările directorului în setările de partajare ale Stalwart.", + "unknown_user": "Utilizator necunoscut" + }, "sidebar": { "account_error": "Necesită atenție", "remove_account_hint": "Apăsați lung pentru a elimina acest cont", diff --git a/locales/rn/ru.json b/locales/rn/ru.json index 37332f3c..ad8ccafe 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "Не удалось загрузить список пользователей и групп.", + "directory_not_browsable": "Ваш сервер не позволяет вам просматривать каталог пользователей.", + "directory_restricted": "Ваш сервер не разрешает просматривать каталог пользователей, поэтому показаны только ваши группы и люди, которые чем-то поделились с вами. Администратор может включить запросы к каталогу в настройках общего доступа Stalwart.", + "unknown_user": "Неизвестный пользователь" + }, "sidebar": { "account_error": "Требует внимания", "remove_account_hint": "Удерживайте, чтобы удалить этот аккаунт", diff --git a/locales/rn/sk.json b/locales/rn/sk.json index 5e5ee35c..5fdeda48 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "Zoznam používateľov a skupín sa nepodarilo načítať.", + "directory_not_browsable": "Váš server vám nepovoľuje prehliadať adresár používateľov.", + "directory_restricted": "Váš server nepovoľuje prehliadať adresár používateľov, preto sa zobrazujú iba vaše skupiny a ľudia, ktorí s vami niečo zdieľali. Správca môže zapnúť dopyty na adresár v nastaveniach zdieľania servera Stalwart.", + "unknown_user": "Neznámy používateľ" + }, "sidebar": { "account_error": "Vyžaduje pozornosť", "remove_account_hint": "Dlhým stlačením odstránite tento účet", diff --git a/locales/rn/tr.json b/locales/rn/tr.json index 6ede608d..6953b206 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "Kullanıcı ve grup listesi yüklenemedi.", + "directory_not_browsable": "Sunucunuz kullanıcı dizinine göz atmanıza izin vermiyor.", + "directory_restricted": "Sunucunuz kullanıcı dizinine göz atılmasına izin vermiyor, bu nedenle yalnızca gruplarınız ve sizinle bir şey paylaşmış kişiler listeleniyor. Bir yönetici, Stalwart'ın paylaşım ayarlarından dizin sorgularını açabilir.", + "unknown_user": "Bilinmeyen kullanıcı" + }, "sidebar": { "account_error": "İlgilenmeniz gerekiyor", "remove_account_hint": "Bu hesabı kaldırmak için uzun basın", diff --git a/locales/rn/uk.json b/locales/rn/uk.json index 54cefff1..78b6d897 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "Не вдалося завантажити список користувачів і груп.", + "directory_not_browsable": "Ваш сервер не дозволяє вам переглядати каталог користувачів.", + "directory_restricted": "Ваш сервер не дозволяє переглядати каталог користувачів, тому показано лише ваші групи та людей, які чимось поділилися з вами. Адміністратор може ввімкнути запити до каталогу в налаштуваннях спільного доступу Stalwart.", + "unknown_user": "Невідомий користувач" + }, "sidebar": { "account_error": "Потребує уваги", "remove_account_hint": "Утримуйте, щоб видалити цей обліковий запис", diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index 5f75312e..a5b2d754 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "無法載入使用者和群組清單。", + "directory_not_browsable": "您的伺服器不允許您瀏覽使用者目錄。", + "directory_restricted": "您的伺服器不允許瀏覽使用者目錄,因此只會列出您所屬的群組,以及曾與您分享內容的使用者。管理員可以在 Stalwart 的分享設定中啟用目錄查詢。", + "unknown_user": "未知的使用者" + }, "sidebar": { "account_error": "需要處理", "remove_account_hint": "長按即可移除此帳號", diff --git a/locales/rn/zh.json b/locales/rn/zh.json index 49769113..248a6ef0 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -971,6 +971,12 @@ } } }, + "sharing": { + "directory_failed": "无法加载用户和群组列表。", + "directory_not_browsable": "您的服务器不允许您浏览用户目录。", + "directory_restricted": "您的服务器不允许浏览用户目录,因此仅列出您所在的群组以及与您共享过内容的用户。管理员可以在 Stalwart 的共享设置中启用目录查询。", + "unknown_user": "未知用户" + }, "sidebar": { "account_error": "需要处理", "remove_account_hint": "长按可移除此账户", diff --git a/src/api/__tests__/files.test.ts b/src/api/__tests__/files.test.ts index bcb2eed6..708c4ea2 100644 --- a/src/api/__tests__/files.test.ts +++ b/src/api/__tests__/files.test.ts @@ -36,11 +36,13 @@ import { getPrincipals, isCrossAccountId, isFolder, + listPrincipals, moveFileNode, renameFileNode, setFileNodeShare, supportsSharing, } from '../files'; +import { directoryNotice } from '../../lib/principal-directory'; const mockRequest = jmapClient.request as ReturnType; const mockHasCapability = jmapClient.hasCapability as ReturnType; @@ -488,6 +490,88 @@ describe('getPrincipals', () => { }); }); +describe('listPrincipals', () => { + // userc's view on the test servers (live-checked). + const DIRECTORY = [ + { id: 'b', type: 'individual', name: 'admin@example.org', description: 'System administrator', email: 'admin@example.org' }, + { id: 'c', type: 'group', name: 'team@example.org', description: 'Team (test group)', email: 'team@example.org' }, + { id: 'd', type: 'individual', name: 'usera@example.org', description: 'User A', email: 'usera@example.org' }, + { id: 'e', type: 'individual', name: 'userb@example.org', description: 'User B', email: 'userb@example.org' }, + { id: 'f', type: 'individual', name: 'userc@example.org', description: 'User C', email: 'userc@example.org' }, + ]; + const OWN = [DIRECTORY[4]]; + const PROPERTIES = ['id', 'type', 'name', 'description', 'email']; + // Stalwart 1.0 with allowDirectoryQueries off (the default). + const QUERIES_OFF = [ + ['error', { type: 'forbidden', description: 'The administrator has disabled directory queries.' }, '0'], + ['error', { type: 'invalidResultReference', description: 'Result reference to 0#Principal/query not found.' }, '1'], + ]; + + it('lists the whole directory on Stalwart 0.16, asking only for the properties it shows', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [ + ['Principal/query', { accountId: 'acc-1', queryState: 'n', canCalculateChanges: true, position: 0, ids: ['b', 'c', 'd', 'e', 'f'] }, '0'], + ['Principal/get', { accountId: 'acc-1', state: 'n', list: DIRECTORY, notFound: [] }, '1'], + ], + }); + + expect(await listPrincipals()).toEqual({ principals: DIRECTORY, status: 'ok' }); + expect(mockRequest).toHaveBeenCalledTimes(1); + expect(mockRequest.mock.calls[0][0][1][1].properties).toEqual(PROPERTIES); + }); + + it('falls back to the principals the user knows when Stalwart 1.0 keeps its directory closed', async () => { + mockRequest + .mockResolvedValueOnce({ methodResponses: QUERIES_OFF }) + .mockResolvedValueOnce({ + methodResponses: [['Principal/get', { accountId: 'acc-1', state: 'n', list: OWN, notFound: [] }, '0']], + }); + + expect(await listPrincipals()).toEqual({ principals: OWN, status: 'restricted', directoryQueriesOff: true }); + const [calls, using] = mockRequest.mock.calls[1]; + expect(calls).toEqual([['Principal/get', { accountId: 'acc-1', ids: null, properties: PROPERTIES }, '0']]); + expect(using).toEqual([CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS]); + }); + + it('calls another forbidden answer restricted without claiming directory queries are off', async () => { + mockRequest + .mockResolvedValueOnce({ + methodResponses: [ + ['error', { type: 'forbidden', description: 'You do not have enough permissions to access this resource.' }, '0'], + ['error', { type: 'invalidResultReference' }, '1'], + ], + }) + .mockResolvedValueOnce({ methodResponses: [['error', { type: 'forbidden' }, '0']] }); + + expect(await listPrincipals()).toEqual({ principals: [], status: 'restricted', directoryQueriesOff: false }); + }); + + it('reports a failed listing with what arrived', async () => { + mockMaxObjectsInGet.mockReturnValue(2); + mockRequest + .mockResolvedValueOnce({ + methodResponses: [ + ['Principal/query', { ids: ['b', 'c'] }, '0'], + ['Principal/get', { list: DIRECTORY.slice(0, 2) }, '1'], + ], + }) + .mockRejectedValueOnce(new Error('JMAP request failed: 503')); + expect(await listPrincipals()).toEqual({ principals: DIRECTORY.slice(0, 2), status: 'error' }); + + mockRequest.mockResolvedValueOnce({ methodResponses: [['error', { type: 'serverFail' }, '0']] }); + expect(await listPrincipals()).toEqual({ principals: [], status: 'error' }); + }); +}); + +describe('directoryNotice', () => { + it('uses the 1.0 wording only for Stalwart 1.0\'s own refusal', () => { + expect(directoryNotice({ status: 'ok' })).toBeNull(); + expect(directoryNotice({ status: 'restricted', directoryQueriesOff: true })).toBe('restricted'); + expect(directoryNotice({ status: 'restricted', directoryQueriesOff: false })).toBe('not_browsable'); + expect(directoryNotice({ status: 'error' })).toBe('failed'); + }); +}); + describe('decodeFileNodeName at the API boundary (#869)', () => { it('decodes percent-encoded names from getAllFileNodes and the cross-account list', async () => { mockRequest.mockResolvedValue({ diff --git a/src/api/files.ts b/src/api/files.ts index 40c7598b..43219534 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -422,35 +422,119 @@ export async function setFileNodeShare( /** Safety bound on how many principals the share picker pages through. */ const MAX_PRINCIPALS = 20_000; -// List all principals visible to the user. Stalwart returns the full -// directory regardless of `filter`, so callers filter client-side. Paged by -// maxObjectsInGet: an unbounded query hands every id to the chained -// Principal/get, which the server refuses with `requestTooLarge` once the -// directory holds more than that (500), leaving the picker empty. -export async function getPrincipals(): Promise { - if (!supportsSharing()) return []; +// The Principal properties the share sheets read. 0.16's default list is the +// same; 1.0's adds `capabilities` for every principal and `accounts`, a whole +// account object with every account capability. +const PRINCIPAL_PROPERTIES = ['id', 'type', 'name', 'description', 'email']; + +// Stalwart 1.0's answer to Principal/query while the administrator keeps +// directory queries off (the default). +const DIRECTORY_QUERIES_OFF = 'The administrator has disabled directory queries.'; + +/** + * How complete a principal listing is: + * - `ok`: the whole directory; + * - `restricted`: the server refused to list its directory (`forbidden`: + * Stalwart 1.0 with directory queries off, or a role without the + * permission). The principals are what Principal/get without ids still + * returns, on 1.0 the user's own account, their groups and the accounts + * that shared something with them; + * - `error`: the listing failed part-way; the principals are what arrived. + */ +export type PrincipalDirectoryStatus = 'ok' | 'restricted' | 'error'; + +export interface PrincipalListing { + principals: Principal[]; + status: PrincipalDirectoryStatus; + /** + * The refusal was Stalwart 1.0's own: an administrator can allow directory + * queries in its sharing settings. + */ + directoryQueriesOff?: boolean; +} + +function principalsUsing(): string[] { + return [CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS]; +} + +// List all principals visible to the user, and how complete the list is. +// Stalwart returns the full directory regardless of `filter`, so callers +// filter client-side. Paged by maxObjectsInGet: an unbounded query hands every +// id to the chained Principal/get, which the server refuses with +// `requestTooLarge` once the directory holds more than that (500), leaving the +// picker empty. +export async function listPrincipals(): Promise { + if (!supportsSharing()) return { principals: [], status: 'ok' }; const accountId = jmapClient.accountId; const pageSize = jmapClient.getMaxObjectsInGet(); const all: Principal[] = []; - for (let position = 0; position < MAX_PRINCIPALS;) { - const res = await jmapClient.request( - [ - ['Principal/query', { accountId, position, limit: pageSize }, '0'], - ['Principal/get', { - accountId, - '#ids': { resultOf: '0', name: 'Principal/query', path: '/ids' }, - }, '1'], - ], - [CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS], - ); - const ids = (res.methodResponses.find((r) => r[0] === 'Principal/query')?.[1].ids ?? []) as string[]; - const getResp = res.methodResponses.find((r) => r[0] === 'Principal/get'); - if (!getResp) break; - all.push(...((getResp[1].list ?? []) as Principal[])); - if (ids.length < pageSize) break; - position += ids.length; + try { + for (let position = 0; position < MAX_PRINCIPALS;) { + const res = await jmapClient.request( + [ + ['Principal/query', { accountId, position, limit: pageSize }, '0'], + ['Principal/get', { + accountId, + '#ids': { resultOf: '0', name: 'Principal/query', path: '/ids' }, + properties: PRINCIPAL_PROPERTIES, + }, '1'], + ], + principalsUsing(), + ); + const responses = res.methodResponses ?? []; + const queryResp = responses.find((r) => r[0] === 'Principal/query'); + const getResp = responses.find((r) => r[0] === 'Principal/get'); + if (!queryResp || !getResp) { + // A refused query makes the chained get fail with + // invalidResultReference; only the query's own error says why. + const error = (responses.find((r) => r[0] === 'error' && r[2] === '0') + ?? responses.find((r) => r[0] === 'error'))?.[1] as { type?: string; description?: string } | undefined; + if (error?.type === 'forbidden') { + return { + principals: await getKnownPrincipals(accountId), + status: 'restricted', + directoryQueriesOff: error.description === DIRECTORY_QUERIES_OFF, + }; + } + console.warn('[files] listing principals failed', error); + return { principals: all, status: 'error' }; + } + const ids = (queryResp[1].ids ?? []) as string[]; + all.push(...((getResp[1].list ?? []) as Principal[])); + if (ids.length < pageSize) break; + position += ids.length; + } + return { principals: all, status: 'ok' }; + } catch (err) { + console.warn('[files] listing principals failed', err); + return { principals: all, status: 'error' }; } - return all; +} + +/** + * Principal/get without ids. When the directory can't be listed, Stalwart 1.0 + * still returns the principals the user already knows: their own account, + * their groups and the accounts that shared something with them. Empty when + * that is refused too. + */ +async function getKnownPrincipals(accountId: string): Promise { + try { + const res = await jmapClient.request( + [['Principal/get', { accountId, ids: null, properties: PRINCIPAL_PROPERTIES }, '0']], + principalsUsing(), + ); + const [method, result] = res.methodResponses?.[0] ?? []; + if (method !== 'Principal/get') return []; + return (result.list ?? []) as Principal[]; + } catch (err) { + console.warn('[files] reading known principals failed', err); + return []; + } +} + +/** The principals of {@link listPrincipals}, without the status. */ +export async function getPrincipals(): Promise { + return (await listPrincipals()).principals; } // The principal id that represents the logged-in user (excluded from the diff --git a/src/components/calendar/CalendarShareSheet.tsx b/src/components/calendar/CalendarShareSheet.tsx index 15c89621..ead9c109 100644 --- a/src/components/calendar/CalendarShareSheet.tsx +++ b/src/components/calendar/CalendarShareSheet.tsx @@ -12,8 +12,9 @@ import { View, } from 'react-native'; import { Trash2, UserPlus, Users } from 'lucide-react-native'; -import { getPrincipals, ownPrincipalId } from '../../api/files'; +import { listPrincipals, ownPrincipalId, type PrincipalListing } from '../../api/files'; import type { Calendar, CalendarRights, Principal } from '../../api/types'; +import { directoryNotice } from '../../lib/principal-directory'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; @@ -74,20 +75,27 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare const t = useLocaleStore((s) => s.t); const [principals, setPrincipals] = React.useState([]); + const [directory, setDirectory] = React.useState>({ status: 'ok' }); const [loading, setLoading] = React.useState(false); const [shares, setShares] = React.useState>({}); const [search, setSearch] = React.useState(''); const [savingId, setSavingId] = React.useState(null); React.useEffect(() => { - if (!calendar) return; + if (!calendar) return undefined; + let cancelled = false; setShares(calendar.shareWith ?? {}); setSearch(''); setLoading(true); - getPrincipals() - .then(setPrincipals) - .catch(() => setPrincipals([])) - .finally(() => setLoading(false)); + void listPrincipals().then(({ principals: list, ...listing }) => { + if (cancelled) return; + setPrincipals(list); + setDirectory(listing); + setLoading(false); + }); + return () => { + cancelled = true; + }; }, [calendar]); const principalsById = React.useMemo(() => { @@ -137,15 +145,32 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare if (!calendar) return null; + // The list below is partial or empty for a reason the user can't see + // otherwise; "Sharing is not available on this server." would be wrong. + const notice = loading ? null : directoryNotice(directory); + const noticeText = + notice === 'failed' ? t('sharing.directory_failed', "Couldn't load the list of users and groups.") + : notice === 'restricted' + ? t( + 'sharing.directory_restricted', + "Your server doesn't allow browsing its user directory, so only your groups and people who have shared with you are listed. An administrator can turn on directory queries in Stalwart's sharing settings.", + ) + : notice === 'not_browsable' + ? t('sharing.directory_not_browsable', "Your server doesn't let you browse its user directory.") + : null; + const renderPrincipalLabel = (principalId: string) => { const p = principalsById.get(principalId); + // Stalwart 1.0 with directory queries off hides every principal the user + // has no relationship with, so a sharee may have no name. + const unresolved = !p && !loading; return ( - {p?.description || p?.name || principalId} + {p?.description || p?.name || (unresolved ? t('sharing.unknown_user', 'Unknown user') : principalId)} - {p?.email ? ( - {p.email} + {p?.email || unresolved ? ( + {p?.email || principalId} ) : null} ); @@ -224,14 +249,19 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare autoCorrect={false} style={styles.input} /> + {noticeText ? ( + {noticeText} + ) : null} {loading ? ( ) : candidates.length === 0 ? ( - - {principals.length === 0 - ? t('calendar.share.no_principals', 'Sharing is not available on this server.') - : t('calendar.share.no_matches', 'No matches')} - + notice && !search.trim() ? null : ( + + {principals.length === 0 && !notice + ? t('calendar.share.no_principals', 'Sharing is not available on this server.') + : t('calendar.share.no_matches', 'No matches')} + + ) ) : ( candidates.map((p) => ( s.t); const [principals, setPrincipals] = useState([]); + const [directory, setDirectory] = useState>({ status: 'ok' }); const [loadingPrincipals, setLoadingPrincipals] = useState(false); const [shares, setShares] = useState>({}); const [search, setSearch] = useState(''); const [savingId, setSavingId] = useState(null); useEffect(() => { - if (!node) return; + if (!node) return undefined; + let cancelled = false; setShares(node.shareWith ?? {}); setSearch(''); setLoadingPrincipals(true); - getPrincipals() - .then(setPrincipals) - .catch(() => setPrincipals([])) - .finally(() => setLoadingPrincipals(false)); + void listPrincipals().then(({ principals: list, ...listing }) => { + if (cancelled) return; + setPrincipals(list); + setDirectory(listing); + setLoadingPrincipals(false); + }); + return () => { + cancelled = true; + }; }, [node]); const principalsById = useMemo(() => { @@ -135,16 +143,33 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps if (!node) return null; const Icon = isFolder(node) ? Folder : FileText; + // The list below is partial or empty for a reason the user can't see + // otherwise; say so instead of "No other users found". + const notice = loadingPrincipals ? null : directoryNotice(directory); + const noticeText = + notice === 'failed' ? t('sharing.directory_failed', "Couldn't load the list of users and groups.") + : notice === 'restricted' + ? t( + 'sharing.directory_restricted', + "Your server doesn't allow browsing its user directory, so only your groups and people who have shared with you are listed. An administrator can turn on directory queries in Stalwart's sharing settings.", + ) + : notice === 'not_browsable' + ? t('sharing.directory_not_browsable', "Your server doesn't let you browse its user directory.") + : null; + const renderPrincipalLabel = (principalId: string) => { const p = principalsById.get(principalId); + // Stalwart 1.0 with directory queries off hides every principal the user + // has no relationship with, so a sharee may have no name. + const unresolved = !p && !loadingPrincipals; return ( - {p?.description || p?.name || principalId} + {p?.description || p?.name || (unresolved ? t('sharing.unknown_user', 'Unknown user') : principalId)} - {p?.email ? ( + {p?.email || unresolved ? ( - {p.email} + {p?.email || principalId} ) : null} @@ -236,6 +261,9 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps autoCapitalize="none" autoCorrect={false} /> + {noticeText ? ( + {noticeText} + ) : null} {loadingPrincipals ? ( ) : candidates.length === 0 ? ( - - {principals.length === 0 - ? t('files.share_no_users', 'No other users found') - : t('files.share_no_matches', 'No matches')} - + notice && !search.trim() ? null : ( + + {principals.length === 0 && !notice + ? t('files.share_no_users', 'No other users found') + : t('files.share_no_matches', 'No matches')} + + ) ) : ( candidates.map((p) => ( , +): DirectoryNotice | null { + if (listing.status === 'ok') return null; + if (listing.status === 'error') return 'failed'; + return listing.directoryQueriesOff ? 'restricted' : 'not_browsable'; +}