mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +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 { CAPABILITIES } from '../types';
|
||||||
import { applyShareChanges, getShareTargets, supportsDelegation, supportsMailboxSharing } from '../delegation';
|
import { applyShareChanges, getShareTargets, supportsDelegation, supportsMailboxSharing } from '../delegation';
|
||||||
import { buildGrantChanges, buildRevokeChanges, summarizeDelegates } from '../../lib/delegation/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 { listPrincipals } from '../files';
|
||||||
|
import { findPrincipalByEmail } from '../delegation';
|
||||||
|
import { getAddressBooks, setAddressBookShare } from '../contacts';
|
||||||
|
|
||||||
const t = (_key: string, fallback?: string) => fallback ?? '';
|
const t = (_key: string, fallback?: string) => fallback ?? '';
|
||||||
|
|
||||||
@@ -152,4 +154,29 @@ live('live Stalwart: delegates', () => {
|
|||||||
expect(await revokeAll()).toBe(true);
|
expect(await revokeAll()).toBe(true);
|
||||||
}
|
}
|
||||||
}, 120_000);
|
}, 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';
|
} from 'react-native';
|
||||||
import { Trash2, UserPlus, Users } from '../icons';
|
import { Trash2, UserPlus, Users } from '../icons';
|
||||||
import { listPrincipals, ownPrincipalId, type PrincipalListing } from '../../api/files';
|
import { listPrincipals, ownPrincipalId, type PrincipalListing } from '../../api/files';
|
||||||
|
import { findPrincipalByEmail } from '../../api/delegation';
|
||||||
import type { Principal } from '../../api/types';
|
import type { Principal } from '../../api/types';
|
||||||
import { directoryNotice } from '../../lib/principal-directory';
|
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 { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
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 [shares, setShares] = React.useState<Record<string, R>>({});
|
||||||
const [search, setSearch] = React.useState('');
|
const [search, setSearch] = React.useState('');
|
||||||
const [savingId, setSavingId] = React.useState<string | null>(null);
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!collection) return undefined;
|
if (!collection) return undefined;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setShares(collection.shareWith ?? {});
|
setShares(collection.shareWith ?? {});
|
||||||
setSearch('');
|
setSearch('');
|
||||||
|
setLookup({ state: 'idle' });
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
void listPrincipals().then(({ principals: list, ...listing }) => {
|
void listPrincipals().then(({ principals: list, ...listing }) => {
|
||||||
if (cancelled) return;
|
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;
|
if (!collection) return null;
|
||||||
|
|
||||||
// The list below is partial or empty for a reason the user can't see
|
// 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
|
<TextInput
|
||||||
accessibilityLabel={t('sharing.add_person', 'Add person or group')}
|
accessibilityLabel={t('sharing.add_person', 'Add person or group')}
|
||||||
value={search}
|
value={search}
|
||||||
onChangeText={setSearch}
|
onChangeText={(text) => {
|
||||||
|
setSearch(text);
|
||||||
|
setLookup({ state: 'idle' });
|
||||||
|
}}
|
||||||
placeholder={t('calendar.share.search_placeholder', 'Search by name or email')}
|
placeholder={t('calendar.share.search_placeholder', 'Search by name or email')}
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
@@ -252,6 +285,30 @@ export function ShareCollectionSheet<R extends object>({
|
|||||||
) : null}
|
) : null}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator size="small" color={c.textMuted} style={{ marginTop: spacing.md }} />
|
<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 ? (
|
) : candidates.length === 0 ? (
|
||||||
notice && !search.trim() ? null : (
|
notice && !search.trim() ? null : (
|
||||||
<Text style={styles.empty}>
|
<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: 'self' }
|
||||||
| { state: 'failed' };
|
| { 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. */
|
/** What a lookup of `address` found, as the dialog shows it; `picked` is set when it found someone. */
|
||||||
export function lookupResult(
|
export function lookupResult(
|
||||||
address: string,
|
address: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user