Lock and unlock people from Directory › Accounts #28

Merged
jcoffey-dev merged 1 commits from feature/directory-lock into main 2026-09-28 00:26:10 +00:00
2 changed files with 148 additions and 7 deletions

No files matched your search

+17 -7
View File
@@ -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<Record<string, unknown>[]>([]);
const [total, setTotal] = useState<number | null>(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<string, unknown>): 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 (
<DropdownMenu>
@@ -1117,6 +1122,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
</DropdownMenuItem>
);
})}
{lockItems}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -1327,9 +1333,12 @@ export function DynamicList({ viewName }: DynamicListProps) {
return (
<td key={col.name} className="px-3 py-2">
{colIndex === 0 && item[col.name] != null ? (
<ObjectHoverCard objectName={resolved.obj.objectName} id={itemId}>
{cell}
</ObjectHoverCard>
<>
<ObjectHoverCard objectName={resolved.obj.objectName} id={itemId}>
{cell}
</ObjectHoverCard>
{directoryLocks.badge(item)}
</>
) : (
cell
)}
@@ -1421,6 +1430,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
</AlertDialog>
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
{directoryLocks.dialogs}
</div>
);
}
+131
View File
@@ -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<string, unknown>): boolean {
return item['@type'] !== 'Group';
}
/** How the row names the person: its address, as the list shows it. */
function labelOf(item: Record<string, unknown>): 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<Map<string, AccountLock>>(new Map());
const [fetches, setFetches] = useState(0);
const [dialog, setDialog] = useState<Dialog>(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<string, unknown>): ReactNode => {
if (!enabled || !isPerson(item)) return null;
const id = String(item.id);
const lock = locks.get(id);
if (lock) {
if (!canDestroy) return null;
return (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setDialog({ kind: 'unlock', lock });
}}
>
<Unlock className="mr-2 h-4 w-4" />
{t('lock.unlockAction', 'Unlock…')}
</DropdownMenuItem>
</>
);
}
if (!canCreate) return null;
return (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={(e) => {
e.stopPropagation();
setDialog({ kind: 'lock', accountId: id, name: labelOf(item) });
}}
>
<Lock className="mr-2 h-4 w-4" />
{t('lock.lockThis', 'Lock this account…')}
</DropdownMenuItem>
</>
);
};
/** A badge beside a locked account's name. */
const badge = (item: Record<string, unknown>): ReactNode => {
if (!enabled || !locks.has(String(item.id))) return null;
return (
<Badge variant="destructive" className="ml-2 gap-1 align-middle">
<Lock className="h-3 w-3" />
{t('lock.lockedBadge', 'Locked')}
</Badge>
);
};
const done = () => {
setDialog(null);
refetch();
};
const dialogs: ReactNode =
dialog?.kind === 'lock' ? (
<LockDialog
accountId={dialog.accountId}
accountName={dialog.name}
onClose={() => setDialog(null)}
onDone={done}
/>
) : dialog?.kind === 'unlock' ? (
<UnlockDialog lock={dialog.lock} onClose={() => setDialog(null)} onDone={done} />
) : null;
return { enabled, menuItems, badge, dialogs };
}