d91971402abfed81d749023106edbbcd4642c901
max
  Tue Aug 18 16:36:22 2026 -0700
hgLogin: update gbMembers.lastUse on OAuth logins, not just gbMemberIdentity.lastUse

OAuth sign-ins recorded the login only in gbMemberIdentity.lastUse (via
linkIdentity) and never touched gbMembers.lastUse, so a returning social-login
user's gbMembers.lastUse went stale.  Move the lastUse stamp into loginAndReturn,
the shared funnel for all social and email-link logins, so every method that
passes through it records the sign-in uniformly.  Drop the now-redundant
incidental lastUse writes on the two email-link paths.  The password path keeps
its own stamp via clearNewPasswordFields (it uses displayLoginSuccess, not
loginAndReturn).  refs #37984

diff --git src/hg/hgLogin/hgLogin.c src/hg/hgLogin/hgLogin.c
index 17e30c1f304..558997735e5 100644
--- src/hg/hgLogin/hgLogin.c
+++ src/hg/hgLogin/hgLogin.c
@@ -1724,35 +1724,39 @@
 if (!emailLinkEnabled())
     return;
 hPrintf("<a class=\"socialButton\" href=\"%s?hgLogin.do.emailLinkPage=1\">"
     "Email me a sign-in link</a>", hgLoginUrl);
 }
 
 static void printUsernameNote()
 /* Print a short hint, shown wherever a new username is chosen, explaining that the username
  * shows up in every short link the user later creates, so it should be short and easy to type. */
 {
 hPrintf("<p style=\"font-size:0.9em\">Note: your username becomes part of every short link "
     "you create later (for example <code>%s/s/<b>username</b>/MySession</code>), so choose "
     "something short and easy to type.</p>", brwAddr);
 }
 
-static void loginAndReturn(char *userName, uint idx)
+static void loginAndReturn(struct sqlConnection *conn, char *userName, uint idx)
 /* Set the permanent login cookies for userName and bounce back to the returnto URL.
- * Every login method (password, social, email link) funnels through here, so they all
- * produce the same long-lived login cookies. */
+ * Every social/email-link login funnels through here, so they all produce the same
+ * long-lived cookies and all record the sign-in in gbMembers.lastUse.  (The password
+ * path uses displayLoginSuccess and stamps lastUse via clearNewPasswordFields.) */
 {
+char query[256];
+sqlSafef(query, sizeof(query), "UPDATE gbMembers SET lastUse=NOW() WHERE idx=%u", idx);
+sqlUpdate(conn, query);
 hPrintf("<h2>%s</h2>", brwName);
 hPrintf("<p>Login successful, setting cookies now&hellip;</p>");
 struct dyString *cookieJS = getLoginCookieJS(userName, idx);
 jsInline(cookieJS->string);
 cartRemove(cart, "hgLogin_userName");
 returnToURL(150);
 }
 
 static void createIdentityTable(struct sqlConnection *conn)
 /* Create the gbMemberIdentity table if it does not exist.  On a mirror whose central db
  * is read-only this may fail; social login simply won't work there (and won't be enabled
  * without client secrets anyway), so ignore any error. */
 {
 if (sqlTableExists(conn, "gbMemberIdentity"))
     return;
@@ -2042,31 +2046,31 @@
     user, realName, emptyForNull(email), emailVerified ? "Y" : "N");
 sqlUpdate(conn, dyStringContents(q));
 dyStringFree(&q);
 uint idx = sqlLastAutoId(conn);
 
 struct oauthIdentity pending;
 ZeroVar(&pending);
 pending.provider = provider;
 pending.subject = subject;
 pending.email = email;
 linkIdentity(conn, idx, &pending);
 
 clearPendingIdentity();
 if (!emailVerified)
     setupNewAccount(conn, email, user);   // send confirmation mail for the unverified address
-loginAndReturn(user, idx);
+loginAndReturn(conn, user, idx);
 }
 
 void chooseAccountPage(struct sqlConnection *conn)
 /* Ask the user which of several accounts sharing an email address to sign in to.  Used by
  * two flows: OAuth (oauth_pending_* in the cart -> the chosen account is linked to the social
  * identity) and the passwordless email link (emailLogin_* in the cart -> just sign in). */
 {
 char *provider = cartUsualString(cart, "oauth_pending_provider", "");
 boolean emailMode = isEmpty(provider);
 if (emailMode && !emailLinkEnabled())
     {
     // The email-link chooser must not run where passwordless login is switched off.
     displayLoginPage(conn);
     return;
     }
@@ -2159,44 +2163,40 @@
         return;
         }
     sqlSafef(query, sizeof(query),
         "SELECT * FROM gbMembers WHERE idx=%d AND (email='%s' OR recovEmail='%s') "
         "AND loginToken='%s' AND loginToken<>'' AND loginTokenExpires > NOW() "
         "AND accountActivated='Y'",
         chosenIdx, email, email, tokenMd5);
     struct gbMembers *m = gbMembersLoadByQuery(conn, query);
     if (m == NULL)
         {
         freez(&errMsg);
         errMsg = cloneString("Please choose one of the listed accounts.");
         chooseAccountPage(conn);
         return;
         }
-    /* Consume the token on every account that shared it (single use), then sign in. */
+    /* Consume the token on every account that shared it (single use), then sign in.
+     * loginAndReturn records the sign-in on the chosen account in gbMembers.lastUse. */
     sqlSafef(query, sizeof(query),
         "UPDATE gbMembers SET loginToken='' WHERE (email='%s' OR recovEmail='%s') AND loginToken='%s'",
         email, email, tokenMd5);
     sqlUpdate(conn, query);
-    /* Record the sign-in on the account actually chosen, the same as the single-account path in
-     * emailLogin, so lastUse reflects the login. */
-    sqlSafef(query, sizeof(query),
-        "UPDATE gbMembers SET lastUse=NOW() WHERE idx=%u", m->idx);
-    sqlUpdate(conn, query);
     cartRemove(cart, "emailLogin_email");
     cartRemove(cart, "emailLogin_tokenMd5");
     cartRemove(cart, "hgLogin_chosenIdx");
-    loginAndReturn(m->userName, m->idx);
+    loginAndReturn(conn, m->userName, m->idx);
     gbMembersFree(&m);
     return;
     }
 
 /* OAuth mode. */
 char *subject = cartUsualString(cart, "oauth_pending_subject", "");
 char *email = cartUsualString(cart, "oauth_pending_email", "");
 if (isEmpty(subject) || isEmpty(email) || !pendingIdentityValid())
     {
     clearPendingIdentity();
     freez(&errMsg);
     errMsg = cloneString("Your login session expired. Please sign in again.");
     displayLoginPage(conn);
     return;
     }
@@ -2212,31 +2212,31 @@
 if (m == NULL)
     {
     freez(&errMsg);
     errMsg = cloneString("Please choose one of the listed accounts.");
     chooseAccountPage(conn);
     return;
     }
 struct oauthIdentity pending;
 ZeroVar(&pending);
 pending.provider = provider;
 pending.subject = subject;
 pending.email = email;
 linkIdentity(conn, m->idx, &pending);
 clearPendingIdentity();
 cartRemove(cart, "hgLogin_chosenIdx");
-loginAndReturn(m->userName, m->idx);
+loginAndReturn(conn, m->userName, m->idx);
 gbMembersFree(&m);
 }
 
 static void resolveIdentity(struct sqlConnection *conn, struct oauthIdentity *id)
 /* Log in the user behind an authenticated provider identity:
  *  1. If the provider gave a verified email matching MORE THAN ONE account, always let the
  *     user pick which one -- even if this identity was linked before. Because login cookies
  *     never expire, a user goes through OAuth very rarely, so an occasional pick is cheap
  *     and it lets a person with several same-email accounts choose freely each time.
  *  2. Else if the (provider,subject) is already linked, log into that account.
  *  3. Else if the verified email matches exactly one account, auto-link and log in.
  *  4. Else send the user to the "choose a username" page to finish a new account.
  * (Providers that don't release an email, e.g. ORCID, never reach step 1 or 3 and rely on
  *  the stored link from step 2.) */
 {
@@ -2261,40 +2261,40 @@
     n = slCount(matches);
     }
 
 if (n > 1)
     {
     setPendingIdentity(id);
     gbMembersFreeList(&matches);
     chooseAccountPage(conn);
     return;
     }
 
 struct gbMembers *linked = memberForIdentity(conn, id);
 if (linked != NULL)
     {
     linkIdentity(conn, linked->idx, id);
-    loginAndReturn(linked->userName, linked->idx);
+    loginAndReturn(conn, linked->userName, linked->idx);
     gbMembersFree(&linked);
     gbMembersFreeList(&matches);
     return;
     }
 
 if (n == 1)
     {
     linkIdentity(conn, matches->idx, id);
-    loginAndReturn(matches->userName, matches->idx);
+    loginAndReturn(conn, matches->userName, matches->idx);
     gbMembersFreeList(&matches);
     return;
     }
 
 gbMembersFreeList(&matches);
 setPendingIdentity(id);
 completeAccountPage(conn);
 }
 
 void oauthStart(struct sqlConnection *conn)
 /* Begin a social login: save an anti-CSRF state nonce (in the cart) and redirect the
  * browser to the provider's authorization page. */
 {
 char *provider = cgiUsualString("provider", "");
 if (!oauthProviderEnabled(provider))
@@ -2501,33 +2501,33 @@
 sqlSafef(query, sizeof(query),
     "SELECT * FROM gbMembers WHERE (email='%s' OR recovEmail='%s') AND loginToken='%s' "
     "AND loginToken<>'' AND loginTokenExpires > NOW() AND accountActivated='Y' ORDER BY idx",
     email, email, tokenMD5);
 struct gbMembers *list = gbMembersLoadByQuery(conn, query);
 int n = slCount(list);
 if (n == 0)
     {
     freez(&errMsg);
     errMsg = cloneString("This login link is invalid or has expired. Please request a new one.");
     displayLoginPage(conn);
     }
 else if (n == 1)
     {
     sqlSafef(query, sizeof(query),
-        "UPDATE gbMembers SET loginToken='', lastUse=NOW() WHERE idx=%u", list->idx);
+        "UPDATE gbMembers SET loginToken='' WHERE idx=%u", list->idx);
     sqlUpdate(conn, query);
-    loginAndReturn(list->userName, list->idx);
+    loginAndReturn(conn, list->userName, list->idx);
     }
 else
     {
     /* Several accounts share this now-verified address: stash the proof and let the user
      * pick one.  chooseAccount re-checks the token before logging in. */
     cartSetString(cart, "emailLogin_email", email);
     cartSetString(cart, "emailLogin_tokenMd5", tokenMD5);
     chooseAccountPage(conn);
     }
 gbMembersFreeList(&list);
 }
 
 static void dropRequestSuppliedFlowVars()
 /* The cart variables holding the state of a login in flight are written by hgLogin and by
  * nothing else: the nonce and provider of an OAuth round trip, the pending identity behind