Skip to content

PZB-1113: Use browser origin for register and reset instead of hardcoded origin - #5235

Merged
gtempus merged 2 commits into
developfrom
feat/use-origin-for-reset-and-register
Jul 22, 2026
Merged

PZB-1113: Use browser origin for register and reset instead of hardcoded origin#5235
gtempus merged 2 commits into
developfrom
feat/use-origin-for-reset-and-register

Conversation

@gtempus

@gtempus gtempus commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the hardcoded CALLBACK_URL in services/user.js with a value derived from window.location.origin at call time. The callbackUrl we send to the authorization microservice on sign-up and password-reset is now whichever host the user's browser is actually on.

Why

We're cutting over from globalforestwatch.org to globalnaturewatch.org next week. The old constant —

const CALLBACK_URL = 'https://www.globalforestwatch.org/my-gfw/';

— pinned the value we hand to the auth service on every request. Since that value is persisted onto the Okta user's profile as the origin attribute (see below), every new account created and every password reset issued would keep steering users back to the legacy domain regardless of where they actually signed up.

With this change, we can deploy to production today and the DNS cutover becomes a no-op for auth flows. No coordinated release, no follow-up hotfix, no window where new accounts silently break. When DNS flips, window.location.origin flips with it, the value stored on the Okta user profile flips with it, and the post-email redirect resolves to whichever host the user started from — production, staging, preview builds, and the new globalnaturewatch.org domain alike.

What changed

  • services/user.js: CALLBACK_URL constant → getCallbackUrl() helper returning `${window.location.origin}/my-gfw/`.
  • Applied in both register and resetPassword.
  • services/__tests__/user.spec.js: new spec verifying both endpoints emit a callbackUrl matching the current browser origin.

How the callback actually flows

Verified against resource-watch/authorization — specifically setCallbackUrl middleware (auth.router.ts), and signUp / sendResetMail in okta.provider.ts.

The callbackUrl we send is not embedded directly into the confirmation/reset email. Instead the auth service stores it as the origin custom attribute on the Okta user profile, and later reads it back to redirect the browser after the user clicks the email link.

sequenceDiagram
    autonumber
    actor U as User
    participant B as Browser<br/>(GFW client JS)
    participant GFW as GFW Server<br/>(Next.js /api/gfw proxy)
    participant AUTH as authorization svc<br/>(api.resourcewatch.org)
    participant Okta as Okta
    participant Mail as Email inbox

    U->>B: Submits register / reset form on https://HOST
    Note over B: getCallbackUrl() runs here — window.location.origin<br/>is a browser API, only defined in the browser
    B->>GFW: POST /api/gfw/auth/sign-up?callbackUrl=https://HOST/my-gfw/
    Note over GFW: pages/api/gfw/[...params].js<br/>next-http-proxy-middleware forwards to GFW_API
    GFW->>AUTH: POST /auth/sign-up?callbackUrl=https://HOST/my-gfw/
    Note over AUTH: setCallbackUrl middleware<br/>stashes value in ctx.session.callbackUrl
    AUTH->>Okta: createUser / updateUserProtectedFields<br/>{ origin: "https://HOST/my-gfw/" }
    Okta->>Mail: Send Okta-templated activation / recovery email<br/>(link points at Okta → auth svc, NOT at HOST directly)
    U->>Mail: Opens email, clicks link
    Mail->>AUTH: GET /auth/sign-up-redirect?email=…<br/>(or Okta recovery landing)
    AUTH->>Okta: getOktaUserByEmail(email)
    Okta-->>AUTH: user.profile.origin = "https://HOST/my-gfw/"
    AUTH-->>B: 302 redirect → https://HOST/my-gfw/?token=…
    B-->>U: Lands on /my-gfw/ on the same HOST they started from, signed in
Loading

Two consequences worth noting:

  • sendResetMail refreshes the stored origin every time a reset is requested (okta.provider.ts line 849). So even users who registered on the legacy domain will get their origin updated the next time they hit "forgot password" from the new domain.
  • Accounts registered but not yet confirmed at cutover still carry the pre-cutover origin. If we want zero fallout there we can trigger a resend from the new host, or leave a temporary redirect on the legacy hostname for /my-gfw/*.

Safety

  • register / resetPassword are only invoked from the LoginForm submit handler (components/forms/login/actions.js), which runs client-side after mount — window is always defined at call time.
  • login itself does not use callbackUrl and is untouched.

Test plan

Automated

  • jest services/__tests__/user.spec.js — 2/2 passing (register + resetPassword each assert callbackUrl matches window.location.origin).
  • eslint services/user.js services/__tests__/user.spec.js — clean.

Manual — sign-up (per environment: localhost, then the Heroku review app for this PR)

  1. Open an incognito / private window (no cached session, no autofill, no lingering ctx.session.callbackUrl).
  2. Navigate to the host under test — start with http://localhost:3000, then repeat against this PR's Heroku review app URL (e.g. https://gfw-pr-<N>.herokuapp.com).
  3. Open DevTools → Network tab, filter for sign-up.
  4. Register with a fresh yopmail address, e.g. gfw-pr-verify-<timestamp>@yopmail.com.
  5. In the captured POST to /api/gfw/auth/sign-up (our Next.js proxy at pages/api/gfw/[...params].js, which forwards to api.resourcewatch.org/auth/sign-up), confirm the query string contains the host you're on — e.g. callbackUrl=http://localhost:3000/my-gfw/ on localhost and callbackUrl=https://gfw-pr-<N>.herokuapp.com/my-gfw/ on the review app — i.e. the host matches the URL bar, not a hardcoded value.
  6. Open https://yopmail.com/, enter the inbox name, open the activation email.
  7. Click the link. (Do not rely on hovering — the visible href points at Okta/the auth service and does the origin lookup server-side; only the final landing URL reveals the stored origin.)
  8. Confirm the browser ends up on /my-gfw/ on the same host you registered from and the account is activated.

Manual — password reset

  1. New incognito window, same host as above.
  2. Trigger "Forgot password" for the yopmail address created above.
  3. Repeat steps 5–8 with reset-password instead of sign-up.

Cross-host verification (proves the dynamic behavior)

Register once from http://localhost:3000 with one yopmail address, then register again from this PR's Heroku review app (https://gfw-pr-<N>.herokuapp.com) with a different yopmail address. Confirm both:

  • the outgoing callbackUrl query param reflects the host of that run (localhost vs. the review app), and
  • the final post-email landing URL is on that same host,

with no code change between the two runs.

Okta admin console — verify the origin attribute plumbing

Because the callback is persisted on the Okta user profile, the piece to verify in Okta is the custom profile attribute and the resulting stored value — not a redirect allow-list.

  1. Log in to the Okta admin console for the WRI tenant.
  2. Directory → Profile Editor → User (default) → confirm a custom string attribute named origin exists and is writable by the API. This is what OktaService.createUserWithoutPassword / updateUserProtectedFields targets.
  3. Directory → People → search for the yopmail account you just registered → Profile tab → confirm origin is set to the exact https://HOST/my-gfw/ you registered from (scheme + host + path, no extra slashes).
  4. Trigger the reset flow from a different host for the same user and re-check the profile — origin should now reflect the new host, proving sendResetMail's update path.
  5. Customizations → Brands → World Resources Institute → Emails → User Activation (and the corresponding Password Reset template) → confirm the templates route back through the authorization service's sign-up-redirect / recovery endpoints (not hard-coded to a specific frontend host) — this is what allows the stored origin to drive the final destination.

@gtempus
gtempus requested review from Copilot and dmannarino July 22, 2026 00:46
@gtempus gtempus changed the title feat(user service): use browser origin for register and reset PZB-1113: Use browser origin for register and reset instead of hardcoded origin Jul 22, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the user-service auth flows so the callbackUrl sent to the authorization service is derived dynamically from the current browser origin, enabling seamless domain cutover and correct post-email redirects across environments.

Changes:

  • Replaced the hardcoded CALLBACK_URL with a getCallbackUrl() helper based on window.location.origin.
  • Applied the dynamic callback URL to both register and resetPassword requests.
  • Added Jest coverage asserting the emitted callbackUrl reflects the current browser origin.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
services/user.js Switches callback URL generation to runtime browser origin and uses it for sign-up/reset requests.
services/tests/user.spec.js Adds unit tests to validate callbackUrl generation for register and resetPassword.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread services/user.js Outdated
const isServer = typeof window === 'undefined';

const CALLBACK_URL = 'https://www.globalforestwatch.org/my-gfw/';
const getCallbackUrl = () => `${window.location.origin}/my-gfw/`;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Why the isServer guard is unnecessary for register / resetPassword

Companion note to the "dynamic callback URL" PR. Explains why removing/omitting the isServer branch around getCallbackUrl() is safe, and links to authoritative React sources so newer devs can build the mental model.


One-line summary

The only path to register / resetPassword is <form onSubmit>, and React does not attach onSubmit to any DOM until hydrateRoot runs in the browser — so window.location.origin is guaranteed to exist at the moment getCallbackUrl() executes.

The concrete call chain

Traced from services/user.js outward:

  1. services/user.js — exports register / resetPassword.
  2. components/forms/login/actions.js:26, 40 — wraps them in redux thunks registerUser / resetUserPassword.
  3. components/forms/login/component.jsx:110<Form onSubmit={submitFunc}> (react-final-form), where submitFunc = registerUser | resetUserPassword.
  4. components/forms/login/component.jsx:173<form className="c-login-form" onSubmit={handleSubmit}>.

That native <form>'s submit event is the only code path that reaches our service functions. Verified by grep: only actions.js imports them, and only the LoginForm uses those thunks.

Why a DOM submit event guarantees a browser context

Three things must all be true for our code to run, and none of them exist on the server:

Requirement Server-side render Browser
A real DOM <form> element to dispatch submit ❌ output is an HTML string
React on* handlers attached to that element ❌ stripped from SSR output ✅ attached at hydration
A user (or script) to trigger the event ❌ SSR is synchronous; no event loop

React makes this explicit — event handlers are not attached during renderToString; they're attached only when hydrateRoot runs in the browser.

Authoritative React sources

Read these in order — they're short and directly address the question:

  1. renderToString (server) — https://react.dev/reference/react-dom/server/renderToString

    "It only calls your components. It does not run Effects or read data. ... In the produced HTML, only the minimal HTML necessary to render the initial UI is included. Event handlers are not attached."

  2. hydrateRoot (client) — https://react.dev/reference/react-dom/client/hydrateRoot

    "hydrateRoot lets you display React components inside a browser DOM node whose HTML content was previously generated by react-dom/server. React will attach to the existing HTML ... React will attach event handlers to the HTML that you pass to hydrateRoot."

  3. react-dom/server overviewhttps://react.dev/reference/react-dom/server
    Confirms all server APIs "render React components to HTML" — none of them execute event handlers.

  4. Next.js rendering model (this app is Pages Router) — https://nextjs.org/docs/pages/building-your-application/rendering/server-side-rendering
    Server functions like getServerSideProps and the SSR pass produce HTML; interactivity comes from the client-side JS bundle after hydration.

  5. React source (if you want to see it in code) — packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js in the React repo skips props matching on* when writing server HTML: https://github.com/facebook/react/tree/main/packages/react-dom-bindings/src/server

Where the isServer guard does still make sense in services/user.js

It's not that isServer is useless everywhere in this file — it's specifically unnecessary in register / resetPassword because their entry points are DOM event handlers. Contrast with:

  • setUserToken (line 15) — could theoretically be called from a code path that runs at module import or during SSR, and it touches localStorage. The guard is genuinely load-bearing there.
  • Module top-level code in utils/request.js — runs on both server and client at import time, so the isServer branching on baseURL is necessary.

Mental model

Guard against window / localStorage / document when the function can be called from module-init or a lifecycle path, e.g.:

  • import-time side effects
  • getServerSideProps / getStaticProps
  • class constructors
  • top-level useState initializers that read storage
  • useLayoutEffect bodies during SSR-adjacent rendering

Skip the guard when the function is only reachable via a user-driven DOM event — those simply cannot fire before hydration.

Comment thread services/user.js
Comment thread services/user.js
Comment thread services/__tests__/user.spec.js
Comment thread services/__tests__/user.spec.js
@willian-viana
willian-viana temporarily deployed to gfw-staging-pr-5235 July 22, 2026 00:49 Inactive

@dmannarino dmannarino left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice explanation, thanks for writing a test! As long as you're satisfied that the lack of URL encoding is okay (looks like no behavior change there), I approve!

@gtempus
gtempus temporarily deployed to gfw-staging-pr-5235 July 22, 2026 13:30 Inactive
@gtempus
gtempus merged commit 0424f8f into develop Jul 22, 2026
4 checks passed
@gtempus
gtempus deleted the feat/use-origin-for-reset-and-register branch July 22, 2026 14:25
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.

4 participants