From 3c1726a23a969aec4c25b7ae22f3e0c1d08720ec Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 17:35:50 -0700 Subject: [PATCH] Lock or unlock beside Delete on a person's page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lock this account… (or Unlock…) now sits with Delete at the foot of the account form, where the account's other lasting actions are. The banner above the form only says whether the account is locked and to whom it is handed; both refresh together after either acts. The lock dialog names the account by its address rather than its id. --- src/components/forms/DynamicForm.tsx | 5 +- src/features/lock/AccountLockBanner.tsx | 84 ++++++------------------- src/features/lock/AccountLockButton.tsx | 71 +++++++++++++++++++++ src/features/lock/accountLock.ts | 3 + src/features/lock/useAccountLock.ts | 34 ++++++++++ 5 files changed, 130 insertions(+), 67 deletions(-) create mode 100644 src/features/lock/AccountLockButton.tsx create mode 100644 src/features/lock/useAccountLock.ts diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index 872f189..a27233f 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -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) { ))}
-
+
{canDelete && ( )} + {/* inbuxa: lock or unlock a person beside Delete (AL-1) */} + {viewName === 'x:Account/User' && objectId && }
{isDirty && ( diff --git a/src/features/lock/AccountLockBanner.tsx b/src/features/lock/AccountLockBanner.tsx index dd2811f..fd55991 100644 --- a/src/features/lock/AccountLockBanner.tsx +++ b/src/features/lock/AccountLockBanner.tsx @@ -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(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} />} - - ); - } + const lock = useAccountLock(accountId, canGet); + if (!canGet || !lock) return null; return (
-
-
- -
-

- {t('lock.bannerTitle', 'Locked: receives mail, can’t sign in, sends nothing on its own.')} +

+ +
+

+ {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(', ')}

-

{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/AccountLockButton.tsx b/src/features/lock/AccountLockButton.tsx new file mode 100644 index 0000000..ce028d9 --- /dev/null +++ b/src/features/lock/AccountLockButton.tsx @@ -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 ( + <> + + {open && setOpen(false)} onDone={done} />} + + ); + } + + if (!canCreate) return null; + return ( + <> + + {open && ( + setOpen(false)} + onDone={done} + /> + )} + + ); +} diff --git a/src/features/lock/accountLock.ts b/src/features/lock/accountLock.ts index e02b755..df20576 100644 --- a/src/features/lock/accountLock.ts +++ b/src/features/lock/accountLock.ts @@ -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'; diff --git a/src/features/lock/useAccountLock.ts b/src/features/lock/useAccountLock.ts new file mode 100644 index 0000000..a51cce5 --- /dev/null +++ b/src/features/lock/useAccountLock.ts @@ -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(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; +} -- 2.54.0