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 ( +
+ + {page} +
+ ); + } 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 ( + <> +
+ +
+ {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 && ( + + )} +
+ {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 && ( + + )} + {canDestroy && ( + + )} +
+
+ {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 ( +