From 8bfe0c0f06107c1407efdf235e94621f9f970fcc Mon Sep 17 00:00:00 2001 From: Stuart Date: Wed, 23 Sep 2026 02:47:21 +1000 Subject: [PATCH 1/3] feat(STU-387): implement account access & data actions without fake deletion or allowance resets - Sign out clears the session and immediately re-renders identity/plan UI - Send new link reflects the real response/failure, duplicate sends guarded - Clear cache scoped to named disposable UI caches; allowance/identity kept - Delete account presented as truthful, keyboard-discoverable, unavailable - Behavioral unit tests + Playwright spec; all 4 criteria falsification-verified Generated by Claude Code. --- app.js | 92 ++++++++++- e2e/stu387-account-access.spec.js | 238 +++++++++++++++++++++++++++++ index.html | 83 ++++++++++ src/accountAccess.js | 132 ++++++++++++++++ src/accountAccess.test.js | 246 ++++++++++++++++++++++++++++++ 5 files changed, 787 insertions(+), 4 deletions(-) create mode 100644 e2e/stu387-account-access.spec.js create mode 100644 src/accountAccess.js create mode 100644 src/accountAccess.test.js diff --git a/app.js b/app.js index 7476d17..1364108 100644 --- a/app.js +++ b/app.js @@ -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, @@ -4507,10 +4513,82 @@ 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, + }) +} + +// --- 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." + return + } + // status === "error": surface the real failure, never a canned success. + btn.disabled = false + btn.textContent = "Send new link" + msg.className = "acct-access-msg error" + msg.textContent = "Couldn't send a link right now. Please try again." +} + +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() { @@ -4523,6 +4601,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() } @@ -6385,6 +6465,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; diff --git a/e2e/stu387-account-access.spec.js b/e2e/stu387-account-access.spec.js new file mode 100644 index 0000000..0311200 --- /dev/null +++ b/e2e/stu387-account-access.spec.js @@ -0,0 +1,238 @@ +import { test, expect } from "@playwright/test"; +import { + applyDefaultRoutes, + guestIdentity, + professionalSubscription, + magicLinkSent, + err, + ENDPOINTS, +} from "./fixtures/apiFixtures.js"; + +/** + * STU-387 — the account Access & data section. + * + * Sign out clears the session and immediately re-renders every identity/plan + * surface. "Send new link" reflects the actual backend response or failure + * through visible pending/sent/error states and never fires a duplicate request + * while one is in flight. "Clear cache" removes only the named disposable UI + * caches and provably preserves the authoritative allowance, identity and + * credentials. The Delete-account control is truthful (account deletion is + * unavailable — there is no backend contract) and keyboard-discoverable; it is + * not an active destructive button and performs no deletion. + */ +const EMAIL = "metered@example.com"; +const currentMonth = () => new Date().toISOString().slice(0, 7); + +function sessionFor(email = EMAIL) { + return { + status: 200, + body: JSON.stringify({ email, sessionToken: "session-token" }), + contentType: "application/json", + }; +} + +function refreshOverride(email = EMAIL) { + return { [ENDPOINTS.authRefreshSession]: sessionFor(email) }; +} + +function identityWithUsage({ email, count, month = currentMonth() }) { + return { + status: 200, + body: JSON.stringify({ + status: "recognized", + user_id: "acct-0001", + identity_token: "identity-token", + usage_count: count, + usage_month: month, + }), + contentType: "application/json", + }; +} + +async function seedSession(page, email = EMAIL) { + await page.addInitScript(({ email }) => { + localStorage.setItem( + "ccc_auth_session", + JSON.stringify({ email, sessionToken: "session-token" }), + ); + localStorage.setItem("bs_identity", "device-uuid-0001"); + localStorage.setItem( + "ccc_usage", + JSON.stringify({ count: 12, month: new Date().toISOString().slice(0, 7) }), + ); + localStorage.setItem("hasSeenWalkthrough", "true"); + }, { email }); +} + +async function openAccount(page) { + await page.locator('a.nav-link[data-view="account"]').click(); + await expect(page.locator("#account-view")).toBeVisible(); + await expect(page.locator("#auth-signedin")).toBeVisible(); +} + +test.describe("STU-387 account access & data", () => { + test("sign out clears the session, empties the signed-in surfaces and shows signed-out immediately", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, { + ...refreshOverride(), + [ENDPOINTS.identity]: identityWithUsage({ email: EMAIL, count: 12 }), + [ENDPOINTS.getSubscription]: professionalSubscription(), + }); + await page.goto("/"); + await openAccount(page); + + await expect(page.locator("#auth-user-email")).toHaveText(EMAIL); + await expect(page.locator("#access-signed-email")).toHaveText(EMAIL); + + await page.locator("#sign-out-btn").click(); + + // The session key is gone and the account view flips to signed-out in the + // same gesture (no reload), i.e. identity/plan UI updated immediately. + const session = await page.evaluate(() => localStorage.getItem("ccc_auth_session")); + const subscriptionCache = await page.evaluate(() => localStorage.getItem("ccc_subscription")); + expect(session).toBeNull(); + expect(subscriptionCache).toBeNull(); + await expect(page.locator("#auth-signedin")).toBeHidden(); + await expect(page.locator("#auth-signedout")).toBeVisible(); + // The topbar/identity email surface is no longer populated. + await expect(page.locator("#auth-user-email")).not.toHaveText(EMAIL); + }); + + test("send new link shows pending then the real success message from the response", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, { + ...refreshOverride(), + [ENDPOINTS.identity]: identityWithUsage({ email: EMAIL, count: 12 }), + [ENDPOINTS.getSubscription]: professionalSubscription(), + [ENDPOINTS.authSendMagicLink]: magicLinkSent(), + }); + await page.goto("/"); + await openAccount(page); + + const msg = page.locator("#send-new-link-msg"); + await page.locator("#send-new-link-btn").click(); + await expect(msg).toHaveText(/Check your email — we sent a link to metered@example.com/); + }); + + test("send new link surfaces the real failure and re-enables the control", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, { + ...refreshOverride(), + [ENDPOINTS.identity]: identityWithUsage({ email: EMAIL, count: 12 }), + [ENDPOINTS.getSubscription]: professionalSubscription(), + [ENDPOINTS.authSendMagicLink]: err(500, "Server exploded"), + }); + await page.goto("/"); + await openAccount(page); + + const btn = page.locator("#send-new-link-btn"); + await btn.click(); + await expect(page.locator("#send-new-link-msg")).toHaveText( + /Couldn't send a link right now/, + ); + await expect(btn).toBeEnabled(); + }); + + test("a second send while one is in flight is refused (single request)", async ({ page }) => { + let calls = 0; + await seedSession(page); + await applyDefaultRoutes(page, { + ...refreshOverride(), + [ENDPOINTS.identity]: identityWithUsage({ email: EMAIL, count: 12 }), + [ENDPOINTS.getSubscription]: professionalSubscription(), + [ENDPOINTS.authSendMagicLink]: async () => { + calls += 1; + // Hold the request in flight for the duration of the assertion window. + await new Promise(() => {}); + }, + }); + await page.goto("/"); + await openAccount(page); + + const btn = page.locator("#send-new-link-btn"); + await btn.click(); // in flight, endpoint not yet resolved + // The control visibly locks while pending, so the user cannot double-send. + await expect(btn).toBeDisabled(); + // Give any (incorrect) second request the chance to fire; only the first + // from the initial click may have reached the endpoint. + await page.waitForTimeout(200); + expect(calls).toBe(1); + // The pending request is intentionally left unresolved; the deterministic + // duplicate-rejection of the controller is asserted in the unit suite + // (src/accountAccess.test.js "a duplicate reauth while one is in flight"). + }); + + test("clear cache removes only disposable UI caches and preserves allowance, identity and keys", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, { + ...refreshOverride(), + [ENDPOINTS.identity]: identityWithUsage({ email: EMAIL, count: 12 }), + [ENDPOINTS.getSubscription]: professionalSubscription(), + }); + await page.goto("/"); + await openAccount(page); + + await page.locator("#clear-cache-btn").click(); + + const state = await page.evaluate(() => ({ + usage: localStorage.getItem("ccc_usage"), + identity: localStorage.getItem("bs_identity"), + session: localStorage.getItem("ccc_auth_session"), + walkthrough: localStorage.getItem("hasSeenWalkthrough"), + })); + // The disposable UI flag is cleared... + expect(state.walkthrough).toBeNull(); + // ...but the authoritative allowance, identity and active session survive, + // so clearing cannot hand out new runs. + expect(JSON.parse(state.usage).count).toBe(12); + expect(state.identity).toBe("device-uuid-0001"); + expect(state.session).not.toBeNull(); + await expect(page.locator("#acct-left-count")).not.toHaveText("—"); + await expect(page.locator("#auth-user-email")).toHaveText(EMAIL); + }); + + test("delete account is an honest, keyboard-discoverable unavailable control and performs no deletion", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, { + ...refreshOverride(), + [ENDPOINTS.identity]: identityWithUsage({ email: EMAIL, count: 12 }), + [ENDPOINTS.getSubscription]: professionalSubscription(), + }); + await page.goto("/"); + await openAccount(page); + + const deleteBtn = page.locator("#delete-account-btn"); + await expect(deleteBtn).toBeVisible(); + // It is a real, focusable (keyboard-discoverable) button — not a pointer-only div. + expect(await deleteBtn.isEnabled()).toBe(true); + const tag = await deleteBtn.evaluate((el) => el.tagName); + expect(tag).toBe("BUTTON"); + + // Activating it opens the explanation and deletes nothing. + await deleteBtn.click(); + const modal = page.locator("#delete-unavailable-modal"); + await expect(modal).toBeVisible(); + // openModal gives the overlay dialog semantics (role/aria-modal) and sets + // aria-hidden=false when it opens. + await expect(modal).toHaveAttribute("aria-hidden", "false"); + await expect(modal).toHaveAttribute("role", "dialog"); + await expect(modal).toHaveAttribute("aria-modal", "true"); + await expect(modal).toContainText("not available"); + await expect(modal).toContainText("Nothing has been removed"); + + const before = await page.evaluate(() => ({ + usage: localStorage.getItem("ccc_usage"), + identity: localStorage.getItem("bs_identity"), + session: localStorage.getItem("ccc_auth_session"), + })); + await page.keyboard.press("Escape"); + await expect(modal).toBeHidden(); + const after = await page.evaluate(() => ({ + usage: localStorage.getItem("ccc_usage"), + identity: localStorage.getItem("bs_identity"), + session: localStorage.getItem("ccc_auth_session"), + })); + expect(after).toEqual(before); // opening + dismissing performs no deletion + await expect(page.locator("#auth-user-email")).toHaveText(EMAIL); // still signed in + }); +}); diff --git a/index.html b/index.html index 9ba5f61..2bb8ecf 100644 --- a/index.html +++ b/index.html @@ -3046,6 +3046,20 @@ .acct-access-row:last-child { border-bottom: 0; } .acct-access-row p { margin: 0; color: var(--muted); } .acct-access-row p:first-child { color: var(--fg); font-weight: 500; flex: none; } + .acct-access-action { align-items: center; } + .acct-access-action .btn-action { flex: none; } + .acct-access-desc { font-size: 12px !important; line-height: 1.45; } + .acct-access-msg { + min-height: 1.25rem; margin: 0 0 10px 0; padding-top: 2px; + font-size: 12.5px; color: var(--muted); + } + .acct-access-msg.error { color: var(--bad); } + .acct-danger-zone { + border: 1px solid var(--bad-soft); + background: color-mix(in oklab, var(--bad-soft), transparent 55%); + } + .acct-danger-zone .acct-card-head h2 { color: var(--bad); } + .acct-danger-note { font-size: 12.5px; line-height: 1.55; color: var(--muted); margin: 0 0 14px 0; max-width: 52ch; } /* Mobile compact */ @media (max-width: 920px) { @@ -3460,6 +3474,54 @@

Provider keys & access

+ +
+
+

Access & data

+ session and local cache +
+
+

Sign-in method

+

Email magic link. No password is stored.

+
+
+
+

Send new link

+

Email a fresh sign-in link to you.

+
+ +
+ +
+
+

Local usage cache

+

Clears disposable UI caches only. Your run count, identity and stored keys are kept.

+
+ +
+
+
+

Sign out of this device

+

Ends the session here. Other devices keep their own session.

+
+ +
+
+ +
+
+

Delete account

+ not available +
+

+ Account deletion isn't available yet. There is no server-side deletion + contract in this build, so deleting would be a lie this surface can't tell. + Nothing is ever removed from a toast, a local clear, or this control. +

+
+ +
+
@@ -3855,6 +3917,27 @@

Sign

+ + +