* 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>
161 lines
4.5 KiB
Go
161 lines
4.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
|
)
|
|
|
|
func (s *Store) createAuthUser(ctx context.Context, u *AuthUser) error {
|
|
u.ID = bson.NewObjectID()
|
|
u.CreatedAt = time.Now()
|
|
_, err := s.db.Collection("finance_users").InsertOne(ctx, u)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) findAuthUserByEmail(ctx context.Context, email string) (*AuthUser, error) {
|
|
var u AuthUser
|
|
err := s.db.Collection("finance_users").FindOne(ctx, bson.M{"email": email}).Decode(&u)
|
|
if err == mongo.ErrNoDocuments {
|
|
return nil, nil
|
|
}
|
|
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{
|
|
"provider": provider,
|
|
"provider_id": providerID,
|
|
}).Decode(&u)
|
|
if err == mongo.ErrNoDocuments {
|
|
return nil, nil
|
|
}
|
|
return &u, err
|
|
}
|
|
|
|
func (s *Store) createAuthSession(ctx context.Context, sess *AuthSession) error {
|
|
sess.ID = bson.NewObjectID()
|
|
sess.CreatedAt = time.Now()
|
|
_, err := s.db.Collection("finance_sessions").InsertOne(ctx, sess)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) getAuthSession(ctx context.Context, id string) (*AuthSession, error) {
|
|
oid, err := bson.ObjectIDFromHex(id)
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
var sess AuthSession
|
|
err = s.db.Collection("finance_sessions").FindOne(ctx, bson.M{"_id": oid}).Decode(&sess)
|
|
if err == mongo.ErrNoDocuments {
|
|
return nil, nil
|
|
}
|
|
return &sess, err
|
|
}
|
|
|
|
func (s *Store) deleteAuthSession(ctx context.Context, id string) error {
|
|
oid, err := bson.ObjectIDFromHex(id)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
_, err = s.db.Collection("finance_sessions").DeleteOne(ctx, bson.M{"_id": oid})
|
|
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}},
|
|
Options: options.Index().SetUnique(true).SetSparse(true),
|
|
})
|
|
s.db.Collection("finance_sessions").Indexes().CreateOne(ctx, mongo.IndexModel{
|
|
Keys: bson.D{{Key: "expires_at", Value: 1}},
|
|
Options: options.Index().SetExpireAfterSeconds(0),
|
|
})
|
|
}
|