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
+
+
+ Open Pali and select this account on{' '}
+ {network
+ ? network.name
+ : `chain ${subscription.chainId}`}
+ .
+
+
+ Open Smart account , then choose{' '}
+ Manage beside{' '}
+ Recovery guardian .
+
+ {hasPendingRecovery && (
+
+ If recovery is unexpected, use{' '}
+ Disable guardian recovery . This
+ needs your current approval method and a network
+ fee.
+
+ )}
+
+
+ Read the recovery guide
+
+
+
+ )}
+ {pastOperations.length > 0 && (
+
+
+ Past recovery activity ({pastOperations.length})
+
+ {pastOperations.map((operation) => (
+
+ ))}
+
+ )}
{removing ? (