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; +}