Gonçalo Rodrigues 91796c9fb9 test(finance): expand unit test coverage from ~55% to 64.7% (#34)
* infra(terraform): manage finance session secret via random_password

Replace the hand-rolled variable (with insecure hardcoded default) with a
random_password resource so Terraform auto-generates a 48-char secret and
owns the finance-api-secrets k8s Secret lifecycle.

To rotate: terraform taint random_password.finance_session_secret && terraform apply

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(finance): active sessions panel + account deletion with full data purge

Sessions panel (/account):
- AuthSession now stores IPAddress and Device (browser + OS hint)
  populated from X-Forwarded-For / User-Agent on every login
- Lists all active sessions with device icon, IP, sign-in time
- Current session badge ("This device") — cannot be self-revoked
- DELETE /sessions/:id revokes any other session (user-scoped)

Account deletion (POST /account/delete):
- Password accounts require password confirmation
- OAuth accounts require typing email address to confirm
- deleteAllUserData purges all 12 finance collections + user record
  in a single call: accounts, categories, transactions, trades,
  ticker_mappings, goals, import_schedules, properties, loans,
  permissions, households, sessions → then the user itself
- Clears session cookie and redirects to login with success message

Infrastructure:
- findAuthUserByID added to store + storeIface
- getSessionsByUserID, deleteSessionForUser added to store + storeIface
- contains() added to template FuncMap
- accountTmpl registered; GET /account, POST /account/delete,
  DELETE /sessions/:id routes wired
- 🔐 nav icon links to /account page
- Full EN + PT i18n coverage for all new strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(finance): expand unit test coverage from ~55% to 64.7%

- Add handler_coverage_test.go (~3300 lines) covering auth flows,
  org request lifecycle, CSV bank import, property/loan views,
  fiscal year operations, session management, and cross-handler
  consistency (values shown on one page match actions on others)
- Add handler_org_test.go (~1800 lines) covering the full org
  handler surface: teams, members, invites, events, budget lines,
  tx requests (all status transitions), ledger, analysis, and reports
- Extend handler_test.go mockStore with: properties/loans slice fields,
  authUsers map with session-aware lookup, household field, org maps,
  and updateFiscalYearStatusErr for error-path testing
- Fix nav bar: Business and Account links now show active state and
  use i18n keys (removes hardcoded emoji); add account key to en/pt locales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 15:07:29 +01:00

105 lines
5.3 KiB
HTML

{{template "base" .}}
{{define "content"}}
{{$d := .}}
<style>
:root, [data-theme] { --muted: var(--text3); }
.sec-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:24px; margin-bottom:16px; }
.sec-card h2 { font-size:15px; font-weight:700; color:var(--text); margin:0 0 4px; }
.sec-card .sec-sub { font-size:13px; color:var(--muted); margin:0 0 20px; }
.session-row { display:flex; align-items:center; gap:12px; padding:12px 0; border-bottom:1px solid var(--border); }
.session-row:last-child { border-bottom:none; }
.session-icon { width:36px; height:36px; border-radius:10px; background:var(--bg3); border:1px solid var(--border); display:flex; align-items:center; justify-content:center; font-size:17px; flex-shrink:0; }
.session-info { flex:1; min-width:0; }
.session-device { font-size:13px; font-weight:500; color:var(--text); }
.session-meta { font-size:12px; color:var(--muted); margin-top:2px; }
.session-badge { display:inline-block; font-size:10px; font-weight:700; padding:2px 7px; border-radius:20px; background:var(--accent-glow); color:var(--accent); border:1px solid var(--accent); margin-left:6px; vertical-align:middle; }
.danger-zone { border-color:rgba(248,113,113,0.25); }
.danger-zone h2 { color:var(--red); }
.danger-form { display:flex; flex-direction:column; gap:10px; max-width:360px; }
.danger-form input { padding:9px 12px; border:1px solid var(--border2); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text); font-size:13.5px; }
.danger-form input:focus { outline:none; border-color:var(--red); box-shadow:0 0 0 3px rgba(248,113,113,0.15); }
.btn-danger-solid { padding:9px 20px; border:none; border-radius:var(--radius-sm); background:var(--red); color:#fff; font-size:13.5px; font-weight:600; cursor:pointer; transition:opacity 0.15s; }
.btn-danger-solid:hover { opacity:0.85; }
</style>
<h1 style="margin:0 0 4px;">{{$d.T.Get "account.title"}}</h1>
<p style="font-size:13px; color:var(--muted); margin:0 0 24px;">{{$d.Email}}</p>
{{if $d.Error}}
<div style="background:var(--red-dim); border:1px solid rgba(248,113,113,0.3); border-radius:var(--radius-sm); padding:12px 16px; font-size:13px; color:var(--red); margin-bottom:16px;">{{$d.Error}}</div>
{{end}}
{{if $d.Success}}
<div style="background:var(--green-dim); border:1px solid rgba(0,229,176,0.3); border-radius:var(--radius-sm); padding:12px 16px; font-size:13px; color:var(--green); margin-bottom:16px;">{{$d.Success}}</div>
{{end}}
<!-- Active sessions -->
<div class="sec-card">
<h2>{{$d.T.Get "account.sessions.title"}}</h2>
<p class="sec-sub">{{$d.T.Get "account.sessions.subtitle"}}</p>
{{if $d.Sessions}}
<div>
{{range $d.Sessions}}
<div class="session-row">
<div class="session-icon">
{{if or (contains .Device "iPhone") (contains .Device "Android")}}📱
{{else}}💻{{end}}
</div>
<div class="session-info">
<div class="session-device">
{{if .Device}}{{.Device}}{{else}}Unknown device{{end}}
{{if .IsCurrent}}<span class="session-badge">{{$d.T.Get "account.sessions.this_device"}}</span>{{end}}
</div>
<div class="session-meta">
{{if .IPAddress}}{{.IPAddress}} · {{end}}{{$d.T.Get "account.sessions.signed_in"}} {{.CreatedAt.Format "02 Jan 2006, 15:04"}}
</div>
</div>
{{if not .IsCurrent}}
<button onclick="revokeSession('{{.ID}}')" style="background:none; border:1px solid var(--border2); border-radius:6px; padding:5px 11px; font-size:12px; color:var(--muted); cursor:pointer; transition:all 0.15s;" onmouseover="this.style.borderColor='var(--red)';this.style.color='var(--red)'" onmouseout="this.style.borderColor='var(--border2)';this.style.color='var(--muted)'">
{{$d.T.Get "account.sessions.btn_revoke"}}
</button>
{{end}}
</div>
{{end}}
</div>
{{else}}
<p style="font-size:13px; color:var(--muted);">{{$d.T.Get "account.sessions.none"}}</p>
{{end}}
</div>
<!-- Danger zone -->
<div class="sec-card danger-zone">
<h2>{{$d.T.Get "account.delete.title"}}</h2>
<p class="sec-sub">{{$d.T.Get "account.delete.subtitle"}}</p>
<form method="POST" action="/account/delete" class="danger-form" onsubmit="return confirmDelete(event)">
{{if $d.HasPassword}}
<label style="font-size:12px; font-weight:600; color:var(--text2);">{{$d.T.Get "account.delete.label_password"}}</label>
<input type="password" name="password" placeholder="••••••••" autocomplete="current-password" required>
{{else}}
<label style="font-size:12px; font-weight:600; color:var(--text2);">{{$d.T.Get "account.delete.label_confirm_email"}}</label>
<input type="text" name="confirm_email" placeholder="{{$d.Email}}" autocomplete="off" required>
{{end}}
<button type="submit" class="btn-danger-solid">{{$d.T.Get "account.delete.btn_delete"}}</button>
</form>
</div>
<script>
const REVOKE_CONFIRM = {{$d.T.Get "account.sessions.confirm_revoke" | printf "%q"}};
const DELETE_CONFIRM = {{$d.T.Get "account.delete.confirm" | printf "%q"}};
function revokeSession(id) {
if (!confirm(REVOKE_CONFIRM)) return;
fetch('/sessions/' + id, { method: 'DELETE' })
.then(r => { if (r.ok) location.reload(); });
}
function confirmDelete(e) {
if (!confirm(DELETE_CONFIRM)) { e.preventDefault(); return false; }
return true;
}
</script>
{{end}}