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.
14 KiB
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:
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:
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
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:
# Around line 47, after plug Plug.Session
plug ToweropsWeb.Plugs.CaptureTimezone
Full context:
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
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:
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):
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
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:
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):
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):
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:
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
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)
mix phx.server
Visit: http://localhost:4000/users/register
Register a new user with email dev-test@example.com
Verification:
iex -S mix phx.server
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:
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:
# 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
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/2andcreate_via_invitation/3 - Users will get database default "UTC"
Option 2: Disable the plug
- Comment out
plug ToweropsWeb.Plugs.CaptureTimezonein endpoint.ex - Session will have no
:detected_timezone, LiveView will use "UTC" fallback
Option 3: Full revert
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