diff --git a/.env.docker b/.env.docker index 1970851b..b96d923c 100644 --- a/.env.docker +++ b/.env.docker @@ -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 diff --git a/README.md b/README.md index 580bdae1..9c467397 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/.env.example b/backend/.env.example index 94509a00..d5fa5482 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/README.md b/backend/README.md index 43a9ec4f..421c7ffe 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/common/tests/test_frontend_url_guard.py b/backend/common/tests/test_frontend_url_guard.py index a5eb6efb..7ab19f7a 100644 --- a/backend/common/tests/test_frontend_url_guard.py +++ b/backend/common/tests/test_frontend_url_guard.py @@ -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 diff --git a/backend/common/tests/test_tag_slugs.py b/backend/common/tests/test_tag_slugs.py index 99ad652a..5ab091db 100644 --- a/backend/common/tests/test_tag_slugs.py +++ b/backend/common/tests/test_tag_slugs.py @@ -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 diff --git a/backend/crm/settings.py b/backend/crm/settings.py index 2f69608b..7f692654 100644 --- a/backend/crm/settings.py +++ b/backend/crm/settings.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 3203c960..746bbadd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -101,7 +101,7 @@ services: - ./frontend:/app - frontend_node_modules:/app/node_modules ports: - - "5173:5173" + - "5181:5181" volumes: postgres_data: diff --git a/docs/api/conventions.md b/docs/api/conventions.md index aec8d877..07907121 100644 --- a/docs/api/conventions.md +++ b/docs/api/conventions.md @@ -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/`. diff --git a/docs/contributing/development-setup.md b/docs/contributing/development-setup.md index 2637f9cb..0c7d288e 100644 --- a/docs/contributing/development-setup.md +++ b/docs/contributing/development-setup.md @@ -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. diff --git a/docs/getting-started/docker-quick-start.md b/docs/getting-started/docker-quick-start.md index e5d58863..38a87509 100644 --- a/docs/getting-started/docker-quick-start.md +++ b/docs/getting-started/docker-quick-start.md @@ -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 diff --git a/docs/getting-started/first-sign-in.md b/docs/getting-started/first-sign-in.md index 12e67ed5..b635bab4 100644 --- a/docs/getting-started/first-sign-in.md +++ b/docs/getting-started/first-sign-in.md @@ -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 diff --git a/docs/getting-started/manual-setup.md b/docs/getting-started/manual-setup.md index b1034b80..cebc6005 100644 --- a/docs/getting-started/manual-setup.md +++ b/docs/getting-started/manual-setup.md @@ -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 diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index ff7e8fe9..87632438 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -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 diff --git a/docs/self-hosting/docker.md b/docs/self-hosting/docker.md index c9a7813b..ee845365 100644 --- a/docs/self-hosting/docker.md +++ b/docs/self-hosting/docker.md @@ -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 diff --git a/docs/self-hosting/google-oauth.md b/docs/self-hosting/google-oauth.md index d326d526..90ddf22e 100644 --- a/docs/self-hosting/google-oauth.md +++ b/docs/self-hosting/google-oauth.md @@ -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:///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). diff --git a/frontend/.env.example b/frontend/.env.example index 6ab492a6..49d0a5e9 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 0d81eb35..ec5a2b9d 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/README.md b/frontend/README.md index a7abee49..fbeda67a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -108,7 +108,7 @@ JWT_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:**