Compliance > Locked accounts #27

Merged
jcoffey-dev merged 2 commits from feature/account-lock into main 2026-09-27 22:55:37 +00:00
9 changed files with 866 additions and 4 deletions

No files matched your search

+21
View File
@@ -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 <AuditLogPage />;
}
if (componentName === 'AccountLocks') {
return <AccountLocksPage />;
}
return (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
Unknown component: {componentName}
@@ -186,6 +198,15 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
</div>
);
}
// INBUXA: a person's page says whether it is locked (AL-1)
if (viewName === 'x:Account/User') {
return (
<div className="space-y-4">
<AccountLockBanner accountId={id} />
{page}
</div>
);
}
return page;
}
+92
View File
@@ -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<AccountLock | null | undefined>(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 (
<>
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => setDialog('lock')}>
<Lock className="mr-2 h-4 w-4" />
{t('lock.lockThis', 'Lock this account…')}
</Button>
</div>
{dialog === 'lock' && <LockDialog accountId={accountId} onClose={() => setDialog(null)} onDone={done} />}
</>
);
}
return (
<div className="rounded-xl border border-destructive/40 bg-destructive/5 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-3">
<Lock className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div className="space-y-1 text-sm">
<p className="font-medium">
{t('lock.bannerTitle', 'Locked: receives mail, can’t sign in, sends nothing on its own.')}
</p>
<p className="text-muted-foreground">{lock.reason}</p>
{lock.delegates.length > 0 && (
<p className="text-muted-foreground">
{t('lock.bannerDelegates', 'Handed to')}{' '}
{lock.delegates.map((d) => `${d.name ?? d.accountId} (${accessLabel(t, d.access)})`).join(', ')}
</p>
)}
</div>
</div>
{canDestroy && (
<Button variant="outline" size="sm" onClick={() => setDialog('unlock')}>
<Unlock className="mr-2 h-4 w-4" />
{t('lock.unlockAction', 'Unlock…')}
</Button>
)}
</div>
{dialog === 'unlock' && <UnlockDialog lock={lock} onClose={() => setDialog(null)} onDone={done} />}
</div>
);
}
+153
View File
@@ -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<Load>({ kind: 'loading' });
const [fetches, setFetches] = useState(0);
const [locking, setLocking] = useState(false);
const [editing, setEditing] = useState<AccountLock | null>(null);
const [unlocking, setUnlocking] = useState<AccountLock | null>(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 (
<div className="mx-auto max-w-5xl space-y-5">
<PageHeader
icon="lock"
title={t('lock.title', 'Locked accounts')}
subtitle={t(
'lock.subtitle',
'Accounts that keep receiving mail but can’t sign in and send nothing on their own, for someone who left, an investigation or a compromised account. Delegates can open them beside their own mail.',
)}
actions={
canCreate && (
<Button variant="destructive" onClick={() => setLocking(true)}>
<Lock className="mr-2 h-4 w-4" />
{t('lock.lockAction', 'Lock an account…')}
</Button>
)
}
/>
{load.kind === 'loading' && <LoadingFallback />}
{load.kind === 'error' && (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">{load.message}</div>
)}
{load.kind === 'ready' && load.locks.length === 0 && (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
{t('lock.none', 'No account is locked.')}
</div>
)}
{load.kind === 'ready' &&
load.locks.map((lock) => (
<div key={lock.id} className="space-y-3 rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 font-medium">
<Lock className="h-4 w-4 text-destructive" />
{lock.name}
</div>
<div className="text-sm text-muted-foreground">
{t('lock.lockedBy', 'Locked {{when}} by {{who}}', {
when: new Date(lock.lockedAt).toLocaleString(),
who: lock.lockedBy,
})}
</div>
<div className="mt-1 text-sm">{lock.reason}</div>
</div>
<div className="flex gap-2">
{canUpdate && (
<Button variant="outline" size="sm" onClick={() => setEditing(lock)}>
<Pencil className="mr-2 h-4 w-4" />
{t('lock.editAction', 'Delegates…')}
</Button>
)}
{canDestroy && (
<Button variant="outline" size="sm" onClick={() => setUnlocking(lock)}>
<Unlock className="mr-2 h-4 w-4" />
{t('lock.unlockAction', 'Unlock…')}
</Button>
)}
</div>
</div>
{lock.delegates.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('lock.noDelegates', 'Not handed to anyone.')}</p>
) : (
<ul className="space-y-1 text-sm">
{lock.delegates.map((d) => (
<li key={d.accountId} className="flex flex-wrap items-center gap-2">
<span className="font-medium">{d.name ?? d.accountId}</span>
<Badge variant="secondary">{accessLabel(t, d.access)}</Badge>
{d.sendAs && <Badge variant="outline">{t('lock.sendsAs', 'sends as')}</Badge>}
{d.until && (
<span className="text-muted-foreground">
{t('lock.untilDay', 'until {{day}}', { day: d.until })}
</span>
)}
</li>
))}
</ul>
)}
</div>
))}
{locking && <LockDialog onClose={() => setLocking(false)} onDone={done} />}
{editing && <LockDialog existing={editing} onClose={() => setEditing(null)} onDone={done} />}
{unlocking && <UnlockDialog lock={unlocking} onClose={() => setUnlocking(null)} onDone={done} />}
</div>
);
}
+312
View File
@@ -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 (
<label className="block space-y-1 text-sm">
<span className="text-muted-foreground">{t('lock.reason', 'Reason')}</span>
<Textarea
value={value}
maxLength={500}
placeholder={t('lock.reasonHint', 'Why, for the audit log: a ticket, a case, “left the company”…')}
onChange={(e) => onChange(e.target.value)}
/>
</label>
);
}
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<Delegate>) =>
onChange(delegates.map((d, j) => (j === i ? { ...d, ...patch } : d)));
return (
<div className="space-y-3">
<div className="text-sm text-muted-foreground">
{t(
'lock.delegatesHint',
'People who can open this account, beside their own mail, while it is locked. Everything they do there is recorded.',
)}
</div>
{delegates.map((d, i) => (
<div key={i} className="space-y-2 rounded-lg border p-3">
<div className="flex items-start justify-between gap-2">
{schema ? (
<ObjectPicker
schema={schema}
objectName="x:Account"
value={d.accountId}
onChange={(id) => update(i, { accountId: id })}
placeholder={t('lock.delegateAccount', 'Choose a person')}
/>
) : (
<Input value={d.accountId} onChange={(e) => update(i, { accountId: e.target.value })} />
)}
<Button
variant="ghost"
size="icon"
aria-label={t('lock.removeDelegate', 'Remove')}
onClick={() => onChange(delegates.filter((_, j) => j !== i))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<label className="space-y-1 text-sm sm:col-span-2">
<span className="text-muted-foreground">{t('lock.accessLabel', 'Access')}</span>
<Select
value={d.access}
onValueChange={(v) => update(i, { access: v as Access, sendAs: v === 'read' ? false : d.sendAs })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACCESS_LEVELS.map((a) => (
<SelectItem key={a} value={a}>
{accessLabel(t, a)}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="space-y-1 text-sm">
<span className="text-muted-foreground">{t('lock.until', 'Until (optional)')}</span>
<Input type="date" value={d.until} onChange={(e) => update(i, { until: e.target.value })} />
</label>
</div>
<p className="text-xs text-muted-foreground">{accessHint(t, d.access)}</p>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={d.sendAs}
disabled={d.access === 'read'}
onCheckedChange={(v) => update(i, { sendAs: v === true })}
/>
<span className={d.access === 'read' ? 'text-muted-foreground' : ''}>
{t('lock.sendAs', 'May send as this account (its Sent keeps a copy)')}
</span>
</label>
</div>
))}
{delegates.length < 10 && (
<Button
variant="outline"
size="sm"
onClick={() => onChange([...delegates, { accountId: '', access: 'read', sendAs: false, until: '' }])}
>
<Plus className="mr-2 h-4 w-4" />
{t('lock.addDelegate', 'Add a delegate')}
</Button>
)}
</div>
);
}
/** 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<Delegate[]>(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 (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-h-[90vh] max-w-xl overflow-y-auto">
<DialogHeader>
<DialogTitle>
{existing
? t('lock.editTitle', 'Delegates of {{name}}', { name: existing.name })
: t('lock.lockTitle', 'Lock an account')}
</DialogTitle>
{!existing && (
<DialogDescription>
{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.',
)}
</DialogDescription>
)}
</DialogHeader>
<div className="space-y-4">
{!existing && (
<label className="block space-y-1 text-sm">
<span className="text-muted-foreground">{t('lock.account', 'Account')}</span>
{fixedAccountId ? (
<div className="font-medium">{accountName ?? fixedAccountId}</div>
) : schema ? (
<ObjectPicker
schema={schema}
objectName="x:Account"
value={accountId}
onChange={setAccountId}
placeholder={t('lock.chooseAccount', 'Choose the account to lock')}
/>
) : null}
</label>
)}
<DelegateEditor delegates={delegates} onChange={setDelegates} />
{problem && <p className="text-sm text-destructive">{problem}</p>}
<ReasonField value={reason} onChange={setReason} />
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>
{t('lock.cancel', 'Cancel')}
</Button>
<Button onClick={() => void save()} disabled={busy || !ready} variant={existing ? 'default' : 'destructive'}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : !existing && <Lock className="mr-2 h-4 w-4" />}
{existing ? t('lock.save', 'Save') : t('lock.lockGo', 'Lock account')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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 (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('lock.unlockTitle', 'Unlock {{name}}', { name: lock.name })}</DialogTitle>
<DialogDescription>
{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.',
)}
</DialogDescription>
</DialogHeader>
<ReasonField value={reason} onChange={setReason} />
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>
{t('lock.cancel', 'Cancel')}
</Button>
<Button onClick={() => void run()} disabled={busy || !reason.trim()}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Unlock className="mr-2 h-4 w-4" />}
{t('lock.unlockGo', 'Unlock')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+50
View File
@@ -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>): 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('');
});
});
+185
View File
@@ -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<string, Record<string, { description?: string; type?: string }> | 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<string>();
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<string, unknown>): AccountLock {
const delegates = Array.isArray(raw.delegates) ? (raw.delegates as Record<string, unknown>[]) : [];
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<AccountLock[]> {
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<string, unknown>[] }).list ?? []).map(fromServer);
}
export async function lockAccount(accountId: string, reason: string, delegates: Delegate[]): Promise<void> {
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<void> {
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<void> {
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;
}
+22
View File
@@ -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');
}
}
+5
View File
@@ -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 };
}
+26 -4
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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<string, PendingBatch>();
// 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<string, unknown>, 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<string, string> = {};
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),
}));
}