fix: stop showing a share's principal id as its address when the server can't name the person

This commit is contained in:
Linus Rath
2026-10-31 13:42:08 +01:00
parent fe6781745f
commit 236e9117a8
4 changed files with 77 additions and 2 deletions
@@ -22,6 +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 { listPrincipals } from '../files';
const t = (_key: string, fallback?: string) => fallback ?? '';
const SERVER = process.env.BULWARK_LIVE_JMAP;
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
@@ -120,4 +124,32 @@ live('live Stalwart: delegates', () => {
expect(gone.calendars.filter((id) => changes.some((c) => c.id === id))).toEqual([]);
expect(gone.addressBooks.filter((id) => changes.some((c) => c.id === id))).toEqual([]);
}, 120_000);
// Stalwart 1.0 with directory queries off (VM 106's 1.0.1) names nobody
// the owner merely shares with: Principal/get answers notFound for the
// delegate's id, a letter or two. The row reads "Unknown person" then, not
// "Unknown person / e"; 0.16 names them.
it('names who has access where the server lets it, and shows no id where it does not', async () => {
const { targets } = await getShareTargets();
const principalId = delegate.accountId;
const results = await applyShareChanges(buildGrantChanges(targets, principalId, { mail: 'none', calendars: 'none', addressBooks: 'default' }, 'read'));
expect(results.filter((r) => !r.ok)).toEqual([]);
try {
const listing = await listPrincipals();
const sessionAccounts = new Map(Object.entries(owner.currentSession?.accounts ?? {})
.filter(([id]) => id !== owner.accountId)
.map(([id, info]) => [id, info.name]));
const principal = resolvePrincipal(principalId, { directory: listing.principals, remembered: new Map(), sessionAccounts });
const row = principalRowDisplay(principal, t);
if (listing.directoryQueriesOff) {
expect(principal).toBeNull();
expect(row).toEqual({ name: 'Unknown person', detail: null });
} else {
expect(principal?.email?.toLowerCase()).toBe(USER2!.toLowerCase());
expect(row.name).not.toBe('Unknown person');
}
} finally {
expect(await revokeAll()).toBe(true);
}
}, 120_000);
});
@@ -164,8 +164,10 @@ export function ShareCollectionSheet<R extends object>({
<Text style={styles.principalName} numberOfLines={1}>
{p?.description || p?.name || (unresolved ? t('sharing.unknown_user', 'Unknown user') : principalId)}
</Text>
{p?.email || unresolved ? (
<Text style={styles.principalEmail} numberOfLines={1}>{p?.email || principalId}</Text>
{/* Never the id in its place: Stalwart 1.0's are a letter or two
("e") and read as a broken address. */}
{p?.email ? (
<Text style={styles.principalEmail} numberOfLines={1}>{p.email}</Text>
) : null}
</View>
);
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { translate } from '../../../i18n';
import { principalRowDisplay, resolvePrincipal } from '../display';
const tEn = (key: string, fallback?: string, params?: Record<string, string | number>) => translate('en', key, fallback, params);
describe('someone with access the server does not name', () => {
// Stalwart 1.0 with directory queries off: Principal/get answers notFound
// for the sharee's id ("e") and the session does not list them.
it('reads as "Unknown person" without the id as an address', () => {
const principal = resolvePrincipal('e', { directory: [], remembered: new Map(), sessionAccounts: new Map() });
expect(principal).toBeNull();
expect(principalRowDisplay(principal, tEn)).toEqual({ name: 'Unknown person', detail: null });
});
it('reads as the person when the directory names them (0.16)', () => {
const userB = { id: 'e', type: 'individual' as const, name: 'userb@example.org', email: 'userb@example.org', description: 'User B' };
const principal = resolvePrincipal('e', { directory: [userB], remembered: new Map(), sessionAccounts: new Map() });
expect(principalRowDisplay(principal, tEn)).toEqual({ name: 'userb@example.org', detail: 'User B' });
});
it('gets no id for an address line in the calendar and address book share sheet', () => {
const sheet = fs.readFileSync(path.resolve(__dirname, '../../../components/sharing/ShareCollectionSheet.tsx'), 'utf8');
const label = sheet.slice(sheet.indexOf('const renderPrincipalLabel'), sheet.indexOf('return (', sheet.indexOf('const renderPrincipalLabel')) + 900);
expect(label).not.toMatch(/p\?\.email \|\| principalId/);
expect(label).toMatch(/\{p\?\.email \? \(/);
});
});
+11
View File
@@ -17,6 +17,17 @@ export function principalDisplay(principal: Pick<Principal, 'id' | 'name' | 'ema
return { name, detail };
}
/**
* The row for someone with access: the principal's name and address, or
* "Unknown person" with nothing under it. Stalwart 1.0 with directory
* queries off names nobody the user merely shares with; its ids are a
* letter or two ("e"), which read as a broken address when shown.
*/
export function principalRowDisplay(principal: Principal | null, t: Translate): { name: string; detail: string | null } {
if (principal) return principalDisplay(principal);
return { name: t('delegates.unknown_person', 'Unknown person'), detail: null };
}
/**
* The principal behind an id: from the directory, the names kept on this
* device, or, as a last way to name someone the directory won't (Stalwart