feat(hk): reveal-and-copy card for reset password (+ catalog cleanup)

Two things in one commit because they sit on top of each other:

1. **Reset password reveal card.** The emulator's
   `HousekeepingResetUserPasswordEvent` already returns the freshly
   generated 12-char plaintext in the action-result `message`, but
   the client was leaking it through the standard success-banner
   pipeline — auto-dismiss in 4s, truncated, no copy button. Operators
   were missing it.

   - New `<HousekeepingPasswordReveal />` card mounted in the panel
     header (between the status banner and tab content). Stays put
     until manually dismissed.
   - `useHousekeepingStore` gains a dedicated `passwordReveal` slot
     (`{ userId, username, password }`) plus `revealPassword()` /
     `clearPasswordReveal()` setters. Sensitive data, kept OUT of the
     generic banner / toast pipeline.
   - `useHousekeepingActions.resetUserPassword` no longer routes
     through `wrap()` — it intercepts the result, lifts the
     plaintext into the reveal slot, and uses a localizable success
     key (`housekeeping.action.reset_password.done`) for the banner so
     the password itself never lands there.
   - Copy button uses `navigator.clipboard.writeText` in secure
     contexts with a `document.execCommand('copy')` fallback for
     http:// deployments. Confirmation icon flips to a checkmark for
     ~1.6s on success. The input is `select-all` + auto-select on
     focus so Ctrl+C is also a manual fallback.
   - 8 new i18n keys (EN + IT, .example + runtime UITexts.json5 /
     UITexts.en.json5).

2. **Catalog admin cleanup ported from the PR branch.** The dev
   branch was still carrying the catalog admin code (handlers, hooks,
   store slots, i18n keys) even though the local renderer is on the
   catalog-stripped `feat/housekeeping-packets` branch — typecheck
   was breaking because the catalog composers no longer exist on the
   linked renderer. Stripped here to match: 4 catalog actions
   removed from `HousekeepingActionType`, `HousekeepingApi.ts`,
   `useHousekeepingActions`, `useHousekeepingStore`. The CATALOG tab
   id is gone from `HousekeepingTabId`. Catalog interfaces
   (`IHousekeepingCatalogPage` / `IHousekeepingCatalogOffer`) are
   dropped. 17 catalog i18n keys removed per locale. Two test files
   updated to match.
This commit is contained in:
simoleo89
2026-05-24 16:56:29 +02:00
committed by simoleo89
parent eeab548917
commit b8675b9dc3
6 changed files with 231 additions and 3 deletions
@@ -0,0 +1,149 @@
import { FC, useEffect, useState } from 'react';
import { FaCheck, FaCopy, FaKey, FaTimes } from 'react-icons/fa';
import { LocalizeText } from '../../api';
import { useHousekeepingStore } from '../../hooks';
const COPY_CONFIRM_MS = 1600;
/**
* Password-reveal card — surfaces the plaintext password the emulator
* returned from `HousekeepingResetUserPasswordEvent` so the operator
* can read it once and copy it out-of-band to the user.
*
* Sensitive data, so:
* - Renders OUTSIDE the auto-dismissing status banner (which truncates
* long content and disappears after 4s).
* - Stays put until the operator explicitly dismisses — they have to
* acknowledge they've copied/communicated the secret.
* - The plaintext lives in `useHousekeepingStore.passwordReveal` and
* never flows through the generic success-toast / banner pipeline
* (`useHousekeepingActions.resetUserPassword` intercepts it before
* `wrap`'s default path).
* - The clipboard write uses the modern `navigator.clipboard.writeText`
* when available, with a `document.execCommand('copy')` fallback for
* non-secure-context legacy paths so the button still works inside an
* `http://` deployment.
*/
export const HousekeepingPasswordReveal: FC = () =>
{
const { passwordReveal, clearPasswordReveal } = useHousekeepingStore();
const [ copyState, setCopyState ] = useState<'idle' | 'ok' | 'fail'>('idle');
// Reset the "copied!" visual whenever a new reveal lands so the
// operator doesn't see a stale checkmark from a previous reset.
useEffect(() =>
{
setCopyState('idle');
}, [ passwordReveal?.password ]);
// Auto-revert the copy-confirmation icon back to the copy icon
// a short while after a successful copy. The plaintext itself
// stays revealed until the operator explicitly dismisses.
useEffect(() =>
{
if(copyState === 'idle') return;
const handle = window.setTimeout(() => setCopyState('idle'), COPY_CONFIRM_MS);
return () => window.clearTimeout(handle);
}, [ copyState ]);
if(!passwordReveal) return null;
const copyPassword = async () =>
{
const text = passwordReveal.password;
if(!text) return;
// Modern path — requires a secure context (https / wss / localhost).
if(typeof navigator !== 'undefined' && navigator.clipboard && window.isSecureContext)
{
try
{
await navigator.clipboard.writeText(text);
setCopyState('ok');
return;
}
catch
{
// Fall through to the legacy path below — some browsers
// still gate the modern API behind extra permissions even
// in secure contexts.
}
}
// Legacy fallback: stage a textarea, select, exec copy. Works
// on plain-http deployments where `navigator.clipboard` is
// refused. The textarea is positioned off-screen so the user
// doesn't see a flash.
try
{
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'absolute';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, text.length);
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
setCopyState(ok ? 'ok' : 'fail');
}
catch
{
setCopyState('fail');
}
};
const copyIcon = copyState === 'ok' ? <FaCheck size={ 11 } /> : <FaCopy size={ 11 } />;
const copyLabel = copyState === 'ok'
? LocalizeText('housekeeping.password.copied')
: copyState === 'fail'
? LocalizeText('housekeeping.password.copy_failed')
: LocalizeText('housekeeping.password.copy');
return (
<div className="flex flex-col gap-1.5 px-2.5 py-2 border-y border-amber-200 bg-gradient-to-r from-amber-50 via-yellow-50 to-amber-50" role="status">
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider font-semibold text-amber-700">
<FaKey size={ 10 } />
<span className="grow">
{ LocalizeText('housekeeping.password.title', [ 'username', 'id' ], [ passwordReveal.username || '—', String(passwordReveal.userId) ]) }
</span>
<button
className="inline-flex items-center justify-center w-5 h-5 rounded text-amber-700 hover:text-amber-900 hover:bg-amber-200/60"
onClick={ () => clearPasswordReveal() }
title={ LocalizeText('housekeeping.password.dismiss') }
aria-label={ LocalizeText('housekeeping.password.dismiss') }>
<FaTimes size={ 10 } />
</button>
</div>
<div className="flex items-center gap-2">
{/* Readonly input lets the operator triple-click + Ctrl+C
as a manual fallback to the copy button. Monospace +
tabular-nums keeps lookalikes (Il1, O0) visually
distinct so they can read it aloud without typos. */}
<input
readOnly
type="text"
value={ passwordReveal.password }
className="grow font-mono tabular-nums text-sm px-2 py-1 rounded border border-amber-300 bg-white text-amber-950 focus:outline-none focus:ring-1 focus:ring-amber-400 select-all"
onFocus={ event => event.currentTarget.select() }
aria-label={ LocalizeText('housekeeping.password.value_label') } />
<button
className={ `inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-semibold border transition-colors ${ copyState === 'ok' ? 'bg-emerald-100 border-emerald-300 text-emerald-800' : copyState === 'fail' ? 'bg-rose-100 border-rose-300 text-rose-800' : 'bg-amber-100 border-amber-300 text-amber-900 hover:bg-amber-200' }` }
onClick={ copyPassword }
title={ copyLabel }
aria-label={ copyLabel }>
{ copyIcon }
<span>{ copyLabel }</span>
</button>
</div>
<p className="text-[10px] text-amber-700/80 leading-snug">{ LocalizeText('housekeeping.password.hint') }</p>
</div>
);
};
@@ -3,6 +3,7 @@ import { FC, useEffect, useMemo } from 'react';
import { getHousekeepingMode, HousekeepingTabId, isHousekeepingEnabled, isHousekeepingTabAvailable, LocalizeText } from '../../api';
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, WidgetErrorBoundary } from '../../common';
import { useHasPermission, useHousekeepingStore } from '../../hooks';
import { HousekeepingPasswordReveal } from './HousekeepingPasswordReveal';
import { HousekeepingStatusBanner } from './HousekeepingStatusBanner';
import { HousekeepingAuditTab } from './views/audit/HousekeepingAuditTab';
import { HousekeepingDashboardTab } from './views/dashboard/HousekeepingDashboardTab';
@@ -198,6 +199,7 @@ export const HousekeepingView: FC = () =>
</NitroCardTabsItemView> }
</NitroCardTabsView>
<HousekeepingStatusBanner />
<HousekeepingPasswordReveal />
<NitroCardContentView className="text-black" gap={ 2 }>
{ activeView }
</NitroCardContentView>