chore: update frontend and backend URLs to use port 5181 for local development

This commit is contained in:
Ashwin 2026-09-26 17:24:25 +05:30
parent e26ab380fc
commit 3f46e09aed
19 changed files with 41 additions and 37 deletions

View file

@ -37,4 +37,4 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Frontend
PUBLIC_DJANGO_API_URL=http://localhost:8000
PUBLIC_DJANGO_API_URL=http://backend:8000

View file

@ -61,7 +61,7 @@ cd Django-CRM
docker compose up --build
```
Frontend at http://localhost:5173, API and Swagger UI at http://localhost:8000/swagger-ui/. See
Frontend at http://localhost:5181, API and Swagger UI at http://localhost:8000/swagger-ui/. See
the [Docker quick start guide](docs/getting-started/docker-quick-start.md) for the default admin
login, loading demo data, and what each service does.

View file

@ -11,7 +11,7 @@ DOMAIN_NAME="http://localhost:8000"
# CSAT survey, and the internal "assigned to you" notifications. Outside dev
# (ENV_TYPE other than "dev") a loopback value is refused at startup, because
# left at this default it mails customers a link to their own machine.
FRONTEND_URL="http://localhost:5173"
FRONTEND_URL="http://localhost:5181"
# AWS (only needed for production / S3 storage)
AWS_BUCKET_NAME=""
@ -44,8 +44,8 @@ DEFAULT_FROM_EMAIL="noreply@localhost"
ADMIN_EMAIL="admin@localhost"
ALLOWED_HOSTS=localhost,127.0.0.1
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
CSRF_TRUSTED_ORIGINS=http://localhost:5173
CORS_ALLOWED_ORIGINS=http://localhost:5181,http://127.0.0.1:5181
CSRF_TRUSTED_ORIGINS=http://localhost:5181
# Set to True only when TLS is terminated by a proxy in front of Django and that
# proxy is the only way in. It makes Django trust X-Forwarded-Proto, which is

View file

@ -156,7 +156,7 @@ CELERY_RESULT_BACKEND=redis://localhost:6379/0
# The web app, NOT this API. Every link the backend emails is built from it:
# the magic-link sign-in URL, the customer invoice and estimate portals, the
# CSAT survey. Point it at the API host and every one of those links 404s.
FRONTEND_URL=http://localhost:5173
FRONTEND_URL=http://localhost:5181
# Google sign-in. Without these the OAuth login flow cannot complete, which is
# the only interactive way into the app.
@ -164,7 +164,7 @@ GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
# Where the browser calls this API from. The SvelteKit dev server by default.
CORS_ALLOWED_ORIGINS=http://localhost:5173
CORS_ALLOWED_ORIGINS=http://localhost:5181
# This API's own public origin
DOMAIN_NAME=http://localhost:8000

View file

@ -3,7 +3,7 @@
``common.links.frontend_url`` builds all of them from it: the customer's invoice
and estimate portal links, the CSAT survey, the internal "assigned to you"
notifications, and, directly in ``common/tasks.py``, the magic-link sign-in URL.
It defaults to ``http://localhost:5173`` so a checkout works with no ``.env``,
It defaults to ``http://localhost:5181`` so a checkout works with no ``.env``,
which means a deployment that never sets it mails customers a link to a port on
their own machine and mails a sign-in token to one. Nothing on the server
reports it: the email sends, the link is simply dead on arrival.
@ -56,31 +56,31 @@ def _import_settings(frontend_url, env_type):
class TestALoopbackFrontendUrlIsRefusedOutsideDev:
def test_the_dev_default_fails_the_import(self):
"""The exact value an operator gets by not setting the variable."""
result = _import_settings("http://localhost:5173", "production")
result = _import_settings("http://localhost:5181", "production")
assert result.returncode != 0
assert "FRONTEND_URL" in result.stderr
def test_the_error_says_what_to_set_it_to(self):
"""A refusal that does not say what to do next is a support ticket."""
result = _import_settings("http://localhost:5173", "production")
result = _import_settings("http://localhost:5181", "production")
assert "https://app.example.com" in result.stderr
def test_the_error_says_the_link_reaches_customers(self):
"""The operator needs to know this is outward-facing, not internal."""
result = _import_settings("http://localhost:5173", "production")
result = _import_settings("http://localhost:5181", "production")
assert "customers" in result.stderr
def test_the_loopback_address_fails_too(self):
result = _import_settings("http://127.0.0.1:5173", "production")
result = _import_settings("http://127.0.0.1:5181", "production")
assert result.returncode != 0
def test_the_wildcard_bind_address_fails(self):
"""`0.0.0.0` is what a Docker compose file tends to carry."""
result = _import_settings("http://0.0.0.0:5173", "production")
result = _import_settings("http://0.0.0.0:5181", "production")
assert result.returncode != 0
@ -141,7 +141,7 @@ class TestANonAbsoluteFrontendUrlIsRefusedOutsideDev:
class TestDevIsLeftAlone:
def test_the_dev_default_imports_cleanly_in_dev(self):
"""In dev the loopback URL is the correct answer, not a warning."""
result = _import_settings("http://localhost:5173", "dev")
result = _import_settings("http://localhost:5181", "dev")
assert result.returncode == 0, result.stderr
assert "FRONTEND_URL" not in result.stderr

View file

@ -9,6 +9,7 @@ rule, and `Tags.name_error` the one owner of what a storable name is.
"""
import importlib
import uuid
from types import SimpleNamespace
import pytest
@ -207,8 +208,10 @@ class TestRecomputeMigration:
module.recompute_tag_slugs(global_apps, SimpleNamespace(connection=connection))
def _with_slug(self, org, name, slug):
# save() recomputes the slug, so store the old one with update().
tag = Tags.objects.create(name=f"placeholder {name}", org=org)
# save() recomputes the slug, so store the old one with update(). The
# placeholder must fit `name`'s 50 characters: Postgres enforces the
# width and SQLite does not, so a longer one only fails on Postgres.
tag = Tags.objects.create(name=f"placeholder {uuid.uuid4().hex}", org=org)
Tags.objects.filter(pk=tag.pk).update(name=name, slug=slug)
return tag

View file

@ -454,7 +454,7 @@ CORS_ORIGIN_ALLOW_ALL = os.environ.get("CORS_ALLOW_ALL", "False").lower() == "tr
CORS_ALLOWED_ORIGINS = [
origin.strip()
for origin in os.environ.get(
"CORS_ALLOWED_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173"
"CORS_ALLOWED_ORIGINS", "http://localhost:5181,http://127.0.0.1:5181"
).split(",")
if origin.strip()
]
@ -523,7 +523,7 @@ DOMAIN_NAME = os.environ.get("DOMAIN_NAME", "http://localhost:8000")
ORG_API_KEY_AUTH_ENABLED = os.environ.get(
"DJANGO_ORG_API_KEY_AUTH", "true"
).strip().lower() not in ("false", "0", "no", "off")
FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://localhost:5173")
FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://localhost:5181")
# Every link this system puts in an email is built from this one value, via
# `common.links.frontend_url`: the magic-link sign-in URL, the customer's

View file

@ -101,7 +101,7 @@ services:
- ./frontend:/app
- frontend_node_modules:/app/node_modules
ports:
- "5173:5173"
- "5181:5181"
volumes:
postgres_data:

View file

@ -16,8 +16,8 @@ of this section's page references point at, at the same, empty prefix. Individua
under their own segment from there, for example `path("leads/", include("leads.urls", ...))`, so a
lead endpoint is `/api/leads/…`.
Locally this is `http://localhost:8000/api/` (the default in the Docker quick start's
`.env.docker`, and what `PUBLIC_DJANGO_API_URL` points the frontend at. See
Locally this is `http://localhost:8000/api/` (the port the Docker quick start publishes. Inside
the compose network the frontend reaches the same API as `http://backend:8000`. See
[Docker quick start](../getting-started/docker-quick-start.md)). In a self-hosted deployment it is
whatever host you put `DOMAIN_NAME` / `ALLOWED_HOSTS` on, still under `/api/`.

View file

@ -66,11 +66,11 @@ pnpm install
pnpm run dev
```
`pnpm run dev` starts the Vite dev server on `http://localhost:5173`. `frontend/vite.config.js`
`pnpm run dev` starts the Vite dev server on `http://localhost:5181`. `frontend/vite.config.js`
configures no dev-server proxy, there is no `server.proxy` entry in it, so the browser calls the
Django API directly, cross-origin: `frontend/src/lib/api.js` builds its base URL from
`PUBLIC_DJANGO_API_URL`, defaulting to `http://localhost:8000/api` when that variable is unset. That
means your backend's `CORS_ALLOWED_ORIGINS` has to include `http://localhost:5173` (it does by
means your backend's `CORS_ALLOWED_ORIGINS` has to include `http://localhost:5181` (it does by
default in `backend/.env.example`: see [Manual setup](../getting-started/manual-setup.md)) or every
request from the dev server fails as a CORS error, not a 404 or connection-refused, which is easy to
misdiagnose as a backend problem.

View file

@ -36,7 +36,7 @@ The variables `.env.docker` actually defines are:
| `DEFAULT_FROM_EMAIL`, `ADMIN_EMAIL`, `ADMIN_PASSWORD` | Console email backend (dev), and the credentials used to bootstrap a Django admin superuser. See [What you got](#what-you-got). |
| `CORS_ALLOW_ALL` | `True` for local development. |
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Blank by default; Google sign-in is disabled until both are set. The redirect URI is not a setting: the frontend sends it in the request body on each call. |
| `PUBLIC_DJANGO_API_URL` | The API base URL the frontend container is built against, `http://localhost:8000`. |
| `PUBLIC_DJANGO_API_URL` | The API origin the frontend's server-side code calls, `http://backend:8000`. It uses the compose service name because `localhost` inside the frontend container is the container itself. Your browser still reaches the API at `http://localhost:8000`. |
## Start the stack
@ -53,7 +53,7 @@ This builds and starts six services, defined in `docker-compose.yml`:
| `backend` | built from the repo root `Dockerfile` | `8000` |
| `celery-worker` | same image as `backend`, runs `celery -A crm worker --loglevel=info` |. |
| `celery-beat` | same image as `backend`, runs `celery -A crm beat --loglevel=info` |, |
| `frontend` | built from `frontend/Dockerfile` | `5173` |
| `frontend` | built from `frontend/Dockerfile` | `5181` |
`backend`, `celery-worker` and `celery-beat` all wait for `db` and `redis` to report healthy
before starting. The `backend` container's entrypoint (`docker/backend/entrypoint.sh`) waits for
@ -106,7 +106,7 @@ docker compose exec backend python manage.py devlogin you@example.com --org Micr
This prints an access token, a refresh token, and the `MicroPyramid` org's UUID, already bound
into the token. See [First sign-in](first-sign-in.md) for how to use them from the frontend at
`http://localhost:5173`.
`http://localhost:5181`.
## What you got
@ -117,7 +117,7 @@ After `docker compose up --build` and one `seed_data` run, you have:
server. This compose file is for development).
- A Celery worker and a Celery beat scheduler, both connected to the `redis` service, running the
periodic and background tasks registered in `backend/crm/celery.py`.
- A SvelteKit frontend on `http://localhost:5173`, built against `PUBLIC_DJANGO_API_URL=http://localhost:8000`.
- A SvelteKit frontend on `http://localhost:5181`, which reaches the API over the compose network at `PUBLIC_DJANGO_API_URL=http://backend:8000`.
- A Django admin superuser, created automatically by `create_default_admin` on first boot, using
`ADMIN_EMAIL` / `ADMIN_PASSWORD` from `.env.docker` (`admin@localhost` / `admin` unless you
override them). This account can sign in to Django's own `/admin/` site; it does not have a CRM

View file

@ -64,7 +64,7 @@ instead (`event.cookies.get('jwt_access')`, `('jwt_refresh')` and `('org')`), an
server-side reads `localStorage` at all. Without those cookies set, `hooks.server.js` treats the
request as signed out and redirects to `/login`, no matter what `localStorage` holds.
To get past the guard, open the devtools console on `http://localhost:5173`, run the printed
To get past the guard, open the devtools console on `http://localhost:5181`, run the printed
`localStorage.setItem(...)` snippet, and set the matching cookies yourself:
```js

View file

@ -144,7 +144,7 @@ you're following the ports in this guide). Then start the dev server:
pnpm run dev
```
This serves the SvelteKit app on `http://localhost:5173` (`vite dev` under the hood, per the
This serves the SvelteKit app on `http://localhost:5181` (`vite dev` under the hood, per the
`dev` script in `frontend/package.json`).
## Running all three

View file

@ -112,7 +112,7 @@ Pooling uses psycopg 3's `psycopg_pool` through Django's PostgreSQL backend. It
| Variable | Default | Required | Purpose |
| --- | --- | --- | --- |
| `CORS_ALLOW_ALL` | `False` | No | Sets Django's `CORS_ORIGIN_ALLOW_ALL`. Note the name doesn't match the Django setting it controls. `"true"` (case-insensitive) allows every origin; anything else leaves it off. |
| `CORS_ALLOWED_ORIGINS` | `http://localhost:5173,http://127.0.0.1:5173` | Yes, once the frontend is served from a real domain | Comma-separated list of allowed CORS origins; each entry is stripped of surrounding whitespace and empty entries are dropped. |
| `CORS_ALLOWED_ORIGINS` | `http://localhost:5181,http://127.0.0.1:5181` | Yes, once the frontend is served from a real domain | Comma-separated list of allowed CORS origins; each entry is stripped of surrounding whitespace and empty entries are dropped. |
| `CSRF_TRUSTED_ORIGINS` | *(empty, no trusted origins)* | Yes, before anyone but you can reach the instance | Comma-separated list, same stripping rule as `CORS_ALLOWED_ORIGINS`. Each entry needs a scheme, e.g. `https://crm.example.com`. A bare hostname is silently useless to Django's CSRF check. |
### URLs
@ -120,7 +120,7 @@ Pooling uses psycopg 3's `psycopg_pool` through Django's PostgreSQL backend. It
| Variable | Default | Required | Purpose |
| --- | --- | --- | --- |
| `DOMAIN_NAME` | `http://localhost:8000` | No. Nothing reads it any more | Base URL of the **backend**. It no longer reaches a single emitted link. The four remaining reads (`invoices/tasks.py:305,482` and `invoices/api_views.py:276,436`) pass it as the `domain=` kwarg of Celery tasks whose signatures keep that parameter only so a task queued before the change still unpacks after deploy, and whose docstrings say it is ignored. Every link now comes from `FRONTEND_URL` via `common/links.py`. It used to build the internal "assigned to you" notifications as well as the customer-facing invoice, estimate and CSAT links, and every one of those was broken: the builders wrote `f"{protocol}://{domain}/..."` around a value that already carried a scheme, so the emitted link had two, and the paths are frontend routes that do not exist on the backend host. Setting this variable changes nothing; it is kept so an existing `.env` does not have to be edited. |
| `FRONTEND_URL` | `http://localhost:5173` | **Yes**, once self-hosted under a real domain | Base URL of the SvelteKit frontend, and the single source for every link that leaves the system in an email: the welcome email and magic-link sign-in URL (`common/tasks.py`), the emailed invoice and estimate portal links, the CSAT survey link, and the "assigned to you" invoice link (all via `common.links.frontend_url`). The customer-facing ones go to people outside the organization, so leaving the `localhost` default in a real deployment emails dead links to customers. **That is now refused rather than emailed:** when `ENV_TYPE` is anything other than `dev`, `crm/settings.py` raises at import if this points at a loopback host (`localhost`, `127.0.0.1`, `0.0.0.0`, `::1`) or is not an absolute `http`/`https` URL, so the deploy fails to start instead of failing silently on somebody else's inbox. Plain HTTP against a real host is allowed, for a deployment behind its own TLS terminator. A trailing slash is stripped, so `https://app.example.com` and `https://app.example.com/` behave the same. |
| `FRONTEND_URL` | `http://localhost:5181` | **Yes**, once self-hosted under a real domain | Base URL of the SvelteKit frontend, and the single source for every link that leaves the system in an email: the welcome email and magic-link sign-in URL (`common/tasks.py`), the emailed invoice and estimate portal links, the CSAT survey link, and the "assigned to you" invoice link (all via `common.links.frontend_url`). The customer-facing ones go to people outside the organization, so leaving the `localhost` default in a real deployment emails dead links to customers. **That is now refused rather than emailed:** when `ENV_TYPE` is anything other than `dev`, `crm/settings.py` raises at import if this points at a loopback host (`localhost`, `127.0.0.1`, `0.0.0.0`, `::1`) or is not an absolute `http`/`https` URL, so the deploy fails to start instead of failing silently on somebody else's inbox. Plain HTTP against a real host is allowed, for a deployment behind its own TLS terminator. A trailing slash is stripped, so `https://app.example.com` and `https://app.example.com/` behave the same. |
### Google OAuth

View file

@ -24,7 +24,7 @@ requirements in [Production deployment](production-deploy.md).
| `backend` | built from the repo-root `Dockerfile` | `8000` | The Django API, served via `manage.py runserver` in this compose file (see the warning above). |
| `celery-worker` | same image as `backend` |, (no published port) | Runs `celery -A crm worker --loglevel=info`; executes background tasks such as outbound email and RLS-context-aware jobs in `backend/common/tasks.py`. |
| `celery-beat` | same image as `backend` |, (no published port) | Runs `celery -A crm beat --loglevel=info`; fires the periodic tasks registered in `backend/crm/celery.py` (recurring invoices, overdue/expiry checks, SLA scanning, cleanup jobs) on their schedules. |
| `frontend` | built from `frontend/Dockerfile` | `5173` | The SvelteKit app, served via `pnpm dev --host 0.0.0.0`, also a dev server, not a production build. |
| `frontend` | built from `frontend/Dockerfile` | `5181` | The SvelteKit app, served via `pnpm dev --host 0.0.0.0`, also a dev server, not a production build. |
`backend`, `celery-worker` and `celery-beat` each declare `depends_on: db (service_healthy), redis
(service_healthy)`, using the `pg_isready` and `redis-cli ping` healthchecks defined on those two
@ -47,7 +47,8 @@ superuser role. See [PostgreSQL and RLS](postgresql-and-rls.md) for why the app
as this role), `CELERY_BROKER_URL`/`CELERY_RESULT_BACKEND` (both pointing at `redis`),
`DEFAULT_FROM_EMAIL`/`ADMIN_EMAIL`/`ADMIN_PASSWORD`, `CORS_ALLOW_ALL=True`,
`GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` (blank. Google sign-in stays disabled until both
are set), and `PUBLIC_DJANGO_API_URL=http://localhost:8000` for the frontend build.
are set), and `PUBLIC_DJANGO_API_URL=http://backend:8000`, the compose service name the
frontend's server-side code uses to reach the API.
## Running it

View file

@ -11,7 +11,7 @@ Create an OAuth 2.0 Client ID of type "Web application" in the Google Cloud Cons
URI it needs authorized is built by the frontend's login page
(`frontend/src/routes/(no-layout)/login/+page.server.js`) as `${GOOGLE_LOGIN_DOMAIN}/login`;
`GOOGLE_LOGIN_DOMAIN` is a **frontend** environment variable (`frontend/.env.example` defaults it to
`http://localhost:5173`), so the value to register with Google is `http://localhost:5173/login` for
`http://localhost:5181`), so the value to register with Google is `http://localhost:5181/login` for
local development, or `https://<your-frontend-host>/login` in production. There is no separate
redirect registration needed for the mobile flow. It doesn't use a redirect at all (see
[Mobile ID tokens](#mobile-id-tokens) below).

View file

@ -1,6 +1,6 @@
# Google OAuth (client_id only - secret is on backend)
GOOGLE_CLIENT_ID="your-google-client-id-here"
GOOGLE_LOGIN_DOMAIN="http://localhost:5173"
GOOGLE_LOGIN_DOMAIN="http://localhost:5181"
# Django API URL (PUBLIC_ prefix makes it available in both server and browser)
PUBLIC_DJANGO_API_URL=http://localhost:8000

View file

@ -40,7 +40,7 @@ RUN pnpm install --frozen-lockfile
# The compose target. Source and node_modules arrive as mounts, so this stage
# deliberately copies no source of its own.
FROM deps AS dev
EXPOSE 5173
EXPOSE 5181
CMD ["pnpm", "dev", "--host", "0.0.0.0"]

View file

@ -108,7 +108,7 @@ JWT_SECRET="<your-generated-secret>"
# Google OAuth (Optional)
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
GOOGLE_LOGIN_DOMAIN="http://localhost:5173"
GOOGLE_LOGIN_DOMAIN="http://localhost:5181"
```
5. **Set up the database:**