diff --git a/src/components/RecoveryAlertsCard.css b/src/components/RecoveryAlertsCard.css index 38b042e..45fd275 100644 --- a/src/components/RecoveryAlertsCard.css +++ b/src/components/RecoveryAlertsCard.css @@ -28,6 +28,25 @@ .recovery-alerts .auth-alert { overflow-wrap: anywhere; } +.recovery-feedback, +.recovery-focus-target { + scroll-margin-top: 90px; + scroll-margin-bottom: 20px; +} +.recovery-feedback:focus-visible, +.recovery-focus-target:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 4px; +} +.recovery-verification { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; +} +.recovery-verification p { + margin: 0; +} .recovery-email { overflow-wrap: anywhere; } @@ -144,6 +163,23 @@ .recovery-link-details[open] summary { margin-bottom: 12px; } +.recovery-review { + border-top: 1px solid var(--panel-outline); + padding-top: 12px; +} +.recovery-review ol { + margin: 0 0 12px; + padding-left: 22px; + color: var(--muted); + font-size: 0.85rem; + line-height: 1.6; +} +.recovery-review li + li { + margin-top: 8px; +} +.recovery-history .recovery-operation + .recovery-operation { + margin-top: 12px; +} .recovery-disable, .recovery-refresh { align-self: flex-start; diff --git a/src/components/RecoveryAlertsCard.js b/src/components/RecoveryAlertsCard.js index f186959..1d52e29 100644 --- a/src/components/RecoveryAlertsCard.js +++ b/src/components/RecoveryAlertsCard.js @@ -110,13 +110,51 @@ function AccountAddress({ address }) { ); } +const TERMINAL_RECOVERIES = new Set(['expired', 'canceled', 'invalidated']); + +function RecoveryOperation({ operation }) { + const pending = ['pending', 'ready', 'scheduled'].includes(operation.status); + return ( +
+ + {{ + scheduled: 'Recovery pending', + pending: 'Recovery pending', + ready: 'Recovery can be executed', + expired: 'Recovery expired', + executed: 'Recovery executed', + canceled: 'Recovery canceled or invalidated', + invalidated: 'Recovery no longer pending', + orphaned: 'Recovery observation no longer canonical', + unknown: 'Recovery status unavailable', + }[operation.status] || 'Recovery observed'} + + + {pending ? 'Can execute from' : 'Original execution time'}{' '} + {datetime(operation.executableAt)} + + {Number(operation.expiresAt) > 0 && ( + + {pending ? 'Expires' : 'Original expiry'}{' '} + {datetime(operation.expiresAt)} + + )} + {pending && ( + + Passing the deadline does not mean it has executed. + + )} +
+ ); +} + export default function RecoveryAlertsCard({ service = recoveryAlertsService, authService = defaultAuthService, defaultOpen = false, discover = discoverWallets, }) { - const { user, handleAuthLost } = useAuth(); + const { user, handleAuthLost, refresh } = useAuth(); const userId = user && user.id; const [open, setOpen] = useState( defaultOpen || @@ -134,21 +172,51 @@ export default function RecoveryAlertsCard({ const [loading, setLoading] = useState(false); const [phase, setPhase] = useState(''); const [error, setError] = useState(null); + const [errorScope, setErrorScope] = useState('general'); + const [errorFocus, setErrorFocus] = useState(0); const [notice, setNotice] = useState(null); const [removing, setRemoving] = useState(null); const [password, setPassword] = useState(''); const [code, setCode] = useState(''); const [needTotp, setNeedTotp] = useState(false); + const [verificationChecked, setVerificationChecked] = useState(false); const generation = useRef(0); const fetchGeneration = useRef(0); const userRef = useRef(userId); userRef.current = userId; const mounted = useRef(true); const busy = useRef(false); + const errorRef = useRef(null); + const removalHeadingRef = useRef(null); const wallet = wallets.find((item) => item.id === walletId); const walletRef = useRef(wallet); walletRef.current = wallet; + // Only an explicit failed action requests focus. Background refreshes and + // typing must never move the viewport or steal keyboard focus. + useEffect(() => { + if (!errorFocus || !errorRef.current) return; + // A user may collapse the enrollment details while the wallet is open. + // Reveal this action's error before focusing; never do this for polling. + const disclosure = errorRef.current.closest('details'); + if (disclosure) disclosure.open = true; + errorRef.current.focus({ preventScroll: true }); + // Instant explicitly bypasses the site's smooth-scroll preference, including + // when the user has requested reduced motion. + errorRef.current.scrollIntoView?.({ + block: 'nearest', + behavior: 'instant', + }); + }, [errorFocus]); + useEffect(() => { + if (!removing || !removalHeadingRef.current) return; + removalHeadingRef.current.focus({ preventScroll: true }); + removalHeadingRef.current.scrollIntoView?.({ + block: 'nearest', + behavior: 'instant', + }); + }, [removing]); + useEffect(() => { mounted.current = true; return () => { @@ -168,16 +236,64 @@ export default function RecoveryAlertsCard({ ); const handleError = useCallback( - (err) => { + (err, scope = 'general', focus = false) => { if (err && err.code === 'unauthorized') { handleAuthLost(); return; } + setErrorScope(scope); setError(errorText(err)); + if (focus) { + setErrorFocus((current) => current + 1); + } }, [handleAuthLost] ); + function renderError(scope) { + return error && errorScope === scope ? ( +
+ {error} +
+ ) : null; + } + + async function checkVerification() { + if (busy.current) return; + busy.current = true; + setPhase('checking-email'); + setError(null); + setNotice(null); + const id = userId; + try { + const updated = await refresh({ preserveSessionOnTransientError: true }); + if ( + !mounted.current || + userRef.current !== id || + !updated || + updated.id !== id + ) + return; + setVerificationChecked(!updated.emailVerified); + setNotice( + updated.emailVerified + ? 'Email verified. You can now link your smart account.' + : null + ); + } catch (err) { + if (mounted.current && userRef.current === id) + handleError(err, 'enroll', true); + } finally { + busy.current = false; + if (mounted.current) setPhase(''); + } + } + const load = useCallback(async () => { const id = userRef.current; if (id == null) return; @@ -205,10 +321,12 @@ export default function RecoveryAlertsCard({ userRef.current === id && fetchGeneration.current === request ) { - setSubscriptions((current) => current.map((subscription) => ({ - ...subscription, - monitoring: { ...subscription.monitoring, status: 'degraded' }, - }))); + setSubscriptions((current) => + current.map((subscription) => ({ + ...subscription, + monitoring: { ...subscription.monitoring, status: 'degraded' }, + })) + ); handleError(err); } } finally { @@ -235,6 +353,7 @@ export default function RecoveryAlertsCard({ setPassword(''); setCode(''); setNeedTotp(false); + setVerificationChecked(false); setLabel(''); setLoading(false); if (open && userId != null) load(); @@ -264,6 +383,7 @@ export default function RecoveryAlertsCard({ setSelection(null); setAccount(''); setNotice(null); + setErrorScope('general'); setError(ERROR_COPY.wallet_changed); }; provider.on?.('accountsChanged', changed); @@ -304,7 +424,7 @@ export default function RecoveryAlertsCard({ walletRef.current?.provider === provider && userRef.current === id ) - handleError(err); + handleError(err, 'enroll', true); } finally { busy.current = false; if (mounted.current) setPhase(''); @@ -344,7 +464,8 @@ export default function RecoveryAlertsCard({ setLabel(''); await load(); } catch (err) { - if (mounted.current && userRef.current === id) handleError(err); + if (mounted.current && userRef.current === id) + handleError(err, 'enroll', true); } finally { busy.current = false; if (mounted.current) setPhase(''); @@ -387,7 +508,7 @@ export default function RecoveryAlertsCard({ } catch (err) { if (mounted.current && userRef.current === id) { if (err && err.code === 'totp_required') setNeedTotp(true); - handleError(err); + handleError(err, 'remove', true); } } finally { busy.current = false; @@ -403,7 +524,8 @@ export default function RecoveryAlertsCard({ (item) => item.chainId === selection.chainId && chain && - item.recoveryModule?.toLowerCase() === chain.recoveryModule?.toLowerCase() && + item.recoveryModule?.toLowerCase() === + chain.recoveryModule?.toLowerCase() && item.account.toLowerCase() === account.toLowerCase() ); const phaseLabel = { @@ -412,6 +534,7 @@ export default function RecoveryAlertsCard({ signing: 'Confirm in wallet…', saving: 'Saving alerts…', removing: 'Disabling alerts…', + 'checking-email': 'Checking verification…', }[phase]; return ( @@ -437,6 +560,7 @@ export default function RecoveryAlertsCard({ className="auth-card__disclosure-toggle" aria-expanded={open} aria-controls="recovery-alerts-body" + disabled={!!phase} aria-label={ open ? 'Collapse recovery alerts' : 'Expand recovery alerts' } @@ -458,11 +582,7 @@ export default function RecoveryAlertsCard({ Alerts may be delayed and cannot cancel recovery. Keep access to your wallet.

- {error && ( -
- {error} -
- )} + {renderError('general')} {notice && (
{notice} @@ -476,11 +596,23 @@ export default function RecoveryAlertsCard({ ); const monitor = monitoringCopy( subscription.monitoring?.status === 'active' && - (!subscription.monitoring.lastCheckedAt || - Date.now() - subscription.monitoring.lastCheckedAt > 60000) + (!subscription.monitoring.lastCheckedAt || + Date.now() - subscription.monitoring.lastCheckedAt > 60000) ? 'degraded' : subscription.monitoring?.status ); + const currentOperations = (subscription.pending || []).filter( + (operation) => !TERMINAL_RECOVERIES.has(operation.status) + ); + const pastOperations = (subscription.pending || []).filter( + (operation) => TERMINAL_RECOVERIES.has(operation.status) + ); + const hasPendingRecovery = currentOperations.some((operation) => + ['pending', 'ready', 'scheduled'].includes(operation.status) + ); + const hasExecutedRecovery = currentOperations.some( + (operation) => operation.status === 'executed' + ); return (
- {(subscription.pending || []).map((operation) => ( -
( + - - {{ - scheduled: 'Recovery pending', - pending: 'Recovery pending', - ready: 'Recovery can be executed', - expired: 'Recovery expired', - executed: 'Recovery executed', - canceled: 'Recovery canceled or invalidated', - invalidated: 'Recovery no longer pending', - orphaned: 'Recovery observation no longer canonical', - unknown: 'Recovery status unavailable', - }[operation.status] || 'Recovery observed'} - - - {['pending', 'ready', 'scheduled'].includes( - operation.status - ) - ? 'Can execute from' - : 'Original execution time'}{' '} - {datetime(operation.executableAt)} - - {Number(operation.expiresAt) > 0 && ( - - {['pending', 'ready', 'scheduled'].includes( - operation.status - ) - ? 'Expires' - : 'Original expiry'}{' '} - {datetime(operation.expiresAt)} - - )} - - Review this operation in your wallet. Passing the - deadline does not mean it has executed. - -
+ operation={operation} + /> ))} + {currentOperations.length > 0 && ( +
+

+ {hasPendingRecovery + ? 'If you did not request recovery, review it in your wallet now. Disabling emails below does not stop recovery.' + : hasExecutedRecovery + ? 'Recovery has executed. Review this account’s current approval methods and activity in Pali. Disabling emails cannot undo it.' + : 'The recovery state needs review. Check this account’s current status and approval methods in Pali.'} +

+
+ How to review in Pali +
    +
  1. + Open Pali and select this account on{' '} + {network + ? network.name + : `chain ${subscription.chainId}`} + . +
  2. +
  3. + Open Smart account, then choose{' '} + Manage beside{' '} + Recovery guardian. +
  4. + {hasPendingRecovery && ( +
  5. + If recovery is unexpected, use{' '} + Disable guardian recovery. This + needs your current approval method and a network + fee. +
  6. + )} +
+ + Read the recovery guide + +
+
+ )} + {pastOperations.length > 0 && ( +
+ + Past recovery activity ({pastOperations.length}) + + {pastOperations.map((operation) => ( + + ))} +
+ )} +
+ )} {wallets.length > 0 ? ( <> -
- - -
+ {wallets.length > 1 ? ( +
+ + +
+ ) : ( +

+ Wallet: {wallets[0].name} +

+ )}