diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx
index c3b5d32..5c41649 100644
--- a/src/components/layout/MainContent.tsx
+++ b/src/components/layout/MainContent.tsx
@@ -61,6 +61,15 @@ const AuditLogPage = lazyFeature(
() => import('@/features/audit/AuditLogPage'),
(m) => m.AuditLogPage,
);
+// inbuxa: Management › Compliance › Locked accounts (audit-hold-lock spec, AL-1).
+const AccountLocksPage = lazyFeature(
+ () => import('@/features/lock/AccountLocksPage'),
+ (m) => m.AccountLocksPage,
+);
+const AccountLockBanner = lazyFeature(
+ () => import('@/features/lock/AccountLockBanner'),
+ (m) => m.AccountLockBanner,
+);
const TenantLegacyProtocols = lazyFeature(
() => import('@/features/hardening/TenantLegacyProtocols'),
(m) => m.TenantLegacyProtocols,
@@ -128,6 +137,9 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
if (componentName === 'AuditLog') {
return ;
}
+ if (componentName === 'AccountLocks') {
+ return ;
+ }
return (
Unknown component: {componentName}
@@ -186,6 +198,15 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
);
}
+ // INBUXA: a person's page says whether it is locked (AL-1)
+ if (viewName === 'x:Account/User') {
+ return (
+
+ );
+ }
return page;
}
diff --git a/src/features/lock/AccountLockBanner.tsx b/src/features/lock/AccountLockBanner.tsx
new file mode 100644
index 0000000..dd2811f
--- /dev/null
+++ b/src/features/lock/AccountLockBanner.tsx
@@ -0,0 +1,92 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * INBUXA: on a person's account page, whether it is locked and to whom it is
+ * handed, with the way to lock or unlock it (audit-hold-lock spec, AL-1).
+ */
+
+import { useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Lock, Unlock } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { useAccountStore } from '@/stores/accountStore';
+import { fetchLocks, type AccountLock } from './accountLock';
+import { LockDialog, UnlockDialog } from './LockDialogs';
+import { accessLabel } from './labels';
+
+export function AccountLockBanner({ accountId }: { accountId: string }) {
+ const { t } = useTranslation();
+ const canGet = useAccountStore((s) => s.hasPermission('sysAccountLockGet'));
+ const canCreate = useAccountStore((s) => s.hasPermission('sysAccountLockCreate'));
+ const canDestroy = useAccountStore((s) => s.hasPermission('sysAccountLockDestroy'));
+ const [lock, setLock] = useState(undefined);
+ const [fetches, setFetches] = useState(0);
+ const [dialog, setDialog] = useState<'lock' | 'unlock' | null>(null);
+ const refetch = useCallback(() => setFetches((n) => n + 1), []);
+
+ useEffect(() => {
+ if (!canGet) return;
+ const controller = new AbortController();
+ fetchLocks(accountId, controller.signal)
+ .then((locks) => {
+ if (!controller.signal.aborted) setLock(locks[0] ?? null);
+ })
+ // An older server without locks: say nothing
+ .catch(() => undefined);
+ return () => controller.abort();
+ }, [accountId, canGet, fetches]);
+
+ if (!canGet || lock === undefined) return null;
+ const done = () => {
+ setDialog(null);
+ refetch();
+ };
+
+ if (!lock) {
+ if (!canCreate) return null;
+ return (
+ <>
+
+ setDialog('lock')}>
+
+ {t('lock.lockThis', 'Lock this account…')}
+
+
+ {dialog === 'lock' && setDialog(null)} onDone={done} />}
+ >
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {t('lock.bannerTitle', 'Locked: receives mail, can’t sign in, sends nothing on its own.')}
+
+
{lock.reason}
+ {lock.delegates.length > 0 && (
+
+ {t('lock.bannerDelegates', 'Handed to')}{' '}
+ {lock.delegates.map((d) => `${d.name ?? d.accountId} (${accessLabel(t, d.access)})`).join(', ')}
+
+ )}
+
+
+ {canDestroy && (
+
setDialog('unlock')}>
+
+ {t('lock.unlockAction', 'Unlock…')}
+
+ )}
+
+ {dialog === 'unlock' &&
setDialog(null)} onDone={done} />}
+
+ );
+}
diff --git a/src/features/lock/AccountLocksPage.tsx b/src/features/lock/AccountLocksPage.tsx
new file mode 100644
index 0000000..de5912d
--- /dev/null
+++ b/src/features/lock/AccountLocksPage.tsx
@@ -0,0 +1,153 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * INBUXA: Management › Compliance › Locked accounts (audit-hold-lock spec,
+ * AL-1 to AL-12). Accounts that receive mail but can't sign in, and who
+ * they're handed to.
+ */
+
+import { useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Lock, Pencil, Unlock } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { PageHeader } from '@/components/common/PageHeader';
+import { LoadingFallback } from '@/components/common/LoadingFallback';
+import { useAccountStore } from '@/stores/accountStore';
+import { fetchLocks, LocksUnavailable, type AccountLock } from './accountLock';
+import { LockDialog, UnlockDialog } from './LockDialogs';
+import { accessLabel } from './labels';
+
+type Load = { kind: 'loading' } | { kind: 'ready'; locks: AccountLock[] } | { kind: 'error'; message: string };
+
+export function AccountLocksPage() {
+ const { t } = useTranslation();
+ const canCreate = useAccountStore((s) => s.hasPermission('sysAccountLockCreate'));
+ const canUpdate = useAccountStore((s) => s.hasPermission('sysAccountLockUpdate'));
+ const canDestroy = useAccountStore((s) => s.hasPermission('sysAccountLockDestroy'));
+ const [load, setLoad] = useState({ kind: 'loading' });
+ const [fetches, setFetches] = useState(0);
+ const [locking, setLocking] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [unlocking, setUnlocking] = useState(null);
+ const refetch = useCallback(() => setFetches((n) => n + 1), []);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ fetchLocks(undefined, controller.signal)
+ .then((locks) => {
+ if (!controller.signal.aborted) setLoad({ kind: 'ready', locks });
+ })
+ .catch((e: unknown) => {
+ if (controller.signal.aborted) return;
+ setLoad({
+ kind: 'error',
+ message:
+ e instanceof LocksUnavailable
+ ? t('lock.unavailable', 'This server can’t lock accounts.')
+ : e instanceof Error
+ ? e.message
+ : String(e),
+ });
+ });
+ return () => controller.abort();
+ }, [fetches, t]);
+
+ const done = () => {
+ setLocking(false);
+ setEditing(null);
+ setUnlocking(null);
+ refetch();
+ };
+
+ return (
+
+
setLocking(true)}>
+
+ {t('lock.lockAction', 'Lock an account…')}
+
+ )
+ }
+ />
+
+ {load.kind === 'loading' && }
+ {load.kind === 'error' && (
+ {load.message}
+ )}
+ {load.kind === 'ready' && load.locks.length === 0 && (
+
+ {t('lock.none', 'No account is locked.')}
+
+ )}
+ {load.kind === 'ready' &&
+ load.locks.map((lock) => (
+
+
+
+
+
+ {lock.name}
+
+
+ {t('lock.lockedBy', 'Locked {{when}} by {{who}}', {
+ when: new Date(lock.lockedAt).toLocaleString(),
+ who: lock.lockedBy,
+ })}
+
+
{lock.reason}
+
+
+ {canUpdate && (
+
setEditing(lock)}>
+
+ {t('lock.editAction', 'Delegates…')}
+
+ )}
+ {canDestroy && (
+
setUnlocking(lock)}>
+
+ {t('lock.unlockAction', 'Unlock…')}
+
+ )}
+
+
+ {lock.delegates.length === 0 ? (
+
{t('lock.noDelegates', 'Not handed to anyone.')}
+ ) : (
+
+ {lock.delegates.map((d) => (
+
+ {d.name ?? d.accountId}
+ {accessLabel(t, d.access)}
+ {d.sendAs && {t('lock.sendsAs', 'sends as')} }
+ {d.until && (
+
+ {t('lock.untilDay', 'until {{day}}', { day: d.until })}
+
+ )}
+
+ ))}
+
+ )}
+
+ ))}
+
+ {locking && setLocking(false)} onDone={done} />}
+ {editing && setEditing(null)} onDone={done} />}
+ {unlocking && setUnlocking(null)} onDone={done} />}
+
+ );
+}
diff --git a/src/features/lock/LockDialogs.tsx b/src/features/lock/LockDialogs.tsx
new file mode 100644
index 0000000..56a57dc
--- /dev/null
+++ b/src/features/lock/LockDialogs.tsx
@@ -0,0 +1,312 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * INBUXA: locking an account, changing who it's handed to, and unlocking it
+ * (audit-hold-lock spec, AL-1 to AL-12). Each asks why (AU-12); the answer
+ * goes to the audit log.
+ */
+
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Loader2, Lock, Plus, Trash2, Unlock } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { ObjectPicker } from '@/components/common/ObjectPicker';
+import { useSchemaStore } from '@/stores/schemaStore';
+import { toast } from '@/hooks/use-toast';
+import {
+ ACCESS_LEVELS,
+ delegateProblem,
+ lockAccount,
+ unlockAccount,
+ updateDelegates,
+ type Access,
+ type AccountLock,
+ type Delegate,
+} from './accountLock';
+import { accessLabel } from './labels';
+
+type T = (key: string, fallback: string) => string;
+
+function accessHint(t: T, access: Access): string {
+ switch (access) {
+ case 'read':
+ return t('lock.access.readHint', 'Sees everything, changes nothing. Reading doesn’t even mark mail read.');
+ case 'organize':
+ return t('lock.access.organizeHint', 'Also flags, files and moves mail and makes folders. Never deletes.');
+ case 'full':
+ return t('lock.access.fullHint', 'Everything the owner could do, deleting included.');
+ }
+}
+
+function ReasonField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
+ const { t } = useTranslation();
+ return (
+
+ {t('lock.reason', 'Reason')}
+
+ );
+}
+
+function DelegateEditor({ delegates, onChange }: { delegates: Delegate[]; onChange: (d: Delegate[]) => void }) {
+ const { t } = useTranslation();
+ const schema = useSchemaStore((s) => s.schema);
+ const update = (i: number, patch: Partial) =>
+ onChange(delegates.map((d, j) => (j === i ? { ...d, ...patch } : d)));
+ return (
+
+
+ {t(
+ 'lock.delegatesHint',
+ 'People who can open this account, beside their own mail, while it is locked. Everything they do there is recorded.',
+ )}
+
+ {delegates.map((d, i) => (
+
+
+ {schema ? (
+ update(i, { accountId: id })}
+ placeholder={t('lock.delegateAccount', 'Choose a person')}
+ />
+ ) : (
+ update(i, { accountId: e.target.value })} />
+ )}
+ onChange(delegates.filter((_, j) => j !== i))}
+ >
+
+
+
+
+
+ {t('lock.accessLabel', 'Access')}
+ update(i, { access: v as Access, sendAs: v === 'read' ? false : d.sendAs })}
+ >
+
+
+
+
+ {ACCESS_LEVELS.map((a) => (
+
+ {accessLabel(t, a)}
+
+ ))}
+
+
+
+
+ {t('lock.until', 'Until (optional)')}
+ update(i, { until: e.target.value })} />
+
+
+
{accessHint(t, d.access)}
+
+ update(i, { sendAs: v === true })}
+ />
+
+ {t('lock.sendAs', 'May send as this account (its Sent keeps a copy)')}
+
+
+
+ ))}
+ {delegates.length < 10 && (
+
onChange([...delegates, { accountId: '', access: 'read', sendAs: false, until: '' }])}
+ >
+
+ {t('lock.addDelegate', 'Add a delegate')}
+
+ )}
+
+ );
+}
+
+/** Lock an account, or change an existing lock's delegates. */
+export function LockDialog({
+ accountId: fixedAccountId,
+ accountName,
+ existing,
+ onClose,
+ onDone,
+}: {
+ accountId?: string;
+ accountName?: string;
+ existing?: AccountLock;
+ onClose: () => void;
+ onDone: () => void;
+}) {
+ const { t } = useTranslation();
+ const schema = useSchemaStore((s) => s.schema);
+ const [accountId, setAccountId] = useState(existing?.id ?? fixedAccountId ?? '');
+ const [reason, setReason] = useState('');
+ const [delegates, setDelegates] = useState(existing?.delegates ?? []);
+ const [busy, setBusy] = useState(false);
+ const problem = accountId ? delegateProblem(delegates, accountId) : null;
+ const ready = !!accountId && reason.trim().length > 0 && !problem;
+
+ const save = async () => {
+ setBusy(true);
+ try {
+ if (existing) await updateDelegates(existing.id, delegates, reason);
+ else await lockAccount(accountId, reason, delegates);
+ toast({
+ title: existing
+ ? t('lock.updated', 'Delegates changed')
+ : t('lock.locked', 'Account locked. Its open sessions were ended.'),
+ });
+ onDone();
+ } catch (e) {
+ toast({
+ variant: 'destructive',
+ title: existing ? t('lock.updateFailed', 'Nothing changed') : t('lock.lockFailed', 'The account wasn’t locked'),
+ description: e instanceof Error ? e.message : String(e),
+ });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+ !open && onClose()}>
+
+
+
+ {existing
+ ? t('lock.editTitle', 'Delegates of {{name}}', { name: existing.name })
+ : t('lock.lockTitle', 'Lock an account')}
+
+ {!existing && (
+
+ {t(
+ 'lock.lockBody',
+ 'Mail keeps arriving, but nobody can sign in to the account, by any means, and it sends nothing on its own: no forwarding, no vacation reply, no read receipts. Open sessions end now.',
+ )}
+
+ )}
+
+
+ {!existing && (
+
+ {t('lock.account', 'Account')}
+ {fixedAccountId ? (
+ {accountName ?? fixedAccountId}
+ ) : schema ? (
+
+ ) : null}
+
+ )}
+
+ {problem &&
{problem}
}
+
+
+
+
+ {t('lock.cancel', 'Cancel')}
+
+ void save()} disabled={busy || !ready} variant={existing ? 'default' : 'destructive'}>
+ {busy ? : !existing && }
+ {existing ? t('lock.save', 'Save') : t('lock.lockGo', 'Lock account')}
+
+
+
+
+ );
+}
+
+export function UnlockDialog({
+ lock,
+ onClose,
+ onDone,
+}: {
+ lock: AccountLock;
+ onClose: () => void;
+ onDone: () => void;
+}) {
+ const { t } = useTranslation();
+ const [reason, setReason] = useState('');
+ const [busy, setBusy] = useState(false);
+
+ const run = async () => {
+ setBusy(true);
+ try {
+ await unlockAccount(lock.id, reason);
+ toast({ title: t('lock.unlocked', 'Account unlocked') });
+ onDone();
+ } catch (e) {
+ toast({
+ variant: 'destructive',
+ title: t('lock.unlockFailed', 'The account is still locked'),
+ description: e instanceof Error ? e.message : String(e),
+ });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+ !open && onClose()}>
+
+
+ {t('lock.unlockTitle', 'Unlock {{name}}', { name: lock.name })}
+
+ {t(
+ 'lock.unlockBody',
+ 'The account can sign in again and its forwarding and vacation reply resume. Delegates lose it, and any shares they had before get back what they were. The password is unchanged: if someone else may know it, set a new one on the account’s page too.',
+ )}
+
+
+
+
+
+ {t('lock.cancel', 'Cancel')}
+
+ void run()} disabled={busy || !reason.trim()}>
+ {busy ? : }
+ {t('lock.unlockGo', 'Unlock')}
+
+
+
+
+ );
+}
diff --git a/src/features/lock/accountLock.test.ts b/src/features/lock/accountLock.test.ts
new file mode 100644
index 0000000..3f8f49c
--- /dev/null
+++ b/src/features/lock/accountLock.test.ts
@@ -0,0 +1,50 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { describe, expect, it } from 'vitest';
+import { delegateProblem, untilFromServer, untilToServer, type Delegate } from './accountLock';
+
+const d = (patch: Partial): Delegate => ({
+ accountId: 'b',
+ access: 'read',
+ sendAs: false,
+ until: '',
+ ...patch,
+});
+
+describe('delegateProblem', () => {
+ it('accepts a plain list', () => {
+ expect(delegateProblem([d({}), d({ accountId: 'c', access: 'full', sendAs: true })], 'z')).toBeNull();
+ });
+
+ it('refuses what the server would', () => {
+ expect(delegateProblem([d({ accountId: '' })], 'z')).toMatch(/Choose/);
+ expect(delegateProblem([d({ accountId: 'z' })], 'z')).toMatch(/own delegate/);
+ expect(delegateProblem([d({}), d({})], 'z')).toMatch(/twice/);
+ expect(delegateProblem([d({ sendAs: true })], 'z')).toMatch(/organize or full/);
+ expect(
+ delegateProblem(
+ Array.from({ length: 11 }, (_, i) => d({ accountId: `a${i}` })),
+ 'z',
+ ),
+ ).toMatch(/At most/);
+ });
+});
+
+describe('until', () => {
+ it('means the end of the chosen local day', () => {
+ const iso = untilToServer('2026-10-01')!;
+ expect(iso).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
+ expect(new Date(iso).getTime()).toBe(new Date(2026, 9, 1, 23, 59, 59).getTime());
+ expect(untilFromServer(iso)).toBe('2026-10-01');
+ });
+
+ it('is empty for no end', () => {
+ expect(untilToServer('')).toBeNull();
+ expect(untilFromServer(null)).toBe('');
+ expect(untilFromServer('not a date')).toBe('');
+ });
+});
diff --git a/src/features/lock/accountLock.ts b/src/features/lock/accountLock.ts
new file mode 100644
index 0000000..e02b755
--- /dev/null
+++ b/src/features/lock/accountLock.ts
@@ -0,0 +1,185 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * INBUXA: `inbuxa:AccountLock` over JMAP (audit-hold-lock spec, AL-1 to
+ * AL-12): an account locked, and the people it is handed to. A lock's id is
+ * the locked account's id. Every change says why (AU-12).
+ */
+
+import { getAccountId, jmapRequest } from '@/services/jmap/client';
+
+export const INBUXA_CAPABILITY = 'urn:inbuxa:jmap';
+const OBJECT = 'inbuxa:AccountLock';
+
+export type Access = 'read' | 'organize' | 'full';
+
+export const ACCESS_LEVELS: Access[] = ['read', 'organize', 'full'];
+
+export interface Delegate {
+ accountId: string;
+ name?: string;
+ access: Access;
+ sendAs: boolean;
+ /** `yyyy-mm-dd`, the last day, local; empty for no end. */
+ until: string;
+}
+
+export interface AccountLock {
+ id: string;
+ name: string;
+ reason: string;
+ /** RFC 3339. */
+ lockedAt: string;
+ lockedBy: string;
+ delegates: Delegate[];
+}
+
+/** The server doesn't offer account locks (an older server). */
+export class LocksUnavailable extends Error {
+ constructor(reason: string) {
+ super(`Account locks unavailable: ${reason}`);
+ }
+}
+
+function methodError(name: string | undefined, result: unknown): Error {
+ const r = result as { type?: string; description?: string } | undefined;
+ if (r?.type === 'unknownMethod' || r?.type === 'unknownCapability') return new LocksUnavailable(r.type);
+ return new Error(r?.description ?? r?.type ?? `${name ?? 'Request'} failed`);
+}
+
+function setError(result: unknown, bucket: string, key: string): Error | null {
+ const failed = (result as Record | undefined>)[
+ bucket
+ ]?.[key];
+ return failed ? new Error(failed.description ?? failed.type ?? 'Not saved') : null;
+}
+
+/** A day entered in the form, as the end of that day in UTC for the server. */
+export function untilToServer(day: string): string | null {
+ if (!day) return null;
+ const [y, m, d] = day.split('-').map(Number);
+ const end = new Date(y, (m ?? 1) - 1, d ?? 1, 23, 59, 59);
+ return end.toISOString().replace(/\.\d{3}Z$/, 'Z');
+}
+
+/** A server date as the form's `yyyy-mm-dd`, local. */
+export function untilFromServer(value: string | null | undefined): string {
+ if (!value) return '';
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return '';
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
+}
+
+/** What's wrong with a delegate list before it's sent, or null (AL-5, AL-8). */
+export function delegateProblem(delegates: Delegate[], lockedId: string): string | null {
+ if (delegates.length > 10) return 'At most 10 delegates.';
+ const seen = new Set();
+ for (const d of delegates) {
+ if (!d.accountId) return 'Choose an account for each delegate.';
+ if (d.accountId === lockedId) return 'An account can’t be its own delegate.';
+ if (seen.has(d.accountId)) return 'A delegate is listed twice.';
+ seen.add(d.accountId);
+ if (d.sendAs && d.access === 'read')
+ return 'Sending as the account needs organize or full access: the message is made in its Drafts first.';
+ }
+ return null;
+}
+
+function toServer(delegates: Delegate[]) {
+ return delegates.map((d) => ({
+ accountId: d.accountId,
+ access: d.access,
+ sendAs: d.sendAs,
+ until: untilToServer(d.until),
+ }));
+}
+
+function fromServer(raw: Record): AccountLock {
+ const delegates = Array.isArray(raw.delegates) ? (raw.delegates as Record[]) : [];
+ return {
+ id: String(raw.id ?? ''),
+ name: String(raw.name ?? ''),
+ reason: String(raw.reason ?? ''),
+ lockedAt: String(raw.lockedAt ?? ''),
+ lockedBy: String(raw.lockedBy ?? ''),
+ delegates: delegates.map((d) => ({
+ accountId: String(d.accountId ?? ''),
+ name: typeof d.name === 'string' ? d.name : undefined,
+ access: (ACCESS_LEVELS as string[]).includes(String(d.access)) ? (d.access as Access) : 'read',
+ sendAs: d.sendAs === true,
+ until: untilFromServer(d.until as string | null),
+ })),
+ };
+}
+
+/** Every lock in reach, or just the one on `accountId`. */
+export async function fetchLocks(accountId?: string, signal?: AbortSignal): Promise {
+ const responses = await jmapRequest(
+ [[`${OBJECT}/get`, { accountId: getAccountId('x:Account'), ids: accountId ? [accountId] : null }, '0']],
+ signal,
+ [INBUXA_CAPABILITY],
+ );
+ const [name, result] = responses[0] ?? [];
+ if (name !== `${OBJECT}/get`) throw methodError(name, result);
+ return ((result as { list?: Record[] }).list ?? []).map(fromServer);
+}
+
+export async function lockAccount(accountId: string, reason: string, delegates: Delegate[]): Promise {
+ const responses = await jmapRequest(
+ [
+ [
+ `${OBJECT}/set`,
+ {
+ accountId: getAccountId('x:Account'),
+ create: { l: { accountId, reason: reason.trim(), delegates: toServer(delegates) } },
+ },
+ '0',
+ ],
+ ],
+ undefined,
+ [INBUXA_CAPABILITY],
+ );
+ const [name, result] = responses[0] ?? [];
+ if (name !== `${OBJECT}/set`) throw methodError(name, result);
+ const failed = setError(result, 'notCreated', 'l');
+ if (failed) throw failed;
+}
+
+export async function updateDelegates(lockId: string, delegates: Delegate[], reason: string): Promise {
+ const responses = await jmapRequest(
+ [
+ [
+ `${OBJECT}/set`,
+ {
+ accountId: getAccountId('x:Account'),
+ reason: reason.trim(),
+ update: { [lockId]: { delegates: toServer(delegates) } },
+ },
+ '0',
+ ],
+ ],
+ undefined,
+ [INBUXA_CAPABILITY],
+ );
+ const [name, result] = responses[0] ?? [];
+ if (name !== `${OBJECT}/set`) throw methodError(name, result);
+ const failed = setError(result, 'notUpdated', lockId);
+ if (failed) throw failed;
+}
+
+export async function unlockAccount(lockId: string, reason: string): Promise {
+ const responses = await jmapRequest(
+ [[`${OBJECT}/set`, { accountId: getAccountId('x:Account'), reason: reason.trim(), destroy: [lockId] }, '0']],
+ undefined,
+ [INBUXA_CAPABILITY],
+ );
+ const [name, result] = responses[0] ?? [];
+ if (name !== `${OBJECT}/set`) throw methodError(name, result);
+ const failed = setError(result, 'notDestroyed', lockId);
+ if (failed) throw failed;
+}
diff --git a/src/features/lock/labels.ts b/src/features/lock/labels.ts
new file mode 100644
index 0000000..1a8a595
--- /dev/null
+++ b/src/features/lock/labels.ts
@@ -0,0 +1,22 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/** INBUXA: the words for a delegate's access level (AL-6). */
+
+import type { Access } from './accountLock';
+
+type T = (key: string, fallback: string) => string;
+
+export function accessLabel(t: T, access: Access): string {
+ switch (access) {
+ case 'read':
+ return t('lock.access.read', 'Read only');
+ case 'organize':
+ return t('lock.access.organize', 'Read and organize');
+ case 'full':
+ return t('lock.access.full', 'Full');
+ }
+}
diff --git a/src/lib/layout.ts b/src/lib/layout.ts
index 3b4ab50..beec0f6 100644
--- a/src/lib/layout.ts
+++ b/src/lib/layout.ts
@@ -81,6 +81,11 @@ function checkSpecialLink(
return { visible: canGet ? canGet('sysAudit') : true, enterprise: false };
}
+ // inbuxa: locked accounts, for whoever may see them (AL-12).
+ if (viewName === 'CustomComponent/AccountLocks') {
+ return { visible: canGet ? canGet('sysAccountLock') : true, enterprise: false };
+ }
+
if (viewName.startsWith('CustomComponent/')) {
return { visible: true, enterprise: false };
}
diff --git a/src/lib/objectOptions.ts b/src/lib/objectOptions.ts
index 3962ba6..05e565f 100644
--- a/src/lib/objectOptions.ts
+++ b/src/lib/objectOptions.ts
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
+ *
+ * Modified by Coffey Labs in 2026 for INBUXA.
*/
import { useCallback, useEffect, useState } from 'react';
@@ -21,6 +23,21 @@ interface PendingBatch {
const pendingByType = new Map();
+// INBUXA: an account's name is only its local part, so two domains' "leslie"
+// look alike in a picker. Label accounts by their full address instead.
+const ADDRESS_LABELED = new Set(['x:Account']);
+
+function labelOf(objectName: string, item: Record, displayProp: string, id: string): string {
+ if (ADDRESS_LABELED.has(objectName) && typeof item.emailAddress === 'string' && item.emailAddress) {
+ return item.emailAddress;
+ }
+ return coerceLabel(item[displayProp], id);
+}
+
+function labelProperties(objectName: string, displayProp: string): string[] {
+ return ADDRESS_LABELED.has(objectName) ? ['id', displayProp, 'emailAddress'] : ['id', displayProp];
+}
+
export function coerceLabel(value: unknown, fallback: string): string {
if (value == null) return fallback;
if (typeof value === 'string') return value;
@@ -67,12 +84,17 @@ async function executeBatch(parentObjectName: string, batch: PendingBatch): Prom
try {
const accountId = getAccountId(parentObjectName);
- const list = await jmapGetBatched(parentObjectName, accountId, ids, ['id', batch.displayProp]);
+ const list = await jmapGetBatched(
+ parentObjectName,
+ accountId,
+ ids,
+ labelProperties(parentObjectName, batch.displayProp),
+ );
const entries: Record = {};
for (const item of list) {
const itemId = item.id as string;
if (!itemId) continue;
- entries[itemId] = coerceLabel(item[batch.displayProp], itemId);
+ entries[itemId] = labelOf(parentObjectName, item, batch.displayProp, itemId);
}
for (const id of ids) {
if (!(id in entries)) entries[id] = id;
@@ -103,7 +125,7 @@ async function fetchObjectList(viewOrObjectName: string, schema: Schema): Promis
objectName,
accountId,
{ filter: filtersStatic && Object.keys(filtersStatic).length > 0 ? filtersStatic : undefined },
- ['id', displayProp],
+ labelProperties(objectName, displayProp),
);
items = result.list;
} catch (err) {
@@ -113,7 +135,7 @@ async function fetchObjectList(viewOrObjectName: string, schema: Schema): Promis
return items.map((item) => ({
id: item.id as string,
- label: coerceLabel(item[displayProp], item.id as string),
+ label: labelOf(objectName, item, displayProp, item.id as string),
}));
}