Skip to content

Handle requests for nonexistent organizations - #268

Merged
shaply merged 2 commits into
mainfrom
ruhun/handle-nonexistent-organizations
Sep 14, 2026
Merged

shaply merged 2 commits into
mainfrom
ruhun/handle-nonexistent-organizations

Conversation

@ruhundesilva

Copy link
Copy Markdown
Collaborator

Closes #252

Description

When a visitor loads the app on a subdomain that has no matching organizations row, they currently get a page that looks broken rather than one that tells them anything. The root cause is in useOrganizationConfig: it called res.json() unconditionally, so the API's { error: "..." } body was merged into config as if it were valid values. That error string then passed the typeof value === "string" cache guard and was written to localStorage with a live 5-minute TTL, and a bare .catch() made a network failure indistinguishable from a successful fetch.

The fix has two halves.

The load-bearing half is the hook: a non-ok response is never parsed as config, never cached, and is now reported through an explicit status field so callers can tell "this nonprofit doesn't exist" apart from "something went wrong". This outlives the ticket — the same swallow-and-render behavior would otherwise recur on a 500, a cold start, or a malformed body.

The visible half is a single shared, unbranded OrganizationNotFound component rendered by all four unauthenticated pages. It echoes the address back, because seeing acme spelled out is what makes a person notice they meant acmefoundation. It deliberately has no login form — if there's a box, people type in it, and they're back at the same dead end, trying passwords and waiting for a reset email that will never arrive. It is unbranded on purpose: a missing organization has no branding, and rendering the default palette for one is exactly the failure this fixes.

A note on scope: this is intentionally not Next.js notFound() / middleware-based tenant resolution. That is the architecturally right home for this and it is deferred, not rejected — there is no middleware.ts and no app/not-found.tsx to extend, and getSlugFromHost maps www.servicestart.com → "www" and api.… → "api", so a naive resolve-or-404 at the routing layer would 404 the marketing site. It needs an owner and an allowlist, not a drive-by.

This also gets more important after #253/#262 merges. Today a missing org at least looks broken. Once branding defaults land, it will look like a clean, correctly-branded login page for a nonprofit that does not exist — a convincing fake is worse than an obvious error.

Changes Made

  • api/organizationConfig.ts — a missing organization now returns 404, not 400. Genuinely malformed requests keep 400, so missing / bad-request / server-error are distinguishable by status alone. The derived-host branch is intentionally untouched: a missing org there still falls back to the session's active organization, which is real behavior for a signed-in user, and the hook never takes that path.
  • lib/hooks/useOrganizationConfig.ts — checks res.ok before parsing; adds an additive status: "ok" | "not-found" | "error" field (the return type is not reshaped, so all five consumers — including components/navigation/Logo.tsx — keep working untouched); moves the cache write inside the ok-path; replaces the bare .catch(). Also bumps the cache key prefix to org-config:v2 — existing users are holding poisoned {error: "..."} entries with live TTLs that the fix alone would not evict.
  • components/OrganizationNotFound.tsx (new) — one shared unbranded component, so copy and styling live in exactly one place.
  • app/login, app/signup, app/forgotpassword, app/resetpassword — one additive early return each, below the hooks. Branding defaults are left intact so this merges cleanly alongside fix(branding): share org color defaults across auth pages #262.
  • Tests — route 404/400 coverage; hook coverage for not-found, 500, and network failure (each asserting the cache is left untouched), plus an existing-org-with-server-default-branding case. A new page-level component test (tests/unit/app/authpages/organizationNotFound.test.tsx) renders all four pages against each status and asserts the not-found state replaces the form when the org is missing, and does not appear on ok or error — so a server error or dropped connection can never tell a visitor their nonprofit does not exist.
  • tests/e2e/login.spec.ts / signup.spec.ts — added ensureServicestartOrganization() in beforeAll. These specs hit localhost, which resolves to the servicestart tenant; that org wasn't guaranteed to exist, and the old swallowed 400 was hiding it. With a real 404 it would have surfaced as a confusing failure in these specs.

Screenshots + Videos

The fix

Video — nonexistent organization across all four pages:

after-not-found.mp4

After — organization does not exist. One shared unbranded page, on every route, naming the address the visitor actually typed:

Login Signup Forgot password Reset password
A-after-login A-after-signup A-after-forgotpassword A-after-resetpassword

The request is now a clean 404 and is never treated as config:
A-after-network-404
·
A-after-network-body

Before — the same four pages. Placeholder branding, no explanation, and a login form that will never work:

Login Signup Forgot password Reset password
A-login A-signup A-forgotpassword A-resetpassword

The failed request, parsed and rendered as though it were valid config:
A-network-400
·
A-network-body

Organizations that do exist are unaffected

Exists, no branding rows — renders normally on the server's defaults, not the not-found state. This is the case that separates "missing" from "unstyled":

Before After Response
B-login B-after-login B-after-network-200

Exists and branded — unchanged:

Before After
C-login C-after-login

Known limitation: a brief flash before the not-found state

The four pages are client components, so they render their defaults during SSR until the config request resolves, then swap to the not-found state. This is visible in the video. Removing it requires resolving the tenant on the server, which is out of scope here (see below).

Known limitation: no e2e coverage for the not-found state

lib/api.ts builds the Hono RPC client against getBaseUrl() (NEXT_PUBLIC_BASE_URL), so every tenant's API calls go to one fixed origin. Loading a page on any other host makes that call cross-origin, and with no CORS headers the browser blocks it before the 404 is readable — the hook correctly sees a network error, not "not-found". That makes the not-found state reachable in a browser only when NEXT_PUBLIC_BASE_URL matches the host being visited, which a single e2e build cannot do for both localhost and a missing tenant.

The state is covered by unit tests on useOrganizationConfig (all four status paths) and verified manually per the steps below. Resolving per-tenant API origins is a separate concern from this ticket and worth its own issue — it affects every API call in the app, not just organization config.

Testing Steps

NEXT_PUBLIC_BASE_URL must match the host you visit (see the limitation above), so each case needs its own dev server. Prefix the variable rather than editing .env. Run localStorage.clear() and reload on each host before checking it.

  1. Nonexistent organization. NEXT_PUBLIC_BASE_URL=http://doesnotexist.lvh.me:3000 pnpm dev, then visit http://doesnotexist.lvh.me:3000/login, /signup, /forgotpassword, /resetpassword. Each shows the unbranded not-found state naming doesnotexist.lvh.me:3000, with no login form anywhere. Network: /api/organizationConfig → 404. Console: Object.keys(localStorage) → empty, i.e. the failed response was never cached.
  2. Existing organization, no branding rows. NEXT_PUBLIC_BASE_URL=http://horizontal-left.lvh.me:3000 pnpm dev, visit http://horizontal-left.lvh.me:3000/login. Renders normally with the server's default palette — not the not-found state. Network: 200, body {"primary_color":"#FD8033","secondary_color":"#FB3552"}. This is the case that separates "missing" from "unstyled".
  3. Existing organization, branded. NEXT_PUBLIC_BASE_URL=http://vertical-icon.lvh.me:3000 pnpm dev, visit http://vertical-icon.lvh.me:3000/login — blue/purple gradient, tagline "Welcome to Vertical Icon Org". Unchanged from before this PR.
  4. Server/network error is not "missing". On any working tenant, set DevTools throttling to Offline and reload. The page renders with its normal defaults, not the not-found state, and localStorage stays clean — a flaky network must never tell a visitor their nonprofit does not exist.
  5. pnpm lint and pnpm test.

A failed organization config request was parsed and rendered as though it were valid configuration, and cached for five minutes, so a visitor on a subdomain with no matching organization got a page that looked broken with no explanation.

- Return 404 rather than 400 for a missing organization, so missing, malformed and server-error are distinguishable by status alone
- Check res.ok before parsing, and never write a non-ok response to the cache
- Add an additive status field to useOrganizationConfig so callers can tell "no such nonprofit" apart from a network or server failure
- Bump the config cache key prefix to retire entries poisoned by the old bug
- Render a shared, unbranded OrganizationNotFound on the four auth pages
The hook tests covered every status, but nothing asserted the four auth pages actually render OrganizationNotFound -- deleting the early return from a page left the suite green. Covers all four pages against not-found, ok and error.
@netlify

netlify Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for servicestart ready!

Name Link
🔨 Latest commit 4e8e573
🔍 Latest deploy log https://app.netlify.com/projects/servicestart/deploys/6aa769d71b7a28000837793e
😎 Deploy Preview https://deploy-preview-268--servicestart.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@shaply
shaply merged commit 8d6132e into main Sep 14, 2026
25 checks passed

This branch was successfully deployed

1 active deployment
Preview — 4e8e5734 Deployed Sep 14, 2026 by shaply via drop-db #250
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Handle Requests for Nonexistent Organizations

2 participants