feat: give a delegate access to mail, calendars and contacts in one step

This commit is contained in:
Linus Rath
2026-10-31 05:29:50 +01:00
parent 113e96bdec
commit 8a5bb72823
19 changed files with 2780 additions and 10 deletions
+243
View File
@@ -0,0 +1,243 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'ci',
request: vi.fn(),
currentSession: null as unknown,
hasCapability: vi.fn(),
getMaxObjectsInSet: vi.fn(() => 500),
getMaxCallsInRequest: vi.fn(() => 16),
},
}));
vi.mock('../files', () => ({
getAllFileNodes: vi.fn(async () => []),
filesAccountId: () => 'ci',
supportsFiles: vi.fn(() => true),
}));
import { jmapClient } from '../jmap-client';
import { getAllFileNodes, supportsFiles } from '../files';
import {
LOGIN_SWITCHED,
applyShareChanges,
findPrincipalByEmail,
getShareTargets,
sharesSeenWithKeywords,
supportsDelegation,
supportsMailboxSharing,
} from '../delegation';
import { buildGrantChanges, buildRevokeChanges } from '../../lib/delegation/delegation';
import { CALENDAR_PRESETS, FILE_PRESETS, MAILBOX_PRESETS, ADDRESS_BOOK_PRESETS } from '../../lib/delegation/share-rights';
import type { ShareChange, ShareTarget } from '../../lib/delegation/share-batch';
// Ported from the client part of the webmail's lib/__tests__/jmap-share-batch.test.ts,
// plus the capability checks of its settings-app supportsSharing.
type Call = [string, Record<string, unknown>, string];
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
const mockHasCapability = jmapClient.hasCapability as ReturnType<typeof vi.fn>;
const mockMaxCalls = jmapClient.getMaxCallsInRequest as ReturnType<typeof vi.fn>;
const ALL_CAPS = {
'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:mail:share': {}, 'urn:ietf:params:jmap:calendars': {},
'urn:ietf:params:jmap:contacts': {}, 'urn:ietf:params:jmap:filenode': {},
};
function setSession(accountCapabilities: Record<string, unknown>, sessionCaps: string[] = ['urn:ietf:params:jmap:principals']) {
const capabilities = Object.fromEntries(['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', ...sessionCaps].map((c) => [c, {}]));
(jmapClient as { currentSession: unknown }).currentSession = {
capabilities,
primaryAccounts: {},
accounts: { ci: { name: 'org-alice@example.org', isPersonal: true, isReadOnly: false, accountCapabilities } },
};
mockHasCapability.mockImplementation((urn: string) => urn in capabilities);
}
const change = (kind: ShareChange['kind'], id: string, rights: ShareChange['rights'], principalId = 'ck'): ShareChange =>
({ kind, accountId: 'ci', id, name: id, principalId, rights });
const sent = () => mockRequest.mock.calls.map(([calls, using]) => ({ calls: calls as Call[], using: using as string[] }));
const allUpdated = (calls: Call[]) => ({
methodResponses: calls.map(([method, args, id]) => [method, { updated: Object.fromEntries(Object.keys(args.update as object).map((k) => [k, null])) }, id]),
});
beforeEach(() => {
vi.clearAllMocks();
mockMaxCalls.mockReturnValue(16);
(supportsFiles as ReturnType<typeof vi.fn>).mockReturnValue(true);
setSession(ALL_CAPS);
});
describe('capability checks', () => {
it('shares mail folders only where the account lists mail:share', () => {
expect(supportsMailboxSharing()).toBe(true);
setSession({ 'urn:ietf:params:jmap:mail': {} }, ['urn:ietf:params:jmap:principals', 'urn:ietf:params:jmap:mail:share']);
expect(supportsMailboxSharing()).toBe(false);
});
it('offers Delegates with principals and something to share, and follows the app gates', () => {
const gates = { calendarsEnabled: true, contactsEnabled: true };
expect(supportsDelegation(gates)).toBe(true);
// Nobody to share with.
setSession(ALL_CAPS, []);
expect(supportsDelegation(gates)).toBe(false);
// Only calendars to share: off with the calendar gate.
setSession({ 'urn:ietf:params:jmap:calendars': {} });
expect(supportsDelegation(gates)).toBe(true);
expect(supportsDelegation({ calendarsEnabled: false, contactsEnabled: true })).toBe(false);
// Nothing to share at all.
setSession({ 'urn:ietf:params:jmap:mail': {} });
expect(supportsDelegation(gates)).toBe(false);
});
it('knows a 1.0 account keeps seen and keywords as one right', () => {
expect(sharesSeenWithKeywords()).toBe(false);
setSession({ ...ALL_CAPS, 'urn:ietf:params:jmap:principals:owner': {} });
expect(sharesSeenWithKeywords()).toBe(true);
});
});
describe('getShareTargets', () => {
const lists = (calls: Call[]) => calls.map(([method, , id]) => [method, {
list: method === 'Mailbox/get' ? [{ id: 'a', name: 'Inbox', role: 'inbox', shareWith: { ck: MAILBOX_PRESETS.read } }]
: method === 'Calendar/get' ? [{ id: 'b', name: 'Calendar', isDefault: true, shareWith: {} }]
: [{ id: 'c', name: 'Contacts', isDefault: true, shareWith: {} }],
}, id]);
it('reads folders, calendars and address books in one request with shareWith, and only the shared files', async () => {
mockRequest.mockImplementation(async (calls: Call[]) => ({ methodResponses: lists(calls) }));
(getAllFileNodes as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'n1', name: 'plan.pdf', parentId: null, shareWith: { ck: FILE_PRESETS.read } },
{ id: 'n2', name: 'notes.txt', parentId: null, shareWith: {} },
]);
const { targets, failed } = await getShareTargets();
expect(mockRequest).toHaveBeenCalledTimes(1);
const [{ calls, using }] = sent();
expect(calls).toEqual([
['Mailbox/get', { accountId: 'ci', ids: null, properties: ['id', 'name', 'role', 'parentId', 'shareWith'] }, 'm'],
['Calendar/get', { accountId: 'ci', ids: null, properties: ['id', 'name', 'isDefault', 'shareWith'] }, 'c'],
['AddressBook/get', { accountId: 'ci', ids: null, properties: ['id', 'name', 'isDefault', 'shareWith'] }, 'a'],
]);
expect(using).toEqual(expect.arrayContaining(['urn:ietf:params:jmap:mail:share', 'urn:ietf:params:jmap:calendars', 'urn:ietf:params:jmap:contacts']));
expect(targets.map((t) => `${t.kind}:${t.id}`)).toEqual(['mailbox:a', 'calendar:b', 'addressBook:c', 'file:n1']);
expect(failed).toEqual([]);
});
it('leaves out folders on an account without mail:share', async () => {
setSession({ 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:calendars': {} });
mockRequest.mockImplementation(async (calls: Call[]) => ({ methodResponses: lists(calls) }));
await getShareTargets({ files: false });
expect(sent()[0].calls.map(([m]) => m)).toEqual(['Calendar/get']);
});
it('skips the types whose feature is off', async () => {
mockRequest.mockImplementation(async (calls: Call[]) => ({ methodResponses: lists(calls) }));
await getShareTargets({ calendars: false, contacts: false, files: false });
expect(sent()[0].calls.map(([m]) => m)).toEqual(['Mailbox/get']);
expect(getAllFileNodes).not.toHaveBeenCalled();
});
it('names the types whose read failed', async () => {
mockRequest.mockImplementation(async (calls: Call[]) => ({
methodResponses: calls.map((call) => (call[0] === 'Calendar/get' ? ['error', { type: 'serverFail' }, call[2]] : lists([call])[0])),
}));
(getAllFileNodes as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('offline'));
vi.spyOn(console, 'warn').mockImplementation(() => {});
const { failed } = await getShareTargets();
expect(failed.sort()).toEqual(['calendar', 'file']);
});
});
describe('applyShareChanges', () => {
it('gives a delegate the Inbox, the default calendar and the default address book in one request', async () => {
mockRequest.mockImplementation(async (calls: Call[]) => allUpdated(calls));
const targets = [
{ kind: 'mailbox' as const, accountId: 'ci', id: 'a', name: 'Inbox', role: 'inbox', shareWith: {} },
{ kind: 'mailbox' as const, accountId: 'ci', id: 'b', name: 'Sent', role: 'sent', shareWith: {} },
{ kind: 'calendar' as const, accountId: 'ci', id: 'c1', name: 'Work', isDefault: true, shareWith: {} },
{ kind: 'addressBook' as const, accountId: 'ci', id: 'ab', name: 'Contacts', isDefault: true, shareWith: {} },
];
const results = await applyShareChanges(buildGrantChanges(targets, 'ck', { mail: 'inbox', calendars: 'default', addressBooks: 'default' }, 'write'));
expect(mockRequest).toHaveBeenCalledTimes(1);
expect(sent()[0].calls).toEqual([
['Mailbox/set', { accountId: 'ci', update: { a: { 'shareWith/ck': MAILBOX_PRESETS.readWrite } } }, 's0'],
['Calendar/set', { accountId: 'ci', update: { c1: { 'shareWith/ck': CALENDAR_PRESETS.readWrite } } }, 's1'],
['AddressBook/set', { accountId: 'ci', update: { ab: { 'shareWith/ck': ADDRESS_BOOK_PRESETS.readWrite } } }, 's2'],
]);
expect(sent()[0].using).toEqual(expect.arrayContaining([
'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:mail:share', 'urn:ietf:params:jmap:calendars', 'urn:ietf:params:jmap:contacts',
]));
expect(results.every((r) => r.ok)).toBe(true);
});
it('takes every grant away with null patches, files included', async () => {
mockRequest.mockImplementation(async (calls: Call[]) => allUpdated(calls));
const targets: ShareTarget[] = [
{ kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', role: 'inbox', shareWith: { ck: MAILBOX_PRESETS.read } },
{ kind: 'file', accountId: 'ci', id: 'n1', name: 'plan.pdf', shareWith: { ck: FILE_PRESETS.read, cj: FILE_PRESETS.read } },
];
await applyShareChanges(buildRevokeChanges(targets, 'ck'));
expect(sent()[0].calls).toEqual([
['Mailbox/set', { accountId: 'ci', update: { a: { 'shareWith/ck': null } } }, 's0'],
['FileNode/set', { accountId: 'ci', update: { n1: { 'shareWith/ck': null } } }, 's1'],
]);
});
it('sends one request per maxCallsInRequest calls and reports every item', async () => {
mockMaxCalls.mockReturnValue(2);
mockRequest.mockImplementation(async (calls: Call[]) => allUpdated(calls));
const results = await applyShareChanges([
change('mailbox', 'a', MAILBOX_PRESETS.read),
change('calendar', 'c2', CALENDAR_PRESETS.read),
change('file', 'n1', null),
]);
expect(mockRequest).toHaveBeenCalledTimes(2);
expect(results.map((r) => [r.change.id, r.ok])).toEqual([['a', true], ['c2', true], ['n1', true]]);
});
it('marks the items of a failed request failed instead of throwing', async () => {
mockRequest.mockRejectedValue(new Error('offline'));
const results = await applyShareChanges([change('mailbox', 'a', MAILBOX_PRESETS.read)]);
expect(results).toEqual([{ change: expect.objectContaining({ id: 'a' }), ok: false, error: 'offline' }]);
});
it('sends nothing once the login the page was opened in is no longer active', async () => {
mockRequest.mockImplementation(async (calls: Call[]) => allUpdated(calls));
const results = await applyShareChanges([change('mailbox', 'a', MAILBOX_PRESETS.read)], () => false);
expect(mockRequest).not.toHaveBeenCalled();
expect(results).toEqual([{ change: expect.objectContaining({ id: 'a' }), ok: false, error: LOGIN_SWITCHED }]);
});
});
describe('findPrincipalByEmail', () => {
it('finds the principal with that address, whatever its case', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['Principal/query', { ids: ['ck'] }, '0'],
['Principal/get', { list: [{ id: 'ck', type: 'individual', name: 'Carol', email: 'org-carol@example.org' }] }, '1'],
],
});
const found = await findPrincipalByEmail(' Org-Carol@Example.org ');
expect(found).toEqual({ principal: expect.objectContaining({ id: 'ck' }), status: 'ok' });
expect(sent()[0].calls[0][1].filter).toEqual({ email: 'org-carol@example.org' });
});
it('says when directory queries are off (Stalwart 1.0)', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['error', { type: 'forbidden', description: 'The administrator has disabled directory queries.' }, '0'],
['error', { type: 'invalidResultReference' }, '1'],
],
});
expect(await findPrincipalByEmail('org-carol@example.org')).toEqual({ principal: null, status: 'forbidden', directoryQueriesOff: true });
});
it('tells a refusal for another reason apart, and an unknown address', async () => {
mockRequest.mockResolvedValueOnce({ methodResponses: [['error', { type: 'forbidden', description: 'You are not authorized to perform this action' }, '0']] });
expect(await findPrincipalByEmail('x@example.org')).toEqual({ principal: null, status: 'forbidden' });
mockRequest.mockResolvedValueOnce({ methodResponses: [['Principal/query', { ids: [] }, '0'], ['Principal/get', { list: [] }, '1']] });
expect(await findPrincipalByEmail('x@example.org')).toEqual({ principal: null, status: 'ok' });
});
});
@@ -0,0 +1,123 @@
/**
* Live checks of Delegates (Settings > Sharing) against a real Stalwart
* server: the first login gives the second one access to its Inbox, its
* default calendar and its default address book in one batch, the second
* login sees them, and taking the access away removes all of it.
*
* Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP`,
* `BULWARK_LIVE_PASS` and `BULWARK_LIVE_USER2` are set (`BULWARK_LIVE_PASS2`
* defaults to the first password).
*
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
* BULWARK_LIVE_USER=e2e-tablet-dark@example.org BULWARK_LIVE_PASS=… \
* BULWARK_LIVE_USER2=e2e-tablet-land@example.org \
* npx vitest run src/api/__tests__/live-stalwart-delegation.test.ts
*
* Every grant to the second login is taken away again afterwards, also
* after a failed check.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { JMAPClient } from '../jmap-client';
import { CAPABILITIES } from '../types';
import { applyShareChanges, getShareTargets, supportsDelegation, supportsMailboxSharing } from '../delegation';
import { buildGrantChanges, buildRevokeChanges, summarizeDelegates } from '../../lib/delegation/delegation';
const SERVER = process.env.BULWARK_LIVE_JMAP;
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
const USER2 = process.env.BULWARK_LIVE_USER2;
const PASS2 = process.env.BULWARK_LIVE_PASS2 ?? PASS;
const live = SERVER && PASS && USER2 ? describe : describe.skip;
type Response = [string, Record<string, any>, string];
let owner: JMAPClient;
let delegate: JMAPClient;
async function useOwner(): Promise<void> {
const mod = await import('../jmap-client');
(mod.jmapClient as unknown as JMAPClient).restoreSnapshot(owner.snapshot());
}
/** Takes every grant of the delegate away (afterAll and the remove step). */
async function revokeAll(): Promise<boolean> {
await useOwner();
const { targets } = await getShareTargets();
const results = await applyShareChanges(buildRevokeChanges(targets, delegate.accountId));
return results.every((r) => r.ok);
}
/** What the delegate sees of the owner's account: its folders, calendars and address books. */
async function delegateView(): Promise<{ inSession: boolean; mailboxes: string[]; calendars: string[]; addressBooks: string[] }> {
const session = await delegate.refreshSession();
const id = owner.accountId;
const inSession = !!session.accounts?.[id];
const read = async (method: string, using: string[]) => {
if (!inSession) return [];
const res = (await delegate.request([[method, { accountId: id, ids: null, properties: ['id', 'name'] }, '0']], using)).methodResponses as Response[];
return res[0][0] === method ? (res[0][1].list as { id: string }[]).map((x) => x.id) : [];
};
return {
inSession,
mailboxes: await read('Mailbox/get', [CAPABILITIES.CORE, CAPABILITIES.MAIL]),
calendars: await read('Calendar/get', [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]),
addressBooks: await read('AddressBook/get', [CAPABILITIES.CORE, CAPABILITIES.CONTACTS]),
};
}
live('live Stalwart: delegates', () => {
beforeAll(async () => {
owner = new JMAPClient();
await owner.connect(SERVER!, USER, PASS);
delegate = new JMAPClient();
await delegate.connect(SERVER!, USER2!, PASS2);
await useOwner();
}, 60_000);
afterAll(async () => {
if (owner && delegate) await revokeAll().catch(() => undefined);
}, 60_000);
it('gives the second login the Inbox, the default calendar and address book in one step, and takes it all back', async () => {
expect(supportsDelegation({ calendarsEnabled: true, contactsEnabled: true })).toBe(true);
// The webmail shares folders where the account lists mail:share, on 0.16 as on 1.0.
expect(supportsMailboxSharing()).toBe(true);
const { targets, failed } = await getShareTargets();
expect(failed).toEqual([]);
const principalId = delegate.accountId;
const changes = buildGrantChanges(targets, principalId, { mail: 'inbox', calendars: 'default', addressBooks: 'default' }, 'read');
const inbox = targets.find((t) => t.kind === 'mailbox' && t.role === 'inbox')!;
expect(changes.map((c) => c.id)).toContain(inbox.id);
// One batch over every type the account has.
for (const kind of ['calendar', 'addressBook'] as const) {
if (targets.some((t) => t.kind === kind)) expect(changes.filter((c) => c.kind === kind)).toHaveLength(1);
}
const results = await applyShareChanges(changes);
expect(results.filter((r) => !r.ok)).toEqual([]);
// The owner's list shows the delegate with the Inbox.
const after = await getShareTargets();
const summary = summarizeDelegates(after.targets, [owner.accountId]).find((s) => s.principalId === principalId);
expect(summary?.mail).toMatchObject({ shared: 1, inbox: true });
expect(summary?.level).toBe('read');
// The delegate sees the owner's account and exactly what was shared.
const seen = await delegateView();
expect(seen.inSession).toBe(true);
expect(seen.mailboxes).toEqual([inbox.id]);
for (const c of changes.filter((x) => x.kind === 'calendar')) expect(seen.calendars).toContain(c.id);
for (const c of changes.filter((x) => x.kind === 'addressBook')) expect(seen.addressBooks).toContain(c.id);
// Remove access: nothing of the owner's is left for the delegate.
expect(await revokeAll()).toBe(true);
const revoked = await getShareTargets();
expect(summarizeDelegates(revoked.targets).some((s) => s.principalId === principalId)).toBe(false);
const gone = await delegateView();
expect(gone.mailboxes).toEqual([]);
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);
});
+264
View File
@@ -0,0 +1,264 @@
// Delegates (webmail JMAPClient.getShareTargets, applyShareChanges,
// findPrincipalByEmail and the capability checks Settings > Sharing uses):
// the JMAP side of giving someone access to mail, calendars and contacts in
// one step. The pure parts are in lib/delegation.
import { jmapClient } from './jmap-client';
import { CAPABILITIES, type JMAPMethodCall, type Principal } from './types';
import { getAllFileNodes, filesAccountId, supportsFiles } from './files';
import { batched } from './jmap-result';
import {
SET_METHOD,
buildShareSetCalls,
failAll,
parseShareTargets,
readShareSetResults,
type ShareChange,
type ShareChangeResult,
type ShareTarget,
} from '../lib/delegation/share-batch';
import type { AnyRights, ShareKind } from '../lib/delegation/share-rights';
export const MAIL_SHARE_CAPABILITY = 'urn:ietf:params:jmap:mail:share';
type MethodResponse = [string, Record<string, unknown>, string];
function accountCaps(accountId: string): Record<string, unknown> | undefined {
return jmapClient.currentSession?.accounts?.[accountId]?.accountCapabilities;
}
/** The capability is in the account's own accountCapabilities (webmail hasAccountCapability). */
function hasAccountCapability(capability: string, accountId: string): boolean {
const caps = accountCaps(accountId);
return !!caps && capability in caps;
}
/**
* Whether the user may use a capability's methods with an account (webmail
* JMAPClient.accountSupports): the account's own list decides; a shared
* account that lists nothing falls back to the server-wide capability.
*/
function accountSupports(accountId: string, capability: string): boolean {
const session = jmapClient.currentSession;
const account = session?.accounts?.[accountId];
if (!account) return false;
const caps = account.accountCapabilities;
if (caps?.[capability]) return true;
if (caps && Object.keys(caps).length > 0) return false;
return account.isPersonal === false && !!session?.capabilities?.[capability];
}
export function getCalendarsAccountId(): string {
return jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.CALENDARS] || jmapClient.accountId;
}
export function getContactsAccountId(): string {
return jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.CONTACTS] || jmapClient.accountId;
}
/** Whether the account's server accepts `shareWith` on Mailbox objects. */
export function supportsMailboxSharing(accountId: string = jmapClient.accountId): boolean {
return hasAccountCapability(MAIL_SHARE_CAPABILITY, accountId);
}
export function supportsCalendarSharing(accountId: string = getCalendarsAccountId()): boolean {
return accountSupports(accountId, CAPABILITIES.CALENDARS);
}
export function supportsContactSharing(accountId: string = getContactsAccountId()): boolean {
return accountSupports(accountId, CAPABILITIES.CONTACTS);
}
export function supportsPrincipals(): boolean {
return jmapClient.hasCapability(CAPABILITIES.PRINCIPALS);
}
/**
* Whether Settings offers Delegates: someone to share with (principals) and
* something to share (webmail settings-app supportsSharing).
*/
export function supportsDelegation(gates: { calendarsEnabled: boolean; contactsEnabled: boolean }): boolean {
if (!jmapClient.currentSession || !supportsPrincipals()) return false;
return supportsMailboxSharing()
|| (supportsCalendarSharing() && gates.calendarsEnabled)
|| (supportsContactSharing() && gates.contactsEnabled);
}
/**
* Whether the server keeps a folder's "mark as read" and keyword rights as
* one right, so a read-only share can also flag mail. Stalwart 1.0 does;
* every 1.0 account lists principals:owner, which 0.16 never does.
*/
export function sharesSeenWithKeywords(accountId: string = jmapClient.accountId): boolean {
return hasAccountCapability(CAPABILITIES.PRINCIPALS_OWNER, accountId);
}
function withOwner(using: string[]): string[] {
return jmapClient.hasCapability(CAPABILITIES.PRINCIPALS_OWNER) ? [...using, CAPABILITIES.PRINCIPALS_OWNER] : using;
}
const MAILBOX_USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL, MAIL_SHARE_CAPABILITY];
const calendarUsing = () => withOwner([CAPABILITIES.CORE, CAPABILITIES.CALENDARS]);
const contactUsing = () => withOwner([CAPABILITIES.CORE, CAPABILITIES.CONTACTS]);
const fileUsing = () => (jmapClient.hasCapability(CAPABILITIES.FILES) ? [CAPABILITIES.CORE, CAPABILITIES.FILES] : [CAPABILITIES.CORE]);
/**
* Every folder, calendar and address book of the user's own account with
* its `shareWith`, and the files that are shared with someone. A type the
* account can't share is left out; one whose read failed is in `failed`,
* so the caller doesn't mistake it for "shared with nobody".
*/
export async function getShareTargets(
include: { calendars?: boolean; contacts?: boolean; files?: boolean } = {},
): Promise<{ targets: ShareTarget[]; failed: ShareKind[] }> {
const calls: JMAPMethodCall[] = [];
const using = new Set<string>([CAPABILITIES.CORE]);
const accounts: { mailbox?: string; calendar?: string; addressBook?: string } = {};
const mailAccountId = jmapClient.accountId;
if (supportsMailboxSharing(mailAccountId)) {
accounts.mailbox = mailAccountId;
calls.push(['Mailbox/get', { accountId: mailAccountId, ids: null, properties: ['id', 'name', 'role', 'parentId', 'shareWith'] }, 'm']);
for (const u of MAILBOX_USING) using.add(u);
}
const calendarsAccountId = getCalendarsAccountId();
if (include.calendars !== false && supportsCalendarSharing(calendarsAccountId)) {
accounts.calendar = calendarsAccountId;
calls.push(['Calendar/get', { accountId: calendarsAccountId, ids: null, properties: ['id', 'name', 'isDefault', 'shareWith'] }, 'c']);
for (const u of calendarUsing()) using.add(u);
}
const contactsAccountId = getContactsAccountId();
if (include.contacts !== false && supportsContactSharing(contactsAccountId)) {
accounts.addressBook = contactsAccountId;
calls.push(['AddressBook/get', { accountId: contactsAccountId, ids: null, properties: ['id', 'name', 'isDefault', 'shareWith'] }, 'a']);
for (const u of contactUsing()) using.add(u);
}
const readCollections = async (): Promise<{ targets: ShareTarget[]; failed: ShareKind[] }> => {
if (calls.length === 0) return { targets: [], failed: [] };
try {
const res = await jmapClient.request(calls, [...using]);
return parseShareTargets(res.methodResponses as MethodResponse[], accounts);
} catch (err) {
console.warn('[delegation] reading sharing failed', err);
return { targets: [], failed: Object.keys(accounts) as ShareKind[] };
}
};
const readFiles = async (): Promise<{ targets: ShareTarget[]; failed: ShareKind[] }> => {
if (include.files === false || !supportsFiles()) return { targets: [], failed: [] };
const accountId = filesAccountId();
try {
const targets: ShareTarget[] = [];
for (const node of await getAllFileNodes()) {
if (!node.shareWith || Object.keys(node.shareWith).length === 0) continue;
targets.push({
kind: 'file',
accountId,
id: node.id,
name: node.name,
parentId: node.parentId ?? null,
shareWith: node.shareWith as Record<string, AnyRights>,
});
}
return { targets, failed: [] };
} catch (err) {
console.warn('[delegation] reading file sharing failed', err);
return { targets: [], failed: ['file'] };
}
};
// The file tree can take several requests; it is read alongside the rest.
const [collections, fileTargets] = await Promise.all([readCollections(), readFiles()]);
return {
targets: [...collections.targets, ...fileTargets.targets],
failed: [...collections.failed, ...fileTargets.failed],
};
}
/** The reason a change was not sent: another login is active now. */
export const LOGIN_SWITCHED = 'The account was switched';
/**
* Applies `shareWith` changes to many items in as few requests as the
* server's limits allow, and says for each one whether it worked. Never
* throws: a request that fails, or one not sent because `stillSameLogin`
* says the login changed, marks its items failed.
*/
export async function applyShareChanges(
changes: ShareChange[],
stillSameLogin: () => boolean = () => true,
): Promise<ShareChangeResult[]> {
if (changes.length === 0) return [];
const { calls, changesByCall } = buildShareSetCalls(changes, { maxObjectsInSet: jmapClient.getMaxObjectsInSet() });
const usingFor: Record<string, string[]> = {
[SET_METHOD.mailbox]: MAILBOX_USING,
[SET_METHOD.calendar]: calendarUsing(),
[SET_METHOD.addressBook]: contactUsing(),
[SET_METHOD.file]: fileUsing(),
};
const results: ShareChangeResult[] = [];
for (const group of batched(calls, jmapClient.getMaxCallsInRequest())) {
if (!stillSameLogin()) {
results.push(...failAll(group, changesByCall, new Error(LOGIN_SWITCHED)));
continue;
}
const using = [...new Set(group.flatMap(([method]) => usingFor[method] ?? []))];
try {
const res = await jmapClient.request(group, using);
results.push(...readShareSetResults(group, changesByCall, res.methodResponses as MethodResponse[]));
} catch (err) {
results.push(...failAll(group, changesByCall, err));
}
}
return results;
}
// Same as api/files: the Principal properties the share sheets read, and
// Stalwart 1.0's answer while directory queries are off (its default).
const PRINCIPAL_PROPERTIES = ['id', 'type', 'name', 'description', 'email'];
const DIRECTORY_QUERIES_OFF = 'The administrator has disabled directory queries.';
export interface PrincipalLookup {
principal: Principal | null;
status: 'ok' | 'forbidden' | 'error';
directoryQueriesOff?: boolean;
}
/**
* The principal with this address, through a directory query. `forbidden`
* when the server refuses queries (Stalwart 1.0 with directory queries off,
* the default), with `directoryQueriesOff` when that is the reason.
*/
export async function findPrincipalByEmail(email: string): Promise<PrincipalLookup> {
if (!supportsPrincipals()) return { principal: null, status: 'error' };
const wanted = email.trim().toLowerCase();
const accountId = jmapClient.accountId;
try {
const res = await jmapClient.request(
[
['Principal/query', { accountId, filter: { email: wanted }, limit: 10 }, '0'],
['Principal/get', {
accountId,
'#ids': { resultOf: '0', name: 'Principal/query', path: '/ids' },
properties: PRINCIPAL_PROPERTIES,
}, '1'],
],
[CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS],
);
const responses = res.methodResponses ?? [];
const queryResp = responses.find((r) => r[0] === 'Principal/query');
const getResp = responses.find((r) => r[0] === 'Principal/get');
if (!queryResp || !getResp) {
// A refused query makes the chained get fail; only the query's error says why.
const error = responses.find((r) => r[0] === 'error')?.[1] as { type?: string; description?: string } | undefined;
if (error?.type === 'forbidden') {
return { principal: null, status: 'forbidden', ...(error.description === DIRECTORY_QUERIES_OFF ? { directoryQueriesOff: true } : {}) };
}
return { principal: null, status: 'error' };
}
const list = (getResp[1].list ?? []) as Principal[];
const principal = list.find((p) => p.email?.toLowerCase() === wanted || p.name?.toLowerCase() === wanted) ?? null;
return { principal, status: 'ok' };
} catch (err) {
console.warn('[delegation] looking up a principal failed', err);
return { principal: null, status: 'error' };
}
}
+536
View File
@@ -0,0 +1,536 @@
import React, { useMemo, useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Info, X } from '../icons';
import Button from '../Button';
import SenderAvatar from '../SenderAvatar';
import { SafeAreaModal } from '../SafeAreaModal';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { findPrincipalByEmail } from '../../api/delegation';
import type { Principal } from '../../api/types';
import type { PrincipalListing } from '../../api/files';
import type { ShareChange, ShareChangeResult, ShareTarget } from '../../lib/delegation/share-batch';
import type { AccessLevel } from '../../lib/delegation/share-rights';
import {
buildGrantChanges,
DEFAULT_DELEGATE_SCOPE,
failedChanges,
scopeIsEmpty,
type CollectionScope,
type DelegateScope,
type LevelChoice,
type MailScope,
} from '../../lib/delegation/delegation';
import {
canLookUpAddress,
delegateCandidates,
lookupResult,
principalDisplay,
type Lookup,
} from '../../lib/delegation/display';
// Ported from the webmail's components/settings/delegates/delegate-dialog.tsx:
// "Give access" and "Change". One person, what they get, and with which
// rights, applied to every folder, calendar and address book in one batch.
export interface DelegateDialogProps {
targets: ShareTarget[];
directory: PrincipalListing | null;
/** The user's own principal ids, never offered. */
ownIds: string[];
/** The user's own addresses: typing one says "That's you". */
ownEmails: string[];
/** Ids that already have access to something. */
existingIds: Set<string>;
/** Set for "Change": the person, and what they have now. */
initial?: { principal: Principal; scope: DelegateScope; level: LevelChoice };
levels: AccessLevel[];
/** The admin limited the rights (sharingRights). */
levelsRestricted: boolean;
/** The server keeps "mark as read" and flags as one right on folders (Stalwart 1.0). */
seenSetsKeywords: boolean;
/** Sends the changes; resolves to a result per change. */
apply: (changes: ShareChange[]) => Promise<ShareChangeResult[]>;
onApplied: (principal: Principal, results: ShareChangeResult[]) => void;
onClose: () => void;
}
export function DelegateDialog({
targets, directory, ownIds, ownEmails, existingIds, initial, levels, levelsRestricted, seenSetsKeywords, apply, onApplied, onClose,
}: DelegateDialogProps) {
const c = useColors();
const styles = useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const changing = !!initial;
const [principal, setPrincipal] = useState<Principal | null>(initial?.principal ?? null);
const [query, setQuery] = useState('');
const [lookup, setLookup] = useState<Lookup>({ state: 'idle' });
const [scope, setScope] = useState<DelegateScope>(initial?.scope ?? DEFAULT_DELEGATE_SCOPE);
const [level, setLevel] = useState<LevelChoice>(() => {
const wanted = initial?.level ?? 'read';
return wanted === 'keep' || levels.includes(wanted) ? wanted : levels[0];
});
// "Keep as it is" is offered where Change started with it, and stays offered.
const keep = {
mail: initial?.scope.mail === 'keep',
calendars: initial?.scope.calendars === 'keep',
addressBooks: initial?.scope.addressBooks === 'keep',
level: initial?.level === 'keep',
};
const [saving, setSaving] = useState(false);
const [failures, setFailures] = useState<ShareChangeResult[] | null>(null);
const has = (kind: ShareTarget['kind']) => targets.some((target) => target.kind === kind);
const directoryOff = directory?.status === 'restricted' && !!directory.directoryQueriesOff;
const candidates = useMemo(
() => delegateCandidates(directory?.principals ?? [], ownIds, query),
[directory, ownIds, query],
);
const typedAddress = query.trim();
const canLookUp = canLookUpAddress(query, candidates);
const lookUp = async () => {
if (!canLookUp || lookup.state === 'busy') return;
if (ownEmails.some((email) => email.toLowerCase() === typedAddress.toLowerCase())) {
setLookup({ state: 'self' });
return;
}
setLookup({ state: 'busy' });
const found = await findPrincipalByEmail(typedAddress);
const result = lookupResult(typedAddress, found, ownIds);
if (result.picked) setPrincipal(result.picked);
setLookup(result.lookup);
};
const send = async (changes: ShareChange[]) => {
if (!principal) return;
setSaving(true);
try {
const results = await apply(changes);
const failed = failedChanges(results);
onApplied(principal, results);
if (failed.length === 0) onClose();
else setFailures(failed);
} finally {
setSaving(false);
}
};
const changes = useMemo(
() => (principal ? buildGrantChanges(targets, principal.id, scope, level, { replace: changing }) : []),
[targets, principal, scope, level, changing],
);
const nothingToDo = changing ? changes.length === 0 : scopeIsEmpty(scope);
const submit = () => {
if (!principal || nothingToDo) return;
void send(changes);
};
const retry = () => {
if (failures) void send(failures.map((f) => f.change));
};
const close = () => {
if (!saving) onClose();
};
const keepLabel = t('delegates.keep', 'Keep as it is');
const none = t('delegates.none', 'None');
return (
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible animationType="slide" onRequestClose={close}>
<SafeAreaView style={styles.screen} edges={['top', 'bottom', 'left', 'right']}>
<KeyboardAvoidingView style={styles.screen} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<View style={styles.header}>
<Text style={styles.title} numberOfLines={1} accessibilityRole="header">
{changing && principal
? t('delegates.dialog_title_change', 'Change access for {name}', { name: principalDisplay(principal).name })
: t('delegates.dialog_title_add', 'Give access')}
</Text>
<Pressable
onPress={close}
disabled={saving}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
style={styles.closeBtn}
>
<X size={22} color={c.textMuted} />
</Pressable>
</View>
{failures ? (
<ScrollView style={styles.body} contentContainerStyle={styles.bodyContent}>
<Text style={styles.label} accessibilityLiveRegion="assertive">
{t('delegates.partial_title', "These couldn't be changed. The rest is done.")}
</Text>
<View style={styles.box}>
{failures.map((f, index) => (
<View key={`${f.change.kind}:${f.change.id}`} style={[styles.failRow, index > 0 && styles.rowBorder]}>
<Text style={styles.text}>
<Text style={styles.textStrong}>{f.change.name}</Text>
{' · '}{t(`delegates.kind.${f.change.kind}`, f.change.kind)}
</Text>
{f.error ? <Text style={styles.small}>{f.error}</Text> : null}
</View>
))}
</View>
</ScrollView>
) : (
<ScrollView style={styles.body} contentContainerStyle={styles.bodyContent} keyboardShouldPersistTaps="handled">
<View style={styles.field}>
<Text style={styles.label}>{t('delegates.person', 'Person')}</Text>
{principal ? (
<View style={styles.pickedCard}>
<SenderAvatar name={principal.name} email={principal.email ?? ''} size={32} disableImages />
<PrincipalText principal={principal} styles={styles} groupLabel={t('delegates.group', 'Group')} />
{!changing && (
<Button variant="ghost" size="sm" onPress={() => { setPrincipal(null); setLookup({ state: 'idle' }); }}>
{t('delegates.change_person', 'Change')}
</Button>
)}
</View>
) : (
<View style={styles.field}>
<TextInput
value={query}
onChangeText={(text) => { setQuery(text); setLookup({ state: 'idle' }); }}
onSubmitEditing={() => void lookUp()}
placeholder={t('delegates.search_placeholder', 'Name or email address')}
placeholderTextColor={c.textMuted}
accessibilityLabel={t('delegates.person', 'Person')}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
returnKeyType="search"
autoFocus
style={styles.input}
/>
{directory === null ? (
<View style={styles.inline}>
<ActivityIndicator size="small" color={c.textMuted} />
<Text style={styles.small}>{t('delegates.loading', 'Loading…')}</Text>
</View>
) : (
<View style={styles.box}>
{candidates.length === 0 && !canLookUp && (
<Text style={[styles.small, styles.candidatePad]}>{t('delegates.no_match', 'Nobody found.')}</Text>
)}
{candidates.slice(0, 50).map((p, index) => (
<Pressable
key={p.id}
onPress={() => setPrincipal(p)}
accessibilityRole="button"
style={({ pressed }) => [styles.candidate, index > 0 && styles.rowBorder, pressed && styles.pressed]}
>
<SenderAvatar name={p.name} email={p.email ?? ''} size={28} disableImages />
<PrincipalText principal={p} styles={styles} groupLabel={t('delegates.group', 'Group')} />
{existingIds.has(p.id) && <Text style={styles.small}>{t('delegates.has_access', 'Has access')}</Text>}
</Pressable>
))}
{canLookUp && (
<Pressable
onPress={() => void lookUp()}
disabled={lookup.state === 'busy'}
accessibilityRole="button"
style={({ pressed }) => [styles.candidate, candidates.length > 0 && styles.rowBorder, pressed && styles.pressed]}
>
{lookup.state === 'busy' && <ActivityIndicator size="small" color={c.textMuted} />}
<Text style={styles.text}>
{lookup.state === 'busy'
? t('delegates.looking_up', 'Looking up…')
: t('delegates.use_address', 'Use {address}', { address: typedAddress })}
</Text>
</Pressable>
)}
</View>
)}
<View accessibilityLiveRegion="polite">
{lookup.state === 'notFound' && (
<Text style={styles.errorSmall}>{t('delegates.not_found', 'Nobody on this server has the address {address}.', { address: lookup.address })}</Text>
)}
{lookup.state === 'failed' && <Text style={styles.errorSmall}>{t('delegates.lookup_failed', "Couldn't look up this address.")}</Text>}
{lookup.state === 'self' && <Text style={styles.errorSmall}>{t('delegates.is_you', "That's you.")}</Text>}
{(directoryOff || lookup.state === 'directoryOff') && (
<Text style={styles.small}>
{directoryOff ? `${t('delegates.known_only', 'Only your groups and people who have shared something with you are listed.')} ` : ''}
{t('delegates.directory_off_note', 'Your administrator has to turn on directory lookups in Stalwart before you can add someone new.')}
</Text>
)}
{directory?.status === 'error' && (
<Text style={styles.small}>{t('delegates.directory_failed', "Couldn't load the list of people and groups.")}</Text>
)}
</View>
</View>
)}
</View>
<View style={styles.field}>
<Text style={styles.label}>{t('delegates.what', 'What')}</Text>
{has('mailbox') && (
<Choice<MailScope>
label={t('delegates.mail', 'Mail')}
value={scope.mail}
onChange={(mail) => setScope({ ...scope, mail })}
options={[
...keepOption<MailScope>(keep.mail, keepLabel),
['all', t('delegates.mail_all', 'All folders')],
['inbox', t('delegates.mail_inbox', 'Inbox only')],
['none', none],
]}
styles={styles}
/>
)}
{has('calendar') && (
<Choice<CollectionScope>
label={t('delegates.calendars', 'Calendars')}
value={scope.calendars}
onChange={(calendars) => setScope({ ...scope, calendars })}
options={[
...keepOption<CollectionScope>(keep.calendars, keepLabel),
['default', t('delegates.calendars_default', 'Default calendar')],
['all', t('delegates.calendars_all', 'All calendars')],
['none', none],
]}
styles={styles}
/>
)}
{has('addressBook') && (
<Choice<CollectionScope>
label={t('delegates.address_books', 'Contacts')}
value={scope.addressBooks}
onChange={(addressBooks) => setScope({ ...scope, addressBooks })}
options={[
...keepOption<CollectionScope>(keep.addressBooks, keepLabel),
['default', t('delegates.address_books_default', 'Default address book')],
['all', t('delegates.address_books_all', 'All address books')],
['none', none],
]}
styles={styles}
/>
)}
{!changing && scopeIsEmpty(scope) && (
<Text style={styles.errorSmall}>{t('delegates.empty_scope', 'Choose mail, calendars or contacts.')}</Text>
)}
{changing && (
<Text style={styles.small}>
{t('delegates.change_note', 'Folders, calendars and address books you leave out lose access. Shared files stay as they are; Remove access takes those away too.')}
</Text>
)}
</View>
<View style={styles.field} accessibilityRole="radiogroup">
<Text style={styles.label}>{t('delegates.rights', 'Rights')}</Text>
{[...(keep.level ? ['keep' as const] : []), ...levels].map((option) => {
const selected = level === option;
const hintKey = option === 'read' && seenSetsKeywords ? 'delegates.rights_read_hint_flags' : `delegates.rights_${option}_hint`;
return (
<Pressable
key={option}
onPress={() => setLevel(option)}
accessibilityRole="radio"
accessibilityState={{ selected }}
style={styles.radioRow}
>
<View style={[styles.radio, selected && styles.radioOn]}>
{selected && <View style={styles.radioDot} />}
</View>
<View style={styles.radioText}>
<Text style={styles.text}>{t(`delegates.rights_${option}`, option)}</Text>
<Text style={styles.small}>{t(hintKey, '')}</Text>
</View>
</Pressable>
);
})}
{levelsRestricted && (
<Text style={styles.small}>{t('settings.org_locked', 'Set by your organisation')}</Text>
)}
</View>
{changing && nothingToDo && (
<Text style={styles.small} accessibilityLiveRegion="polite">
{t('delegates.nothing_to_change', 'Nothing to change. Pick what they should get, or use Remove access.')}
</Text>
)}
<View style={styles.note}>
<Info size={16} color={c.textMuted} />
<Text style={[styles.small, styles.noteText]}>
{t('delegates.send_note', "Delegates can read and organise your mail and calendar. Stalwart doesn't let them send as you or on your behalf. To share one address between several people, ask your administrator for a shared mailbox.")}
</Text>
</View>
</ScrollView>
)}
<View style={styles.footer}>
{failures ? (
<>
<Button variant="outline" onPress={close} disabled={saving}>{t('common.close', 'Close')}</Button>
<Button onPress={retry} loading={saving}>{t('delegates.retry', 'Try again')}</Button>
</>
) : (
<>
<Button variant="outline" onPress={close} disabled={saving}>{t('common.cancel', 'Cancel')}</Button>
<Button onPress={submit} loading={saving} disabled={!principal || nothingToDo}>
{changing ? t('delegates.apply_change', 'Save') : t('delegates.apply_add', 'Give access')}
</Button>
</>
)}
</View>
</KeyboardAvoidingView>
</SafeAreaView>
</SafeAreaModal>
);
}
type Styles = ReturnType<typeof makeStyles>;
function PrincipalText({ principal, styles, groupLabel }: { principal: Principal; styles: Styles; groupLabel: string }) {
const { name, detail } = principalDisplay(principal);
return (
<View style={styles.principalText}>
<View style={styles.inline}>
<Text style={styles.textStrong} numberOfLines={1}>{name}</Text>
{principal.type === 'group' && <Text style={styles.groupTag}>{groupLabel}</Text>}
</View>
{detail ? <Text style={styles.small} numberOfLines={1}>{detail}</Text> : null}
</View>
);
}
function keepOption<T extends string>(offered: boolean, label: string): [T, string][] {
return offered ? [['keep' as T, label]] : [];
}
function Choice<T extends string>({ label, value, onChange, options, styles }: {
label: string;
value: T;
onChange: (value: T) => void;
options: [T, string][];
styles: Styles;
}) {
return (
<View style={styles.choice} accessibilityRole="radiogroup" accessibilityLabel={label}>
<Text style={styles.choiceLabel}>{label}</Text>
<View style={styles.chips}>
{options.map(([option, text]) => {
const selected = value === option;
return (
<Pressable
key={option}
onPress={() => onChange(option)}
accessibilityRole="radio"
accessibilityState={{ selected }}
hitSlop={4}
style={[styles.chip, selected && styles.chipOn]}
>
<Text style={[styles.chipText, selected && styles.chipTextOn]}>{text}</Text>
</Pressable>
);
})}
</View>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
screen: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
title: { ...typography.h3, color: c.text, flex: 1 },
closeBtn: { padding: 6, borderRadius: radius.md },
body: { flex: 1 },
bodyContent: { padding: spacing.lg, gap: spacing.xl, width: '100%', maxWidth: 640, alignSelf: 'center' },
field: { gap: spacing.sm },
label: { ...typography.bodyMedium, color: c.text },
text: { ...typography.body, color: c.text },
textStrong: { ...typography.bodyMedium, color: c.text, flexShrink: 1 },
small: { ...typography.caption, color: c.textMuted },
errorSmall: { ...typography.caption, color: c.error },
inline: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
input: {
minHeight: 44,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
color: c.text,
backgroundColor: c.surface,
...typography.body,
},
box: { borderWidth: 1, borderColor: c.border, borderRadius: radius.md, overflow: 'hidden' },
candidate: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingHorizontal: spacing.md, minHeight: 48, paddingVertical: spacing.sm },
candidatePad: { paddingHorizontal: spacing.md, paddingVertical: spacing.sm },
rowBorder: { borderTopWidth: 1, borderTopColor: c.border },
pressed: { backgroundColor: c.muted },
pickedCard: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
principalText: { flex: 1, minWidth: 0 },
groupTag: { ...typography.caption, color: c.textMuted, backgroundColor: c.muted, paddingHorizontal: 4, borderRadius: radius.sm },
failRow: { paddingHorizontal: spacing.md, paddingVertical: spacing.sm, gap: 2 },
choice: { gap: spacing.xs },
choiceLabel: { ...typography.body, color: c.textMuted },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs },
chip: {
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
},
chipOn: { borderColor: c.primary, backgroundColor: c.muted },
chipText: { ...typography.caption, color: c.textMuted },
chipTextOn: { color: c.text },
radioRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm, paddingVertical: spacing.xs },
radio: {
width: 20, height: 20, borderRadius: 10,
borderWidth: 2, borderColor: c.border,
alignItems: 'center', justifyContent: 'center',
marginTop: 1,
},
radioOn: { borderColor: c.primary },
radioDot: { width: 10, height: 10, borderRadius: 5, backgroundColor: c.primary },
radioText: { flex: 1, gap: 2 },
note: { flexDirection: 'row', gap: spacing.sm, backgroundColor: c.muted, borderRadius: radius.md, padding: spacing.md },
noteText: { flex: 1 },
footer: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderTopWidth: 1,
borderTopColor: c.border,
},
});
}
@@ -0,0 +1,302 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, Alert, StyleSheet, Text, View } from 'react-native';
import { UserPlus } from '../icons';
import Button from '../Button';
import SenderAvatar from '../SenderAvatar';
import { SettingsSection } from './settings-section';
import { DelegateDialog } from './DelegateDialog';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { useAuthStore } from '../../stores/auth-store';
import { useAccountStore } from '../../stores/account-store';
import { toast } from '../../stores/toast-store';
import { jmapClient } from '../../api/jmap-client';
import { listPrincipals, type PrincipalListing } from '../../api/files';
import { applyShareChanges, getShareTargets, sharesSeenWithKeywords } from '../../api/delegation';
import type { Principal } from '../../api/types';
import { servedLoginOf } from '../../lib/draft-login';
import { loginMovedFrom } from '../../lib/compose-login';
import type { ShareChange, ShareChangeResult, ShareTarget } from '../../lib/delegation/share-batch';
import type { ShareKind } from '../../lib/delegation/share-rights';
import {
allowedLevels,
buildRevokeChanges,
changeStartFor,
failedChanges,
summarizeDelegates,
type DelegateSummary,
} from '../../lib/delegation/delegation';
import { describeSummary, principalDisplay, resolvePrincipal } from '../../lib/delegation/display';
import {
pruneRememberedPrincipals,
rememberPrincipals,
rememberedPrincipals,
} from '../../lib/delegation/known-principals';
import { delegationPolicy } from '../../lib/delegation/policy';
// Ported from the webmail's components/settings/delegates/delegates-settings.tsx:
// Settings > Sharing > Delegates. Give someone access to mail, calendars and
// contacts in one step, and see and take back who has access to what. The
// admin's hiding, rights limit and app gates come from lib/delegation/policy
// (nothing is limited until the app has the org policy).
// Names kept for a login go with it: removed from the app, or everyone signed out.
useAccountStore.subscribe((state, prev) => {
if (state.accounts !== prev.accounts) void pruneRememberedPrincipals(state.accounts.map((a) => a.id));
});
type DialogState = { mode: 'add' } | { mode: 'change'; summary: DelegateSummary } | null;
export function DelegateSettings() {
const c = useColors();
const styles = useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const session = useAuthStore((s) => s.session);
const accountKey = useAuthStore((s) => s.activeAccountId) ?? '';
const username = useAuthStore((s) => s.username);
const policy = delegationPolicy();
const levels = useMemo(() => allowedLevels(policy.allowedValues), [policy.allowedValues]);
const { calendarsEnabled, contactsEnabled, filesEnabled } = policy;
const [targets, setTargets] = useState<ShareTarget[] | null>(null);
const [failedKinds, setFailedKinds] = useState<ShareKind[]>([]);
const [directory, setDirectory] = useState<PrincipalListing | null>(null);
const [remembered, setRemembered] = useState<Map<string, Principal>>(() => new Map());
const [dialog, setDialog] = useState<DialogState>(null);
const [removingId, setRemovingId] = useState<string | null>(null);
// The login the page was read in: changes are sent only while it is active.
const ownerRef = useRef<string | null>(null);
// Each read is numbered; an answer that arrives after a newer read started
// (another account) is dropped instead of shown.
const readSeq = useRef(0);
const reload = useCallback(async () => {
const seq = ++readSeq.current;
const owner = servedLoginOf(jmapClient);
const result = await getShareTargets({ calendars: calendarsEnabled, contacts: contactsEnabled, files: filesEnabled });
if (seq !== readSeq.current || loginMovedFrom(owner)) return;
ownerRef.current = owner;
setTargets(result.targets);
setFailedKinds(result.failed);
}, [calendarsEnabled, contactsEnabled, filesEnabled]);
useEffect(() => {
if (!session || policy.hidden) return undefined;
let cancelled = false;
const reads = readSeq;
setTargets(null);
setFailedKinds([]);
setDirectory(null);
setDialog(null);
void rememberedPrincipals(accountKey).then((map) => {
if (!cancelled) setRemembered(map);
});
void reload();
void listPrincipals().then((listing) => {
if (!cancelled) setDirectory(listing);
});
return () => {
cancelled = true;
reads.current++;
};
}, [session, accountKey, policy.hidden, reload]);
const ownId = session ? jmapClient.accountId : '';
const ownIds = useMemo(() => (ownId ? [ownId] : []), [ownId]);
const ownEmails = useMemo(() => {
const emails = new Set<string>();
if (username?.includes('@')) emails.add(username);
for (const p of directory?.principals ?? []) if (ownIds.includes(p.id) && p.email) emails.add(p.email);
return [...emails];
}, [username, directory, ownIds]);
const summaries = useMemo(() => (targets ? summarizeDelegates(targets, ownIds) : []), [targets, ownIds]);
// An account shared with the user is in the session under the owner's address.
const sessionAccounts = useMemo(() => {
const map = new Map<string, string>();
for (const [id, info] of Object.entries(session?.accounts ?? {})) {
if (id !== ownId && info.name) map.set(id, info.name);
}
return map;
}, [session, ownId]);
const principalFor = useCallback(
(id: string) => resolvePrincipal(id, { directory: directory?.principals ?? [], remembered, sessionAccounts }),
[directory, remembered, sessionAccounts],
);
// Remember the names of the people who have access, so the list can name
// them when the directory no longer does. Never the whole directory.
useEffect(() => {
if (!accountKey || summaries.length === 0 || !directory) return;
const named = summaries
.map((s) => directory.principals.find((p) => p.id === s.principalId))
.filter((p): p is Principal => !!p);
if (named.length > 0) void rememberPrincipals(accountKey, named);
}, [accountKey, summaries, directory]);
if (policy.hidden || !session) return null;
// Change and Remove act on everything the person has; with a type unread,
// that isn't known, so they wait until a read succeeds.
const incomplete = failedKinds.length > 0;
const send = (changes: ShareChange[]): Promise<ShareChangeResult[]> => {
const owner = ownerRef.current;
return applyShareChanges(changes, () => !loginMovedFrom(owner));
};
const onApplied = (principal: Principal, results: ShareChangeResult[]) => {
void rememberPrincipals(accountKey, [principal]).then(() => rememberedPrincipals(accountKey)).then(setRemembered);
if (failedChanges(results).length === 0) {
toast.success(dialog?.mode === 'change'
? t('delegates.updated', 'Access updated')
: t('delegates.applied', '{name} now has access', { name: principalDisplay(principal).name }));
}
void reload();
};
const nameOf = (id: string) => {
const principal = principalFor(id);
return principal ? principalDisplay(principal).name : t('delegates.unknown_person', 'Unknown person');
};
const remove = async (summary: DelegateSummary) => {
if (!targets || incomplete) return;
setRemovingId(summary.principalId);
try {
const results = await send(buildRevokeChanges(targets, summary.principalId));
const failed = failedChanges(results);
if (failed.length === 0) toast.success(t('delegates.removed', 'Access removed'));
else toast.error(t('delegates.remove_partial', 'These still have access: {items}', { items: failed.map((f) => f.change.name).join(', ') }));
await reload();
} finally {
setRemovingId(null);
}
};
const confirmRemove = (summary: DelegateSummary) => {
const name = nameOf(summary.principalId);
Alert.alert(
t('delegates.remove_title', 'Remove access for {name}?', { name }),
t('delegates.remove_message', '{name} loses access to everything you shared from your own account: mail, calendars, contacts and files.', { name }),
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{ text: t('delegates.remove', 'Remove access'), style: 'destructive', onPress: () => void remove(summary) },
],
);
};
return (
<SettingsSection
title={t('delegates.title', 'Delegates')}
description={t('delegates.description', 'Give someone access to your mail, calendars and contacts in one step, and see who has access to what.')}
>
<View style={styles.giveRow}>
<Button
onPress={() => setDialog({ mode: 'add' })}
disabled={!targets}
icon={<UserPlus size={16} color={c.primaryForeground} />}
>
{t('delegates.give_access', 'Give access')}
</Button>
</View>
<View style={styles.block}>
<Text style={styles.heading}>{t('delegates.who_has_access', 'Who has access')}</Text>
{incomplete && (
<View style={styles.inline} accessibilityLiveRegion="polite">
<Text style={styles.errorSmall}>
{t('delegates.read_failed', "Some of your sharing couldn't be read, so Change and Remove access wait until it can.")}
</Text>
<Button variant="outline" size="sm" onPress={() => void reload()}>{t('delegates.retry', 'Try again')}</Button>
</View>
)}
{targets === null ? (
<View style={styles.inline}>
<ActivityIndicator size="small" color={c.textMuted} />
<Text style={styles.muted}>{t('delegates.loading', 'Loading…')}</Text>
</View>
) : summaries.length === 0 ? (
<Text style={styles.muted}>{t('delegates.nobody', 'Nobody has access to your mail, calendars, contacts or files.')}</Text>
) : (
<View style={styles.list}>
{summaries.map((summary, index) => {
const principal = principalFor(summary.principalId);
const display = principal
? principalDisplay(principal)
: { name: t('delegates.unknown_person', 'Unknown person'), detail: summary.principalId };
const busy = removingId === summary.principalId;
return (
<View key={summary.principalId} style={[styles.row, index > 0 && styles.rowBorder]}>
<View style={styles.rowMain}>
<SenderAvatar name={principal?.name ?? ''} email={principal?.email ?? ''} size={32} disableImages />
<View style={styles.rowText}>
<View style={styles.inline}>
<Text style={styles.name} numberOfLines={1}>{display.name}</Text>
{principal?.type === 'group' && <Text style={styles.groupTag}>{t('delegates.group', 'Group')}</Text>}
</View>
{display.detail ? <Text style={styles.small} numberOfLines={1}>{display.detail}</Text> : null}
<Text style={styles.small}>{describeSummary(summary, t)}</Text>
</View>
</View>
<View style={styles.rowButtons}>
<Button variant="outline" size="sm" onPress={() => setDialog({ mode: 'change', summary })} disabled={busy || incomplete}>
{t('delegates.change', 'Change')}
</Button>
<Button variant="outline" size="sm" onPress={() => confirmRemove(summary)} disabled={incomplete} loading={busy}>
{t('delegates.remove', 'Remove access')}
</Button>
</View>
</View>
);
})}
</View>
)}
</View>
{dialog && targets && (
<DelegateDialog
targets={targets}
directory={directory}
ownIds={ownIds}
ownEmails={ownEmails}
existingIds={new Set(summaries.map((s) => s.principalId))}
initial={dialog.mode === 'change'
? {
principal: principalFor(dialog.summary.principalId)
?? { id: dialog.summary.principalId, name: t('delegates.unknown_person', 'Unknown person'), type: 'individual' },
...changeStartFor(targets, dialog.summary.principalId, levels),
}
: undefined}
levels={levels}
levelsRestricted={policy.allowedValues !== null && levels.length < 2}
seenSetsKeywords={sharesSeenWithKeywords()}
apply={send}
onApplied={onApplied}
onClose={() => setDialog(null)}
/>
)}
</SettingsSection>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
giveRow: { flexDirection: 'row', marginTop: spacing.sm },
block: { gap: spacing.sm, marginTop: spacing.lg },
heading: { ...typography.bodyMedium, color: c.text },
inline: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: spacing.sm },
muted: { ...typography.body, color: c.textMuted },
small: { ...typography.caption, color: c.textMuted },
errorSmall: { ...typography.caption, color: c.error, flexShrink: 1 },
list: { borderWidth: 1, borderColor: c.border, borderRadius: radius.md, overflow: 'hidden' },
row: { gap: spacing.sm, paddingHorizontal: spacing.md, paddingVertical: spacing.md },
rowBorder: { borderTopWidth: 1, borderTopColor: c.border },
rowMain: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
rowText: { flex: 1, minWidth: 0, gap: 2 },
name: { ...typography.bodyMedium, color: c.text, flexShrink: 1 },
groupTag: { ...typography.caption, color: c.textMuted, backgroundColor: c.muted, paddingHorizontal: 4, borderRadius: radius.sm },
rowButtons: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, paddingLeft: 44 },
});
}
@@ -2,15 +2,21 @@ import { describe, expect, it } from 'vitest';
import { getDictionary, translate } from '../../i18n';
import { buildSettingsSearchIndex, normalizeSettingsQuery, tabMatchesQuery } from '../settings-search';
// Settings search finds the Stalwart 1.0 pages (webmail lib/settings-search)
// by the words people use for them.
// Settings search finds the two Stalwart 1.0 pages (webmail lib/settings-search
// `sharing` and `recovery`) by the words people use for them.
const en = getDictionary('en');
const tEn = (key: string) => translate('en', key);
const index = buildSettingsSearchIndex(en, tEn);
const finds = (tab: 'recovery', label: string, q: string) => tabMatchesQuery(index, tab, label, normalizeSettingsQuery(q));
const finds = (tab: 'sharing' | 'recovery', label: string, q: string) => tabMatchesQuery(index, tab, label, normalizeSettingsQuery(q));
describe('settings search for Delegates and Recover deleted items', () => {
it('finds Delegates under Sharing', () => {
expect(finds('sharing', 'Sharing', 'delegate')).toBe(true);
expect(finds('sharing', 'Sharing', 'Delegates')).toBe(true);
expect(finds('sharing', 'Sharing', 'undelete')).toBe(false);
});
describe('settings search for Recover deleted items', () => {
it('finds Recover deleted items', () => {
expect(finds('recovery', 'Recover deleted items', 'undelete')).toBe(true);
expect(finds('recovery', 'Recover deleted items', 'emptied')).toBe(true);
@@ -0,0 +1,235 @@
import { describe, expect, it } from 'vitest';
import {
allowedLevels,
buildGrantChanges,
buildRevokeChanges,
changeStartFor,
selectTargets,
summarizeDelegates,
} from '../delegation';
import type { ShareTarget } from '../share-batch';
import { ADDRESS_BOOK_PRESETS, CALENDAR_PRESETS, FILE_PRESETS, MAILBOX_PRESETS } from '../share-rights';
// Ported from the webmail's lib/delegation/__tests__/delegation.test.ts.
const CAROL = 'ck';
const BOB = 'cj';
// A 1.0-style read grant of a folder: keywords come back with seen.
const MAILBOX_READ_STORED = { ...MAILBOX_PRESETS.read, maySetKeywords: true };
function targets(): ShareTarget[] {
return [
{ kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', role: 'inbox', shareWith: {} },
{ kind: 'mailbox', accountId: 'ci', id: 'b', name: 'Deleted Items', role: 'trash', shareWith: {} },
{ kind: 'mailbox', accountId: 'ci', id: 'f', name: 'Projects', role: null, shareWith: { [BOB]: MAILBOX_PRESETS.readWrite } },
{ kind: 'calendar', accountId: 'ci', id: 'c1', name: 'Work', isDefault: false, shareWith: {} },
{ kind: 'calendar', accountId: 'ci', id: 'c2', name: 'Personal', isDefault: true, shareWith: {} },
{ kind: 'addressBook', accountId: 'ci', id: 'ab', name: 'Contacts', isDefault: true, shareWith: {} },
{ kind: 'addressBook', accountId: 'ci', id: 'ts', name: 'Trusted Senders', isDefault: false, shareWith: {} },
{ kind: 'file', accountId: 'ci', id: 'n1', name: 'report.pdf', shareWith: { [CAROL]: FILE_PRESETS.read } },
];
}
const ids = (list: { kind: string; id: string }[]) => list.map((t) => `${t.kind}:${t.id}`);
describe('selectTargets', () => {
it('takes every folder, the default calendar and the default address book by default', () => {
expect(ids(selectTargets(targets(), { mail: 'all', calendars: 'default', addressBooks: 'default' }))).toEqual([
'mailbox:a', 'mailbox:b', 'mailbox:f', 'calendar:c2', 'addressBook:ab',
]);
});
it('takes only the Inbox, every calendar, and no address book', () => {
expect(ids(selectTargets(targets(), { mail: 'inbox', calendars: 'all', addressBooks: 'none' }))).toEqual([
'mailbox:a', 'calendar:c1', 'calendar:c2',
]);
});
it('leaves out the trusted senders list and files even with "all"', () => {
const picked = ids(selectTargets(targets(), { mail: 'none', calendars: 'none', addressBooks: 'all' }));
expect(picked).toEqual(['addressBook:ab']);
});
it('uses the first calendar when none is marked default', () => {
const list = targets().map((t) => (t.kind === 'calendar' ? { ...t, isDefault: false } : t));
expect(ids(selectTargets(list, { mail: 'none', calendars: 'default', addressBooks: 'none' }))).toEqual(['calendar:c1']);
});
});
describe('buildGrantChanges', () => {
it('builds read patches per type', () => {
const changes = buildGrantChanges(targets(), CAROL, { mail: 'inbox', calendars: 'default', addressBooks: 'default' }, 'read');
expect(changes).toEqual([
{ kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', principalId: CAROL, rights: MAILBOX_PRESETS.read },
{ kind: 'calendar', accountId: 'ci', id: 'c2', name: 'Personal', principalId: CAROL, rights: CALENDAR_PRESETS.read },
{ kind: 'addressBook', accountId: 'ci', id: 'ab', name: 'Contacts', principalId: CAROL, rights: ADDRESS_BOOK_PRESETS.read },
]);
});
it('builds read and write patches that never let a delegate send or re-share', () => {
const changes = buildGrantChanges(targets(), CAROL, { mail: 'inbox', calendars: 'default', addressBooks: 'default' }, 'write');
const [mail, calendar, book] = changes.map((c) => c.rights as unknown as Record<string, boolean>);
expect(mail).toMatchObject({ mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetKeywords: true, maySubmit: false, mayShare: false });
expect(calendar).toMatchObject({ mayReadItems: true, mayWriteAll: true, mayShare: false });
expect(book).toMatchObject({ mayRead: true, mayWrite: true, mayShare: false });
});
it('takes away what a change leaves out, but never files', () => {
const list = targets();
list[2].shareWith[CAROL] = MAILBOX_READ_STORED; // Projects
list[3].shareWith[CAROL] = CALENDAR_PRESETS.read; // Work
const changes = buildGrantChanges(list, CAROL, { mail: 'inbox', calendars: 'default', addressBooks: 'none' }, 'read', { replace: true });
expect(changes.filter((c) => c.rights === null).map((c) => `${c.kind}:${c.id}`)).toEqual(['mailbox:f', 'calendar:c1']);
expect(changes.some((c) => c.kind === 'file')).toBe(false);
});
it('takes nothing away without replace', () => {
const list = targets();
list[3].shareWith[CAROL] = CALENDAR_PRESETS.read;
const changes = buildGrantChanges(list, CAROL, { mail: 'inbox', calendars: 'none', addressBooks: 'none' }, 'read');
expect(changes.every((c) => c.rights !== null)).toBe(true);
});
});
describe('buildRevokeChanges', () => {
it('removes the person from everything, files included, and leaves others alone', () => {
const list = targets();
list[0].shareWith[CAROL] = MAILBOX_READ_STORED;
list[4].shareWith[CAROL] = CALENDAR_PRESETS.readWrite;
const changes = buildRevokeChanges(list, CAROL);
expect(changes.map((c) => [`${c.kind}:${c.id}`, c.principalId, c.rights])).toEqual([
['mailbox:a', CAROL, null],
['calendar:c2', CAROL, null],
['file:n1', CAROL, null],
]);
});
});
describe('summarizeDelegates', () => {
it('groups grants by person and counts each type', () => {
const list = targets();
list[0].shareWith[CAROL] = MAILBOX_READ_STORED;
list[1].shareWith[CAROL] = MAILBOX_READ_STORED;
list[4].shareWith[CAROL] = CALENDAR_PRESETS.read;
const summary = summarizeDelegates(list);
expect(summary.map((s) => s.principalId)).toEqual([CAROL, BOB]);
const carol = summary[0];
expect(carol.level).toBe('read');
expect(carol.mail).toEqual({ shared: 2, total: 3, inbox: true });
expect(carol.calendars).toEqual({ shared: 1, total: 2 });
expect(carol.addressBooks).toEqual({ shared: 0, total: 1 });
expect(carol.files).toBe(1);
expect(summary[1]).toMatchObject({ level: 'write', mail: { shared: 1, total: 3, inbox: false } });
});
it('says "mixed" when read and write grants meet, and leaves out excluded ids', () => {
const list = targets();
list[0].shareWith[CAROL] = MAILBOX_PRESETS.readWrite;
const summary = summarizeDelegates(list, [BOB]);
expect(summary.map((s) => [s.principalId, s.level])).toEqual([[CAROL, 'mixed']]);
});
it('gives a free/busy-only calendar no level', () => {
const list = targets().filter((t) => t.kind !== 'file' && t.id !== 'f');
list.find((t) => t.id === 'c1')!.shareWith[CAROL] = CALENDAR_PRESETS.freeBusy;
expect(summarizeDelegates(list)[0].level).toBeNull();
});
});
describe('changeStartFor', () => {
const withCarol = (list: ShareTarget[], ids: string[], rights: object) => {
for (const t of list) if (ids.includes(t.id)) t.shareWith[CAROL] = rights as never;
return list;
};
it('starts from exact matches: every folder, the default calendar, read', () => {
const list = withCarol(targets(), ['a', 'b', 'f'], MAILBOX_READ_STORED);
withCarol(list, ['c2'], CALENDAR_PRESETS.read);
expect(changeStartFor(list, CAROL)).toEqual({ scope: { mail: 'all', calendars: 'default', addressBooks: 'none' }, level: 'read' });
});
it('starts from Inbox only and all calendars, read and write', () => {
const list = withCarol(targets(), ['a'], MAILBOX_PRESETS.readWrite);
withCarol(list, ['c1', 'c2'], CALENDAR_PRESETS.readWrite);
expect(changeStartFor(list, CAROL)).toEqual({ scope: { mail: 'inbox', calendars: 'all', addressBooks: 'none' }, level: 'write' });
});
it('keeps mail as it is when a folder was added after "all", and the patch leaves mail alone', () => {
// Inbox and Trash shared, Projects created later.
const list = withCarol(targets(), ['a', 'b'], MAILBOX_READ_STORED);
const start = changeStartFor(list, CAROL);
expect(start.scope.mail).toBe('keep');
const changes = buildGrantChanges(list, CAROL, { ...start.scope, calendars: 'default' }, 'read', { replace: true });
expect(changes.filter((c) => c.kind === 'mailbox')).toEqual([]);
});
it('keeps one shared non-Inbox folder as it is instead of turning it into the Inbox', () => {
const list = withCarol(targets(), ['b'], MAILBOX_READ_STORED);
expect(changeStartFor(list, CAROL).scope.mail).toBe('keep');
});
it('keeps a non-default calendar as it is', () => {
const list = withCarol(targets(), ['c1'], CALENDAR_PRESETS.read);
const start = changeStartFor(list, CAROL);
expect(start.scope.calendars).toBe('keep');
expect(buildGrantChanges(list, CAROL, start.scope, start.level, { replace: true })).toEqual([]);
});
it('keeps free/busy only as it is, until the user picks a scope', () => {
const list = withCarol(targets(), ['c2'], CALENDAR_PRESETS.freeBusy);
const start = changeStartFor(list, CAROL);
expect(start.scope.calendars).toBe('keep');
expect(start.level).toBe('keep');
expect(buildGrantChanges(list, CAROL, start.scope, start.level, { replace: true })).toEqual([]);
// Picking "Default calendar" with read replaces it.
const picked = buildGrantChanges(list, CAROL, { ...start.scope, calendars: 'default' }, 'read', { replace: true });
expect(picked.map((c) => [c.id, c.rights])).toEqual([['c2', CALENDAR_PRESETS.read]]);
});
it('keeps manager rights untouched', () => {
const list = withCarol(targets(), ['a', 'b', 'f'], MAILBOX_PRESETS.manager);
const start = changeStartFor(list, CAROL);
expect(start).toEqual({ scope: { mail: 'keep', calendars: 'none', addressBooks: 'none' }, level: 'keep' });
expect(buildGrantChanges(list, CAROL, start.scope, start.level, { replace: true })).toEqual([]);
});
it('keeps mixed levels per type: grants stay, anything added gets that type\'s level', () => {
const list = withCarol(targets(), ['a', 'b', 'f'], MAILBOX_PRESETS.readWrite);
withCarol(list, ['c2'], CALENDAR_PRESETS.read);
const start = changeStartFor(list, CAROL);
expect(start).toEqual({ scope: { mail: 'all', calendars: 'default', addressBooks: 'none' }, level: 'keep' });
expect(buildGrantChanges(list, CAROL, start.scope, start.level, { replace: true })).toEqual([]);
// Widening calendars to all adds Work at the calendars' level (read), not mail's (write).
const wider = buildGrantChanges(list, CAROL, { ...start.scope, calendars: 'all' }, 'keep', { replace: true });
expect(wider.map((c) => [c.id, c.rights])).toEqual([['c1', CALENDAR_PRESETS.read]]);
});
it('counts a shared trusted senders book as something the dialog cannot express', () => {
const list = withCarol(targets(), ['ab', 'ts'], ADDRESS_BOOK_PRESETS.read);
expect(changeStartFor(list, CAROL).scope.addressBooks).toBe('keep');
});
it('clamps a write delegate to read when the admin allows only read', () => {
const list = withCarol(targets(), ['a'], MAILBOX_PRESETS.readWrite);
expect(changeStartFor(list, CAROL, ['read'])).toEqual({ scope: { mail: 'inbox', calendars: 'none', addressBooks: 'none' }, level: 'read' });
// Mixed with write starts at read too, so nothing keeps write by default.
withCarol(list, ['c2'], CALENDAR_PRESETS.read);
expect(changeStartFor(list, CAROL, ['read']).level).toBe('read');
});
});
describe('allowedLevels', () => {
it('offers both without a restriction', () => {
expect(allowedLevels(null)).toEqual(['read', 'write']);
});
it('offers only what the admin allows', () => {
expect(allowedLevels(['read'])).toEqual(['read']);
expect(allowedLevels(['write'])).toEqual(['write']);
});
it('falls back to read when the list names neither', () => {
expect(allowedLevels(['manager'])).toEqual(['read']);
});
});
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { translate } from '../../../i18n';
import {
canLookUpAddress,
delegateCandidates,
describeSummary,
lookupResult,
principalDisplay,
resolvePrincipal,
} from '../display';
import { summarizeDelegates } from '../delegation';
import type { ShareTarget } from '../share-batch';
import { CALENDAR_PRESETS, FILE_PRESETS, MAILBOX_PRESETS } from '../share-rights';
import type { Principal } from '../../../api/types';
const tEn = (key: string, fallback?: string, params?: Record<string, string | number>) => translate('en', key, fallback, params);
const bob: Principal = { id: 'cj', type: 'individual', name: 'Bob', email: 'bob@example.org' };
const team: Principal = { id: 'cg', type: 'group', name: 'team@example.org', email: 'team@example.org' };
describe('principalDisplay', () => {
it('names a person and shows their address under the name', () => {
expect(principalDisplay(bob)).toEqual({ name: 'Bob', detail: 'bob@example.org' });
expect(principalDisplay(team)).toEqual({ name: 'team@example.org', detail: null });
expect(principalDisplay({ id: 'x', name: '', email: null, description: 'Room 1' })).toEqual({ name: 'x', detail: 'Room 1' });
});
});
describe('resolvePrincipal', () => {
it('asks the directory, then the names kept on the device, then the shared accounts in the session', () => {
const remembered = new Map([['ck', { id: 'ck', type: 'individual' as const, name: 'Carol', email: 'carol@example.org' }]]);
const sessionAccounts = new Map([['cm', 'mallory@example.org']]);
const sources = { directory: [bob], remembered, sessionAccounts };
expect(resolvePrincipal('cj', sources)?.name).toBe('Bob');
expect(resolvePrincipal('ck', sources)?.name).toBe('Carol');
expect(resolvePrincipal('cm', sources)).toEqual({ id: 'cm', name: 'mallory@example.org', email: 'mallory@example.org', type: 'individual' });
expect(resolvePrincipal('zz', sources)).toBeNull();
});
});
describe('describeSummary', () => {
const targets: ShareTarget[] = [
{ kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', role: 'inbox', shareWith: { cj: MAILBOX_PRESETS.read, ck: MAILBOX_PRESETS.readWrite } },
{ kind: 'mailbox', accountId: 'ci', id: 'b', name: 'Sent', role: 'sent', shareWith: { ck: MAILBOX_PRESETS.readWrite } },
{ kind: 'calendar', accountId: 'ci', id: 'c', name: 'Work', isDefault: true, shareWith: { ck: CALENDAR_PRESETS.read } },
{ kind: 'file', accountId: 'ci', id: 'n', name: 'a.pdf', shareWith: { cf: FILE_PRESETS.read } },
];
const [bobSummary, carol, filesOnly] = summarizeDelegates(targets);
it('says what each person has, in the words of the webmail catalog', () => {
expect(describeSummary(bobSummary, tEn)).toBe('Inbox · Read');
expect(describeSummary(carol, tEn)).toBe('All mail folders · 1 calendar · Different rights');
expect(describeSummary(filesOnly, tEn)).toBe('1 file or folder · Read');
});
});
describe('the person picker', () => {
it('never offers the user, and searches names and addresses', () => {
const me: Principal = { id: 'ci', type: 'individual', name: 'Me', email: 'me@example.org' };
expect(delegateCandidates([me, bob, team], ['ci'], '').map((p) => p.id)).toEqual(['cj', 'cg']);
expect(delegateCandidates([me, bob, team], ['ci'], 'TEAM').map((p) => p.id)).toEqual(['cg']);
});
it('offers a lookup only for an address nobody listed has', () => {
expect(canLookUpAddress('dave@example.org', [bob])).toBe(true);
expect(canLookUpAddress('Bob@example.org', [bob])).toBe(false);
expect(canLookUpAddress('dave', [])).toBe(false);
});
it('turns the lookup into what the dialog shows', () => {
expect(lookupResult('bob@example.org', { principal: bob, status: 'ok' }, ['ci'])).toEqual({ lookup: { state: 'idle' }, picked: bob });
expect(lookupResult('me@example.org', { principal: { ...bob, id: 'ci' }, status: 'ok' }, ['ci']).lookup).toEqual({ state: 'self' });
expect(lookupResult('x@example.org', { principal: null, status: 'forbidden' }, ['ci']).lookup).toEqual({ state: 'directoryOff' });
expect(lookupResult('x@example.org', { principal: null, status: 'ok' }, ['ci']).lookup).toEqual({ state: 'notFound', address: 'x@example.org' });
expect(lookupResult('x@example.org', { principal: null, status: 'error' }, ['ci']).lookup).toEqual({ state: 'failed' });
});
});
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it } from 'vitest';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
DELEGATE_NAMES_KEY,
pruneRememberedPrincipals,
rememberPrincipals,
rememberedPrincipals,
} from '../known-principals';
import type { Principal } from '../../../api/types';
// Ported from the webmail's lib/delegation/__tests__/known-principals.test.ts
// (AsyncStorage instead of localStorage; logins that are gone are pruned).
const person = (id: string): Principal => ({ id, type: 'individual', name: `${id}@example.org`, email: `${id}@example.org` });
beforeEach(async () => {
await AsyncStorage.clear();
});
describe('known principals', () => {
it('keeps names per login', async () => {
await rememberPrincipals('acc-1', [person('ck')]);
await rememberPrincipals('acc-2', [person('cj')]);
expect([...(await rememberedPrincipals('acc-1')).keys()]).toEqual(['ck']);
expect([...(await rememberedPrincipals('acc-2')).keys()]).toEqual(['cj']);
});
it('keeps both of two remembers sent at once', async () => {
await Promise.all([rememberPrincipals('acc-1', [person('ck')]), rememberPrincipals('acc-1', [person('cj')])]);
expect([...(await rememberedPrincipals('acc-1')).keys()].sort()).toEqual(['cj', 'ck']);
});
it('forgets the logins that are gone and leaves the others', async () => {
await rememberPrincipals('acc-1', [person('ck')]);
await rememberPrincipals('acc-2', [person('cj')]);
await pruneRememberedPrincipals(['acc-2']);
expect((await rememberedPrincipals('acc-1')).size).toBe(0);
expect([...(await rememberedPrincipals('acc-2')).keys()]).toEqual(['cj']);
});
it('leaves no key behind when everyone signed out', async () => {
await rememberPrincipals('acc-1', [person('ck')]);
await pruneRememberedPrincipals([]);
expect(await AsyncStorage.getItem(DELEGATE_NAMES_KEY)).toBeNull();
});
it('keeps at most 200 per login, dropping the oldest', async () => {
await rememberPrincipals('acc-1', Array.from({ length: 205 }, (_, i) => person(`p${i}`)));
const ids = [...(await rememberedPrincipals('acc-1')).keys()];
expect(ids).toHaveLength(200);
expect(ids[0]).toBe('p5');
});
});
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { buildShareSetCalls, parseShareTargets, readShareSetResults, type ShareChange } from '../share-batch';
import { CALENDAR_PRESETS, FILE_PRESETS, MAILBOX_PRESETS } from '../share-rights';
// Ported from the pure part of the webmail's lib/__tests__/jmap-share-batch.test.ts.
const change = (kind: ShareChange['kind'], id: string, rights: ShareChange['rights'], principalId = 'ck', accountId = 'ci'): ShareChange =>
({ kind, accountId, id, name: id, principalId, rights });
describe('buildShareSetCalls', () => {
it('puts each type in one call with a shareWith patch per item', () => {
const { calls } = buildShareSetCalls([
change('mailbox', 'a', MAILBOX_PRESETS.read),
change('mailbox', 'b', MAILBOX_PRESETS.read),
change('calendar', 'c2', CALENDAR_PRESETS.read),
change('file', 'n1', null),
], { maxObjectsInSet: 500 });
expect(calls).toEqual([
['Mailbox/set', { accountId: 'ci', update: { a: { 'shareWith/ck': MAILBOX_PRESETS.read }, b: { 'shareWith/ck': MAILBOX_PRESETS.read } } }, 's0'],
['Calendar/set', { accountId: 'ci', update: { c2: { 'shareWith/ck': CALENDAR_PRESETS.read } } }, 's1'],
['FileNode/set', { accountId: 'ci', update: { n1: { 'shareWith/ck': null } } }, 's2'],
]);
});
it('splits a type past maxObjectsInSet', () => {
const changes = ['a', 'b', 'c'].map((id) => change('mailbox', id, null));
const { calls, changesByCall } = buildShareSetCalls(changes, { maxObjectsInSet: 2 });
expect(calls.map(([, args]) => Object.keys(args.update as object))).toEqual([['a', 'b'], ['c']]);
expect(changesByCall.get('s1')!.map((c) => c.id)).toEqual(['c']);
});
it('converts rights through wireRights, but never a revoke', () => {
const { calls } = buildShareSetCalls([change('file', 'n1', FILE_PRESETS.read), change('file', 'n2', null)], {
maxObjectsInSet: 500,
wireRights: () => ({ mayRead: true, mayWrite: false, mayShare: false }),
});
expect(calls[0][1].update).toEqual({ n1: { 'shareWith/ck': { mayRead: true, mayWrite: false, mayShare: false } }, n2: { 'shareWith/ck': null } });
});
});
describe('readShareSetResults', () => {
it('reports each item: updated, refused with the reason, or the whole call failing', () => {
const changes = [
change('mailbox', 'a', MAILBOX_PRESETS.read),
change('mailbox', 'b', MAILBOX_PRESETS.read),
change('mailbox', 'z', MAILBOX_PRESETS.read),
change('calendar', 'c2', CALENDAR_PRESETS.read),
];
const { calls, changesByCall } = buildShareSetCalls(changes, { maxObjectsInSet: 500 });
const results = readShareSetResults(calls, changesByCall, [
['Mailbox/set', { updated: { a: null }, notUpdated: { b: { type: 'forbidden', description: 'You may not share this folder' } } }, 's0'],
['error', { type: 'forbidden' }, 's1'],
]);
expect(results.map((r) => [r.change.id, r.ok, r.error])).toEqual([
['a', true, undefined],
['b', false, 'You may not share this folder'],
['z', false, undefined],
['c2', false, 'forbidden'],
]);
});
});
describe('parseShareTargets', () => {
it('reads every type and names the ones whose call failed', () => {
const { targets, failed } = parseShareTargets([
['Mailbox/get', { list: [{ id: 'a', name: 'Inbox', role: 'inbox', parentId: null, shareWith: { ck: MAILBOX_PRESETS.read } }] }, 'm'],
['error', { type: 'serverFail' }, 'c'],
['AddressBook/get', { list: [{ id: 'ab', name: 'Contacts', isDefault: true, shareWith: null }] }, 'a'],
], { mailbox: 'ci', calendar: 'ci', addressBook: 'ci' });
expect(targets).toEqual([
{ kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', role: 'inbox', parentId: null, shareWith: { ck: MAILBOX_PRESETS.read } },
{ kind: 'addressBook', accountId: 'ci', id: 'ab', name: 'Contacts', isDefault: true, shareWith: {} },
]);
expect(failed).toEqual(['calendar']);
});
});
+257
View File
@@ -0,0 +1,257 @@
/**
* Delegates (Settings > Sharing; webmail lib/delegation/delegation.ts): give
* one person access to mail, calendars and contacts in one step, list who
* has access to what, and take it away everywhere. Pure functions over the
* user's shareable items (api/delegation getShareTargets); applyShareChanges
* sends what they build.
*
* Stalwart has no "send as" or "send on behalf" for a person's own account,
* so delegates get no submission right; the dialog says so.
*/
import type { ShareChange, ShareChangeResult, ShareTarget } from './share-batch';
import { accessLevelOf, accessRights, detectPreset, type AccessLevel, type ShareKind } from './share-rights';
/** `keep`: "Keep as it is", offered by Change when what the person has fits no other choice. */
export type MailScope = 'all' | 'inbox' | 'none' | 'keep';
export type CollectionScope = 'default' | 'all' | 'none' | 'keep';
export type LevelChoice = AccessLevel | 'keep';
/** What a delegate gets: which folders, calendars and address books. */
export interface DelegateScope {
mail: MailScope;
calendars: CollectionScope;
addressBooks: CollectionScope;
}
type DelegateKind = 'mailbox' | 'calendar' | 'addressBook';
const SCOPE_KEY: Record<DelegateKind, keyof DelegateScope> = { mailbox: 'mail', calendar: 'calendars', addressBook: 'addressBooks' };
const DELEGATE_KINDS: DelegateKind[] = ['mailbox', 'calendar', 'addressBook'];
export const DEFAULT_DELEGATE_SCOPE: DelegateScope = { mail: 'all', calendars: 'default', addressBooks: 'default' };
export const ACCESS_LEVELS: readonly AccessLevel[] = ['read', 'write'];
// Bulwark's own list of senders whose images load (stores/contacts-store,
// TRUSTED_SENDERS_BOOK_NAME). It is an address book on the server, but not
// one a delegate needs.
const TRUSTED_SENDERS_BOOK_NAME = 'Trusted Senders';
function ofKind(targets: ShareTarget[], kind: ShareKind): ShareTarget[] {
return targets.filter((t) => t.kind === kind);
}
function defaultOf(collections: ShareTarget[]): ShareTarget[] {
const flagged = collections.find((c) => c.isDefault);
return flagged ? [flagged] : collections.slice(0, 1);
}
function delegableAddressBooks(targets: ShareTarget[]): ShareTarget[] {
return ofKind(targets, 'addressBook').filter((b) => b.name !== TRUSTED_SENDERS_BOOK_NAME || b.isDefault);
}
/** The items of one type the dialog's choices are about. */
function itemsOfType(targets: ShareTarget[], kind: DelegateKind): ShareTarget[] {
return kind === 'addressBook' ? delegableAddressBooks(targets) : ofKind(targets, kind);
}
const isInbox = (t: ShareTarget) => t.role?.toLowerCase() === 'inbox';
/** The items one choice covers; null for "Keep as it is". */
function itemsFor(targets: ShareTarget[], kind: DelegateKind, choice: MailScope | CollectionScope): ShareTarget[] | null {
if (choice === 'keep') return null;
const items = itemsOfType(targets, kind);
if (choice === 'none') return [];
if (choice === 'all') return items;
if (choice === 'inbox') return items.filter(isInbox);
return defaultOf(items);
}
/** The items a scope covers. Files are never part of it, nor types left as they are. */
export function selectTargets(targets: ShareTarget[], scope: DelegateScope): ShareTarget[] {
return DELEGATE_KINDS.flatMap((kind) => itemsFor(targets, kind, scope[SCOPE_KEY[kind]]) ?? []);
}
function change(target: ShareTarget, principalId: string, rights: ShareChange['rights']): ShareChange {
return { kind: target.kind, accountId: target.accountId, id: target.id, name: target.name, principalId, rights };
}
/** read or write when every grant of one type has exactly that preset, otherwise null. */
function uniformLevel(items: ShareTarget[], kind: ShareKind, principalId: string): AccessLevel | null {
const presets = new Set(items.filter((t) => principalId in t.shareWith).map((t) => detectPreset(kind, t.shareWith[principalId])));
if (presets.size !== 1) return null;
const [preset] = [...presets];
return preset === 'read' ? 'read' : preset === 'readWrite' ? 'write' : null;
}
/**
* The changes that give `principalId` access to what `scope` covers at
* `level`. A type left as it is ("keep") gets no change at all. With
* `replace` (the "Change" of someone who already has access), the items of a
* chosen type outside the scope lose their access; shared files stay as they
* are, since the dialog doesn't offer them. With level "keep", items the
* person has keep their rights and items added get the rights the rest of
* that type has, or read.
*/
export function buildGrantChanges(
targets: ShareTarget[],
principalId: string,
scope: DelegateScope,
level: LevelChoice,
options: { replace?: boolean } = {},
): ShareChange[] {
const changes: ShareChange[] = [];
for (const kind of DELEGATE_KINDS) {
const chosen = itemsFor(targets, kind, scope[SCOPE_KEY[kind]]);
if (!chosen) continue;
const all = itemsOfType(targets, kind);
const typeLevel = uniformLevel(all, kind, principalId) ?? 'read';
const ids = new Set(chosen.map((t) => t.id));
for (const t of chosen) {
if (level === 'keep') {
if (principalId in t.shareWith) continue;
changes.push(change(t, principalId, accessRights(kind, typeLevel)));
} else {
changes.push(change(t, principalId, accessRights(kind, level)));
}
}
if (options.replace) {
for (const t of all) {
if (!ids.has(t.id) && principalId in t.shareWith) changes.push(change(t, principalId, null));
}
}
}
return changes;
}
/** The changes that take every grant of `principalId` away, files included. */
export function buildRevokeChanges(targets: ShareTarget[], principalId: string): ShareChange[] {
return targets.filter((t) => principalId in t.shareWith).map((t) => change(t, principalId, null));
}
export interface DelegateItem {
kind: ShareKind;
id: string;
name: string;
/** null: shared without read access (calendar free/busy only). */
level: AccessLevel | null;
}
export interface DelegateSummary {
principalId: string;
/** "mixed" when some items are read only and others writable; null when nothing is readable. */
level: AccessLevel | 'mixed' | null;
mail: { shared: number; total: number; inbox: boolean };
calendars: { shared: number; total: number };
addressBooks: { shared: number; total: number };
files: number;
items: DelegateItem[];
}
/**
* Everyone who has access to something of the user's, one entry per person
* or group, in the order they first appear. `excludeIds` leaves out the
* user's own principal, should a server list it.
*/
export function summarizeDelegates(targets: ShareTarget[], excludeIds: string[] = []): DelegateSummary[] {
const exclude = new Set(excludeIds);
const byPrincipal = new Map<string, DelegateItem[]>();
for (const target of targets) {
for (const [principalId, rights] of Object.entries(target.shareWith)) {
if (exclude.has(principalId) || !rights) continue;
const items = byPrincipal.get(principalId) ?? [];
items.push({ kind: target.kind, id: target.id, name: target.name, level: accessLevelOf(target.kind, rights) });
byPrincipal.set(principalId, items);
}
}
const totals = {
mailbox: ofKind(targets, 'mailbox').length,
calendar: ofKind(targets, 'calendar').length,
addressBook: delegableAddressBooks(targets).length,
};
const inboxIds = new Set(ofKind(targets, 'mailbox').filter((m) => m.role?.toLowerCase() === 'inbox').map((m) => m.id));
return [...byPrincipal.entries()].map(([principalId, items]) => {
const count = (kind: ShareKind) => items.filter((i) => i.kind === kind).length;
const levels = new Set(items.map((i) => i.level).filter((l): l is AccessLevel => l !== null));
return {
principalId,
level: levels.size === 0 ? null : levels.size > 1 ? 'mixed' : [...levels][0],
mail: { shared: count('mailbox'), total: totals.mailbox, inbox: items.some((i) => i.kind === 'mailbox' && inboxIds.has(i.id)) },
calendars: { shared: count('calendar'), total: totals.calendar },
addressBooks: { shared: count('addressBook'), total: totals.addressBook },
files: count('file'),
items,
};
});
}
export interface ChangeStart {
scope: DelegateScope;
level: LevelChoice;
}
/**
* The dialog's starting point for "Change". A type gets a choice only when
* the person's grants match it exactly (every folder for "all", exactly the
* Inbox for "Inbox only", exactly the default calendar, ...), all with the
* read or the read-and-write preset. Anything else (a folder added since,
* another single folder, free/busy only, manager or custom rights) starts
* as "Keep as it is", which leaves that type out of the patch. The level is
* read or write when every grant has that preset, else "keep". Without
* write among `allowed`, a level that would keep or give write starts at
* read: saving then lowers the types the user chose to read.
*/
export function changeStartFor(targets: ShareTarget[], principalId: string, allowed: AccessLevel[] = [...ACCESS_LEVELS]): ChangeStart {
const scopeFor = (kind: DelegateKind): MailScope | CollectionScope => {
// Address books Delegates doesn't offer still count: a grant there isn't one the dialog can express.
const relevant = kind === 'addressBook' ? ofKind(targets, kind) : itemsOfType(targets, kind);
const shared = relevant.filter((t) => principalId in t.shareWith);
if (shared.length === 0) return 'none';
if (shared.some((t) => !['read', 'readWrite'].includes(detectPreset(kind, t.shareWith[principalId])))) return 'keep';
const ids = shared.map((t) => t.id).sort().join('\n');
const exact = (choice: MailScope | CollectionScope) => {
const covered = itemsFor(targets, kind, choice) ?? [];
return covered.length > 0 && covered.map((t) => t.id).sort().join('\n') === ids;
};
if (kind === 'mailbox') return exact('all') ? 'all' : exact('inbox') ? 'inbox' : 'keep';
return exact('default') ? 'default' : exact('all') ? 'all' : 'keep';
};
const scope: DelegateScope = {
mail: scopeFor('mailbox') as MailScope,
calendars: scopeFor('calendar') as CollectionScope,
addressBooks: scopeFor('addressBook') as CollectionScope,
};
const granted = targets.filter((t) => t.kind !== 'file' && principalId in t.shareWith);
const presets = new Set(granted.map((t) => detectPreset(t.kind, t.shareWith[principalId])));
let level: LevelChoice = presets.size === 1 && presets.has('read') ? 'read'
: presets.size === 1 && presets.has('readWrite') ? 'write'
: 'keep';
const hasWrite = granted.some((t) => accessLevelOf(t.kind, t.shareWith[principalId]) === 'write');
if (!allowed.includes('write') && (level === 'write' || (level === 'keep' && hasWrite))) level = 'read';
else if (level !== 'keep' && !allowed.includes(level)) level = 'keep';
return { scope, level };
}
/**
* The levels the admin allows (`sharingRights.allowedValues`). Values
* other than "read" and "write" are ignored; a list with neither leaves
* read only, the safe end.
*/
export function allowedLevels(allowedValues: unknown[] | null): AccessLevel[] {
if (!allowedValues) return [...ACCESS_LEVELS];
const allowed = ACCESS_LEVELS.filter((level) => allowedValues.includes(level));
return allowed.length > 0 ? allowed : ['read'];
}
/** Whether a scope gives access to nothing at all. */
export function scopeIsEmpty(scope: DelegateScope): boolean {
return Object.values(scope).every((choice) => choice === 'none');
}
/** The changes that failed, for the report and for "Try again". */
export function failedChanges(results: ShareChangeResult[]): ShareChangeResult[] {
return results.filter((r) => !r.ok);
}
+100
View File
@@ -0,0 +1,100 @@
// What the Delegates page shows about people and their access (webmail
// components/settings/delegates/principal-display.ts, describeSummary in
// delegates-settings.tsx and the search of delegate-dialog.tsx). Kept free
// of React so it can be unit-tested.
import type { Principal } from '../../api/types';
import type { DelegateSummary } from './delegation';
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => string;
/** The line to show for a principal, and the smaller one under it. */
export function principalDisplay(principal: Pick<Principal, 'id' | 'name' | 'email' | 'description'>): { name: string; detail: string | null } {
const name = principal.name || principal.email || principal.id;
const detail = principal.email && principal.email !== name
? principal.email
: principal.description && principal.description !== name ? principal.description : null;
return { name, detail };
}
/**
* 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
* 1.0 with directory queries off), the account they shared with the user,
* which the session lists under the owner's address.
*/
export function resolvePrincipal(
id: string,
sources: { directory: Principal[]; remembered: Map<string, Principal>; sessionAccounts: Map<string, string> },
): Principal | null {
const known = sources.directory.find((p) => p.id === id) ?? sources.remembered.get(id);
if (known) return known;
const accountName = sources.sessionAccounts.get(id);
if (!accountName) return null;
// The webmail tells groups apart by the policy subject's groups, which the app doesn't have.
return { id, name: accountName, email: accountName.includes('@') ? accountName : null, type: 'individual' };
}
/** "All mail folders · 1 calendar · Read", in the order of the dialog. */
export function describeSummary(summary: DelegateSummary, t: Translate): string {
const parts: string[] = [];
const { mail, calendars, addressBooks, files } = summary;
if (mail.shared > 0) {
parts.push(mail.shared === mail.total
? t('delegates.summary.mail_all', 'All mail folders')
: mail.shared === 1 && mail.inbox
? t('delegates.summary.mail_inbox', 'Inbox')
: t('delegates.summary.mail_some', '{count, plural, one {# mail folder} other {# mail folders}}', { count: mail.shared }));
}
if (calendars.shared > 0) {
parts.push(t('delegates.summary.calendars', '{count, plural, one {# calendar} other {# calendars}}', { count: calendars.shared }));
}
if (addressBooks.shared > 0) {
parts.push(t('delegates.summary.address_books', '{count, plural, one {# address book} other {# address books}}', { count: addressBooks.shared }));
}
if (files > 0) {
parts.push(t('delegates.summary.files', '{count, plural, one {# file or folder} other {# files or folders}}', { count: files }));
}
const level = summary.level ?? 'none';
const levelFallback = { read: 'Read', write: 'Read and write', mixed: 'Different rights', none: 'Free/busy only' }[level];
parts.push(t(`delegates.level.${level}`, levelFallback));
return parts.join(' · ');
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** The people the dialog offers for a search: never the user. */
export function delegateCandidates(directory: Principal[], ownIds: string[], query: string): Principal[] {
const own = new Set(ownIds);
const q = query.trim().toLowerCase();
return directory
.filter((p) => !own.has(p.id))
.filter((p) => !q || p.name?.toLowerCase().includes(q) || p.email?.toLowerCase().includes(q) || p.description?.toLowerCase().includes(q));
}
/** Whether "Use {address}" is offered: a typed address nobody listed has. */
export function canLookUpAddress(query: string, candidates: Principal[]): boolean {
const typed = query.trim();
return EMAIL_RE.test(typed) && !candidates.some((p) => p.email?.toLowerCase() === typed.toLowerCase());
}
export type Lookup =
| { state: 'idle' }
| { state: 'busy' }
| { state: 'notFound'; address: string }
| { state: 'directoryOff' }
| { state: 'self' }
| { state: 'failed' };
/** What a lookup of `address` found, as the dialog shows it; `picked` is set when it found someone. */
export function lookupResult(
address: string,
found: { principal: Principal | null; status: 'ok' | 'forbidden' | 'error' },
ownIds: string[],
): { lookup: Lookup; picked: Principal | null } {
if (found.principal && ownIds.includes(found.principal.id)) return { lookup: { state: 'self' }, picked: null };
if (found.principal) return { lookup: { state: 'idle' }, picked: found.principal };
if (found.status === 'forbidden') return { lookup: { state: 'directoryOff' }, picked: null };
if (found.status === 'ok') return { lookup: { state: 'notFound', address }, picked: null };
return { lookup: { state: 'failed' }, picked: null };
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Names of the people and groups the user gave access to, kept on this
* device (webmail lib/delegation/known-principals.ts). Stalwart 1.0 with
* directory queries off (its default) won't name a principal the user
* shares with unless that principal also shares something back, so the
* "Who has access" list would otherwise show "Unknown person" for the very
* people the user just picked by name. Only the people who have access and
* the one just picked are kept, never the directory; per login, at most MAX
* entries, gone when the login is removed (pruneRememberedPrincipals) or
* everyone signs out. A convenience: losing it costs names, not access.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Principal } from '../../api/types';
export const DELEGATE_NAMES_KEY = 'bulwark-delegate-names';
const MAX = 200;
type Entry = Pick<Principal, 'id' | 'name' | 'email' | 'type'>;
type Store = Record<string, Record<string, Entry>>;
async function readStore(): Promise<Store> {
try {
const raw = await AsyncStorage.getItem(DELEGATE_NAMES_KEY);
const parsed = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Store) : {};
} catch {
return {};
}
}
async function writeStore(store: Store): Promise<void> {
try {
if (Object.keys(store).length === 0) await AsyncStorage.removeItem(DELEGATE_NAMES_KEY);
else await AsyncStorage.setItem(DELEGATE_NAMES_KEY, JSON.stringify(store));
} catch {
// Storage unavailable: the list shows "Unknown person" instead.
}
}
// Writes one after the other, so two quick remembers don't drop each other.
let queue: Promise<unknown> = Promise.resolve();
function serial<T>(task: () => Promise<T>): Promise<T> {
const next = queue.then(task, task);
queue = next.catch(() => undefined);
return next;
}
/** Remember these principals for `accountKey` (the signed-in login). */
export function rememberPrincipals(accountKey: string, principals: Principal[]): Promise<void> {
if (!accountKey || principals.length === 0) return Promise.resolve();
return serial(async () => {
const store = await readStore();
const own = { ...(store[accountKey] ?? {}) };
for (const p of principals) {
delete own[p.id];
own[p.id] = { id: p.id, name: p.name, email: p.email ?? null, type: p.type };
}
const ids = Object.keys(own);
for (const id of ids.slice(0, Math.max(0, ids.length - MAX))) delete own[id];
store[accountKey] = own;
await writeStore(store);
});
}
/** The principals remembered for `accountKey`, by id. */
export async function rememberedPrincipals(accountKey: string): Promise<Map<string, Principal>> {
await queue;
const own = (await readStore())[accountKey] ?? {};
return new Map(Object.values(own).map((entry) => [entry.id, entry as Principal]));
}
/**
* Forget the names kept for every login not in `liveAccountKeys` (removed
* from the app, or everyone signed out).
*/
export function pruneRememberedPrincipals(liveAccountKeys: string[]): Promise<void> {
return serial(async () => {
const store = await readStore();
const live = new Set(liveAccountKeys);
const stale = Object.keys(store).filter((key) => !live.has(key));
if (stale.length === 0) return;
for (const key of stale) delete store[key];
await writeStore(store);
});
}
+21
View File
@@ -0,0 +1,21 @@
// What the organisation policy says about Delegates. The webmail reads it
// from its policy store: the admin can hide the page (restriction
// `delegation`), limit the rights offered (`sharingRights.allowedValues`,
// read and/or write) and switch calendars, contacts or files off
// (`calendarEnabled`, `contactsEnabled`, `filesEnabled`). The app has no
// policy route yet, so this answers "shown, every right, every app"; once
// the policy arrives, this is the one place to plug it in.
export interface DelegationPolicy {
/** The admin hid the page. */
hidden: boolean;
/** `sharingRights.allowedValues`; null = no limit. */
allowedValues: unknown[] | null;
calendarsEnabled: boolean;
contactsEnabled: boolean;
filesEnabled: boolean;
}
export function delegationPolicy(): DelegationPolicy {
return { hidden: false, allowedValues: null, calendarsEnabled: true, contactsEnabled: true, filesEnabled: true };
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Changing the `shareWith` of many folders, calendars, address books and
* files in one go, for the Delegates page (webmail lib/jmap/share-batch.ts):
* the method calls for one request and a result for every item, so a
* partial failure can say what failed.
* The single-item helpers (setCalendarShare, setFileNodeShare) send the same
* `shareWith/<principalId>` patch.
*/
import { batched } from '../../api/jmap-result';
import type { AnyRights, ShareKind } from './share-rights';
/** One shareable item of the user's own account, with its grants. */
export interface ShareTarget {
kind: ShareKind;
accountId: string;
/** The id in `accountId`. */
id: string;
name: string;
/** Mailboxes: the folder role ("inbox", "sent", ...). */
role?: string | null;
parentId?: string | null;
/** Calendars and address books: the account's default one. */
isDefault?: boolean;
shareWith: Record<string, AnyRights>;
}
/** Grant (`rights`) or revoke (`null`) one principal's access to one item. */
export interface ShareChange {
kind: ShareKind;
accountId: string;
id: string;
name: string;
principalId: string;
rights: AnyRights | null;
}
export interface ShareChangeResult {
change: ShareChange;
ok: boolean;
/** The server's reason, when it gave one. */
error?: string;
}
type MethodCall = [string, Record<string, unknown>, string];
type MethodResponse = [string, Record<string, unknown>, string];
export const SET_METHOD: Record<ShareKind, string> = {
mailbox: 'Mailbox/set',
calendar: 'Calendar/set',
addressBook: 'AddressBook/set',
file: 'FileNode/set',
};
export interface ShareSetCalls {
calls: MethodCall[];
/** The changes each call carries, by call id. */
changesByCall: Map<string, ShareChange[]>;
}
/**
* One /set call per type and account, split so none touches more than
* `maxObjectsInSet` items. `wireRights` lets the caller convert rights for
* a server that names them differently (old file rights).
*/
export function buildShareSetCalls(
changes: ShareChange[],
options: { maxObjectsInSet: number; wireRights?: (change: ShareChange) => unknown },
): ShareSetCalls {
const groups = new Map<string, ShareChange[]>();
for (const change of changes) {
const key = `${change.kind}\u0000${change.accountId}`;
const list = groups.get(key);
if (list) list.push(change);
else groups.set(key, [change]);
}
const calls: MethodCall[] = [];
const changesByCall = new Map<string, ShareChange[]>();
for (const group of groups.values()) {
const { kind, accountId } = group[0];
// Batch by item, not by change: two principals on one item are one object.
const ids = [...new Set(group.map((c) => c.id))];
for (const idBatch of batched(ids, options.maxObjectsInSet)) {
const inBatch = new Set(idBatch);
const batchChanges = group.filter((c) => inBatch.has(c.id));
const update: Record<string, Record<string, unknown>> = {};
for (const change of batchChanges) {
const rights = change.rights && options.wireRights ? options.wireRights(change) : change.rights;
(update[change.id] ??= {})[`shareWith/${change.principalId}`] = rights;
}
const callId = `s${calls.length}`;
calls.push([SET_METHOD[kind], { accountId, update }, callId]);
changesByCall.set(callId, batchChanges);
}
}
return { calls, changesByCall };
}
function describe(error: unknown): string | undefined {
const e = error as { description?: unknown; type?: unknown } | undefined;
if (typeof e?.description === 'string' && e.description) return e.description;
if (typeof e?.type === 'string' && e.type) return e.type;
return undefined;
}
/** A result for every change of `calls`, from the responses to them. */
export function readShareSetResults(
calls: MethodCall[],
changesByCall: Map<string, ShareChange[]>,
responses: MethodResponse[] | undefined,
): ShareChangeResult[] {
const results: ShareChangeResult[] = [];
for (const [, , callId] of calls) {
const changes = changesByCall.get(callId) ?? [];
const response = responses?.find((r) => r[2] === callId);
if (!response) {
for (const change of changes) results.push({ change, ok: false });
continue;
}
const [name, result] = response;
if (name === 'error') {
const error = describe(result);
for (const change of changes) results.push({ change, ok: false, error });
continue;
}
const updated = (result.updated ?? {}) as Record<string, unknown>;
const notUpdated = (result.notUpdated ?? {}) as Record<string, unknown>;
for (const change of changes) {
if (change.id in notUpdated) results.push({ change, ok: false, error: describe(notUpdated[change.id]) });
else if (change.id in updated) results.push({ change, ok: true });
else results.push({ change, ok: false });
}
}
return results;
}
/** Every change of `calls` failed with one error (the request didn't go through). */
export function failAll(calls: MethodCall[], changesByCall: Map<string, ShareChange[]>, error: unknown): ShareChangeResult[] {
const message = error instanceof Error ? error.message : describe(error);
return calls.flatMap(([, , callId]) => (changesByCall.get(callId) ?? []).map((change) => ({ change, ok: false, error: message })));
}
function asShareWith(value: unknown): Record<string, AnyRights> {
return value && typeof value === 'object' ? (value as Record<string, AnyRights>) : {};
}
/**
* The items of a Mailbox/get, Calendar/get and AddressBook/get response
* (call ids `m`, `c`, `a`), and the types whose call failed.
*/
export function parseShareTargets(
responses: MethodResponse[] | undefined,
accounts: { mailbox?: string; calendar?: string; addressBook?: string },
): { targets: ShareTarget[]; failed: ShareKind[] } {
const targets: ShareTarget[] = [];
const failed: ShareKind[] = [];
const read = (kind: ShareKind, callId: string, method: string, accountId: string | undefined) => {
if (!accountId) return;
const response = responses?.find((r) => r[2] === callId);
if (!response || response[0] !== method) {
failed.push(kind);
return;
}
const list = (response[1].list ?? []) as Array<Record<string, unknown>>;
for (const item of list) {
if (typeof item.id !== 'string') continue;
targets.push({
kind,
accountId,
id: item.id,
name: typeof item.name === 'string' ? item.name : item.id,
...(kind === 'mailbox' ? { role: (item.role as string | null | undefined) ?? null, parentId: (item.parentId as string | null | undefined) ?? null } : {}),
...(kind !== 'mailbox' ? { isDefault: item.isDefault === true } : {}),
shareWith: asShareWith(item.shareWith),
});
}
};
read('mailbox', 'm', 'Mailbox/get', accounts.mailbox);
read('calendar', 'c', 'Calendar/get', accounts.calendar);
read('addressBook', 'a', 'AddressBook/get', accounts.addressBook);
return { targets, failed };
}
+183
View File
@@ -0,0 +1,183 @@
/**
* The rights Bulwark grants when it shares a folder, calendar, address book
* or file (`shareWith/<principalId>`), as named presets (webmail
* lib/jmap/share-rights.ts). The Delegates page builds its patches from here.
*
* What Stalwart stores differs from what it was sent in places:
* - 1.0 keeps `maySetSeen` and `maySetKeywords` of a mailbox as one right, so
* a "read" grant (seen yes, keywords no) reads back with both true.
* - v1.0.0 adds `maySetMetadata` to every type. Bulwark never sends it (0.16
* would refuse the property), and the server stores false.
* So presets are compared on the rights they define, and the mailbox
* keyword right is allowed to follow the seen right.
*/
export type ShareKind = 'calendar' | 'addressBook' | 'file' | 'mailbox';
export type RolePreset = 'freeBusy' | 'read' | 'readWrite' | 'manager' | 'custom';
type NamedPreset = Exclude<RolePreset, 'custom' | 'freeBusy'>;
export interface ShareCalendarRights {
mayReadFreeBusy: boolean;
mayReadItems: boolean;
mayWriteAll: boolean;
mayWriteOwn: boolean;
mayUpdatePrivate: boolean;
mayRSVP: boolean;
mayShare: boolean;
mayDelete: boolean;
}
export interface ShareAddressBookRights {
mayRead: boolean;
mayWrite: boolean;
mayShare?: boolean;
mayDelete: boolean;
}
export interface ShareFileRights {
mayRead: boolean;
mayAddChildren: boolean;
mayRename: boolean;
mayDelete: boolean;
mayModifyContent: boolean;
mayShare: boolean;
}
export interface ShareMailboxRights {
mayReadItems: boolean;
mayAddItems: boolean;
mayRemoveItems: boolean;
maySetSeen: boolean;
maySetKeywords: boolean;
mayCreateChild: boolean;
mayRename: boolean;
mayDelete: boolean;
maySubmit: boolean;
mayShare?: boolean;
}
export type AnyRights = ShareCalendarRights | ShareAddressBookRights | ShareFileRights | ShareMailboxRights;
export const CALENDAR_PRESETS: Record<Exclude<RolePreset, 'custom'>, ShareCalendarRights> = {
freeBusy: {
mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false, mayWriteOwn: false,
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
},
read: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false,
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
},
readWrite: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: false,
},
manager: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
},
};
export const ADDRESS_BOOK_PRESETS: Record<NamedPreset, ShareAddressBookRights> = {
read: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false },
readWrite: { mayRead: true, mayWrite: true, mayShare: false, mayDelete: false },
manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
};
export const FILE_PRESETS: Record<NamedPreset, ShareFileRights> = {
read: {
mayRead: true, mayAddChildren: false, mayRename: false,
mayDelete: false, mayModifyContent: false, mayShare: false,
},
readWrite: {
mayRead: true, mayAddChildren: true, mayRename: true,
mayDelete: true, mayModifyContent: true, mayShare: false,
},
manager: {
mayRead: true, mayAddChildren: true, mayRename: true,
mayDelete: true, mayModifyContent: true, mayShare: true,
},
};
// Mail folder rights (RFC 8621 §2 + mail:share). "Read & write" lets the
// grantee file, flag and remove mail; "Manager" additionally lets them
// rename/delete the folder, create subfolders, send as it and re-share.
export const MAILBOX_PRESETS: Record<NamedPreset, ShareMailboxRights> = {
read: {
mayReadItems: true, mayAddItems: false, mayRemoveItems: false, maySetSeen: true,
maySetKeywords: false, mayCreateChild: false, mayRename: false, mayDelete: false,
maySubmit: false, mayShare: false,
},
readWrite: {
mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true,
maySetKeywords: true, mayCreateChild: false, mayRename: false, mayDelete: false,
maySubmit: false, mayShare: false,
},
manager: {
mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true,
maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true,
maySubmit: true, mayShare: true,
},
};
function presetsFor(kind: ShareKind): Partial<Record<RolePreset, AnyRights>> {
if (kind === 'calendar') return CALENDAR_PRESETS;
if (kind === 'file') return FILE_PRESETS;
if (kind === 'mailbox') return MAILBOX_PRESETS;
return ADDRESS_BOOK_PRESETS;
}
function matches(kind: ShareKind, preset: AnyRights, rights: AnyRights): boolean {
const have = rights as unknown as Record<string, boolean | undefined>;
return Object.entries(preset).every(([key, want]) => {
const got = have[key] ?? false;
if (got === want) return true;
// Stalwart 1.0 stores the two as one right (see the top of this file).
return kind === 'mailbox' && key === 'maySetKeywords' && got === true && have.maySetSeen === true;
});
}
/** The rights a preset grants; undefined for "custom". */
export function presetRights(kind: ShareKind, preset: RolePreset): AnyRights | undefined {
if (preset === 'custom') return undefined;
return presetsFor(kind)[preset];
}
/** The preset a stored grant amounts to, or "custom". */
export function detectPreset(kind: ShareKind, rights: AnyRights): RolePreset {
for (const [name, preset] of Object.entries(presetsFor(kind)) as [RolePreset, AnyRights][]) {
if (matches(kind, preset, rights)) return name;
}
return 'custom';
}
/** Read or read and write, as the Delegates page offers them. */
export type AccessLevel = 'read' | 'write';
/** The rights Delegates grants for one level. */
export function accessRights(kind: ShareKind, level: AccessLevel): AnyRights {
return presetRights(kind, level === 'write' ? 'readWrite' : 'read')!;
}
/**
* What a stored grant lets the grantee do, in the two words of the Delegates
* page: "write" when they may change anything inside, "read" when they may
* only read, null when they may not even read (free/busy only, or nothing).
*/
export function accessLevelOf(kind: ShareKind, rights: AnyRights | null | undefined): AccessLevel | null {
if (!rights) return null;
const r = rights as unknown as Record<string, boolean | undefined>;
switch (kind) {
case 'mailbox':
if (!r.mayReadItems) return null;
return r.mayAddItems || r.mayRemoveItems ? 'write' : 'read';
case 'calendar':
if (!r.mayReadItems) return null;
return r.mayWriteAll || r.mayWriteOwn ? 'write' : 'read';
case 'addressBook':
if (!r.mayRead) return null;
return r.mayWrite ? 'write' : 'read';
case 'file':
if (!r.mayRead) return null;
return r.mayModifyContent || r.mayAddChildren || r.mayRename || r.mayDelete || r.mayWrite ? 'write' : 'read';
}
}
@@ -0,0 +1,16 @@
import { useAuthStore } from '../../stores/auth-store';
import { supportsDelegation } from '../../api/delegation';
import { delegationPolicy } from './policy';
/**
* Whether Settings lists Delegates for the active account: not hidden by
* the policy, and the server has someone to share with and something to
* share (webmail settings-app supportsSharing). Re-read with every session.
*/
export function useDelegationAvailable(): boolean {
return useAuthStore((s) => {
if (!s.session) return false;
const policy = delegationPolicy();
return !policy.hidden && supportsDelegation(policy);
});
}
+4 -2
View File
@@ -14,7 +14,7 @@
import { supportsDeviceSync } from './platform-capabilities';
export type SettingsTabId =
| 'account' | 'language' | 'notifications' | 'accessibility'
| 'account' | 'sharing' | 'language' | 'notifications' | 'accessibility'
| 'appearance' | 'layout'
| 'reading' | 'composing' | 'identities' | 'vacation'
| 'filters' | 'recovery' | 'templates' | 'folders' | 'keywords' | 'downloads'
@@ -38,7 +38,8 @@ export const SETTINGS_SEARCH_PATHS: Record<SettingsTabId, string[]> = {
'settings.account.accounts',
'settings.account.shared_accounts',
],
// Recover deleted items shows only where the server has it.
// Delegates and Recover deleted items show only where the server has them.
sharing: ['delegates.title'],
recovery: ['recover_deleted.title'],
language: [
'settings.appearance.language',
@@ -258,6 +259,7 @@ export const SETTINGS_SEARCH_PATHS: Record<SettingsTabId, string[]> = {
// translation doesn't contain the literal word.
export const SETTINGS_SEARCH_KEYWORDS: Record<SettingsTabId, string> = {
account: 'profile email password user signin signout reorder switch default multi-account shared storage quota',
sharing: 'delegate delegation share access assistant permissions revoke folders calendars contacts colleague',
recovery: 'recover restore undelete deleted trash emptied lost messages',
language: 'locale region date time format translation',
notifications: 'alert push badge unifiedpush relay device reminder',
+11 -4
View File
@@ -9,7 +9,7 @@ import {
Filter, FileText, FolderOpen, Tags, HardDrive,
BookUser, PanelLeftClose, Bell, RefreshCw,
LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Download,
Database, Bug, Scale, RotateCcw,
Database, Bug, Scale, Users, RotateCcw,
type AppIcon,
} from '../components/icons';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
@@ -45,8 +45,10 @@ import { LanguageSettings } from '../components/settings/LanguageSettings';
import { ComposingSettings } from '../components/settings/ComposingSettings';
import { LayoutSettings } from '../components/settings/LayoutSettings';
import { DownloadsSettings } from '../components/settings/DownloadsSettings';
import { DelegateSettings } from '../components/settings/DelegateSettings';
import { RecoverDeletedSettings } from '../components/settings/RecoverDeletedSettings';
import { useDeletedItemsRecovery } from '../lib/recovery/use-deleted-items-recovery';
import { useDelegationAvailable } from '../lib/delegation/use-delegation-available';
import { useLocaleStore } from '../stores/locale-store';
import { useAuthStore } from '../stores/auth-store';
import { useManagedAccountStore } from '../stores/managed-account-store';
@@ -100,6 +102,7 @@ const GROUP_ORDER: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'ap
const TABS: TabDef[] = [
// General
{ id: 'account', label: 'Account', icon: User, group: 'general', implemented: true },
{ id: 'sharing', label: 'Sharing', icon: Users, group: 'general', implemented: true },
{ id: 'language', label: 'Language', icon: Languages, group: 'general', implemented: true },
{ id: 'notifications', label: 'Notifications', icon: Bell, group: 'general', implemented: true },
{ id: 'accessibility', label: 'Accessibility', icon: Accessibility, group: 'general', implemented: true },
@@ -148,6 +151,7 @@ const TAB_LABEL_KEYS: Partial<Record<Tab, string>> = {
debug: 'settings.tabs.troubleshooting',
licences: 'settings.about.licences',
privacy: 'settings.about.privacy',
sharing: 'delegates.tab',
recovery: 'recover_deleted.tab',
};
const tabLabelKey = (id: Tab) => TAB_LABEL_KEYS[id] ?? `settings.tabs.${id}`;
@@ -168,6 +172,7 @@ const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
folders: FolderSettings,
keywords: KeywordSettings,
downloads: DownloadsSettings,
sharing: DelegateSettings,
recovery: RecoverDeletedSettings,
security: AccountSecuritySettings,
content_senders: ContentSendersSettings,
@@ -222,14 +227,16 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
const hasFiles = useHasFiles();
const hasSieve = useHasSieve();
const hasVacation = useHasVacation();
// Webmail 1.19: Recover deleted items is left out of the list, not shown
// as "Not on this server", where the server lacks it.
// Webmail 1.19: Delegates and Recover deleted items are left out of the
// list, not shown as "Not on this server", where the server lacks them.
const recoveryAvailable = useDeletedItemsRecovery().available;
const delegationAvailable = useDelegationAvailable();
const hiddenTabs = React.useMemo(() => {
const set = new Set<Tab>();
if (!recoveryAvailable) set.add('recovery');
if (!delegationAvailable) set.add('sharing');
return set;
}, [recoveryAvailable]);
}, [recoveryAvailable, delegationAvailable]);
// A shared/group account picked under "Shared with me" scopes Settings to
// the panes that account can be managed in (webmail: scoped settings).