fix: look up a typed address in the share sheet and say why the server won't find it

This commit is contained in:
Linus Rath
2026-10-31 13:43:58 +01:00
parent 236e9117a8
commit 72b41eabde
4 changed files with 137 additions and 2 deletions
@@ -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);
});
@@ -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<R extends object>({
const [shares, setShares] = React.useState<Record<string, R>>({});
const [search, setSearch] = React.useState('');
const [savingId, setSavingId] = React.useState<string | null>(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<Lookup>({ 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<R extends object>({
}
};
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<R extends object>({
<TextInput
accessibilityLabel={t('sharing.add_person', 'Add person or group')}
value={search}
onChangeText={setSearch}
onChangeText={(text) => {
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<R extends object>({
) : null}
{loading ? (
<ActivityIndicator size="small" color={c.textMuted} style={{ marginTop: spacing.md }} />
) : offerLookup ? (
<View>
<Pressable
onPress={() => void lookUp()}
disabled={lookup.state === 'busy' || !!savingId}
style={({ pressed }) => [styles.candidateRow, pressed && styles.rowPressed]}
accessibilityRole="button"
accessibilityState={{ busy: lookup.state === 'busy' }}
>
<Text style={[styles.principalName, styles.principalInfo]} numberOfLines={1}>
{lookup.state === 'busy'
? t('delegates.looking_up', 'Looking up…')
: t('delegates.use_address', 'Use {address}', { address: typedAddress })}
</Text>
{lookup.state === 'busy' || savingId ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<UserPlus size={18} color={c.primary} />
)}
</Pressable>
{lookupMessage(lookup, t) ? (
<Text style={styles.empty} accessibilityLiveRegion="polite">{lookupMessage(lookup, t)}</Text>
) : null}
</View>
) : candidates.length === 0 ? (
notice && !search.trim() ? null : (
<Text style={styles.empty}>
@@ -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<string, string | number>) => 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\)/);
});
});
+16
View File
@@ -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,