mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
fix: look up a typed address in the share sheet and say why the server won't find it
This commit is contained in:
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user