Skip to content

46864 frontend [ Configuration ] Public forms via URL-path /forms/#217

Open
EBirkenfeld wants to merge 7 commits into
masterfrom
frontend/configuration/46864__public_forms_via_url_path
Open

46864 frontend [ Configuration ] Public forms via URL-path /forms/#217
EBirkenfeld wants to merge 7 commits into
masterfrom
frontend/configuration/46864__public_forms_via_url_path

Conversation

@EBirkenfeld
Copy link
Copy Markdown
Collaborator

@EBirkenfeld EBirkenfeld commented May 26, 2026

1. Problem

When navigating to a public form link http://localhost/forms/{token}, the user was forcefully redirected to the sign-in page (/auth/signin) instead of seeing the public form.

Where it manifested: any link of the form domain.com/forms/{token} — the form never loaded, immediate redirect to login.

2. Context

Previously, public forms worked only via subdomain (form.domain.com). This required a separate DNS domain and complicated self-hosted deployments — not all installations have the ability to set up wildcard DNS or an additional domain.

Switching to path-based mode (domain.com/forms/...) simplifies self-hosted deployments where everything runs on a single domain. The subdomain mode remains backward-compatible for existing installations.

Root cause: The frontend router (Express on server and React Router on client) was unaware of path-based forms — all traffic to /forms/* hit the main application, which required authentication.

3. Solution

Support for two modes of form access without breaking changes:

  1. Path-based (new, default): domain.com/forms/{token} — always available
  2. Subdomain (existing): form.domain.com/{token} — available when FORM_DOMAIN is set

At each stack level (nginx → Express → React Router), /forms/* routing was added, and the app part identification logic (identifyAppPart) was extended for path-based detection.

4. Implementation Details

File Change
nginx/templates/nginx.conf.template Added location /forms/ — proxy to frontend
frontend/src/server/server.ts app.use('/forms', formsRouter) — path-based route (always); subdomain — conditional when FORM_DOMAIN is set
frontend/src/public/utils/identifyAppPart/constants.ts New getFormsBasename(pathname) — pure, testable utility for determining basename
frontend/src/public/forms.tsx createBrowserHistory({ basename: getFormsBasename(...) }) — React Router correctly strips /forms
identifyAppPartOnClient.ts Two checks: path-based (getFormsBasename) and subdomain (hostname.includes)
identifyAppPartOnServer.ts Two checks: path-based (req.baseUrl === '/forms') and subdomain
docker-compose.yml, docker-compose.src.yml FORMS_URL default http://localhost/forms, FORM_DOMAIN — empty string
backend/src/settings.py CORS_ORIGIN_WHITELIST — safe origin extraction from FORMS_URL (supports path-based URLs)
mainHandler.test.ts, authMiddleware.test.ts Added missing path, baseUrl, cookie properties to Express mock objects

New test files:

  • getFormsBasename.test.ts — 20 test cases
  • identifyAppPartOnClient.test.ts — client-side detection tests
  • identifyAppPartOnServer.test.ts — server-side detection tests

5. What to Test

5.1 Preconditions

  • Docker environment is running (docker-compose up)
  • FORM_DOMAIN is not set (empty string by default) — verifies path-based mode
  • A template with a public form is created, form link is obtained

5.2 Positive Scenarios

  1. Path-based form access

    • Open http://localhost/forms/{token}
    • ✅ Expected: form is displayed, no redirect to login
  2. Embedded form via path

    • Open http://localhost/forms/{token}/embed
    • ✅ Expected: embedded form displays correctly
  3. Main application is unaffected

    • Log in, navigate to /dashboard, /templates, /tasks
    • ✅ Expected: everything works as before
  4. Subdomain mode (when FORM_DOMAIN is set)

    • Set FORM_DOMAIN=form.localhost
    • Open http://form.localhost/{token}
    • ✅ Expected: form displays via subdomain
  5. Both modes simultaneously

    • With FORM_DOMAIN set, verify both domain/forms/{token} and form.domain/{token}
    • ✅ Expected: both variants work

5.3 Negative Scenarios and Edge Cases

  1. Non-existent token

    • Open http://localhost/forms/nonexistent-token-123
    • ✅ Expected: forms app loads (no redirect to login), API returns error
  2. Path /forms without token

    • Open http://localhost/forms and http://localhost/forms/
    • ✅ Expected: forms app loads (no redirect to login)
  3. Similar paths don't break

    • Open http://localhost/formations or http://localhost/formsubmit
    • ✅ Expected: main application loads (requires auth), not forms
  4. Case sensitivity

    • Open http://localhost/Forms/{token} or http://localhost/FORMS/{token}
    • ✅ Expected: not detected as forms (case-sensitive)

5.4 Verification Points

  • UI: form renders, fields display, submission works
  • Network (DevTools): API requests go to /api/..., not /forms/api/...
  • Console: no React Router errors or 404s on assets

5.5 API Checks

  • API requests from the form (fetching template data, submitting form) should go to /api/v2/... — check Network tab
  • CORS should not block requests — no CORS errors in console

5.6 What Was NOT Tested

  • Not tested on mobile devices
  • Not tested in production environment (dev only)
  • Not tested with SSL (HTTP only)
  • Locales were not separately tested (out of scope)

6. Affected Areas (dependencies)

Component What to verify
identifyAppPart (used in server.ts, mainHandler, authMiddleware) Authenticated users don't hit forms, forms don't require auth
formsRouter (Express) Guest-task and public forms are handled by the same router
Nginx config Static assets (/static/, /favicon.ico) are not intercepted by /forms/
CORS (backend settings.py) API requests from forms are not blocked

7. Refactoring

  • getFormsBasename — extracted path-based detection logic into a pure function (from inline code in forms.tsx and duplication in identifyAppPartOnClient.ts)
  • identifyAppPartOnClient/OnServer — separated two checks (path-based and subdomain) for clarity
  • Express mocks in tests — added missing properties (path, baseUrl, cookie)

Additionally verify: existing flows (guest-task, auth, OAuth) work without regressions.

8. Commits

All changes are in the current working tree. Files:

Modified (11): backend/src/settings.py, docker-compose.src.yml, docker-compose.yml, frontend/src/public/forms.tsx, constants.ts, identifyAppPartOnClient.ts, identifyAppPartOnServer.ts, server.ts, mainHandler.test.ts, authMiddleware.test.ts, nginx.conf.template

New (3): getFormsBasename.test.ts, identifyAppPartOnClient.test.ts, identifyAppPartOnServer.test.ts

9. Release Notes

Public Forms: Path-Based Routing — Public forms are now accessible via domain.com/forms/{token} without requiring a separate subdomain. This simplifies self-hosted deployments by eliminating the need for wildcard DNS or an additional domain. The subdomain mode (form.domain.com) remains fully supported for backward compatibility.


Note

Medium Risk
Changes how unauthenticated traffic is classified (forms vs main app) across nginx, Express, and React; empty FORM_DOMAIN/FORMS_URL defaults alter self-hosted behavior until env is set explicitly.

Overview
Public forms can be served at /forms/{token} on the main site, with form.domain.com still supported when FORM_DOMAIN is set.

Routing and app detection: Nginx proxies /forms/ to the frontend; Express always mounts the forms router at /forms and only enables subdomain forwarding when formSubdomain is configured. Client and server identifyAppPart check path-based /forms first, then subdomain hostname. The forms SPA uses getFormsBasename so React Router strips /forms in path mode and leaves routes unchanged on a forms subdomain.

Config defaults: Docker clears default FORMS_URL / FORM_DOMAIN (subdomain off unless set); Django derives FORMS_URL as FRONTEND_URL/forms when unset and adds a separate CORS origin only when the parsed forms origin differs from the frontend.

Tests: Coverage for basename detection, client/server app-part identification, and Express handler mocks (path, baseUrl, cookie).

Reviewed by Cursor Bugbot for commit 0d5a052. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add path-based public forms routing under /forms/ as an alternative to subdomain mode

  • Mounts the public forms app at /forms/* on the main domain in server.ts, in addition to the existing subdomain-based routing.
  • Adds getFormsBasename() in constants.ts to detect path-based forms and return the correct router basename; client and server app-part identification updated to check path first.
  • Nginx gains a /forms/ location block proxying to the frontend upstream in nginx.conf.template.
  • FORMS_URL now defaults to FRONTEND_URL + '/forms' when not set, and CORS_ORIGIN_WHITELIST is built from parsed origins to avoid duplicates.
  • Behavioral Change: FORM_DOMAIN defaults to empty in docker-compose, disabling subdomain mode by default; subdomain routing is preserved when formSubdomain is explicitly configured.

Macroscope summarized 0d5a052.

@EBirkenfeld EBirkenfeld self-assigned this May 26, 2026
@EBirkenfeld EBirkenfeld added the Frontend Web client changes request label May 26, 2026
Comment thread frontend/src/server/server.ts
Comment thread backend/src/settings.py Outdated
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2eadc67. Configure here.

Comment thread docker-compose.yml Outdated

return window.location.hostname.includes(formSubdomain);
},
// Path-based forms: domain.com/forms/*
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

2 checks need to be combined because they do the same thing with 2 different paths

const { formSubdomain } = getConfig();
return req.hostname.includes(formSubdomain);
},
// Path-based forms: domain.com/forms/*
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

2 checks need to be combined because they do the same thing with 2 different paths

return window.location.hostname.includes(formSubdomain);
},
// Path-based forms: domain.com/forms/*
check: () => !!getFormsBasename(window.location.pathname),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It's not quite clear what the check does, it's better to write the isFormPath utility where we already check all valid pathname or subdomains.

config: { mainPage },
} = getPublicFormConfig();

const formsHistory = createBrowserHistory({
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If our forms are running on a subdomain, and getFormsBasename returns undefined, there is a chance that the history will not work correctly. Then you need to set '/' instead of undefined.

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

Labels

Frontend Web client changes request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants