Move CaptureTimezone plug to browser pipeline
The plug was incorrectly placed in the endpoint, causing it to run on all requests including APIs. Moved to :browser pipeline where it belongs, and removed redundant fetch_session call since the pipeline handles it.
This commit is contained in:
parent
d05c493943
commit
77e62df9da
6 changed files with 527 additions and 7 deletions
|
|
@ -25,4 +25,8 @@ config :swoosh, local: false
|
|||
# manifest is generated by the `mix assets.deploy` task,
|
||||
# which you should run after static files are built and
|
||||
# before starting your production server.
|
||||
config :towerops, ToweropsWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
|
||||
config :towerops, ToweropsWeb.Endpoint,
|
||||
cache_static_manifest: "priv/static/cache_manifest.json",
|
||||
# Force SSL redirects and enable HSTS (HTTP Strict Transport Security)
|
||||
# HSTS tells browsers to only use HTTPS for this domain for the next year
|
||||
force_ssl: [hsts: true, rewrite_on: [:x_forwarded_host, :x_forwarded_port, :x_forwarded_proto]]
|
||||
|
|
|
|||
|
|
@ -206,9 +206,6 @@ if config_env() == :prod do
|
|||
|
||||
config :towerops, ToweropsWeb.Endpoint,
|
||||
url: [host: host, port: 443, scheme: "https"],
|
||||
# Force SSL redirects and enable HSTS (HTTP Strict Transport Security)
|
||||
# HSTS tells browsers to only use HTTPS for this domain for the next year
|
||||
force_ssl: [hsts: true, rewrite_on: [:x_forwarded_host, :x_forwarded_port, :x_forwarded_proto]],
|
||||
http: [
|
||||
# Enable IPv6 and bind on all interfaces.
|
||||
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
|
||||
|
|
|
|||
521
docs/plans/2026-02-01-cloudflare-timezone-detection.md
Normal file
521
docs/plans/2026-02-01-cloudflare-timezone-detection.md
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
# Cloudflare Timezone Detection Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Automatically set user timezone from Cloudflare's cf-timezone header during registration, with UTC fallback.
|
||||
|
||||
**Architecture:** Session-based timezone capture via plug → LiveView reads from session → merges into registration params → User schema accepts timezone field.
|
||||
|
||||
**Tech Stack:** Phoenix Plug, LiveView, Ecto changesets
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create CaptureTimezone Plug
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/towerops_web/plugs/capture_timezone.ex`
|
||||
- Create: `test/towerops_web/plugs/capture_timezone_test.exs`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Create test file with basic header capture tests:
|
||||
|
||||
```elixir
|
||||
defmodule ToweropsWeb.Plugs.CaptureTimezoneTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
alias ToweropsWeb.Plugs.CaptureTimezone
|
||||
|
||||
describe "call/2" do
|
||||
test "captures cf-timezone header and stores in session", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("cf-timezone", "America/Chicago")
|
||||
|> CaptureTimezone.call([])
|
||||
|
||||
assert get_session(conn, :detected_timezone) == "America/Chicago"
|
||||
end
|
||||
|
||||
test "defaults to UTC when cf-timezone header is missing", %{conn: conn} do
|
||||
conn = CaptureTimezone.call(conn, [])
|
||||
|
||||
assert get_session(conn, :detected_timezone) == "UTC"
|
||||
end
|
||||
|
||||
test "defaults to UTC when cf-timezone header is empty", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("cf-timezone", "")
|
||||
|> CaptureTimezone.call([])
|
||||
|
||||
assert get_session(conn, :detected_timezone) == "UTC"
|
||||
end
|
||||
|
||||
test "handles multiple timezone headers by taking first", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("cf-timezone", "America/Chicago")
|
||||
|> put_req_header("cf-timezone", "Europe/London")
|
||||
|> CaptureTimezone.call([])
|
||||
|
||||
assert get_session(conn, :detected_timezone) == "America/Chicago"
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `mix test test/towerops_web/plugs/capture_timezone_test.exs`
|
||||
|
||||
Expected: FAIL with "module ToweropsWeb.Plugs.CaptureTimezone is not available"
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Create plug file:
|
||||
|
||||
```elixir
|
||||
defmodule ToweropsWeb.Plugs.CaptureTimezone do
|
||||
@moduledoc """
|
||||
Captures the cf-timezone header from Cloudflare and stores it in session.
|
||||
|
||||
Cloudflare provides timezone detection via the cf-timezone header using
|
||||
IANA timezone database format (e.g., "America/Chicago", "Europe/London").
|
||||
|
||||
Falls back to "UTC" if the header is not present (development, non-Cloudflare).
|
||||
"""
|
||||
import Plug.Conn
|
||||
|
||||
@doc false
|
||||
def init(opts), do: opts
|
||||
|
||||
@doc false
|
||||
def call(conn, _opts) do
|
||||
timezone =
|
||||
case get_req_header(conn, "cf-timezone") do
|
||||
[tz] when is_binary(tz) and byte_size(tz) > 0 -> tz
|
||||
_ -> "UTC"
|
||||
end
|
||||
|
||||
put_session(conn, :detected_timezone, timezone)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `mix test test/towerops_web/plugs/capture_timezone_test.exs`
|
||||
|
||||
Expected: All 4 tests PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/towerops_web/plugs/capture_timezone.ex test/towerops_web/plugs/capture_timezone_test.exs
|
||||
git commit -m "Add CaptureTimezone plug to extract cf-timezone header"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add Plug to Endpoint Pipeline
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/towerops_web/endpoint.ex`
|
||||
|
||||
**Step 1: Add plug to browser pipeline**
|
||||
|
||||
Find the browser pipeline section (after `plug Plug.Session` and before `plug ToweropsWeb.Router`) and add:
|
||||
|
||||
```elixir
|
||||
# Around line 47, after plug Plug.Session
|
||||
plug ToweropsWeb.Plugs.CaptureTimezone
|
||||
```
|
||||
|
||||
Full context:
|
||||
|
||||
```elixir
|
||||
plug Plug.Session,
|
||||
store: :cookie,
|
||||
key: "_towerops_key",
|
||||
signing_salt: "...",
|
||||
same_site: "Lax"
|
||||
|
||||
plug ToweropsWeb.Plugs.CaptureTimezone # <-- ADD THIS LINE
|
||||
plug ToweropsWeb.Router
|
||||
```
|
||||
|
||||
**Step 2: Verify endpoint compiles**
|
||||
|
||||
Run: `mix compile`
|
||||
|
||||
Expected: Compilation success with no warnings
|
||||
|
||||
**Step 3: Manual verification via logs**
|
||||
|
||||
Start server: `mix phx.server`
|
||||
|
||||
Visit: `http://localhost:4000`
|
||||
|
||||
Check logs for session updates (optional verification)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/towerops_web/endpoint.ex
|
||||
git commit -m "Add CaptureTimezone plug to browser pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update User Schema for Timezone
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/towerops/accounts/user.ex:123-129`
|
||||
- Modify: `test/towerops/accounts_test.exs`
|
||||
|
||||
**Step 1: Write failing test for timezone in registration**
|
||||
|
||||
Add test to `test/towerops/accounts_test.exs` in the "register_user_with_organization/1" section:
|
||||
|
||||
```elixir
|
||||
test "accepts timezone in registration params" do
|
||||
valid_attrs = %{
|
||||
email: "user@example.com",
|
||||
password: "a valid password",
|
||||
privacy_policy_consent: true,
|
||||
terms_of_service_consent: true,
|
||||
organization_name: "Test Org",
|
||||
timezone: "America/Chicago"
|
||||
}
|
||||
|
||||
assert {:ok, user} = Accounts.register_user_with_organization(valid_attrs)
|
||||
assert user.timezone == "America/Chicago"
|
||||
end
|
||||
|
||||
test "defaults to UTC when timezone not provided" do
|
||||
valid_attrs = %{
|
||||
email: "user@example.com",
|
||||
password: "a valid password",
|
||||
privacy_policy_consent: true,
|
||||
terms_of_service_consent: true,
|
||||
organization_name: "Test Org"
|
||||
}
|
||||
|
||||
assert {:ok, user} = Accounts.register_user_with_organization(valid_attrs)
|
||||
assert user.timezone == "UTC"
|
||||
end
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `mix test test/towerops/accounts_test.exs --seed 0`
|
||||
|
||||
Expected: Tests fail because timezone is not in cast fields
|
||||
|
||||
**Step 3: Update registration_changeset to accept timezone**
|
||||
|
||||
In `lib/towerops/accounts/user.ex`, modify the `registration_changeset/3` function (around line 123):
|
||||
|
||||
```elixir
|
||||
def registration_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:email, :password, :timezone, :privacy_policy_consent, :terms_of_service_consent])
|
||||
|> validate_email_for_registration(opts)
|
||||
|> validate_password(opts)
|
||||
|> validate_consent()
|
||||
end
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `mix test test/towerops/accounts_test.exs --seed 0`
|
||||
|
||||
Expected: Both new tests PASS
|
||||
|
||||
**Step 5: Run all tests to ensure no regressions**
|
||||
|
||||
Run: `mix test`
|
||||
|
||||
Expected: All tests PASS
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/towerops/accounts/user.ex test/towerops/accounts_test.exs
|
||||
git commit -m "Accept timezone parameter in user registration changeset"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Update UserRegistrationLive to Use Detected Timezone
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/towerops_web/live/user_registration_live.ex:12-26`
|
||||
- Modify: `lib/towerops_web/live/user_registration_live.ex:52-76`
|
||||
- Create: `test/towerops_web/live/user_registration_live_test.exs` (if needed)
|
||||
|
||||
**Step 1: Add timezone to socket assigns in mount**
|
||||
|
||||
In `lib/towerops_web/live/user_registration_live.ex`, modify the `mount/3` function:
|
||||
|
||||
```elixir
|
||||
def mount(params, session, socket) do
|
||||
# Check if registering via invitation
|
||||
invitation_token = params["invitation_token"] || params["token"]
|
||||
invitation = invitation_token && Organizations.get_invitation_by_token(invitation_token)
|
||||
|
||||
# Get detected timezone from session (set by CaptureTimezone plug)
|
||||
detected_timezone = Map.get(session, "detected_timezone", "UTC")
|
||||
|
||||
changeset = Accounts.change_user_registration(%User{})
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:form, to_form(changeset))
|
||||
|> assign(:invitation, invitation)
|
||||
|> assign(:invitation_token, invitation_token)
|
||||
|> assign(:password_breach_count, nil)
|
||||
|> assign(:detected_timezone, detected_timezone)
|
||||
|> assign(:trigger_action, false)}
|
||||
end
|
||||
```
|
||||
|
||||
**Step 2: Merge timezone into user_params in create_with_organization**
|
||||
|
||||
Modify the `create_with_organization/2` private function (around line 64):
|
||||
|
||||
```elixir
|
||||
defp create_with_organization(socket, user_params) do
|
||||
# Merge detected timezone into params
|
||||
user_params_with_timezone = Map.put(user_params, "timezone", socket.assigns.detected_timezone)
|
||||
|
||||
case Accounts.register_user_with_organization(user_params_with_timezone) do
|
||||
{:ok, user} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Account created successfully.")
|
||||
|> redirect(to: ~p"/users/log-in?email=#{user.email}")
|
||||
|> assign(:trigger_action, true)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Step 3: Merge timezone into user_params in create_via_invitation**
|
||||
|
||||
Modify the `create_via_invitation/3` private function (around line 78):
|
||||
|
||||
```elixir
|
||||
defp create_via_invitation(socket, user_params, invitation_token) do
|
||||
# Merge detected timezone into params
|
||||
user_params_with_timezone = Map.put(user_params, "timezone", socket.assigns.detected_timezone)
|
||||
|
||||
with {:ok, invitation} <- get_valid_invitation(invitation_token),
|
||||
{:ok, user} <- Accounts.register_user(user_params_with_timezone),
|
||||
{:ok, _membership} <- Organizations.accept_invitation(invitation, user.id) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Account created successfully. Welcome to #{invitation.organization.name}!")
|
||||
|> redirect(to: ~p"/users/log-in?email=#{user.email}")
|
||||
|> assign(:trigger_action, true)}
|
||||
else
|
||||
{:error, :invalid_invitation} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Invalid or expired invitation link.")
|
||||
|> assign(:invitation, nil)
|
||||
|> assign(:invitation_token, nil)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Step 4: Write integration test**
|
||||
|
||||
Create or update `test/towerops_web/live/user_registration_live_test.exs` to verify timezone is set:
|
||||
|
||||
```elixir
|
||||
test "sets timezone from session during registration", %{conn: conn} do
|
||||
# Set detected timezone in session (simulating CaptureTimezone plug)
|
||||
conn = conn |> init_test_session(%{"detected_timezone" => "America/Chicago"})
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/users/register")
|
||||
|
||||
form =
|
||||
form(lv, "#registration_form", %{
|
||||
user: %{
|
||||
email: "test@example.com",
|
||||
password: "valid_password_123",
|
||||
organization_name: "Test Org",
|
||||
privacy_policy_consent: "true",
|
||||
terms_of_service_consent: "true"
|
||||
}
|
||||
})
|
||||
|
||||
render_submit(form)
|
||||
|
||||
# Verify user was created with detected timezone
|
||||
user = Towerops.Accounts.get_user_by_email("test@example.com")
|
||||
assert user.timezone == "America/Chicago"
|
||||
end
|
||||
|
||||
test "defaults to UTC when timezone not in session", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/users/register")
|
||||
|
||||
form =
|
||||
form(lv, "#registration_form", %{
|
||||
user: %{
|
||||
email: "test2@example.com",
|
||||
password: "valid_password_123",
|
||||
organization_name: "Test Org",
|
||||
privacy_policy_consent: "true",
|
||||
terms_of_service_consent: "true"
|
||||
}
|
||||
})
|
||||
|
||||
render_submit(form)
|
||||
|
||||
# Verify user was created with UTC default
|
||||
user = Towerops.Accounts.get_user_by_email("test2@example.com")
|
||||
assert user.timezone == "UTC"
|
||||
end
|
||||
```
|
||||
|
||||
**Step 5: Run LiveView tests**
|
||||
|
||||
Run: `mix test test/towerops_web/live/user_registration_live_test.exs`
|
||||
|
||||
Expected: All tests PASS
|
||||
|
||||
**Step 6: Run full test suite**
|
||||
|
||||
Run: `mix test`
|
||||
|
||||
Expected: All tests PASS
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/towerops_web/live/user_registration_live.ex test/towerops_web/live/user_registration_live_test.exs
|
||||
git commit -m "Use detected timezone from session in user registration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Manual Testing and Verification
|
||||
|
||||
**Step 1: Test in development (no cf-timezone header)**
|
||||
|
||||
```bash
|
||||
mix phx.server
|
||||
```
|
||||
|
||||
Visit: `http://localhost:4000/users/register`
|
||||
|
||||
Register a new user with email `dev-test@example.com`
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
iex -S mix phx.server
|
||||
```
|
||||
|
||||
```elixir
|
||||
user = Towerops.Accounts.get_user_by_email("dev-test@example.com")
|
||||
user.timezone
|
||||
# Expected: "UTC" (fallback)
|
||||
```
|
||||
|
||||
**Step 2: Test with simulated Cloudflare header**
|
||||
|
||||
Use curl to simulate Cloudflare header:
|
||||
|
||||
```bash
|
||||
curl -H "cf-timezone: America/New_York" http://localhost:4000/users/register -v
|
||||
```
|
||||
|
||||
Or use browser dev tools to add header (requires browser extension).
|
||||
|
||||
**Step 3: Verify in production logs (after deployment)**
|
||||
|
||||
Check production logs for cf-timezone header presence:
|
||||
|
||||
```bash
|
||||
# On production
|
||||
grep "cf-timezone" /var/log/phoenix/production.log
|
||||
```
|
||||
|
||||
**Step 4: Document in CHANGELOG or release notes**
|
||||
|
||||
Add entry noting automatic timezone detection feature.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Final Testing and Cleanup
|
||||
|
||||
**Step 1: Run full test suite with coverage**
|
||||
|
||||
Run: `mix test --cover`
|
||||
|
||||
Expected: All tests PASS, coverage >90% (excluding protobuf modules)
|
||||
|
||||
**Step 2: Run precommit checks**
|
||||
|
||||
Run: `mix precommit`
|
||||
|
||||
Expected: All checks PASS (compile, format, tests)
|
||||
|
||||
**Step 3: Run dialyzer (optional but recommended)**
|
||||
|
||||
Run: `mix dialyzer`
|
||||
|
||||
Expected: No new warnings
|
||||
|
||||
**Step 4: Final commit if any formatting changes**
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Run formatter and precommit checks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise in production:
|
||||
|
||||
**Option 1: Revert the LiveView changes**
|
||||
- Remove timezone merge in `create_with_organization/2` and `create_via_invitation/3`
|
||||
- Users will get database default "UTC"
|
||||
|
||||
**Option 2: Disable the plug**
|
||||
- Comment out `plug ToweropsWeb.Plugs.CaptureTimezone` in endpoint.ex
|
||||
- Session will have no `:detected_timezone`, LiveView will use "UTC" fallback
|
||||
|
||||
**Option 3: Full revert**
|
||||
```bash
|
||||
git revert <commit-hash>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] All tests pass
|
||||
- [ ] New users in production have correct timezone (verify via DB query)
|
||||
- [ ] No regressions in registration flow
|
||||
- [ ] Development environment still works (UTC fallback)
|
||||
- [ ] Code follows project conventions (mix precommit passes)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Timezone is IANA format from Cloudflare, compatible with Elixir libraries
|
||||
- Users can still change timezone later in settings
|
||||
- No UI changes needed (fully transparent to users)
|
||||
- Works for both normal registration and invitation-based registration
|
||||
|
|
@ -77,7 +77,6 @@ defmodule ToweropsWeb.Endpoint do
|
|||
plug Plug.MethodOverride
|
||||
plug Plug.Head
|
||||
plug Plug.Session, @session_options
|
||||
plug ToweropsWeb.Plugs.CaptureTimezone
|
||||
plug ToweropsWeb.Router
|
||||
|
||||
# Disable logging for health check endpoint to reduce log noise from K8s probes
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ defmodule ToweropsWeb.Plugs.CaptureTimezone do
|
|||
|
||||
@doc false
|
||||
def call(conn, _opts) do
|
||||
conn = fetch_session(conn)
|
||||
|
||||
timezone =
|
||||
case get_req_header(conn, "cf-timezone") do
|
||||
[tz] when is_binary(tz) and byte_size(tz) > 0 -> tz
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ defmodule ToweropsWeb.Router do
|
|||
"frame-ancestors 'none';"
|
||||
}
|
||||
|
||||
plug ToweropsWeb.Plugs.CaptureTimezone
|
||||
plug :fetch_current_scope_for_user
|
||||
plug ToweropsWeb.Plugs.UpdateSessionActivity
|
||||
plug :store_return_to_for_liveview
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue