Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 94 additions & 4 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ import {
PLUS_ALIAS_REJECTED_CODE,
trimEmail,
} from "./src/authMagicLink.js";
import {
clearDisposableUiCaches,
createSendLinkController,
isDeleteAccountSupported,
performSignOut,
} from "./src/accountAccess.js";
import { planCustomCodeVerification } from "./src/customCodeVerification.js";
import {
deployOutcomeOfStreamResult,
Expand Down Expand Up @@ -4507,10 +4513,88 @@ async function handleMagicLinkRequest() {
}

function handleSignOut() {
clearSession()
clearSubscriptionCache()
updateAuthUI()
updateSubscriptionUI()
// Reset in-memory auth/subscription state first so the immediate re-render
// below reflects a signed-out identity/plan.
authState.email = null
authState.sessionToken = null
authState.isVerified = false
subscriptionState = createSubscriptionState({ isResolved: true })
// Remove the session + subscription cache keys (scoped adapter) and then
// synchronously re-render every identity/plan surface.
performSignOut({
storage: window.localStorage,
onIdentityChanged: updateAuthUI,
onPlanChanged: updateSubscriptionUI,
})
Comment on lines +4518 to +4528

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Late response restores signed-out plan

When handleSignOut runs during fetchSubscription, the pending response restores the old plan. The signed-out browser then exposes its allowance and model access.

Learn more

fetchSubscription applies its response and writes the subscription cache without checking that the session which started the request is still current. handleSignOut clears the current state, but it neither aborts nor invalidates that request. When the response arrives, subscriptionState becomes the former account's tier; updateSubscriptionUI then feeds that tier into allowance and model gating even though the user is signed out.

Example: A Pro user opens the page and the subscription request stalls. They sign out before it returns. The Pro response then lands, and the guest session receives the Pro run limit and model choices instead of Free access.

Recommended fix: Add an auth-session generation or capture the initiating token/email in fetchSubscription. Before applying any response, error, or cache write, verify that the captured session still matches authState; increment the generation on sign-out and session changes. An AbortController can cancel pending work, but retain the identity check because cancellation can race with response completion.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +4524 to +4528

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Sign-out state can return

If a subscription request is already pending when the user signs out, its response still restores subscriptionState and rewrites ccc_subscription. The generation path can then render and use the former paid tier while the user is signed out. Sign-out needs to invalidate or ignore responses that belong to the ended session.

Greptile automatically discovered a related ticket stating that sign-out must clear the session and immediately update all identity and plan UI, which informed this comment.

Source Used: Linear — Implement account access actions without fake deletion or allowance resets

Prompt To Fix With AI
This is a comment left during a code review.
Path: app.js
Line: 4524-4528

Comment:
**Sign-out state can return**

If a subscription request is already pending when the user signs out, its response still restores `subscriptionState` and rewrites `ccc_subscription`. The generation path can then render and use the former paid tier while the user is signed out. Sign-out needs to invalidate or ignore responses that belong to the ended session.

Greptile automatically discovered a related ticket stating that sign-out must clear the session and immediately update all identity and plan UI, which informed this comment.

**Source Used:** Linear — [Implement account access actions without fake deletion or allowance resets](https://linear.app/stuart-gardoll/issue/STU-387/implement-account-access-actions-without-fake-deletion-or-allowance)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Devin

}

// --- ACCOUNT ACCESS & DATA WIRING (STU-387) ---
// One "Send new link" controller guards against duplicate requests and reflects
// the actual response or failure into the row's status line.
const accessSendNewLink = createSendLinkController({
sendLink: sendMagicLink,
getEmail: () => authState.email || "",
onState: renderSendNewLinkState,
})

function renderSendNewLinkState(state) {
const btn = document.getElementById("send-new-link-btn")
const msg = document.getElementById("send-new-link-msg")
if (!btn || !msg) return
if (state.status === "pending") {
btn.disabled = true
btn.textContent = "Sending…"
msg.className = "acct-access-msg"
msg.textContent = "Sending a fresh sign-in link…"
return
}
if (state.status === "sent") {
btn.disabled = false
btn.textContent = "Send new link"
msg.className = "acct-access-msg"
msg.textContent = getMagicLinkResultMessage(state.data, authState.email || "") || "Link sent — check your email."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Link result uses wrong account

The request sends to the email captured when it starts, but this line formats the success message using the current authState.email. If the user signs out or changes accounts before the request finishes, the status claims the link went to a blank or different address, and that stale message can reappear in the next signed-in session. Preserve the request-time email in the result and clear this status when the session changes.

Greptile automatically discovered a related ticket stating that reauthentication must reflect the actual response and prevent duplicate requests, which informed this comment.

Source Used: Linear — Implement account access actions without fake deletion or allowance resets

Prompt To Fix With AI
This is a comment left during a code review.
Path: app.js
Line: 4555

Comment:
**Link result uses wrong account**

The request sends to the email captured when it starts, but this line formats the success message using the current `authState.email`. If the user signs out or changes accounts before the request finishes, the status claims the link went to a blank or different address, and that stale message can reappear in the next signed-in session. Preserve the request-time email in the result and clear this status when the session changes.

Greptile automatically discovered a related ticket stating that reauthentication must reflect the actual response and prevent duplicate requests, which informed this comment.

**Source Used:** Linear — [Implement account access actions without fake deletion or allowance resets](https://linear.app/stuart-gardoll/issue/STU-387/implement-account-access-actions-without-fake-deletion-or-allowance)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Devin

return
}
// status === "error": surface the real failure, never a canned success. The
// propagated Error carries a user-safe reason (e.g. "HTTP 429", "HTTP 500")
// rather than raw server internals, and a rate-limit must not read identically
// to a server error. Fall back to the generic copy only when no reason escaped.
btn.disabled = false
btn.textContent = "Send new link"
msg.className = "acct-access-msg error"
const reason = state.error?.message
? `Couldn't send a link right now. ${state.error.message}`
: "Couldn't send a link right now. Please try again."
msg.textContent = reason
}

async function handleSendNewLink() {
await accessSendNewLink()
}

function handleClearCache() {
// Scoped to the named disposable UI caches only — never usage, identity,
// credentials, or the active session, so it cannot hand out new runs.
const removed = clearDisposableUiCaches(window.localStorage)
const detail = removed.length
? `Cleared disposable UI caches: ${removed.join(", ")}.`
: "Nothing disposable to clear."
showToast(`${detail} Your run count, identity and stored keys are kept.`, "info")
}

function handleDeleteAccount() {
// Truthful: no backend deletion contract exists in this slice, so this control
// only explains that deletion is unavailable. It performs no deletion and
// never resets usage, identity, credentials, or the session.
if (isDeleteAccountSupported()) return
const modal = document.getElementById("delete-unavailable-modal")
if (modal) openModal(modal, { trigger: document.activeElement })
}

function closeDeleteUnavailableModal(event) {
if (event && event.target !== event.currentTarget) return
const modal = document.getElementById("delete-unavailable-modal")
if (modal) closeModal(modal)
}

function updateAuthUI() {
Expand All @@ -4523,6 +4607,8 @@ function updateAuthUI() {
if (guestUsage) guestUsage.classList.toggle('hidden', signedIn)
const emailEl = document.getElementById('auth-user-email')
if (emailEl) emailEl.textContent = authState.email || ''
const accessEmailEl = document.getElementById('access-signed-email')
if (accessEmailEl) accessEmailEl.textContent = authState.email || ''
updateGuestUsageCounter()
updateSubscriptionUI()
}
Expand Down Expand Up @@ -6385,6 +6471,10 @@ window.openSignInModal = openSignInModal;
window.closeSignInModal = closeSignInModal;
window.handleMagicLinkRequest = handleMagicLinkRequest;
window.handleSignOut = handleSignOut;
window.handleSendNewLink = handleSendNewLink;
window.handleClearCache = handleClearCache;
window.handleDeleteAccount = handleDeleteAccount;
window.closeDeleteUnavailableModal = closeDeleteUnavailableModal;
window.startCheckout = startCheckout;
window.openCustomerPortal = openCustomerPortal;
window.openPricingModal = openPricingModal;
Expand Down
Loading
Loading