Lock or unlock beside Delete on a person's page #29
No files matched your search
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user