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:
parent
6485f58f23
commit
91796c9fb9
@ -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))
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
3327
apps/finance/services/api/main/handler_coverage_test.go
Normal file
3327
apps/finance/services/api/main/handler_coverage_test.go
Normal file
File diff suppressed because it is too large
Load Diff
1781
apps/finance/services/api/main/handler_org_test.go
Normal file
1781
apps/finance/services/api/main/handler_org_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
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) 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) updateLoan(_ context.Context, _, _ string, _ bson.M) error { return nil }
|
||||
func (m *mockStore) deleteLoan(_ context.Context, _, _ string) 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, 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, 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 }
|
||||
@ -245,18 +356,54 @@ 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) createAuthUser(_ context.Context, _ *AuthUser) error { return nil }
|
||||
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)
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
}
|
||||
|
||||
@ -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}},
|
||||
|
||||
104
apps/finance/services/api/main/templates/account.html
Normal file
104
apps/finance/services/api/main/templates/account.html
Normal 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}}
|
||||
@ -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}}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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,8 +25,8 @@ resource "kubernetes_secret" "finance_api" {
|
||||
namespace = kubernetes_namespace.domains["finance"].metadata[0].name
|
||||
}
|
||||
data = {
|
||||
SESSION_SECRET = var.finance_session_secret
|
||||
GOOGLE_CLIENT_ID = var.finance_google_client_id
|
||||
GOOGLE_CLIENT_SECRET = var.finance_google_client_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
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user