Lock and unlock people from Directory › Accounts #28
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user