Lock or unlock beside Delete on a person's page #29

Merged
jcoffey-dev merged 1 commits from feature/lock-beside-delete into main 2026-09-28 00:37:36 +00:00
5 changed files with 130 additions and 67 deletions

No files matched your search

+4 -1
View File
@@ -41,6 +41,7 @@ import {
} from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ArrowLeft, Save, Trash2, Loader2 } from 'lucide-react';
import { AccountLockButton } from '@/features/lock/AccountLockButton';
import { useSchemaStore } from '@/stores/schemaStore';
import { useAccountStore } from '@/stores/accountStore';
@@ -920,13 +921,15 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
))}
<div className="flex items-center justify-between pt-2 pb-8">
<div>
<div className="flex items-center gap-3">
{canDelete && (
<Button type="button" variant="destructive" disabled={saving} onClick={() => setDeleteConfirmOpen(true)}>
<Trash2 className="h-4 w-4 mr-2" />
{t('common.delete', 'Delete')}
</Button>
)}
{/* inbuxa: lock or unlock a person beside Delete (AL-1) */}
{viewName === 'x:Account/User' && objectId && <AccountLockButton accountId={objectId} disabled={saving} />}
</div>
<div className="flex items-center gap-3">
{isDirty && (
+18 -66
View File
@@ -6,87 +6,39 @@
/**
* 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).
* handed (audit-hold-lock spec, AL-1). Locking and unlocking are beside
* Delete at the foot of the page (AccountLockButton).
*/
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Lock, Unlock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Lock } from 'lucide-react';
import { useAccountStore } from '@/stores/accountStore';
import { fetchLocks, type AccountLock } from './accountLock';
import { LockDialog, UnlockDialog } from './LockDialogs';
import { useAccountLock } from './useAccountLock';
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} />}
</>
);
}
const lock = useAccountLock(accountId, canGet);
if (!canGet || !lock) return null;
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.')}
<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>
<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>
);
}
+71
View File
@@ -0,0 +1,71 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* INBUXA: Lock this account… or Unlock… beside Delete on a person's page,
* where the account's other lasting actions are (audit-hold-lock spec,
* AL-1). The banner above the form says whether it is locked; both follow
* LOCK_CHANGED so they agree after either acts.
*/
import { 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 { useSchemaStore } from '@/stores/schemaStore';
import { useObjectLabel } from '@/lib/objectOptions';
import { LOCK_CHANGED } from './accountLock';
import { useAccountLock } from './useAccountLock';
import { LockDialog, UnlockDialog } from './LockDialogs';
export function AccountLockButton({ accountId, disabled }: { accountId: string; disabled?: boolean }) {
const { t } = useTranslation();
const schema = useSchemaStore((s) => s.schema);
const canGet = useAccountStore((s) => s.hasPermission('sysAccountLockGet'));
const canCreate = useAccountStore((s) => s.hasPermission('sysAccountLockCreate'));
const canDestroy = useAccountStore((s) => s.hasPermission('sysAccountLockDestroy'));
const lock = useAccountLock(accountId, canGet);
const { label } = useObjectLabel('x:Account', accountId, schema!);
const [open, setOpen] = useState(false);
if (!canGet || lock === undefined) return null;
const done = () => {
setOpen(false);
window.dispatchEvent(new Event(LOCK_CHANGED));
};
if (lock) {
if (!canDestroy) return null;
return (
<>
<Button type="button" variant="outline" disabled={disabled} onClick={() => setOpen(true)}>
<Unlock className="mr-2 h-4 w-4" />
{t('lock.unlockAction', 'Unlock…')}
</Button>
{open && <UnlockDialog lock={lock} onClose={() => setOpen(false)} onDone={done} />}
</>
);
}
if (!canCreate) return null;
return (
<>
<Button type="button" variant="outline" disabled={disabled} onClick={() => setOpen(true)}>
<Lock className="mr-2 h-4 w-4" />
{t('lock.lockThis', 'Lock this account…')}
</Button>
{open && (
<LockDialog
accountId={accountId}
accountName={label ?? undefined}
onClose={() => setOpen(false)}
onDone={done}
/>
)}
</>
);
}
+3
View File
@@ -13,6 +13,9 @@
import { getAccountId, jmapRequest } from '@/services/jmap/client';
export const INBUXA_CAPABILITY = 'urn:inbuxa:jmap';
/** Fired on window after a lock or unlock, so every view of it refreshes. */
export const LOCK_CHANGED = 'inbuxa:lock-changed';
const OBJECT = 'inbuxa:AccountLock';
export type Access = 'read' | 'organize' | 'full';
+34
View File
@@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useCallback, useEffect, useState } from 'react';
import { fetchLocks, LOCK_CHANGED, type AccountLock } from './accountLock';
/** The account's lock, `null` when it isn't locked, `undefined` until known. */
export function useAccountLock(accountId: string, enabled: boolean) {
const [lock, setLock] = useState<AccountLock | null | undefined>(undefined);
const [fetches, setFetches] = useState(0);
const refetch = useCallback(() => setFetches((n) => n + 1), []);
useEffect(() => {
window.addEventListener(LOCK_CHANGED, refetch);
return () => window.removeEventListener(LOCK_CHANGED, refetch);
}, [refetch]);
useEffect(() => {
if (!enabled) 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, enabled, fetches]);
return lock;
}