From b8675b9dc3540ec8b86d81508568578e09318051 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 24 May 2026 16:56:29 +0200 Subject: [PATCH] feat(hk): reveal-and-copy card for reset password (+ catalog cleanup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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. --- .../housekeeping-texts-en.example | 8 + .../housekeeping-texts-it.example | 8 + .../HousekeepingPasswordReveal.tsx | 149 ++++++++++++++++++ .../housekeeping/HousekeepingView.tsx | 2 + .../housekeeping/useHousekeepingActions.ts | 51 +++++- .../housekeeping/useHousekeepingStore.ts | 16 ++ 6 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 src/components/housekeeping/HousekeepingPasswordReveal.tsx diff --git a/public/configuration/housekeeping-texts-en.example b/public/configuration/housekeeping-texts-en.example index b432660..3e16a74 100644 --- a/public/configuration/housekeeping-texts-en.example +++ b/public/configuration/housekeeping-texts-en.example @@ -16,6 +16,14 @@ "housekeeping.action.pending": "Action pending…", "housekeeping.action.success": "Action completed", "housekeeping.action.error": "Action failed", + "housekeeping.action.reset_password.done": "Password reset — new password shown below.", + "housekeeping.password.title": "%username% (#%id%) · new password", + "housekeeping.password.value_label": "Generated password", + "housekeeping.password.copy": "Copy", + "housekeeping.password.copied": "Copied", + "housekeeping.password.copy_failed": "Copy failed", + "housekeeping.password.dismiss": "Dismiss", + "housekeeping.password.hint": "Share with the user out-of-band. This is shown once — close this card when you're done; the password is never displayed again.", "housekeeping.error.invalid_input": "Invalid input — check the user id and the value you provided.", "housekeeping.error.user_not_found": "User not found.", "housekeeping.error.user_offline": "User is offline — this action only works on online users.", diff --git a/public/configuration/housekeeping-texts-it.example b/public/configuration/housekeeping-texts-it.example index 80ea048..8f5903f 100644 --- a/public/configuration/housekeeping-texts-it.example +++ b/public/configuration/housekeeping-texts-it.example @@ -16,6 +16,14 @@ "housekeeping.action.pending": "Azione in corso…", "housekeeping.action.success": "Azione completata", "housekeeping.action.error": "Azione fallita", + "housekeeping.action.reset_password.done": "Password resettata — la nuova password è mostrata sotto.", + "housekeeping.password.title": "%username% (#%id%) · nuova password", + "housekeeping.password.value_label": "Password generata", + "housekeeping.password.copy": "Copia", + "housekeeping.password.copied": "Copiata", + "housekeeping.password.copy_failed": "Copia fallita", + "housekeeping.password.dismiss": "Chiudi", + "housekeeping.password.hint": "Condividila con l'utente fuori dal client. La password viene mostrata una sola volta — chiudi questa card quando hai finito, non sarà più visibile.", "housekeeping.error.invalid_input": "Input non valido — controlla l'id utente e il valore inserito.", "housekeeping.error.user_not_found": "Utente non trovato.", "housekeeping.error.user_offline": "Utente offline — questa azione funziona solo sugli utenti online.", diff --git a/src/components/housekeeping/HousekeepingPasswordReveal.tsx b/src/components/housekeeping/HousekeepingPasswordReveal.tsx new file mode 100644 index 0000000..b88f476 --- /dev/null +++ b/src/components/housekeeping/HousekeepingPasswordReveal.tsx @@ -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' ? : ; + const copyLabel = copyState === 'ok' + ? LocalizeText('housekeeping.password.copied') + : copyState === 'fail' + ? LocalizeText('housekeeping.password.copy_failed') + : LocalizeText('housekeeping.password.copy'); + + return ( +
+
+ + + { LocalizeText('housekeeping.password.title', [ 'username', 'id' ], [ passwordReveal.username || '—', String(passwordReveal.userId) ]) } + + +
+ +
+ {/* 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. */} + event.currentTarget.select() } + aria-label={ LocalizeText('housekeeping.password.value_label') } /> + +
+ +

{ LocalizeText('housekeeping.password.hint') }

+
+ ); +}; diff --git a/src/components/housekeeping/HousekeepingView.tsx b/src/components/housekeeping/HousekeepingView.tsx index 219ba60..ea572be 100644 --- a/src/components/housekeeping/HousekeepingView.tsx +++ b/src/components/housekeeping/HousekeepingView.tsx @@ -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 = () => } + { activeView } diff --git a/src/hooks/housekeeping/useHousekeepingActions.ts b/src/hooks/housekeeping/useHousekeepingActions.ts index b462ca1..9f32058 100644 --- a/src/hooks/housekeeping/useHousekeepingActions.ts +++ b/src/hooks/housekeeping/useHousekeepingActions.ts @@ -94,7 +94,7 @@ const validationOr = (key: HousekeepingErrorKey, markDone: (e: string | null, s: */ export const useHousekeepingActions = () => { - const { selectedUser, selectedRoom, markActionPending, markActionDone, setSelectedUser, setSelectedRoom, recordActionMetric } = useHousekeepingStore(); + const { selectedUser, selectedRoom, markActionPending, markActionDone, setSelectedUser, setSelectedRoom, recordActionMetric, revealPassword } = useHousekeepingStore(); const { showSingleBubble } = useNotification(); // Stable closure-bound runner so every action below stays a // one-liner: only the runner thunk + a per-action telemetry @@ -151,8 +151,53 @@ export const useHousekeepingActions = () => { if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null; - return runAction(() => HousekeepingApi.resetUserPassword(userId), 'resetUserPassword'); - }, [ runAction, markActionDone ]); + // Run the action with a localizable success message — we + // INTERCEPT before `wrap`'s default behavior leaks the plaintext + // into the auto-dismissing status banner. The emulator returns + // the freshly-generated plaintext in `result.message`; we lift it + // into the dedicated `passwordReveal` slot which renders a + // persistent card with a copy button. The wrapping `runAction` + // would also fire a transient toast with whatever string lands + // in `message`, so we bypass it via a direct API call + manual + // status writes here. + const username = (selectedUser && selectedUser.id === userId) ? selectedUser.username : ''; + + markActionPending(); + const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now(); + const measure = (isError: boolean) => + { + const endedAt = typeof performance !== 'undefined' ? performance.now() : Date.now(); + recordActionMetric('resetUserPassword', endedAt - startedAt, isError); + }; + + try + { + const result = await HousekeepingApi.resetUserPassword(userId); + + if(!result || result.ok === false) + { + markActionDone(result?.message || 'housekeeping.action.error', null); + measure(true); + return result ?? null; + } + + const plaintext = result.message ?? ''; + + if(plaintext) revealPassword(userId, username, plaintext); + + // Generic success key — does NOT include the plaintext, so + // even if the banner is visible the password isn't in it. + markActionDone(null, 'housekeeping.action.reset_password.done'); + measure(false); + return result; + } + catch(error) + { + markActionDone(String((error as Error)?.message ?? error), null); + measure(true); + return null; + } + }, [ markActionPending, markActionDone, selectedUser, revealPassword, recordActionMetric ]); const setUserRank = useCallback(async (userId: number, rank: number) => { diff --git a/src/hooks/housekeeping/useHousekeepingStore.ts b/src/hooks/housekeeping/useHousekeepingStore.ts index a60039e..93dfca6 100644 --- a/src/hooks/housekeeping/useHousekeepingStore.ts +++ b/src/hooks/housekeeping/useHousekeepingStore.ts @@ -42,6 +42,19 @@ const useHousekeepingStoreInner = () => // on referential equality — mutating a Set in place would miss // updates. Capped via the dedupe in toggleUserSelection. const [ selectedUserIds, setSelectedUserIds ] = useState([]); + // Password-reveal state — when reset-password succeeds, the emulator + // returns the freshly-generated plaintext password in the action + // result. We hold it in a dedicated state slot (not the success + // banner) so it doesn't auto-dismiss and the operator can read / + // copy it. Cleared manually via `clearPasswordReveal()` — sensitive + // data, treat it like a one-shot secret. + const [ passwordReveal, setPasswordReveal ] = useState<{ userId: number; username: string; password: string } | null>(null); + const revealPassword = useCallback((userId: number, username: string, password: string) => + { + if(!password) return; + setPasswordReveal({ userId, username, password }); + }, []); + const clearPasswordReveal = useCallback(() => setPasswordReveal(null), []); // Per-action latency / count / error metrics. Map → triggers a // new reference on every update so subscribers re-render. // Capped per-action via `recordSample`'s sliding window so the @@ -497,6 +510,9 @@ const useHousekeepingStoreInner = () => selectedUserIds, toggleUserSelection, clearUserSelection, + passwordReveal, + revealPassword, + clearPasswordReveal, metricsByAction, recordActionMetric, resetActionMetrics