From e445734455874c4c23a5a05f2004310dbe8ee900 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 17:24:24 -0700 Subject: [PATCH] =?UTF-8?q?Lock=20and=20unlock=20people=20from=20Directory?= =?UTF-8?q?=20=E2=80=BA=20Accounts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each person's row menu offers Lock this account… or Unlock…, with the same dialogs as Compliance › Locked accounts, and a locked account shows a Locked badge beside its address. The account page's banner stays, so locking is in all three places admins look (audit-hold-lock spec, AL-1). --- src/components/lists/DynamicList.tsx | 24 +++-- src/features/lock/useDirectoryLocks.tsx | 131 ++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 src/features/lock/useDirectoryLocks.tsx diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index 79781af..89f7eeb 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -75,6 +75,7 @@ import type { JmapSetResponse, JmapSetError } from '@/types/jmap'; import type { ResolvedSchema } from '@/lib/schemaResolver'; import { ExplainButton } from '@/features/ai/explain/ExplainButton'; import { useExplainStore } from '@/features/ai/explain/explainStore'; +import { useDirectoryLocks } from '@/features/lock/useDirectoryLocks'; const ENUM_FILTER_COMBOBOX_THRESHOLD = 15; @@ -373,6 +374,8 @@ export function DynamicList({ viewName }: DynamicListProps) { const list = resolveList(schema, viewName, obj.objectName); return { obj, schema: schem, list }; }, [schema, viewName]); + // inbuxa: lock and unlock people from the account list (AL-1) + const directoryLocks = useDirectoryLocks(resolved?.obj.objectName === 'x:Account'); const [items, setItems] = useState[]>([]); const [total, setTotal] = useState(null); @@ -861,7 +864,7 @@ export function DynamicList({ viewName }: DynamicListProps) { const hasItemActions = (list.itemActions?.length ?? 0) > 0; // inbuxa: "Explain this" on each log entry (ai-explain EX-17) const explainLogs = explainAvailable && obj.objectName === 'x:Log'; - const hasRowCell = hasItemActions || explainLogs; + const hasRowCell = hasItemActions || explainLogs || directoryLocks.enabled; const pageStart = anchorStack.length * PAGE_SIZE; const rangeStart = pageStart + 1; @@ -1058,9 +1061,11 @@ export function DynamicList({ viewName }: DynamicListProps) { } function renderItemActions(item: Record): React.ReactNode { - if (!hasItemActions || !list.itemActions) return null; + // inbuxa: Lock… or Unlock… for a person (AL-1) + const lockItems = directoryLocks.menuItems(item); + if ((!hasItemActions || !list.itemActions) && !lockItems) return null; - const filteredActions = list.itemActions.flatMap((action): { action: ItemAction; locked: boolean }[] => { + const filteredActions = (list.itemActions ?? []).flatMap((action): { action: ItemAction; locked: boolean }[] => { if (action.type === 'separator') return [{ action, locked: false }]; if (action.type === 'delete') return canDelete ? [{ action, locked: false }] : []; if (action.type === 'setProperty') return canUpdate ? [{ action, locked: false }] : []; @@ -1076,7 +1081,7 @@ export function DynamicList({ viewName }: DynamicListProps) { return [{ action, locked: false }]; }); - if (filteredActions.length === 0) return null; + if (filteredActions.length === 0 && !lockItems) return null; return ( @@ -1117,6 +1122,7 @@ export function DynamicList({ viewName }: DynamicListProps) { ); })} + {lockItems} ); @@ -1327,9 +1333,12 @@ export function DynamicList({ viewName }: DynamicListProps) { return ( {colIndex === 0 && item[col.name] != null ? ( - - {cell} - + <> + + {cell} + + {directoryLocks.badge(item)} + ) : ( cell )} @@ -1421,6 +1430,7 @@ export function DynamicList({ viewName }: DynamicListProps) { setUpsellOpen(false)} /> + {directoryLocks.dialogs} ); } diff --git a/src/features/lock/useDirectoryLocks.tsx b/src/features/lock/useDirectoryLocks.tsx new file mode 100644 index 0000000..b65ea8f --- /dev/null +++ b/src/features/lock/useDirectoryLocks.tsx @@ -0,0 +1,131 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: locking from Management › Directory › Accounts, where admins look + * for it first (audit-hold-lock spec, AL-1). Each person's row menu offers + * Lock… or Unlock…, and a locked account carries a badge, beside the + * Compliance › Locked accounts page and the account page's banner. + */ + +import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Lock, Unlock } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'; +import { useAccountStore } from '@/stores/accountStore'; +import { fetchLocks, type AccountLock } from './accountLock'; +import { LockDialog, UnlockDialog } from './LockDialogs'; + +type Dialog = { kind: 'lock'; accountId: string; name?: string } | { kind: 'unlock'; lock: AccountLock } | null; + +/** A person's account, as a list row: a group can't be locked. */ +function isPerson(item: Record): boolean { + return item['@type'] !== 'Group'; +} + +/** How the row names the person: its address, as the list shows it. */ +function labelOf(item: Record): string | undefined { + for (const key of ['emailAddress', 'name']) { + const value = item[key]; + if (typeof value === 'string' && value) return value; + } + return undefined; +} + +export function useDirectoryLocks(active: boolean) { + 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 enabled = active && canGet; + const [locks, setLocks] = useState>(new Map()); + const [fetches, setFetches] = useState(0); + const [dialog, setDialog] = useState(null); + const refetch = useCallback(() => setFetches((n) => n + 1), []); + + useEffect(() => { + if (!enabled) return; + const controller = new AbortController(); + fetchLocks(undefined, controller.signal) + .then((list) => { + if (!controller.signal.aborted) setLocks(new Map(list.map((l) => [l.id, l]))); + }) + // An older server without locks: offer nothing + .catch(() => undefined); + return () => controller.abort(); + }, [enabled, fetches]); + + /** The row menu's lock item, or nothing. */ + const menuItems = (item: Record): ReactNode => { + if (!enabled || !isPerson(item)) return null; + const id = String(item.id); + const lock = locks.get(id); + if (lock) { + if (!canDestroy) return null; + return ( + <> + + { + e.stopPropagation(); + setDialog({ kind: 'unlock', lock }); + }} + > + + {t('lock.unlockAction', 'Unlock…')} + + + ); + } + if (!canCreate) return null; + return ( + <> + + { + e.stopPropagation(); + setDialog({ kind: 'lock', accountId: id, name: labelOf(item) }); + }} + > + + {t('lock.lockThis', 'Lock this account…')} + + + ); + }; + + /** A badge beside a locked account's name. */ + const badge = (item: Record): ReactNode => { + if (!enabled || !locks.has(String(item.id))) return null; + return ( + + + {t('lock.lockedBadge', 'Locked')} + + ); + }; + + const done = () => { + setDialog(null); + refetch(); + }; + + const dialogs: ReactNode = + dialog?.kind === 'lock' ? ( + setDialog(null)} + onDone={done} + /> + ) : dialog?.kind === 'unlock' ? ( + setDialog(null)} onDone={done} /> + ) : null; + + return { enabled, menuItems, badge, dialogs }; +} -- 2.54.0