diff --git a/src/api/__tests__/live-stalwart-delegation.test.ts b/src/api/__tests__/live-stalwart-delegation.test.ts index ed20f910..f2ab9665 100644 --- a/src/api/__tests__/live-stalwart-delegation.test.ts +++ b/src/api/__tests__/live-stalwart-delegation.test.ts @@ -22,8 +22,10 @@ import { JMAPClient } from '../jmap-client'; import { CAPABILITIES } from '../types'; import { applyShareChanges, getShareTargets, supportsDelegation, supportsMailboxSharing } from '../delegation'; import { buildGrantChanges, buildRevokeChanges, summarizeDelegates } from '../../lib/delegation/delegation'; -import { principalRowDisplay, resolvePrincipal } from '../../lib/delegation/display'; +import { lookupMessage, lookupResult, principalRowDisplay, resolvePrincipal } from '../../lib/delegation/display'; import { listPrincipals } from '../files'; +import { findPrincipalByEmail } from '../delegation'; +import { getAddressBooks, setAddressBookShare } from '../contacts'; const t = (_key: string, fallback?: string) => fallback ?? ''; @@ -152,4 +154,29 @@ live('live Stalwart: delegates', () => { expect(await revokeAll()).toBe(true); } }, 120_000); + + // The calendar and address book share sheet looks a typed address up on + // its own. 0.16 finds it and the book is shared; 1.0 with directory + // queries off refuses every lookup, and the sheet says an administrator + // has to turn them on instead of "No matches". + it('shares an address book with a typed address the server finds, and says why when it will not look', async () => { + await useOwner(); + const listing = await listPrincipals(); + const found = await findPrincipalByEmail(USER2!); + const result = lookupResult(USER2!, found, [owner.accountId]); + if (listing.directoryQueriesOff) { + expect(found).toMatchObject({ principal: null, status: 'forbidden', directoryQueriesOff: true }); + expect(lookupMessage(result.lookup, t)).toMatch(/administrator has to turn on directory lookups/); + return; + } + expect(result.picked?.id).toBe(delegate.accountId); + const book = (await getAddressBooks()).find((b) => b.isDefault) ?? (await getAddressBooks())[0]; + try { + await setAddressBookShare(book.id, result.picked!.id, { mayRead: true, mayWrite: false, mayDelete: false, mayShare: false } as never); + expect((await delegateView()).addressBooks).toContain(book.id); + } finally { + await useOwner(); + expect(await revokeAll()).toBe(true); + } + }, 120_000); }); diff --git a/src/components/sharing/ShareCollectionSheet.tsx b/src/components/sharing/ShareCollectionSheet.tsx index 48d2c9bd..470f9523 100644 --- a/src/components/sharing/ShareCollectionSheet.tsx +++ b/src/components/sharing/ShareCollectionSheet.tsx @@ -13,8 +13,10 @@ import { } from 'react-native'; import { Trash2, UserPlus, Users } from '../icons'; import { listPrincipals, ownPrincipalId, type PrincipalListing } from '../../api/files'; +import { findPrincipalByEmail } from '../../api/delegation'; import type { Principal } from '../../api/types'; import { directoryNotice } from '../../lib/principal-directory'; +import { canLookUpAddress, lookupMessage, lookupResult, type Lookup } from '../../lib/delegation/display'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; @@ -72,12 +74,17 @@ export function ShareCollectionSheet({ const [shares, setShares] = React.useState>({}); const [search, setSearch] = React.useState(''); const [savingId, setSavingId] = React.useState(null); + // A typed address nobody listed has: looked up on its own, as the + // Delegates dialog does (webmail delegate-dialog.tsx). The listing can + // miss people the server still finds by address. + const [lookup, setLookup] = React.useState({ state: 'idle' }); React.useEffect(() => { if (!collection) return undefined; let cancelled = false; setShares(collection.shareWith ?? {}); setSearch(''); + setLookup({ state: 'idle' }); setLoading(true); void listPrincipals().then(({ principals: list, ...listing }) => { if (cancelled) return; @@ -138,6 +145,29 @@ export function ShareCollectionSheet({ } }; + const typedAddress = search.trim(); + // Anyone listed with that address is a candidate, has access or is the user. + const offerLookup = !loading && candidates.length === 0 && canLookUpAddress(search, principals); + + const lookUp = async () => { + if (!offerLookup || lookup.state === 'busy' || savingId) return; + setLookup({ state: 'busy' }); + const found = await findPrincipalByEmail(typedAddress); + let ownIds: string[] = []; + try { + ownIds = [ownPrincipalId()]; + } catch { + // No session yet: the server's answer still says who it is. + } + const result = lookupResult(typedAddress, found, ownIds); + setLookup(result.lookup); + const picked = result.picked; + if (!picked) return; + setPrincipals((prev) => (prev.some((p) => p.id === picked.id) ? prev : [...prev, picked])); + if (shares[picked.id] == null) await applyShare(picked.id, addRights); + setSearch(''); + }; + if (!collection) return null; // The list below is partial or empty for a reason the user can't see @@ -240,7 +270,10 @@ export function ShareCollectionSheet({ { + setSearch(text); + setLookup({ state: 'idle' }); + }} placeholder={t('calendar.share.search_placeholder', 'Search by name or email')} placeholderTextColor={c.textMuted} autoCapitalize="none" @@ -252,6 +285,30 @@ export function ShareCollectionSheet({ ) : null} {loading ? ( + ) : offerLookup ? ( + + void lookUp()} + disabled={lookup.state === 'busy' || !!savingId} + style={({ pressed }) => [styles.candidateRow, pressed && styles.rowPressed]} + accessibilityRole="button" + accessibilityState={{ busy: lookup.state === 'busy' }} + > + + {lookup.state === 'busy' + ? t('delegates.looking_up', 'Looking up…') + : t('delegates.use_address', 'Use {address}', { address: typedAddress })} + + {lookup.state === 'busy' || savingId ? ( + + ) : ( + + )} + + {lookupMessage(lookup, t) ? ( + {lookupMessage(lookup, t)} + ) : null} + ) : candidates.length === 0 ? ( notice && !search.trim() ? null : ( diff --git a/src/components/sharing/__tests__/share-sheet-lookup.test.ts b/src/components/sharing/__tests__/share-sheet-lookup.test.ts new file mode 100644 index 00000000..3f89098d --- /dev/null +++ b/src/components/sharing/__tests__/share-sheet-lookup.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { translate } from '../../../i18n'; +import { canLookUpAddress, lookupMessage, lookupResult } from '../../../lib/delegation/display'; +import type { Principal } from '../../../api/types'; + +const tEn = (key: string, fallback?: string, params?: Record) => translate('en', key, fallback, params); +const sheet = fs.readFileSync(path.resolve(__dirname, '../ShareCollectionSheet.tsx'), 'utf8'); + +describe('sharing a calendar or address book with a typed address', () => { + it('offers to look the address up when nobody listed has it', () => { + // Stalwart 1.0 with directory queries off lists only the user and their groups. + const listed: Principal[] = [{ id: 'd', type: 'individual', name: 'usera@example.org', email: 'usera@example.org' }]; + expect(canLookUpAddress('userc@example.org', listed)).toBe(true); + expect(canLookUpAddress('usera@example.org', listed)).toBe(false); + expect(canLookUpAddress('userc', listed)).toBe(false); + }); + + it('says why the server could not find the address instead of "No matches"', () => { + const off = lookupResult('userc@example.org', { principal: null, status: 'forbidden' }, ['d']); + expect(off.picked).toBeNull(); + expect(lookupMessage(off.lookup, tEn)).toMatch(/administrator has to turn on directory lookups/); + const missing = lookupResult('nobody@example.org', { principal: null, status: 'ok' }, ['d']); + expect(lookupMessage(missing.lookup, tEn)).toBe('Nobody on this server has the address nobody@example.org.'); + expect(lookupMessage({ state: 'idle' }, tEn)).toBeNull(); + }); + + it('is wired into the share sheet: looked up on its own and shared once found', () => { + expect(sheet).toMatch(/canLookUpAddress\(search, principals\)/); + expect(sheet).toMatch(/await findPrincipalByEmail\(typedAddress\)/); + expect(sheet).toMatch(/if \(shares\[picked\.id\] == null\) await applyShare\(picked\.id, addRights\);/); + expect(sheet).toMatch(/lookupMessage\(lookup, t\)/); + }); +}); diff --git a/src/lib/delegation/display.ts b/src/lib/delegation/display.ts index c3de31be..b6f0fb46 100644 --- a/src/lib/delegation/display.ts +++ b/src/lib/delegation/display.ts @@ -97,6 +97,22 @@ export type Lookup = | { state: 'self' } | { state: 'failed' }; +/** The line under a typed address after a lookup, or null (webmail delegate-dialog.tsx). */ +export function lookupMessage(lookup: Lookup, t: Translate): string | null { + switch (lookup.state) { + case 'notFound': + return t('delegates.not_found', 'Nobody on this server has the address {address}.', { address: lookup.address }); + case 'failed': + return t('delegates.lookup_failed', "Couldn't look up this address."); + case 'self': + return t('delegates.is_you', "That's you."); + case 'directoryOff': + return t('delegates.directory_off_note', 'Your administrator has to turn on directory lookups in Stalwart before you can add someone new.'); + default: + return null; + } +} + /** What a lookup of `address` found, as the dialog shows it; `picked` is set when it found someone. */ export function lookupResult( address: string,