Skip to content

STU-386: Restyle magic-link sign-in and preserve return-to-task - #82

Merged
sgardoll merged 3 commits into
milestone/stu-implement-updated-ui-uxfrom
sgardoll/stu-386-restyle-magic-link-sign-in-and-preserve-return-to-task
Sep 22, 2026
Merged

sgardoll merged 3 commits into
milestone/stu-implement-updated-ui-uxfrom
sgardoll/stu-386-restyle-magic-link-sign-in-and-preserve-return-to-task

Conversation

@sgardoll

@sgardoll sgardoll commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Summary

Restyles the sign-in modal with the redesign's design tokens and STU-374's shared accessible-modal primitive, and fixes two behavioral gaps in the magic-link flow:

  • Retry stays usable. The submit button was permanently disabled after a successful send. It now always re-enables (briefly showing "Sent!"), so resending or switching to a different address works without a reload.
  • Return-to-task. openSignInModal() now records the current surface (home/composer, plans, or account/billing — tracked via the app's existing hash-based switchView router) in localStorage before opening. A successful magic-link verification restores that surface and refreshes usage/plan state, without triggering checkout or deploy.
  • Inline failure recovery. An expired/invalid token now reopens the sign-in modal with the error shown inline (in addition to the existing toast), putting retry a click away.
  • Alias-policy display. STU-149's PLUS_ALIAS_REJECTED message already flowed through; it now also marks the input aria-invalid and preserves the rejected address so the user can see what was rejected.

Existing auth endpoints, request/response contracts, and token-stripping behavior are unchanged. Scope is limited to sign-in markup/styles and the UI state adapter, per the ticket's file ownership — no changes to generation/pipeline, results/artifact inspection, deployment UI, or the billing/plans surfaces themselves beyond wiring their existing "Sign In" triggers.

Acceptance criteria

  • Browser fixtures cover valid/invalid email, send failure then retry, sent then resend, token failure and successful session refresh — see e2e/stu386-magic-link-signin.spec.js (7 new tests, all passing) and new fixtures in e2e/fixtures/apiFixtures.js (magicLinkAliasRejected, magicLinkSendFailure, magicLinkVerifyFailure).
  • Sign-in return restores the initiating surface and refreshes user/usage/plan state without auto-purchasing or auto-deploying — covered by the "signing in from the Plans surface returns to Plans after verification" test, which also asserts no Stripe checkout redirect occurs.
  • Keyboard focus, close/Escape and live feedback satisfy the shared dialog contract — reuses src/sharedControls.js (STU-374) unchanged; scripts/test-accessible-modals.mjs (which exercises signin-modal specifically) passes.
  • Alias-policy error is shown inline when provided; normal addresses remain supported — covered by a dedicated test.

Test plan

  • npm test — 288/288 unit tests pass
  • npm run test:ui (Playwright) — 20/20 pass (7 new + 13 existing)
  • npm run test:accessibility — passes, including signin-modal
  • npm run test:key-storage — passes
  • python3 scripts/test-deploy-guard.py — 9/9 pass
  • npm run build — succeeds; dist/ regenerated and committed per repo convention

Notes / deviations

  • The exact call sites triggering openSignInModal() (paywall in composer, startCheckout for plans, and the Account view's own "Sign In" button) needed no per-call-site changes — the return-surface capture reads window.location.hash at the moment the modal opens, which the app's existing router already keeps in sync with the visible surface.
  • openCustomerPortal()'s sign-in fallback exists for session-expiry edge cases but its own "Manage Subscription" button is only rendered once already signed in, so it wasn't independently exercised in e2e; the underlying openSignInModal() behavior it relies on is covered via the other call sites.

Linear: STU-386
https://linear.app/stuart-gardoll/issue/STU-386


Devin Review

Applies the redesign's design tokens and accessible-modal contract
(STU-374's shared sharedControls.js primitive) to the sign-in modal's
markup and styles, and fixes two behavioral gaps in the underlying
magic-link flow:

- The submit button was permanently disabled after a successful send,
  so a user who wanted to resend or use a different address had no way
  back in. It now always re-enables, with a transient "Sent!" label.
- Sign-in requests from the composer, plans, or billing surfaces had no
  memory of where they came from. openSignInModal() now records the
  current view (via the existing hash-based switchView router) in
  localStorage before opening, and a successful magic-link verification
  restores that surface and refreshes usage/plan state — without
  triggering checkout or deploy.
- A failed/expired token now reopens the sign-in modal with the error
  shown inline (in addition to the existing toast), so retry is a click
  away instead of a dead end.
- STU-149's alias-policy error already had a message path; it now also
  marks the input aria-invalid and preserves the rejected address.

Existing auth endpoints, request/response contracts, and token
stripping are unchanged. Scope is limited to sign-in markup/styles and
the UI state adapter, per file ownership.

Adds e2e/stu386-magic-link-signin.spec.js covering valid/invalid email,
send failure + retry, sent + resend, alias-policy rejection, token
failure, successful session refresh, and the Plans return-to-task
round trip, plus new deterministic fixtures in e2e/fixtures/apiFixtures.js.

Linear: STU-386
@sgardoll
sgardoll marked this pull request as ready for review September 22, 2026 09:28
@greptile-apps

greptile-apps Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

The PR is not yet safe to merge because an expired-link retry can return users to Home instead of their initiating Plans or Account surface.

Fix All in DevinFindings

  1. P1 Retry loses return surface ▶
Fix with agent prompt
### Issue 1
app.js:4278-4283
When verification fails, `consumeSignInReturnSurface()` deletes the initiating surface before `openSignInModal()` records the callback page’s empty hash as `#home`. If a user started on Plans or Account, follows an expired link, and then successfully retries from the reopened modal, the new link returns them Home instead of to their original task. Preserve the stored surface through the inline retry rather than consuming and replacing it.

Greptile automatically discovered a related ticket stating that expired-link retry must preserve the initiating surface, which informed this comment.

---

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

Summary

Restyles the magic-link sign-in modal, adds accessible inline status states and retry support, and persists the initiating hash so successful verification can restore the relevant application surface. It also adds deterministic browser fixtures and end-to-end coverage for validation, sending, alias rejection, verification, and Plans restoration.

  • Adds return-surface storage with a 30-minute lifetime.
  • Re-enables magic-link submission after success or failure.
  • Reopens the modal with inline feedback for invalid or expired tokens.
  • Updates source and generated sign-in markup and styling.
  • Adds seven Playwright scenarios and supporting API fixtures.
  • The expired-link retry path currently loses the original return surface.

Greptile automatically discovered a related ticket that helped explain the purpose of this PR: restyle every magic-link state while preserving retry usability and return-to-task behavior.

Reviews (1) · Last reviewed commit: "STU-386: restyle magic-link sign-in and ..."

Comment thread app.js
Comment on lines +4278 to +4283
consumeSignInReturnSurface()
const message = err.message || 'Sign-in link invalid or expired.'
showToast(message, 'error')
// Surface the failure inline too, with the retry control right there,
// instead of leaving the user to hunt for a way back in.
openSignInModal()

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 Retry loses return surface

When verification fails, consumeSignInReturnSurface() deletes the initiating surface before openSignInModal() records the callback page’s empty hash as #home. If a user started on Plans or Account, follows an expired link, and then successfully retries from the reopened modal, the new link returns them Home instead of to their original task. Preserve the stored surface through the inline retry rather than consuming and replacing it.

Greptile automatically discovered a related ticket stating that expired-link retry must preserve the initiating surface, which informed this comment.

Source Used: Linear — Restyle magic-link sign-in and preserve return-to-task behavior

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

Comment:
**Retry loses return surface**

When verification fails, `consumeSignInReturnSurface()` deletes the initiating surface before `openSignInModal()` records the callback page’s empty hash as `#home`. If a user started on Plans or Account, follows an expired link, and then successfully retries from the reopened modal, the new link returns them Home instead of to their original task. Preserve the stored surface through the inline retry rather than consuming and replacing it.

Greptile automatically discovered a related ticket stating that expired-link retry must preserve the initiating surface, which informed this comment.

**Source Used:** Linear — [Restyle magic-link sign-in and preserve return-to-task behavior](https://linear.app/stuart-gardoll/issue/STU-386/restyle-magic-link-sign-in-and-preserve-return-to-task-behavior)

---

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

…ui-ux' into sgardoll/stu-386-restyle-magic-link-sign-in-and-preserve-return-to-task

# Conflicts:
#	dist/assets/index-D6csqwUm.js
#	dist/index.html
The tier label was unified to Free/Pro/Power by STU-382, which landed on the
integration branch after this PR was cut. The assertion passed on this branch's
original base (which rendered "Professional") and fails on the merged tree —
the sole failure, 89/90. Found by an independent reviewer building the real
merged tree rather than testing the branch in isolation.

This merge commit also brings the branch up to date with the integration branch
(merge, not rebase). The only conflicts were in generated dist/ artifacts, which
CI rebuilds; they were resolved to the integration branch's version.
@sgardoll
sgardoll merged commit 73290e3 into milestone/stu-implement-updated-ui-ux Sep 22, 2026
3 checks passed

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 5 potential issues.

Devin Review

Comment thread app.js
Comment on lines +4553 to +4554
openSignInModal()
setSignInMessage(message, 'error')

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.

🟡 First-visit token failure hides retry

For first-time free users, openSignInModal() displays the token error before showWalkthroughIfNeeded replaces it. The user sees onboarding instead of retry controls.

Learn more

The shared modal manager allows only one active modal. Opening a second modal forcibly closes the first through openModal. After token verification fails, these lines open the sign-in modal, but startup continues and showWalkthroughIfNeeded opens onboarding for users without hasSeenWalkthrough. The E2E setup masks this because every new test pre-populates that key.

Example: A new free user opens an expired link. The sign-in error appears during initializeAuth, then the walkthrough replaces it later in the same startup sequence. The expected inline retry is no longer visible.

Recommended fix: Suppress or defer showWalkthroughIfNeeded() when magic-link verification has opened the sign-in recovery modal. Add an E2E case without hasSeenWalkthrough.

Devin Review


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

Comment thread app.js
if (returnHash && !window.location.hash) window.location.hash = returnHash
} catch (err) {
showToast(err.message || 'Sign-in link invalid or expired.', 'error')
consumeSignInReturnSurface()

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.

🟡 Expired-link retry loses return task

After a token fails, consumeSignInReturnSurface() deletes the initiating surface. openSignInModal() then records home, so a replacement link cannot return to the task.

Learn more

A return surface is consumed by reading and immediately deleting its storage entry. The failure path discards that value, then calls openSignInModal, which stores the callback page's hash or #home. A successful replacement link therefore restores the callback location instead of the surface where sign-in began.

Example: A user starts sign-in from Plans and opens an expired link without a hash. The failure consumes #plans, then retry records #home. After requesting and opening a new link, the app lands on Home instead of Plans.

Recommended fix: Preserve a valid return hash across verification failure and inline retry. Either leave the entry unconsumed on failure, or pass the consumed hash into the retry modal and write it back without replacing it with the callback location.

Devin Review


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

Comment thread app.js
Comment on lines +4577 to +4580
localStorage.setItem(SIGNIN_RETURN_STORAGE_KEY, JSON.stringify({
hash: window.location.hash || '#home',
ts: Date.now(),
}))

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.

🟡 Concurrent sign-ins swap return destinations

Opening openSignInModal() in another tab overwrites the shared return record. Either tab's magic link then restores the most recently recorded surface.

Learn more

localStorage is shared by every tab on the same origin, and this implementation stores only one return hash. The magic-link callback has no flow-specific key, so it cannot associate a link with the tab or request that created it. Any later modal opening replaces the destination for all outstanding links.

Example: Alice opens sign-in from Plans in tab A, then opens sign-in from Account in tab B. Tab B overwrites #plans with #account. Opening tab A's email link now returns to Account.

Recommended fix: Correlate each magic-link request with a unique flow identifier carried through the callback and key return data by that identifier. If the backend contract cannot carry state, use per-tab storage and ensure links return to their originating tab rather than relying on a singleton origin-wide record.

Devin Review


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

Comment thread app.js
Comment on lines +4591 to +4593
const { hash, ts } = JSON.parse(raw)
if (!hash || typeof ts !== 'number' || Date.now() - ts > SIGNIN_RETURN_TTL_MS) return null
return hash

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.

🔍 Stored return hashes lack router validation

The return record accepts any nonempty hash, while switchView() supports only home, account, and plans. Corrupt or legacy values can leave the URL inconsistent with the visible view.

Devin Review


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

Comment on lines +85 to +106
test("shows an inline alias-policy error and keeps normal addresses working", async ({ page }) => {
await applyDefaultRoutes(page, {
[ENDPOINTS.identity]: guestIdentity(),
[ENDPOINTS.getSubscription]: freeSubscription(),
[ENDPOINTS.authSendMagicLink]: magicLinkAliasRejected("user+tag@gmail.com"),
});

await page.goto("/#account");
await page.locator("#auth-signedout button", { hasText: "Sign In" }).click();

const input = page.locator("#signin-email-input");
const submit = page.locator("#signin-submit-btn");
await input.fill("user+tag@gmail.com");
await submit.click();

// STU-149's alias-policy error is surfaced inline, not swallowed.
await expect(page.locator("#signin-message")).toContainText("primary email address");
await expect(input).toHaveAttribute("aria-invalid", "true");
await expect(submit).toBeEnabled();
// The address is preserved so the user can see what was rejected.
await expect(input).toHaveValue("user+tag@gmail.com");
});

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.

🔍 Alias recovery remains untested

The alias test only submits the rejected address, despite claiming normal addresses still work. It misses correction and successful resubmission after rejection.

Devin Review


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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant