[object Object]

← back to Animals

Honor ?next= after login/signup so claimers return to the claim flow

2f8b7b59d524ad1f3c0bb3cfe455f787d055ea5c · 2026-05-26 10:15:39 -0700 · SteveStudio2

requireUserHtml already redirects logged-out users to /login?next=/clinic/:id/claim,
but the auth forms hardcoded location.href='/circles' and ignored it. Now both
forms read ?next= (with an open-redirect guard: only single-leading-slash local
paths, rejecting //host and backslash), carry it across the login<->signup
cross-links, and redirect there on success.

Files touched

Diff

commit 2f8b7b59d524ad1f3c0bb3cfe455f787d055ea5c
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Tue May 26 10:15:39 2026 -0700

    Honor ?next= after login/signup so claimers return to the claim flow
    
    requireUserHtml already redirects logged-out users to /login?next=/clinic/:id/claim,
    but the auth forms hardcoded location.href='/circles' and ignored it. Now both
    forms read ?next= (with an open-redirect guard: only single-leading-slash local
    paths, rejecting //host and backslash), carry it across the login<->signup
    cross-links, and redirect there on success.
---
 src/server/community.js        |  4 ++--
 src/server/community_render.js | 22 ++++++++++++++++------
 2 files changed, 18 insertions(+), 8 deletions(-)

diff --git a/src/server/community.js b/src/server/community.js
index 31dc49b..62cad77 100644
--- a/src/server/community.js
+++ b/src/server/community.js
@@ -67,8 +67,8 @@ function sanitizePhotoUrls(urls) {
 const MARKETPLACE_BODY_MAX = 10_000;
 
 // ── auth pages ────────────────────────────────────────────────────────────
-community.get('/signup', (req, res) => res.send(renderSignup({ user: req.user })));
-community.get('/login',  (req, res) => res.send(renderLogin({ user: req.user })));
+community.get('/signup', (req, res) => res.send(renderSignup({ user: req.user, next: req.query.next })));
+community.get('/login',  (req, res) => res.send(renderLogin({ user: req.user, next: req.query.next })));
 community.post('/auth/signup', signup);
 community.post('/auth/login',  login);
 community.post('/auth/logout', logout);
diff --git a/src/server/community_render.js b/src/server/community_render.js
index b577066..cee5be6 100644
--- a/src/server/community_render.js
+++ b/src/server/community_render.js
@@ -44,13 +44,23 @@ function footer() {
 }
 
 // ── auth pages ────────────────────────────────────────────────────────────
-export function renderSignup({ user }) {
+// Only ever return a local path — blocks //evil.com, \evil, and absolute URLs
+// so a poisoned ?next= can't turn the auth pages into an open redirect.
+function safeNext(next) {
+  return (typeof next === 'string' && /^\/[^/\\]/.test(next)) ? next : '';
+}
+function withNext(path, next) {
+  const n = safeNext(next);
+  return n ? `${path}?next=${encodeURIComponent(n)}` : path;
+}
+
+export function renderSignup({ user, next }) {
   if (user) return head('Already signed in') + nav(user) + `<main class="container"><h1>You're signed in</h1><p><a href="/marketplace">Browse marketplace</a> or <a href="/circles">your circles</a>.</p></main>` + footer() + '</body></html>';
   return head('Join PawCircles') + nav(null) + `
 <main class="container narrow">
   <h1>Join PawCircles</h1>
   <p class="muted">Free. Your ZIP unlocks your local pet community — vets, marketplace, lost-pet board, recommendations.</p>
-  <form class="auth-form" onsubmit="event.preventDefault();const fd=new FormData(this);const obj=Object.fromEntries(fd);obj.species_pets=fd.getAll('species_pets');fetch('/auth/signup',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(obj)}).then(r=>r.json()).then(j=>{if(j.ok){location.href='/circles'}else{this.querySelector('.err').textContent=j.error||'signup failed'}});">
+  <form class="auth-form" onsubmit="event.preventDefault();const fd=new FormData(this);const obj=Object.fromEntries(fd);obj.species_pets=fd.getAll('species_pets');const n=new URLSearchParams(location.search).get('next');const dest=(n&&/^\/[^/\\]/.test(n))?n:'/circles';fetch('/auth/signup',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(obj)}).then(r=>r.json()).then(j=>{if(j.ok){location.href=dest}else{this.querySelector('.err').textContent=j.error||'signup failed'}});">
     <label>Email <input name="email" type="email" required></label>
     <label>Password <input name="password" type="password" minlength="8" required></label>
     <label>Display name <input name="full_name"></label>
@@ -68,22 +78,22 @@ export function renderSignup({ user }) {
     <button type="submit">Create account</button>
     <p class="err" style="color:#c0382b"></p>
   </form>
-  <p class="muted small">Already have an account? <a href="/login">Log in</a>.</p>
+  <p class="muted small">Already have an account? <a href="${withNext('/login', next)}">Log in</a>.</p>
 </main>` + footer() + '</body></html>';
 }
 
-export function renderLogin({ user }) {
+export function renderLogin({ user, next }) {
   if (user) return head('Already signed in') + nav(user) + `<main class="container"><h1>You're signed in.</h1></main>` + footer() + '</body></html>';
   return head('Log in') + nav(null) + `
 <main class="container narrow">
   <h1>Log in</h1>
-  <form class="auth-form" onsubmit="event.preventDefault();const fd=new FormData(this);fetch('/auth/login',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(Object.fromEntries(fd))}).then(r=>r.json()).then(j=>{if(j.ok){location.href='/circles'}else{this.querySelector('.err').textContent=j.error||'login failed'}});">
+  <form class="auth-form" onsubmit="event.preventDefault();const fd=new FormData(this);const n=new URLSearchParams(location.search).get('next');const dest=(n&&/^\/[^/\\]/.test(n))?n:'/circles';fetch('/auth/login',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(Object.fromEntries(fd))}).then(r=>r.json()).then(j=>{if(j.ok){location.href=dest}else{this.querySelector('.err').textContent=j.error||'login failed'}});">
     <label>Email <input name="email" type="email" required></label>
     <label>Password <input name="password" type="password" required></label>
     <button type="submit">Log in</button>
     <p class="err" style="color:#c0382b"></p>
   </form>
-  <p class="muted small">No account? <a href="/signup">Sign up</a>.</p>
+  <p class="muted small">No account? <a href="${withNext('/signup', next)}">Sign up</a>.</p>
 </main>` + footer() + '</body></html>';
 }
 

← cda6f25 snapshot — gitify backup 2026-05-19  ·  back to Animals  ·  Add lost-pet alert opt-in surface (/me prefs page + signup c ecd4ce9 →