diff --git a/config/prod.exs b/config/prod.exs index a6990679..f3b7d33f 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -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]] diff --git a/config/runtime.exs b/config/runtime.exs index 80192534..8595b3f0 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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. diff --git a/docs/plans/2026-02-01-cloudflare-timezone-detection.md b/docs/plans/2026-02-01-cloudflare-timezone-detection.md new file mode 100644 index 00000000..71b1d726 --- /dev/null +++ b/docs/plans/2026-02-01-cloudflare-timezone-detection.md @@ -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 +``` + +--- + +## 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 diff --git a/lib/towerops_web/endpoint.ex b/lib/towerops_web/endpoint.ex index 36fbd15f..3ac163cc 100644 --- a/lib/towerops_web/endpoint.ex +++ b/lib/towerops_web/endpoint.ex @@ -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 diff --git a/lib/towerops_web/plugs/capture_timezone.ex b/lib/towerops_web/plugs/capture_timezone.ex index d57ed3a6..adae2ead 100644 --- a/lib/towerops_web/plugs/capture_timezone.ex +++ b/lib/towerops_web/plugs/capture_timezone.ex @@ -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 diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 703b90e9..1ebc63b5 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -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