From 6c70255a1caf3eb8230aec4c1f18182cb50e3cb5 Mon Sep 17 00:00:00 2001 From: lshw54 Date: Fri, 19 Jun 2026 03:31:33 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(otp):=20restore=20double-click-to-copy?= =?UTF-8?q?=20and=20cache=20OTP=20per=20sub-account=20(#300)=20Two=20OTP?= =?UTF-8?q?=20UX=20regressions/requests=20vs=20WPF=205.9.2:=201.=20Regress?= =?UTF-8?q?ion=20=E2=80=94=20double-clicking=20the=20generated=20OTP=20use?= =?UTF-8?q?d=20to=20auto-copy=20it;=20=20=20=20the=20rewrite=20left=20the?= =?UTF-8?q?=20dedicated=20copy=20icon=20as=20the=20only=20path.=20Restore?= =?UTF-8?q?=20=20=20=20the=20gesture:=20AccountList's=20OTP=20field=20now?= =?UTF-8?q?=20copies=20on=20dblclick=20(with=20a=20=20=20=20GetOtpSuccessA?= =?UTF-8?q?ndCopy=20toast,=20since=20a=20double-click=20has=20no=20persist?= =?UTF-8?q?ent=20=20=20=20affordance=20to=20confirm=20the=20copy=20landed)?= =?UTF-8?q?.=202.=20Feature=20=E2=80=94=20switching=20between=20sub-accoun?= =?UTF-8?q?ts=20blanked=20the=20OTP=20field.=20Add=20a=20=20=20=20per-sid?= =?UTF-8?q?=20OTP=20cache=20so=20each=20account's=20last=20token=20is=20re?= =?UTF-8?q?stored=20when=20the=20=20=20=20user=20toggles=20back,=20instead?= =?UTF-8?q?=20of=20resetting.=20Cache=20is=20session/list-scoped=20=20=20?= =?UTF-8?q?=20(cleared=20on=20game=20switch=20+=20logout);=20no=20time-bas?= =?UTF-8?q?ed=20expiry=20by=20design=20=E2=80=94=20=20=20=20Start=20Game?= =?UTF-8?q?=20always=20routes=20through=20a=20fresh=20fetch,=20so=20a=20ca?= =?UTF-8?q?ched=20value=20is=20=20=20=20never=20auto-launched=20off.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/AccountList.vue | 69 +++++++++++++++++++++++--- tests/unit/pages/AccountList.spec.ts | 73 ++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 7 deletions(-) diff --git a/src/pages/AccountList.vue b/src/pages/AccountList.vue index 4d977e5..9265190 100644 --- a/src/pages/AccountList.vue +++ b/src/pages/AccountList.vue @@ -843,6 +843,10 @@ async function selectActiveGame( * `redrawSAccountList`'s `SelectedIndex = -1` reset. */ account.selectedSid = null + // Issue #300: the new game has a different account set — drop the + // per-account OTP cache so a stale token can't surface against a + // same-named sid in another game. + otpCache.value.clear() if (refresh) { await loadList() @@ -1560,6 +1564,31 @@ async function handleGetEmail(): Promise { */ const otpValue = ref('') +/** + * Per-sub-account OTP cache (issue #300 feature request). + * + * Keyed by service-account `sid` → the last OTP fetched for that + * account this session. Lets the user toggle between sub-accounts + * without losing an OTP they already generated: switching away and + * back restores the cached value instead of blanking the field. + * + * Lifetime is intentionally session/list-scoped, not time-scoped: + * + * - Populated in {@link handleGetOtp} on every successful fetch. + * - Read by the `watch(selectedSid)` below to restore on row change. + * - Cleared when the active game changes ({@link selectActiveGame}, + * the new game has a different account set) and on logout (the + * whole component unmounts). + * + * We deliberately do NOT expire entries here — Beanfun OTPs are + * short-lived, but the user explicitly asked for the value to persist + * across toggles, and the canonical "is it still valid?" answer is a + * fresh Get OTP press. Showing the last value is strictly better than + * blanking it, and never auto-launches off a cached token (Start Game + * always routes through a fresh {@link handleGetOtp}). + */ +const otpCache = ref(new Map()) + /** * In-flight guard for the OTP fetch + auto-paste sequence. Drives * (a) the Get OTP button's `:disabled` rule (re-entrancy guard) and @@ -1588,19 +1617,24 @@ const gettingOtp = ref(false) const autoPaste = ref(configStore.getOr('autoPaste', 'false').toLowerCase() === 'true') /** - * Reset the OTP value when the user picks a different row. Without - * this, the OTP for the previously-selected account would visually - * stay attached to whichever row is currently highlighted — a - * mis-binding bug that's easy to introduce because OTP refresh is - * gesture-driven, not selection-driven. + * Re-bind the OTP value when the user picks a different row. + * + * The OTP is row-bound and must never visually stay attached to a + * different highlighted account (a mis-binding bug that's easy to + * introduce because OTP refresh is gesture-driven, not selection- + * driven). Issue #300: rather than unconditionally blanking, restore + * the cached OTP for the newly-selected account when one exists, so + * toggling between sub-accounts keeps each account's last token + * visible. Accounts with no cached OTP (or the `null` selection) + * fall back to an empty field. * * Logout is handled separately by `account.clearSessionData()` — * `selectedSid` becomes null along with everything else. */ watch( () => account.selectedSid, - () => { - otpValue.value = '' + (sid) => { + otpValue.value = (sid && otpCache.value.get(sid)) || '' }, ) @@ -1749,6 +1783,9 @@ async function handleGetOtp(): Promise { } otpValue.value = otp + // Issue #300: remember this account's OTP so switching sub-accounts + // and back restores it instead of blanking the field. + otpCache.value.set(target.sid, otp) /* * D8f — OTP+launch chain. WPF `MainWindow.xaml.cs` L2152-2155 @@ -1848,6 +1885,22 @@ function handleCopyOtp(): void { void clipboardWriteOtp(otpValue.value, false) } +/** + * Double-click the OTP field → copy to clipboard (issue #300 + * regression). Up to 5.9.2 double-clicking the generated OTP + * auto-copied it; the rewrite dropped that, leaving the dedicated + * copy icon as the only path. Restore the gesture here. + * + * Unlike the silent copy-icon ({@link handleCopyOtp}), the + * double-click surfaces the `GetOtpSuccessAndCopy` toast: a + * double-click has no persistent affordance (no button highlight), + * so explicit feedback is what tells the user the copy landed. + */ +function handleOtpDblClick(): void { + if (!otpValue.value) return + void clipboardWriteOtp(otpValue.value, true) +} + /* --------------- drag-and-drop reorder (D7) --------------- */ /** @@ -2577,7 +2630,9 @@ onBeforeUnmount(() => { :value="otpValue" :placeholder="t('accountList.otpPlaceholder')" class="account-list__otp-field" + :title="t('accountList.copyOtp')" data-test="account-list-otp-field" + @dblclick="handleOtpDblClick" />