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.
521 lines
14 KiB
Markdown
521 lines
14 KiB
Markdown
# 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
|