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>
This commit is contained in:
Gonçalo Rodrigues 2026-06-20 15:07:29 +01:00 committed by GitHub
parent 6485f58f23
commit 91796c9fb9
14 changed files with 5746 additions and 44 deletions

View File

@ -148,6 +148,7 @@ func parseTmpl(files ...string) *template.Template {
}
return template.JS("[" + strings.Join(vals, ",") + "]")
},
"contains": strings.Contains,
}).ParseFS(templateFS, files...))
}
@ -182,6 +183,7 @@ var (
autoImportTmpl = parseTmpl("templates/base.html", "templates/auto_import.html")
peopleTmpl = parseTmpl("templates/base.html", "templates/people.html")
settingsTmpl = parseTmpl("templates/base.html", "templates/settings.html")
accountTmpl = parseTmpl("templates/base.html", "templates/account.html")
// Org — list/create/join stay on personal base; inner org pages use business base
orgListTmpl = parseTmpl("templates/base.html", "templates/org_list.html")
@ -353,10 +355,14 @@ type storeIface interface {
// Auth
createAuthUser(ctx context.Context, u *AuthUser) error
findAuthUserByEmail(ctx context.Context, email string) (*AuthUser, error)
findAuthUserByID(ctx context.Context, userID string) (*AuthUser, error)
findAuthUserByProvider(ctx context.Context, provider, providerID string) (*AuthUser, error)
createAuthSession(ctx context.Context, sess *AuthSession) error
getAuthSession(ctx context.Context, id string) (*AuthSession, error)
deleteAuthSession(ctx context.Context, id string) error
getSessionsByUserID(ctx context.Context, userID string) ([]AuthSession, error)
deleteSessionForUser(ctx context.Context, sessionID, userID string) error
deleteAllUserData(ctx context.Context, userID string) error
}
type Handler struct {
@ -2767,6 +2773,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /auth/logout", h.AuthLogout)
mux.HandleFunc("GET /auth/oauth/google", h.AuthGoogleStart)
mux.HandleFunc("GET /auth/oauth/google/callback", h.AuthGoogleCallback)
mux.HandleFunc("GET /account", h.authMW(h.AccountPage))
mux.HandleFunc("POST /account/delete", h.authMW(h.DeleteAccount))
mux.HandleFunc("DELETE /sessions/{id}", h.authMW(h.RevokeSession))
mux.HandleFunc("GET /{$}", h.Homepage)
mux.HandleFunc("GET /dashboard", h.authMW(h.Dashboard))

View File

@ -163,6 +163,38 @@ func (h *Handler) authFromSession(r *http.Request) (authInfo, bool) {
return authInfo{UserID: sess.UserID.Hex(), Email: sess.Email}, true
}
func deviceHint(ua string) string {
lower := strings.ToLower(ua)
browser := "Unknown browser"
switch {
case strings.Contains(lower, "edg"):
browser = "Edge"
case strings.Contains(lower, "chrome"):
browser = "Chrome"
case strings.Contains(lower, "firefox"):
browser = "Firefox"
case strings.Contains(lower, "safari"):
browser = "Safari"
}
os := ""
switch {
case strings.Contains(lower, "iphone"):
os = "iPhone"
case strings.Contains(lower, "android"):
os = "Android"
case strings.Contains(lower, "windows"):
os = "Windows"
case strings.Contains(lower, "mac os"):
os = "macOS"
case strings.Contains(lower, "linux"):
os = "Linux"
}
if os != "" {
return browser + " on " + os
}
return browser
}
func (h *Handler) startSession(w http.ResponseWriter, r *http.Request, userID bson.ObjectID, email string) error {
// Rotate: delete any existing session to prevent session fixation.
if cookie, err := r.Cookie(cookieName); err == nil {
@ -174,6 +206,8 @@ func (h *Handler) startSession(w http.ResponseWriter, r *http.Request, userID bs
UserID: userID,
Email: email,
ExpiresAt: time.Now().Add(sessionTTL),
IPAddress: clientIP(r),
Device: deviceHint(r.Header.Get("User-Agent")),
}
if err := h.store.createAuthSession(r.Context(), sess); err != nil {
return err
@ -215,9 +249,14 @@ func (h *Handler) AuthLogin(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("error") == "oauth" {
errMsg = "Google sign-in failed. Please try again or use email and password."
}
successMsg := ""
if r.URL.Query().Get("deleted") == "1" {
successMsg = h.t(r).Get("account.delete.success_login")
}
renderRaw(w, authLoginTmpl, map[string]any{
"GoogleEnabled": h.googleID != "",
"Error": errMsg,
"Success": successMsg,
"T": h.t(r),
})
}
@ -523,3 +562,134 @@ func (h *Handler) googleUserInfo(ctx context.Context, accessToken string) (*goog
}
return &u, nil
}
// ── Account / security page ───────────────────────────────────────────────────
func (h *Handler) AccountPage(w http.ResponseWriter, r *http.Request) {
a := h.getAuth(r)
if a.UserID == "" {
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
return
}
t := h.t(r)
// Current session ID (to highlight it in the list)
currentID := ""
if cookie, err := r.Cookie(cookieName); err == nil {
currentID, _ = h.verifySessionToken(cookie.Value)
}
sessions, _ := h.store.getSessionsByUserID(r.Context(), a.UserID)
var views []SessionView
for _, s := range sessions {
views = append(views, SessionView{
ID: s.ID.Hex(),
CreatedAt: s.CreatedAt,
IPAddress: s.IPAddress,
Device: s.Device,
IsCurrent: s.ID.Hex() == currentID,
})
}
user, _ := h.store.findAuthUserByID(r.Context(), a.UserID)
render(w, accountTmpl, AccountData{
T: t,
UserID: a.UserID,
Email: a.Email,
Title: t.Get("account.title"),
Route: "account",
Sessions: views,
HasPassword: user != nil && user.PasswordHash != "",
Success: r.URL.Query().Get("success"),
})
}
func (h *Handler) RevokeSession(w http.ResponseWriter, r *http.Request) {
a := h.getAuth(r)
if a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
sessionID := r.PathValue("id")
// Prevent revoking your own current session via this endpoint (use logout instead)
if cookie, err := r.Cookie(cookieName); err == nil {
if cur, ok := h.verifySessionToken(cookie.Value); ok && cur == sessionID {
http.Error(w, "use /auth/logout to end your current session", http.StatusBadRequest)
return
}
}
_ = h.store.deleteSessionForUser(r.Context(), sessionID, a.UserID)
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
a := h.getAuth(r)
if a.UserID == "" {
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
return
}
t := h.t(r)
fail := func(msg string) {
sessions, _ := h.store.getSessionsByUserID(r.Context(), a.UserID)
var views []SessionView
for _, s := range sessions {
views = append(views, SessionView{
ID: s.ID.Hex(),
CreatedAt: s.CreatedAt,
IPAddress: s.IPAddress,
Device: s.Device,
})
}
user, _ := h.store.findAuthUserByID(r.Context(), a.UserID)
render(w, accountTmpl, AccountData{
T: t,
UserID: a.UserID,
Email: a.Email,
Title: t.Get("account.title"),
Route: "account",
Sessions: views,
HasPassword: user != nil && user.PasswordHash != "",
Error: msg,
})
}
user, err := h.store.findAuthUserByID(r.Context(), a.UserID)
if err != nil || user == nil {
fail(t.Get("account.delete.error_generic"))
return
}
// Password accounts require password confirmation; OAuth accounts require typing email.
if user.PasswordHash != "" {
password := r.FormValue("password")
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
fail(t.Get("account.delete.error_wrong_password"))
return
}
} else {
confirm := r.FormValue("confirm_email")
if !strings.EqualFold(confirm, user.Email) {
fail(t.Get("account.delete.error_wrong_email"))
return
}
}
if err := h.store.deleteAllUserData(r.Context(), a.UserID); err != nil {
slog.Error("deleteAllUserData", "user", a.UserID, "err", err)
fail(t.Get("account.delete.error_generic"))
return
}
// Clear the session cookie.
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: h.isSecure(),
MaxAge: -1,
})
http.Redirect(w, r, "/auth/login?deleted=1", http.StatusSeeOther)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -25,12 +25,29 @@ type mockStore struct {
trades []Trade
goals []Goal
permissions []Permission
properties []Property
loans []Loan
createGoalErr error
updateGoalErr error
deleteTransactionErr error
createTransactionErr error
createTradesErr error
updateFiscalYearStatusErr error
authUsers map[string]*AuthUser
sessions []AuthSession
household *Household
deleteAllUserDataErr error
// Org support (keyed for lookup)
orgsBySlug map[string]*Org
orgsByID map[string]*Org
membersByKey map[string]*OrgMember // key: orgID+":"+userID
invitesByToken map[string]*OrgInvite
fiscalYears []FiscalYear
orgEvents []OrgEvent
txRequests []TxRequest
}
func (m *mockStore) getAccounts(_ context.Context, _ string) ([]Account, error) {
@ -160,6 +177,9 @@ func (m *mockStore) getTickerMappings(_ context.Context, _ string) ([]TickerMapp
func (m *mockStore) saveTickerMapping(_ context.Context, _, _, _ string) error { return nil }
func (m *mockStore) getHousehold(_ context.Context, _ string) (*Household, error) {
if m.household != nil {
return m.household, nil
}
return nil, fmt.Errorf("not found")
}
func (m *mockStore) createHousehold(_ context.Context, _ *Household) error { return nil }
@ -172,24 +192,74 @@ func (m *mockStore) deleteImportSchedule(_ context.Context, _, _ string) error
// ── Property & Loan stubs ─────────────────────────────────────────────────────
func (m *mockStore) getProperties(_ context.Context, _ string) ([]Property, error) { return nil, nil }
func (m *mockStore) getProperty(_ context.Context, _, _ string) (*Property, error) { return nil, nil }
func (m *mockStore) createProperty(_ context.Context, _ *Property) error { return nil }
func (m *mockStore) getProperties(_ context.Context, _ string) ([]Property, error) {
return m.properties, nil
}
func (m *mockStore) getProperty(_ context.Context, id, _ string) (*Property, error) {
for i := range m.properties {
if m.properties[i].ID == id {
return &m.properties[i], nil
}
}
return nil, nil
}
func (m *mockStore) createProperty(_ context.Context, p *Property) error {
m.properties = append(m.properties, *p)
return nil
}
func (m *mockStore) updateProperty(_ context.Context, _, _ string, _ bson.M) error { return nil }
func (m *mockStore) deleteProperty(_ context.Context, _, _ string) error { return nil }
func (m *mockStore) getLoans(_ context.Context, _ string) ([]Loan, error) { return nil, nil }
func (m *mockStore) getLoan(_ context.Context, _, _ string) (*Loan, error) { return nil, nil }
func (m *mockStore) createLoan(_ context.Context, _ *Loan) error { return nil }
func (m *mockStore) deleteProperty(_ context.Context, id, _ string) error {
for i, p := range m.properties {
if p.ID == id {
m.properties = append(m.properties[:i], m.properties[i+1:]...)
return nil
}
}
return nil
}
func (m *mockStore) getLoans(_ context.Context, _ string) ([]Loan, error) {
return m.loans, nil
}
func (m *mockStore) getLoan(_ context.Context, id, _ string) (*Loan, error) {
for i := range m.loans {
if m.loans[i].ID == id {
return &m.loans[i], nil
}
}
return nil, nil
}
func (m *mockStore) createLoan(_ context.Context, l *Loan) error {
m.loans = append(m.loans, *l)
return nil
}
func (m *mockStore) updateLoan(_ context.Context, _, _ string, _ bson.M) error { return nil }
func (m *mockStore) deleteLoan(_ context.Context, _, _ string) error { return nil }
func (m *mockStore) deleteLoan(_ context.Context, id, _ string) error {
for i, l := range m.loans {
if l.ID == id {
m.loans = append(m.loans[:i], m.loans[i+1:]...)
return nil
}
}
return nil
}
// ── Org stubs (not exercised in unit tests) ───────────────────────────────────
func (m *mockStore) getOrgsForUser(_ context.Context, _ string) ([]OrgWithRole, error) {
return nil, nil
}
func (m *mockStore) getOrg(_ context.Context, _ string) (*Org, error) { return nil, nil }
func (m *mockStore) getOrgBySlug(_ context.Context, _ string) (*Org, error) { return nil, nil }
func (m *mockStore) getOrg(_ context.Context, id string) (*Org, error) {
if o, ok := m.orgsByID[id]; ok {
return o, nil
}
return nil, fmt.Errorf("not found")
}
func (m *mockStore) getOrgBySlug(_ context.Context, slug string) (*Org, error) {
if o, ok := m.orgsBySlug[slug]; ok {
return o, nil
}
return nil, fmt.Errorf("not found")
}
func (m *mockStore) createOrg(_ context.Context, _ *Org) error { return nil }
func (m *mockStore) slugExists(_ context.Context, _ string) (bool, error) { return false, nil }
func (m *mockStore) getTeams(_ context.Context, _ string) ([]OrgTeam, error) { return nil, nil }
@ -197,26 +267,60 @@ func (m *mockStore) getTeam(_ context.Context, _, _ string) (*OrgTeam, error)
func (m *mockStore) createTeam(_ context.Context, _ *OrgTeam) error { return nil }
func (m *mockStore) deleteTeam(_ context.Context, _, _ string) error { return nil }
func (m *mockStore) getMembers(_ context.Context, _ string) ([]OrgMember, error) { return nil, nil }
func (m *mockStore) getMember(_ context.Context, _, _ string) (*OrgMember, error) {
return nil, nil
func (m *mockStore) getMember(_ context.Context, orgID, userID string) (*OrgMember, error) {
key := orgID + ":" + userID
if me, ok := m.membersByKey[key]; ok {
return me, nil
}
return nil, fmt.Errorf("not a member")
}
func (m *mockStore) createMember(_ context.Context, _ *OrgMember) error { return nil }
func (m *mockStore) updateMemberRole(_ context.Context, _, _ string, _ OrgRole) error { return nil }
func (m *mockStore) removeMember(_ context.Context, _, _ string) error { return nil }
func (m *mockStore) getInvites(_ context.Context, _ string) ([]OrgInvite, error) { return nil, nil }
func (m *mockStore) getInviteByToken(_ context.Context, _ string) (*OrgInvite, error) { return nil, nil }
func (m *mockStore) getInviteByToken(_ context.Context, token string) (*OrgInvite, error) {
if inv, ok := m.invitesByToken[token]; ok {
return inv, nil
}
return nil, fmt.Errorf("not found")
}
func (m *mockStore) createInvite(_ context.Context, _ *OrgInvite) error { return nil }
func (m *mockStore) consumeInvite(_ context.Context, _ string) error { return nil }
func (m *mockStore) revokeInvite(_ context.Context, _, _ string) error { return nil }
func (m *mockStore) getFiscalYears(_ context.Context, _ string) ([]FiscalYear, error) { return nil, nil }
func (m *mockStore) getFiscalYear(_ context.Context, _, _ string) (*FiscalYear, error) { return nil, nil }
func (m *mockStore) getActiveFiscalYear(_ context.Context, _ string) (*FiscalYear, error) { return nil, nil }
func (m *mockStore) getFiscalYears(_ context.Context, _ string) ([]FiscalYear, error) {
return m.fiscalYears, nil
}
func (m *mockStore) getFiscalYear(_ context.Context, id, _ string) (*FiscalYear, error) {
for _, y := range m.fiscalYears {
if y.ID == id {
return &y, nil
}
}
return nil, fmt.Errorf("not found")
}
func (m *mockStore) getActiveFiscalYear(_ context.Context, _ string) (*FiscalYear, error) {
for _, y := range m.fiscalYears {
if y.Status == FiscalYearActive {
return &y, nil
}
}
return nil, fmt.Errorf("no active fiscal year")
}
func (m *mockStore) createFiscalYear(_ context.Context, _ *FiscalYear) error { return nil }
func (m *mockStore) updateFiscalYearStatus(_ context.Context, _, _ string, _ FiscalYearStatus, _ bson.M) error {
return nil
return m.updateFiscalYearStatusErr
}
func (m *mockStore) getEvents(_ context.Context, _, _ string) ([]OrgEvent, error) {
return m.orgEvents, nil
}
func (m *mockStore) getEvent(_ context.Context, id, _ string) (*OrgEvent, error) {
for i := range m.orgEvents {
if m.orgEvents[i].ID == id {
return &m.orgEvents[i], nil
}
}
return nil, fmt.Errorf("event not found")
}
func (m *mockStore) getEvents(_ context.Context, _, _ string) ([]OrgEvent, error) { return nil, nil }
func (m *mockStore) getEvent(_ context.Context, _, _ string) (*OrgEvent, error) { return nil, nil }
func (m *mockStore) createEvent(_ context.Context, _ *OrgEvent) error { return nil }
func (m *mockStore) updateEvent(_ context.Context, _, _ string, _ bson.M) error { return nil }
func (m *mockStore) deleteEvent(_ context.Context, _, _ string) error { return nil }
@ -231,7 +335,14 @@ func (m *mockStore) deleteBudgetLine(_ context.Context, _, _ string) error
func (m *mockStore) getEventComments(_ context.Context, _, _ string) ([]EventComment, error) { return nil, nil }
func (m *mockStore) createEventComment(_ context.Context, _ *EventComment) error { return nil }
func (m *mockStore) getTxRequests(_ context.Context, _ string, _ bson.M) ([]TxRequest, error) { return nil, nil }
func (m *mockStore) getTxRequest(_ context.Context, _, _ string) (*TxRequest, error) { return nil, nil }
func (m *mockStore) getTxRequest(_ context.Context, id, _ string) (*TxRequest, error) {
for i := range m.txRequests {
if m.txRequests[i].ID == id {
return &m.txRequests[i], nil
}
}
return nil, fmt.Errorf("not found")
}
func (m *mockStore) createTxRequest(_ context.Context, _ *TxRequest) error { return nil }
func (m *mockStore) appendStatusLog(_ context.Context, _, _ string, _ StatusLogEntry) error { return nil }
func (m *mockStore) updateTxRequest(_ context.Context, _, _ string, _ bson.M) error { return nil }
@ -246,17 +357,53 @@ func (m *mockStore) getAttachments(_ context.Context, _, _ string) ([]OrgAttachm
func (m *mockStore) createAttachment(_ context.Context, _ *OrgAttachment) error { return nil }
func (m *mockStore) createAuthUser(_ context.Context, _ *AuthUser) error { return nil }
func (m *mockStore) findAuthUserByEmail(_ context.Context, _ string) (*AuthUser, error) {
func (m *mockStore) findAuthUserByEmail(_ context.Context, email string) (*AuthUser, error) {
if m.authUsers != nil {
for _, u := range m.authUsers {
if u.Email == email {
return u, nil
}
}
}
return nil, nil
}
func (m *mockStore) findAuthUserByProvider(_ context.Context, _, _ string) (*AuthUser, error) {
return nil, nil
}
func (m *mockStore) createAuthSession(_ context.Context, _ *AuthSession) error { return nil }
func (m *mockStore) getAuthSession(_ context.Context, _ string) (*AuthSession, error) {
func (m *mockStore) createAuthSession(_ context.Context, sess *AuthSession) error {
m.sessions = append(m.sessions, *sess)
return nil
}
func (m *mockStore) getAuthSession(_ context.Context, id string) (*AuthSession, error) {
for i := range m.sessions {
if m.sessions[i].ID.Hex() == id {
return &m.sessions[i], nil
}
}
return nil, nil
}
func (m *mockStore) deleteAuthSession(_ context.Context, _ string) error { return nil }
func (m *mockStore) findAuthUserByID(_ context.Context, userID string) (*AuthUser, error) {
if m.authUsers != nil {
if u, ok := m.authUsers[userID]; ok {
return u, nil
}
}
return nil, nil
}
func (m *mockStore) getSessionsByUserID(_ context.Context, _ string) ([]AuthSession, error) {
return m.sessions, nil
}
func (m *mockStore) deleteSessionForUser(_ context.Context, _, _ string) error { return nil }
func (m *mockStore) deleteAllUserData(_ context.Context, _ string) error {
return m.deleteAllUserDataErr
}
func (m *mockStore) getGoalFundedCentsAll(_ context.Context, _ string) (map[string]int64, error) {
return nil, nil
}
func (m *mockStore) getGoalTransactions(_ context.Context, _, _ string) ([]Transaction, error) {
return nil, nil
}
// ── helpers ───────────────────────────────────────────────────────────────────
@ -907,6 +1054,7 @@ func TestDashboard_GoalMissAlert(t *testing.T) {
TargetCents: 5000000,
SavedCents: 0,
Deadline: now.AddDate(0, 5, 0),
Committed: true,
}},
}
h := newHandler(store)

View File

@ -8,7 +8,8 @@ portfolio = "Portfolio"
goals = "Goals"
property = "Property"
people = "People"
business = "🏢 Business"
business = "Business"
account = "Account"
hub_back = "← Hub"
[nav.analysis]
@ -1085,3 +1086,29 @@ body = "How many months it would actually take based on your average monthly
title = "Free cash after this goal"
body = "Your estimated monthly free cash if you commit to this goal. Red means you'd need to cut spending elsewhere."
formula = "Income Living All committed goals This goal"
# ── Account / security page ───────────────────────────────────────────────────
[account]
title = "Account & Security"
[account.sessions]
title = "Active sessions"
subtitle = "Every device currently signed into your account. Revoke any session you don't recognise."
this_device = "This device"
signed_in = "Signed in"
btn_revoke = "Revoke"
confirm_revoke = "Sign out this session? The device will need to log in again."
none = "No active sessions found."
[account.delete]
title = "Delete account"
subtitle = "Permanently deletes your account and all associated data — transactions, goals, accounts, portfolio, and everything else. This cannot be undone."
label_password = "Confirm your password"
label_confirm_email = "Type your email address to confirm"
btn_delete = "Delete my account permanently"
confirm = "This will permanently delete all your data. There is no undo. Are you sure?"
error_wrong_password = "Incorrect password."
error_wrong_email = "Email address does not match."
error_generic = "Something went wrong. Please try again."
success_login = "Your account has been deleted. All data has been removed."

View File

@ -8,7 +8,8 @@ portfolio = "Carteira"
goals = "Objetivos"
property = "Imóveis"
people = "Pessoas"
business = "🏢 Empresa"
business = "Empresa"
account = "Conta"
hub_back = "← Hub"
[nav.analysis]
@ -1085,3 +1086,29 @@ body = "Quantos meses levaria realmente com base na sua poupança mensal méd
title = "Dinheiro livre após este objetivo"
body = "O seu dinheiro livre mensal estimado se se comprometer com este objetivo. Vermelho significa que precisaria de reduzir despesas."
formula = "Rendimento Vida Todos os objetivos comprometidos Este objetivo"
# ── Página de conta / segurança ───────────────────────────────────────────────
[account]
title = "Conta & Segurança"
[account.sessions]
title = "Sessões ativas"
subtitle = "Todos os dispositivos com sessão iniciada na sua conta. Revogue qualquer sessão que não reconheça."
this_device = "Este dispositivo"
signed_in = "Sessão iniciada"
btn_revoke = "Revogar"
confirm_revoke = "Terminar esta sessão? O dispositivo terá de iniciar sessão novamente."
none = "Nenhuma sessão ativa encontrada."
[account.delete]
title = "Eliminar conta"
subtitle = "Elimina permanentemente a sua conta e todos os dados associados — transações, objetivos, contas, carteira e tudo o mais. Esta ação não pode ser desfeita."
label_password = "Confirme a sua palavra-passe"
label_confirm_email = "Escreva o seu endereço de e-mail para confirmar"
btn_delete = "Eliminar a minha conta permanentemente"
confirm = "Isto irá eliminar permanentemente todos os seus dados. Não há forma de desfazer. Tem a certeza?"
error_wrong_password = "Palavra-passe incorreta."
error_wrong_email = "O endereço de e-mail não corresponde."
error_generic = "Algo correu mal. Por favor, tente novamente."
success_login = "A sua conta foi eliminada. Todos os dados foram removidos."

View File

@ -314,6 +314,28 @@ type PeopleData struct {
PartnerGoals []GoalPlan
}
// SessionView is a display-safe projection of AuthSession.
type SessionView struct {
ID string
CreatedAt time.Time
IPAddress string
Device string
IsCurrent bool
}
// AccountData backs the /account security page.
type AccountData struct {
T *Translator
UserID string
Email string
Title string
Route string
Sessions []SessionView
HasPassword bool // false for OAuth-only accounts
Error string
Success string
}
// SettingsData combines Accounts and Categories into a single page.
type SettingsData struct {
T *Translator

View File

@ -22,4 +22,6 @@ type AuthSession struct {
Email string `bson:"email"`
ExpiresAt time.Time `bson:"expires_at"`
CreatedAt time.Time `bson:"created_at"`
IPAddress string `bson:"ip,omitempty"`
Device string `bson:"device,omitempty"` // "Chrome on macOS" etc.
}

View File

@ -25,6 +25,19 @@ func (s *Store) findAuthUserByEmail(ctx context.Context, email string) (*AuthUse
return &u, err
}
func (s *Store) findAuthUserByID(ctx context.Context, userID string) (*AuthUser, error) {
oid, err := bson.ObjectIDFromHex(userID)
if err != nil {
return nil, nil
}
var u AuthUser
err = s.db.Collection("finance_users").FindOne(ctx, bson.M{"_id": oid}).Decode(&u)
if err == mongo.ErrNoDocuments {
return nil, nil
}
return &u, err
}
func (s *Store) findAuthUserByProvider(ctx context.Context, provider, providerID string) (*AuthUser, error) {
var u AuthUser
err := s.db.Collection("finance_users").FindOne(ctx, bson.M{
@ -66,6 +79,75 @@ func (s *Store) deleteAuthSession(ctx context.Context, id string) error {
return err
}
func (s *Store) getSessionsByUserID(ctx context.Context, userID string) ([]AuthSession, error) {
oid, err := bson.ObjectIDFromHex(userID)
if err != nil {
return nil, nil
}
cur, err := s.db.Collection("finance_sessions").Find(ctx,
bson.M{"user_id": oid},
options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}),
)
if err != nil {
return nil, err
}
var sessions []AuthSession
if err := cur.All(ctx, &sessions); err != nil {
return nil, err
}
return sessions, nil
}
func (s *Store) deleteSessionForUser(ctx context.Context, sessionID, userID string) error {
sid, err := bson.ObjectIDFromHex(sessionID)
if err != nil {
return nil
}
uid, err := bson.ObjectIDFromHex(userID)
if err != nil {
return nil
}
_, err = s.db.Collection("finance_sessions").DeleteOne(ctx, bson.M{"_id": sid, "user_id": uid})
return err
}
// deleteAllUserData purges every record belonging to userID across all collections,
// then deletes the user account itself. Irreversible.
func (s *Store) deleteAllUserData(ctx context.Context, userID string) error {
uid, err := bson.ObjectIDFromHex(userID)
if err != nil {
return err
}
filter := bson.M{"user_id": uid}
orFilter := bson.M{"$or": bson.A{bson.M{"owner_id": uid}, bson.M{"partner_id": uid}}}
orFilterPerms := bson.M{"$or": bson.A{bson.M{"owner_id": uid}, bson.M{"viewer_id": uid}}}
collections := []struct {
name string
filter interface{}
}{
{"finance_accounts", filter},
{"finance_categories", filter},
{"finance_transactions", filter},
{"finance_trades", filter},
{"finance_ticker_mappings", filter},
{"finance_goals", filter},
{"finance_import_schedules", filter},
{"finance_properties", filter},
{"finance_loans", filter},
{"finance_permissions", orFilterPerms},
{"finance_households", orFilter},
{"finance_sessions", bson.M{"user_id": uid}},
}
for _, c := range collections {
if _, err := s.db.Collection(c.name).DeleteMany(ctx, c.filter); err != nil {
return err
}
}
_, err = s.db.Collection("finance_users").DeleteOne(ctx, bson.M{"_id": uid})
return err
}
func (s *Store) ensureAuthIndexes(ctx context.Context) {
s.db.Collection("finance_users").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "email", Value: 1}},

View File

@ -0,0 +1,104 @@
{{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}}

View File

@ -176,6 +176,9 @@
<h1>{{.T.Get "auth.login.heading"}}</h1>
<p class="sub">{{.T.Get "auth.login.subtext"}} <a href="/auth/register">{{.T.Get "auth.login.subtext_link"}}</a></p>
{{if .Success}}
<div style="background:rgba(0,229,176,0.1); border:1px solid rgba(0,229,176,0.3); border-radius:8px; padding:12px 16px; font-size:13px; color:#00e5b0; margin-bottom:16px;">{{.Success}}</div>
{{end}}
{{if .Error}}
<div class="error-box">{{.Error}}</div>
{{end}}

View File

@ -659,7 +659,8 @@
</div>
</div>
<div class="nav-spacer"></div>
<a href="/orgs" style="font-size:12px; color:var(--text3); padding:5px 9px; border:1px solid var(--border); border-radius:var(--radius-sm); text-decoration:none; transition:all 0.18s;" onmouseover="this.style.color='var(--text2)';this.style.borderColor='var(--border2)'" onmouseout="this.style.color='var(--text3)';this.style.borderColor='var(--border)'">{{.T.Get "nav.business"}}</a>
<a href="/orgs" class="{{if eq .Route "orgs"}}active{{end}}">{{.T.Get "nav.business"}}</a>
<a href="/account" class="{{if eq .Route "account"}}active{{end}}">{{.T.Get "nav.account"}}</a>
<span class="nav-email">{{.Email}}</span>
<form method="POST" action="/lang" style="display:inline;">
<select name="lang" onchange="this.form.submit()" style="font-size:12px; background:var(--bg2); color:var(--text2); border:1px solid var(--border2); border-radius:var(--radius-sm); padding:4px 6px; cursor:pointer;">
@ -696,6 +697,7 @@
<a href="/auto-import" class="{{if eq .Route "auto-import"}}active{{end}}">{{.T.Get "nav.settings.import_guide"}}</a>
<hr>
<a href="/orgs">{{.T.Get "nav.business"}}</a>
<a href="/account" class="{{if eq .Route "account"}}active{{end}}">{{.T.Get "nav.account"}}</a>
<a href="/">{{.T.Get "nav.hub_back"}}</a>
</div>

View File

@ -1,10 +1,8 @@
# SESSION_SECRET must be a random 32+ byte hex string.
# Set it via: TF_VAR_finance_session_secret=<value> terraform apply
variable "finance_session_secret" {
description = "HMAC secret for finance-api session cookies (32+ random bytes)"
type = string
default = "dev-secret-change-in-production-32x"
sensitive = true
# Session secret is auto-generated and stored in Terraform state (gitignored).
# To rotate: terraform taint random_password.finance_session_secret && terraform apply
resource "random_password" "finance_session_secret" {
length = 48
special = false # alphanumeric only safe as an env var value
}
variable "finance_google_client_id" {
@ -27,7 +25,7 @@ resource "kubernetes_secret" "finance_api" {
namespace = kubernetes_namespace.domains["finance"].metadata[0].name
}
data = {
SESSION_SECRET = var.finance_session_secret
SESSION_SECRET = random_password.finance_session_secret.result
GOOGLE_CLIENT_ID = var.finance_google_client_id
GOOGLE_CLIENT_SECRET = var.finance_google_client_secret
}