mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
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:
@@ -16,6 +16,14 @@
|
|||||||
"housekeeping.action.pending": "Action pending…",
|
"housekeeping.action.pending": "Action pending…",
|
||||||
"housekeeping.action.success": "Action completed",
|
"housekeeping.action.success": "Action completed",
|
||||||
"housekeeping.action.error": "Action failed",
|
"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.invalid_input": "Invalid input — check the user id and the value you provided.",
|
||||||
"housekeeping.error.user_not_found": "User not found.",
|
"housekeeping.error.user_not_found": "User not found.",
|
||||||
"housekeeping.error.user_offline": "User is offline — this action only works on online users.",
|
"housekeeping.error.user_offline": "User is offline — this action only works on online users.",
|
||||||
|
|||||||
@@ -16,6 +16,14 @@
|
|||||||
"housekeeping.action.pending": "Azione in corso…",
|
"housekeeping.action.pending": "Azione in corso…",
|
||||||
"housekeeping.action.success": "Azione completata",
|
"housekeeping.action.success": "Azione completata",
|
||||||
"housekeeping.action.error": "Azione fallita",
|
"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.invalid_input": "Input non valido — controlla l'id utente e il valore inserito.",
|
||||||
"housekeeping.error.user_not_found": "Utente non trovato.",
|
"housekeeping.error.user_not_found": "Utente non trovato.",
|
||||||
"housekeeping.error.user_offline": "Utente offline — questa azione funziona solo sugli utenti online.",
|
"housekeeping.error.user_offline": "Utente offline — questa azione funziona solo sugli utenti online.",
|
||||||
|
|||||||
@@ -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 { getHousekeepingMode, HousekeepingTabId, isHousekeepingEnabled, isHousekeepingTabAvailable, LocalizeText } from '../../api';
|
||||||
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, WidgetErrorBoundary } from '../../common';
|
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, WidgetErrorBoundary } from '../../common';
|
||||||
import { useHasPermission, useHousekeepingStore } from '../../hooks';
|
import { useHasPermission, useHousekeepingStore } from '../../hooks';
|
||||||
|
import { HousekeepingPasswordReveal } from './HousekeepingPasswordReveal';
|
||||||
import { HousekeepingStatusBanner } from './HousekeepingStatusBanner';
|
import { HousekeepingStatusBanner } from './HousekeepingStatusBanner';
|
||||||
import { HousekeepingAuditTab } from './views/audit/HousekeepingAuditTab';
|
import { HousekeepingAuditTab } from './views/audit/HousekeepingAuditTab';
|
||||||
import { HousekeepingDashboardTab } from './views/dashboard/HousekeepingDashboardTab';
|
import { HousekeepingDashboardTab } from './views/dashboard/HousekeepingDashboardTab';
|
||||||
@@ -198,6 +199,7 @@ export const HousekeepingView: FC = () =>
|
|||||||
</NitroCardTabsItemView> }
|
</NitroCardTabsItemView> }
|
||||||
</NitroCardTabsView>
|
</NitroCardTabsView>
|
||||||
<HousekeepingStatusBanner />
|
<HousekeepingStatusBanner />
|
||||||
|
<HousekeepingPasswordReveal />
|
||||||
<NitroCardContentView className="text-black" gap={ 2 }>
|
<NitroCardContentView className="text-black" gap={ 2 }>
|
||||||
{ activeView }
|
{ activeView }
|
||||||
</NitroCardContentView>
|
</NitroCardContentView>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ const validationOr = (key: HousekeepingErrorKey, markDone: (e: string | null, s:
|
|||||||
*/
|
*/
|
||||||
export const useHousekeepingActions = () =>
|
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();
|
const { showSingleBubble } = useNotification();
|
||||||
// Stable closure-bound runner so every action below stays a
|
// Stable closure-bound runner so every action below stays a
|
||||||
// one-liner: only the runner thunk + a per-action telemetry
|
// 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;
|
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
|
||||||
|
|
||||||
return runAction(() => HousekeepingApi.resetUserPassword(userId), 'resetUserPassword');
|
// Run the action with a localizable success message — we
|
||||||
}, [ runAction, markActionDone ]);
|
// 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) =>
|
const setUserRank = useCallback(async (userId: number, rank: number) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -42,6 +42,19 @@ const useHousekeepingStoreInner = () =>
|
|||||||
// on referential equality — mutating a Set in place would miss
|
// on referential equality — mutating a Set in place would miss
|
||||||
// updates. Capped via the dedupe in toggleUserSelection.
|
// updates. Capped via the dedupe in toggleUserSelection.
|
||||||
const [ selectedUserIds, setSelectedUserIds ] = useState<number[]>([]);
|
const [ selectedUserIds, setSelectedUserIds ] = useState<number[]>([]);
|
||||||
|
// 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
|
// Per-action latency / count / error metrics. Map → triggers a
|
||||||
// new reference on every update so subscribers re-render.
|
// new reference on every update so subscribers re-render.
|
||||||
// Capped per-action via `recordSample`'s sliding window so the
|
// Capped per-action via `recordSample`'s sliding window so the
|
||||||
@@ -497,6 +510,9 @@ const useHousekeepingStoreInner = () =>
|
|||||||
selectedUserIds,
|
selectedUserIds,
|
||||||
toggleUserSelection,
|
toggleUserSelection,
|
||||||
clearUserSelection,
|
clearUserSelection,
|
||||||
|
passwordReveal,
|
||||||
|
revealPassword,
|
||||||
|
clearPasswordReveal,
|
||||||
metricsByAction,
|
metricsByAction,
|
||||||
recordActionMetric,
|
recordActionMetric,
|
||||||
resetActionMetrics
|
resetActionMetrics
|
||||||
|
|||||||
Reference in New Issue
Block a user