feat: public landing page + split personal/business nav (#26)

* feat: public landing page with auth-conditional state

Rewrites homepage.html as a full marketing landing page serving both
unauthenticated visitors (Sign In CTA) and authenticated users (Personal
+ Business portal links). Fixes handler to pass UserID so auth-conditional
rendering activates correctly.

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

* fix(k8s): expose / without auth so homepage is publicly reachable

Adds a second Ingress (api-public) for the exact path / with no
forward-auth middleware. Traefik prefers the Exact match for the root,
while the Prefix ingress (with auth) still protects all other routes.

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

* fix: homepage renders correctly at / for unauthenticated visitors

Two fixes:
1. Added parseStandalone() helper — parseTmpl() roots on "" but ParseFS()
   stores standalone (no {{define}}) files under their base filename, so
   Execute() ran the empty root and returned Content-Length: 0.
2. Added router.priority: 100 annotation to api-public ingress so Traefik
   picks the Exact / rule over the Prefix / rule (Traefik ranks by rule
   string length by default, which made PathPrefix beat Path).

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

* feat: self-contained auth — email/password + Google OAuth, HMAC session cookies

Embeds a full authentication system into the finance API so it can be
deployed as a standalone container without any external auth dependency.

- Email/password registration and login with bcrypt hashing
- Google OAuth 2.0 (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET env vars)
- HMAC-SHA256 signed session cookies (SESSION_SECRET env var, 30-day TTL)
- Sessions stored in MongoDB finance_sessions with TTL index auto-expiry
- Users stored in MongoDB finance_users with unique email index
- /auth/login, /auth/register, /auth/logout, /auth/oauth/google routes
- authMW now redirects to /auth/login?next=... instead of auth.homelab.local
- getAuth() resolves session cookie first, falls back to X-Auth-* headers
- Default categories seeded automatically on new account creation
- seed.go checks finance_users before the shared legacy users collection

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

* fix: homepage sign-in links point to /auth/login instead of auth.homelab.local

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

* fix(k8s): remove forward-auth middleware from finance ingress

The app now handles its own auth at /auth/login — Traefik no longer
needs to forward-auth requests, which was causing redirects to
auth.homelab.local instead of finance.homelab.local.

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-15 18:18:09 +01:00 committed by GitHub
parent 541a1c3556
commit fb6c839352
12 changed files with 1846 additions and 656 deletions

View File

@ -3,8 +3,6 @@ kind: Ingress
metadata: metadata:
name: api name: api
namespace: finance namespace: finance
annotations:
traefik.ingress.kubernetes.io/router.middlewares: auth-forward-auth@kubernetescrd
spec: spec:
ingressClassName: traefik ingressClassName: traefik
rules: rules:

View File

@ -12,6 +12,7 @@ import (
"log/slog" "log/slog"
"math" "math"
"net/http" "net/http"
"net/url"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -150,8 +151,19 @@ func parseTmpl(files ...string) *template.Template {
}).ParseFS(templateFS, files...)) }).ParseFS(templateFS, files...))
} }
// parseStandalone parses a single template file that has no {{define}} blocks.
// parseTmpl roots on "", but ParseFS stores content under the base filename,
// so Execute() would run the empty root. Here we root on the base filename so
// Execute() runs the actual content.
func parseStandalone(file string) *template.Template {
name := file[strings.LastIndex(file, "/")+1:]
return template.Must(template.New(name).ParseFS(templateFS, file))
}
var ( var (
homepageTmpl = parseTmpl("templates/homepage.html") homepageTmpl = parseStandalone("templates/homepage.html")
authLoginTmpl = parseStandalone("templates/auth_login.html")
authRegisterTmpl = parseStandalone("templates/auth_register.html")
baseTmpl = parseTmpl("templates/base.html") baseTmpl = parseTmpl("templates/base.html")
dashboardTmpl = parseTmpl("templates/base.html", "templates/dashboard.html") dashboardTmpl = parseTmpl("templates/base.html", "templates/dashboard.html")
txnsTmpl = parseTmpl("templates/base.html", "templates/transactions.html") txnsTmpl = parseTmpl("templates/base.html", "templates/transactions.html")
@ -196,7 +208,12 @@ type authInfo struct {
Roles string Roles string
} }
func getAuth(r *http.Request) authInfo { // getAuth resolves the current user from the session cookie first, then falls
// back to X-Auth-* headers (Traefik forward-auth / tests).
func (h *Handler) getAuth(r *http.Request) authInfo {
if a, ok := h.authFromSession(r); ok {
return a
}
return authInfo{ return authInfo{
UserID: r.Header.Get("X-Auth-User-Id"), UserID: r.Header.Get("X-Auth-User-Id"),
Email: r.Header.Get("X-Auth-Email"), Email: r.Header.Get("X-Auth-Email"),
@ -318,21 +335,39 @@ type storeIface interface {
updateLedgerEntry(ctx context.Context, id, orgID string, update bson.M) error updateLedgerEntry(ctx context.Context, id, orgID string, update bson.M) error
getAttachments(ctx context.Context, requestID, orgID string) ([]OrgAttachment, error) getAttachments(ctx context.Context, requestID, orgID string) ([]OrgAttachment, error)
createAttachment(ctx context.Context, a *OrgAttachment) error createAttachment(ctx context.Context, a *OrgAttachment) error
// Auth
createAuthUser(ctx context.Context, u *AuthUser) error
findAuthUserByEmail(ctx context.Context, email 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
} }
type Handler struct { type Handler struct {
store storeIface store storeIface
secret string // HMAC key for session tokens
googleID string
googleSecret string
baseURL string // used to build OAuth redirect URLs
} }
func NewHandler(store *Store) *Handler { func NewHandler(store *Store, secret, googleID, googleSecret, baseURL string) *Handler {
return &Handler{store: store} return &Handler{
store: store,
secret: secret,
googleID: googleID,
googleSecret: googleSecret,
baseURL: baseURL,
}
} }
func (h *Handler) authMW(next http.HandlerFunc) http.HandlerFunc { func (h *Handler) authMW(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
a := getAuth(r) a := h.getAuth(r)
if a.UserID == "" { if a.UserID == "" {
http.Redirect(w, r, "https://auth.homelab.local/login", http.StatusFound) http.Redirect(w, r, "/auth/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
return return
} }
next(w, r) next(w, r)
@ -341,7 +376,7 @@ func (h *Handler) authMW(next http.HandlerFunc) http.HandlerFunc {
func (h *Handler) ownerOrViewerMW(next http.HandlerFunc) http.HandlerFunc { func (h *Handler) ownerOrViewerMW(next http.HandlerFunc) http.HandlerFunc {
return h.authMW(func(w http.ResponseWriter, r *http.Request) { return h.authMW(func(w http.ResponseWriter, r *http.Request) {
a := getAuth(r) a := h.getAuth(r)
ownerID := r.PathValue("user_id") ownerID := r.PathValue("user_id")
if ownerID == "" { if ownerID == "" {
ownerID = a.UserID ownerID = a.UserID
@ -371,15 +406,16 @@ func (h *Handler) ownerOrViewerMW(next http.HandlerFunc) http.HandlerFunc {
} }
func (h *Handler) Homepage(w http.ResponseWriter, r *http.Request) { func (h *Handler) Homepage(w http.ResponseWriter, r *http.Request) {
a := getAuth(r) a := h.getAuth(r)
renderRaw(w, homepageTmpl, map[string]interface{}{ renderRaw(w, homepageTmpl, map[string]interface{}{
"Email": a.Email, "Email": a.Email,
"UserID": a.UserID,
}) })
} }
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
now := time.Now() now := time.Now()
thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
@ -725,7 +761,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
func (h *Handler) Transactions(w http.ResponseWriter, r *http.Request) { func (h *Handler) Transactions(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
var filter bson.M var filter bson.M
cat := r.URL.Query().Get("category") cat := r.URL.Query().Get("category")
@ -796,7 +832,7 @@ func (h *Handler) Transactions(w http.ResponseWriter, r *http.Request) {
func (h *Handler) CreateTransaction(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateTransaction(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
var body struct { var body struct {
AccountID string `json:"account_id"` AccountID string `json:"account_id"`
@ -838,7 +874,7 @@ func (h *Handler) CreateTransaction(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) ImportPage(w http.ResponseWriter, r *http.Request) { func (h *Handler) ImportPage(w http.ResponseWriter, r *http.Request) {
a := getAuth(r) a := h.getAuth(r)
accounts, _ := h.store.getAccounts(r.Context(), a.UserID) accounts, _ := h.store.getAccounts(r.Context(), a.UserID)
render(w, importTmpl, map[string]interface{}{ render(w, importTmpl, map[string]interface{}{
"UserID": a.UserID, "UserID": a.UserID,
@ -853,7 +889,7 @@ func (h *Handler) ImportPage(w http.ResponseWriter, r *http.Request) {
func (h *Handler) ImportPreview(w http.ResponseWriter, r *http.Request) { func (h *Handler) ImportPreview(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
if err := r.ParseMultipartForm(32 << 20); err != nil { if err := r.ParseMultipartForm(32 << 20); err != nil {
slog.Error("import preview multipart", slog.Error("import preview multipart",
@ -987,7 +1023,7 @@ func GenericMapping(data []byte) CSVColumnMapping {
func (h *Handler) ImportConfirm(w http.ResponseWriter, r *http.Request) { func (h *Handler) ImportConfirm(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
@ -1123,7 +1159,7 @@ func autoCategorize(desc string, catMap map[string]string) string {
func (h *Handler) UpdateTransaction(w http.ResponseWriter, r *http.Request) { func (h *Handler) UpdateTransaction(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
id := r.PathValue("id") id := r.PathValue("id")
var body struct { var body struct {
@ -1153,7 +1189,7 @@ func (h *Handler) UpdateTransaction(w http.ResponseWriter, r *http.Request) {
func (h *Handler) DeleteTransaction(w http.ResponseWriter, r *http.Request) { func (h *Handler) DeleteTransaction(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
id := r.PathValue("id") id := r.PathValue("id")
if err := h.store.deleteTransaction(ctx, id, a.UserID); err != nil { if err := h.store.deleteTransaction(ctx, id, a.UserID); err != nil {
slog.Error("delete transaction", "err", err) slog.Error("delete transaction", "err", err)
@ -1164,7 +1200,7 @@ func (h *Handler) DeleteTransaction(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) Accounts(w http.ResponseWriter, r *http.Request) { func (h *Handler) Accounts(w http.ResponseWriter, r *http.Request) {
a := getAuth(r) a := h.getAuth(r)
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
@ -1213,7 +1249,7 @@ func (h *Handler) Accounts(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) Categories(w http.ResponseWriter, r *http.Request) { func (h *Handler) Categories(w http.ResponseWriter, r *http.Request) {
a := getAuth(r) a := h.getAuth(r)
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
@ -1288,7 +1324,7 @@ func (h *Handler) Categories(w http.ResponseWriter, r *http.Request) {
func (h *Handler) Reports(w http.ResponseWriter, r *http.Request) { func (h *Handler) Reports(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{}) txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{})
if err != nil { if err != nil {
@ -1344,7 +1380,7 @@ func (h *Handler) Reports(w http.ResponseWriter, r *http.Request) {
func (h *Handler) Projections(w http.ResponseWriter, r *http.Request) { func (h *Handler) Projections(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{}) txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{})
if err != nil { if err != nil {
@ -1443,7 +1479,7 @@ func (h *Handler) Projections(w http.ResponseWriter, r *http.Request) {
func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) { func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
trades, err := h.store.getTrades(ctx, a.UserID) trades, err := h.store.getTrades(ctx, a.UserID)
if err != nil { if err != nil {
@ -1502,7 +1538,7 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) {
func (h *Handler) SaveTickerMapping(w http.ResponseWriter, r *http.Request) { func (h *Handler) SaveTickerMapping(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
isin := strings.TrimSpace(r.FormValue("isin")) isin := strings.TrimSpace(r.FormValue("isin"))
ticker := strings.TrimSpace(r.FormValue("ticker")) ticker := strings.TrimSpace(r.FormValue("ticker"))
if isin == "" || ticker == "" { if isin == "" || ticker == "" {
@ -1519,7 +1555,7 @@ func (h *Handler) SaveTickerMapping(w http.ResponseWriter, r *http.Request) {
func (h *Handler) ImportSecurities(w http.ResponseWriter, r *http.Request) { func (h *Handler) ImportSecurities(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
if err := r.ParseMultipartForm(32 << 20); err != nil { if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, "bad request", http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
@ -1574,7 +1610,7 @@ func (h *Handler) ImportSecurities(w http.ResponseWriter, r *http.Request) {
func (h *Handler) Sharing(w http.ResponseWriter, r *http.Request) { func (h *Handler) Sharing(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
@ -1654,7 +1690,7 @@ func (h *Handler) Sharing(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) SearchUsers(w http.ResponseWriter, r *http.Request) { func (h *Handler) SearchUsers(w http.ResponseWriter, r *http.Request) {
a := getAuth(r) a := h.getAuth(r)
q := r.URL.Query().Get("q") q := r.URL.Query().Get("q")
if q == "" || len(q) < 2 { if q == "" || len(q) < 2 {
json.NewEncoder(w).Encode([]map[string]string{}) json.NewEncoder(w).Encode([]map[string]string{})
@ -1693,7 +1729,7 @@ func (h *Handler) healthz(w http.ResponseWriter, r *http.Request) {
func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) { func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
@ -1878,7 +1914,7 @@ func parseFloat(s string) float64 {
func (h *Handler) Simulator(w http.ResponseWriter, r *http.Request) { func (h *Handler) Simulator(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
txns, _ := h.store.getTransactions(ctx, a.UserID, bson.M{}) txns, _ := h.store.getTransactions(ctx, a.UserID, bson.M{})
goals, _ := h.store.getGoals(ctx, a.UserID) goals, _ := h.store.getGoals(ctx, a.UserID)
@ -2008,7 +2044,7 @@ func (h *Handler) Simulator(w http.ResponseWriter, r *http.Request) {
func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) { func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{}) txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{})
if err != nil { if err != nil {
@ -2097,7 +2133,7 @@ func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) {
// ── Tax Summary ─────────────────────────────────────────────────────────────── // ── Tax Summary ───────────────────────────────────────────────────────────────
func (h *Handler) Tax(w http.ResponseWriter, r *http.Request) { func (h *Handler) Tax(w http.ResponseWriter, r *http.Request) {
auth := getAuth(r) auth := h.getAuth(r)
ctx := r.Context() ctx := r.Context()
// year selector // year selector
@ -2277,7 +2313,7 @@ func (h *Handler) Tax(w http.ResponseWriter, r *http.Request) {
func (h *Handler) TaxExport(w http.ResponseWriter, r *http.Request) { func (h *Handler) TaxExport(w http.ResponseWriter, r *http.Request) {
// Reuse Tax logic output as CSV — redirect with same year param // Reuse Tax logic output as CSV — redirect with same year param
auth := getAuth(r) auth := h.getAuth(r)
ctx := r.Context() ctx := r.Context()
yearStr := r.URL.Query().Get("year") yearStr := r.URL.Query().Get("year")
@ -2312,7 +2348,7 @@ func (h *Handler) TaxExport(w http.ResponseWriter, r *http.Request) {
// ── Household ───────────────────────────────────────────────────────────────── // ── Household ─────────────────────────────────────────────────────────────────
func (h *Handler) Household(w http.ResponseWriter, r *http.Request) { func (h *Handler) Household(w http.ResponseWriter, r *http.Request) {
auth := getAuth(r) auth := h.getAuth(r)
ctx := r.Context() ctx := r.Context()
now := time.Now() now := time.Now()
@ -2409,7 +2445,7 @@ func (h *Handler) Household(w http.ResponseWriter, r *http.Request) {
// ── Auto Import ─────────────────────────────────────────────────────────────── // ── Auto Import ───────────────────────────────────────────────────────────────
func (h *Handler) AutoImport(w http.ResponseWriter, r *http.Request) { func (h *Handler) AutoImport(w http.ResponseWriter, r *http.Request) {
auth := getAuth(r) auth := h.getAuth(r)
accounts, _ := h.store.getAccounts(r.Context(), auth.UserID) accounts, _ := h.store.getAccounts(r.Context(), auth.UserID)
render(w, autoImportTmpl, &AutoImportData{ render(w, autoImportTmpl, &AutoImportData{
UserID: auth.UserID, UserID: auth.UserID,
@ -2424,7 +2460,7 @@ func (h *Handler) AutoImport(w http.ResponseWriter, r *http.Request) {
func (h *Handler) People(w http.ResponseWriter, r *http.Request) { func (h *Handler) People(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
tab := r.URL.Query().Get("tab") tab := r.URL.Query().Get("tab")
if tab == "" { if tab == "" {
tab = "sharing" tab = "sharing"
@ -2553,7 +2589,7 @@ func (h *Handler) People(w http.ResponseWriter, r *http.Request) {
func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) { func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
tab := r.URL.Query().Get("tab") tab := r.URL.Query().Get("tab")
if tab == "" { if tab == "" {
tab = "accounts" tab = "accounts"
@ -2574,6 +2610,15 @@ func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) RegisterRoutes(mux *http.ServeMux) { func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Auth (no authMW — these are public by definition)
mux.HandleFunc("GET /auth/login", h.AuthLogin)
mux.HandleFunc("POST /auth/login", h.AuthLogin)
mux.HandleFunc("GET /auth/register", h.AuthRegister)
mux.HandleFunc("POST /auth/register", h.AuthRegister)
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 /{$}", h.Homepage) mux.HandleFunc("GET /{$}", h.Homepage)
mux.HandleFunc("GET /dashboard", h.authMW(h.Dashboard)) mux.HandleFunc("GET /dashboard", h.authMW(h.Dashboard))
mux.HandleFunc("GET /transactions", h.Transactions) mux.HandleFunc("GET /transactions", h.Transactions)

View File

@ -0,0 +1,404 @@
package main
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
const (
cookieName = "finsession"
sessionTTL = 30 * 24 * time.Hour
)
// ── session token ─────────────────────────────────────────────────────────────
func (h *Handler) signSessionID(id string) string {
mac := hmac.New(sha256.New, []byte(h.secret))
mac.Write([]byte(id))
return id + "." + hex.EncodeToString(mac.Sum(nil))
}
func (h *Handler) verifySessionToken(token string) (string, bool) {
i := strings.LastIndex(token, ".")
if i < 0 {
return "", false
}
id := token[:i]
expected := h.signSessionID(id)
if !hmac.Equal([]byte(token), []byte(expected)) {
return "", false
}
return id, true
}
func (h *Handler) authFromSession(r *http.Request) (authInfo, bool) {
cookie, err := r.Cookie(cookieName)
if err != nil {
return authInfo{}, false
}
id, ok := h.verifySessionToken(cookie.Value)
if !ok {
return authInfo{}, false
}
sess, err := h.store.getAuthSession(r.Context(), id)
if err != nil || sess == nil || time.Now().After(sess.ExpiresAt) {
return authInfo{}, false
}
return authInfo{
UserID: sess.UserID.Hex(),
Email: sess.Email,
}, true
}
func (h *Handler) startSession(w http.ResponseWriter, r *http.Request, userID bson.ObjectID, email string) error {
sess := &AuthSession{
UserID: userID,
Email: email,
ExpiresAt: time.Now().Add(sessionTTL),
}
if err := h.store.createAuthSession(r.Context(), sess); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: h.signSessionID(sess.ID.Hex()),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionTTL.Seconds()),
})
return nil
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
}
// ── login ─────────────────────────────────────────────────────────────────────
func (h *Handler) AuthLogin(w http.ResponseWriter, r *http.Request) {
// Already signed in → go to dashboard
if a, ok := h.authFromSession(r); ok && a.UserID != "" {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
if r.Method == http.MethodPost {
h.authLoginPost(w, r)
return
}
renderRaw(w, authLoginTmpl, map[string]any{
"GoogleEnabled": h.googleID != "",
})
}
func (h *Handler) authLoginPost(w http.ResponseWriter, r *http.Request) {
email := strings.TrimSpace(r.FormValue("email"))
password := r.FormValue("password")
fail := func(msg string) {
renderRaw(w, authLoginTmpl, map[string]any{
"Error": msg,
"Email": email,
"GoogleEnabled": h.googleID != "",
})
}
if email == "" || password == "" {
fail("Email and password are required.")
return
}
user, err := h.store.findAuthUserByEmail(r.Context(), email)
if err != nil || user == nil || user.PasswordHash == "" {
fail("Invalid email or password.")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
fail("Invalid email or password.")
return
}
if err := h.startSession(w, r, user.ID, user.Email); err != nil {
http.Error(w, "session error", http.StatusInternalServerError)
return
}
next := r.URL.Query().Get("next")
if next == "" || !strings.HasPrefix(next, "/") {
next = "/dashboard"
}
http.Redirect(w, r, next, http.StatusSeeOther)
}
// ── register ──────────────────────────────────────────────────────────────────
func (h *Handler) AuthRegister(w http.ResponseWriter, r *http.Request) {
if a, ok := h.authFromSession(r); ok && a.UserID != "" {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
if r.Method == http.MethodPost {
h.authRegisterPost(w, r)
return
}
renderRaw(w, authRegisterTmpl, map[string]any{
"GoogleEnabled": h.googleID != "",
})
}
func (h *Handler) authRegisterPost(w http.ResponseWriter, r *http.Request) {
email := strings.TrimSpace(r.FormValue("email"))
name := strings.TrimSpace(r.FormValue("name"))
password := r.FormValue("password")
confirm := r.FormValue("confirm")
fail := func(msg string) {
renderRaw(w, authRegisterTmpl, map[string]any{
"Error": msg,
"Email": email,
"Name": name,
"GoogleEnabled": h.googleID != "",
})
}
if email == "" || password == "" {
fail("Email and password are required.")
return
}
if password != confirm {
fail("Passwords do not match.")
return
}
if len(password) < 8 {
fail("Password must be at least 8 characters.")
return
}
existing, err := h.store.findAuthUserByEmail(r.Context(), email)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if existing != nil {
fail("An account with that email already exists.")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
user := &AuthUser{Email: email, Name: name, PasswordHash: string(hash)}
if err := h.store.createAuthUser(r.Context(), user); err != nil {
fail("Could not create account. Please try again.")
return
}
if err := h.store.seedCategories(r.Context(), user.ID.Hex()); err != nil {
slog.Warn("seed categories on register", "err", err)
}
if err := h.startSession(w, r, user.ID, user.Email); err != nil {
http.Error(w, "session error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
// ── logout ────────────────────────────────────────────────────────────────────
func (h *Handler) AuthLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(cookieName); err == nil {
if id, ok := h.verifySessionToken(cookie.Value); ok {
_ = h.store.deleteAuthSession(r.Context(), id)
}
}
clearSessionCookie(w)
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
}
// ── Google OAuth ──────────────────────────────────────────────────────────────
const (
googleAuthURL = "https://accounts.google.com/o/oauth2/v2/auth"
googleTokenURL = "https://oauth2.googleapis.com/token"
googleUserURL = "https://www.googleapis.com/oauth2/v3/userinfo"
)
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func (h *Handler) googleRedirectURL() string {
base := strings.TrimRight(h.baseURL, "/")
if base == "" {
base = "http://localhost:8080"
}
return base + "/auth/oauth/google/callback"
}
func (h *Handler) AuthGoogleStart(w http.ResponseWriter, r *http.Request) {
if h.googleID == "" {
http.NotFound(w, r)
return
}
state := randomHex(16)
http.SetCookie(w, &http.Cookie{
Name: "oauthstate",
Value: state,
Path: "/auth",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 600,
})
params := url.Values{
"client_id": {h.googleID},
"redirect_uri": {h.googleRedirectURL()},
"response_type": {"code"},
"scope": {"openid email profile"},
"state": {state},
"access_type": {"offline"},
"prompt": {"select_account"},
}
http.Redirect(w, r, googleAuthURL+"?"+params.Encode(), http.StatusFound)
}
func (h *Handler) AuthGoogleCallback(w http.ResponseWriter, r *http.Request) {
if h.googleID == "" {
http.NotFound(w, r)
return
}
stateCookie, err := r.Cookie("oauthstate")
if err != nil || stateCookie.Value != r.URL.Query().Get("state") {
http.Error(w, "invalid OAuth state", http.StatusBadRequest)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "missing code", http.StatusBadRequest)
return
}
token, err := h.googleExchangeCode(r.Context(), code)
if err != nil {
slog.Error("google token exchange", "err", err)
http.Redirect(w, r, "/auth/login?error=oauth", http.StatusFound)
return
}
gUser, err := h.googleUserInfo(r.Context(), token)
if err != nil {
slog.Error("google userinfo", "err", err)
http.Redirect(w, r, "/auth/login?error=oauth", http.StatusFound)
return
}
// Find by OAuth provider first, then fall back to matching email
user, err := h.store.findAuthUserByProvider(r.Context(), "google", gUser.Sub)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if user == nil {
user, err = h.store.findAuthUserByEmail(r.Context(), gUser.Email)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}
if user == nil {
user = &AuthUser{
Email: gUser.Email,
Name: gUser.Name,
Provider: "google",
ProviderID: gUser.Sub,
}
if err := h.store.createAuthUser(r.Context(), user); err != nil {
slog.Error("create oauth user", "err", err)
http.Redirect(w, r, "/auth/login?error=oauth", http.StatusFound)
return
}
_ = h.store.seedCategories(r.Context(), user.ID.Hex())
}
if err := h.startSession(w, r, user.ID, user.Email); err != nil {
http.Error(w, "session error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
type googleTokenResp struct {
AccessToken string `json:"access_token"`
}
type googleUserResp struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
}
func (h *Handler) googleExchangeCode(ctx context.Context, code string) (string, error) {
form := url.Values{
"code": {code},
"client_id": {h.googleID},
"client_secret": {h.googleSecret},
"redirect_uri": {h.googleRedirectURL()},
"grant_type": {"authorization_code"},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, googleTokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("google token error %d: %s", resp.StatusCode, body)
}
var tr googleTokenResp
if err := json.Unmarshal(body, &tr); err != nil {
return "", err
}
return tr.AccessToken, nil
}
func (h *Handler) googleUserInfo(ctx context.Context, accessToken string) (*googleUserResp, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleUserURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("google userinfo error %d: %s", resp.StatusCode, body)
}
var u googleUserResp
if err := json.Unmarshal(body, &u); err != nil {
return nil, err
}
return &u, nil
}

View File

@ -1,9 +1,7 @@
package main package main
import ( import (
"crypto/rand"
"encoding/csv" "encoding/csv"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
@ -31,7 +29,7 @@ var slugRe = regexp.MustCompile(`^[a-z0-9-]{2,40}$`)
func (h *Handler) requireOrgMember(next func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember)) http.HandlerFunc { func (h *Handler) requireOrgMember(next func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember)) http.HandlerFunc {
return h.authMW(func(w http.ResponseWriter, r *http.Request) { return h.authMW(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
slug := r.PathValue("slug") slug := r.PathValue("slug")
org, err := h.store.getOrgBySlug(ctx, slug) org, err := h.store.getOrgBySlug(ctx, slug)
@ -71,7 +69,7 @@ func canManageOrg(role OrgRole) bool {
func (h *Handler) OrgList(w http.ResponseWriter, r *http.Request) { func (h *Handler) OrgList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
orgs, err := h.store.getOrgsForUser(ctx, a.UserID) orgs, err := h.store.getOrgsForUser(ctx, a.UserID)
if err != nil { if err != nil {
@ -90,7 +88,7 @@ func (h *Handler) OrgList(w http.ResponseWriter, r *http.Request) {
func (h *Handler) OrgCreate(w http.ResponseWriter, r *http.Request) { func (h *Handler) OrgCreate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
render(w, orgCreateTmpl, map[string]any{ render(w, orgCreateTmpl, map[string]any{
@ -309,7 +307,7 @@ func (h *Handler) OrgMemberRemove(w http.ResponseWriter, r *http.Request) {
h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) { h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
memberID := r.PathValue("member_id") memberID := r.PathValue("member_id")
// prevent removing yourself // prevent removing yourself
a := getAuth(r) a := h.getAuth(r)
if me.UserID == a.UserID && memberID == me.ID { if me.UserID == a.UserID && memberID == me.ID {
http.Error(w, "cannot remove yourself", http.StatusBadRequest) http.Error(w, "cannot remove yourself", http.StatusBadRequest)
return return
@ -417,7 +415,7 @@ func (h *Handler) OrgInviteRevoke(w http.ResponseWriter, r *http.Request) {
// OrgJoin handles the invite link: GET shows a confirmation page, POST accepts. // OrgJoin handles the invite link: GET shows a confirmation page, POST accepts.
func (h *Handler) OrgJoin(w http.ResponseWriter, r *http.Request) { func (h *Handler) OrgJoin(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := h.getAuth(r)
token := r.PathValue("token") token := r.PathValue("token")
inv, err := h.store.getInviteByToken(ctx, token) inv, err := h.store.getInviteByToken(ctx, token)
@ -1967,12 +1965,6 @@ func (h *Handler) RegisterOrgRoutes(mux *http.ServeMux) {
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// parseEuroAmount converts a user-entered decimal string (e.g. "12.50") to cents. // parseEuroAmount converts a user-entered decimal string (e.g. "12.50") to cents.
func parseEuroAmount(s string) (int64, error) { func parseEuroAmount(s string) (int64, error) {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)

View File

@ -232,10 +232,23 @@ func (m *mockStore) getAttachments(_ context.Context, _, _ string) ([]OrgAttachm
} }
func (m *mockStore) createAttachment(_ context.Context, _ *OrgAttachment) error { return nil } 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) {
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) {
return nil, nil
}
func (m *mockStore) deleteAuthSession(_ context.Context, _ string) error { return nil }
// ── helpers ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
func newHandler(store *mockStore) *Handler { func newHandler(store *mockStore) *Handler {
return &Handler{store: store} return &Handler{store: store, secret: "test-secret"}
} }
func authReq(method, path string, body url.Values) *http.Request { func authReq(method, path string, body url.Values) *http.Request {
@ -1487,7 +1500,7 @@ func TestProjections_WithTransactions(t *testing.T) {
func TestNewHandler(t *testing.T) { func TestNewHandler(t *testing.T) {
// NewHandler wraps a *Store into a Handler. // NewHandler wraps a *Store into a Handler.
// Pass a nil *Store — the function just assigns; no methods are called. // Pass a nil *Store — the function just assigns; no methods are called.
h := NewHandler((*Store)(nil)) h := NewHandler((*Store)(nil), "test-secret", "", "", "")
if h == nil { if h == nil {
t.Fatal("NewHandler returned nil") t.Fatal("NewHandler returned nil")
} }

View File

@ -32,10 +32,20 @@ func main() {
defer db.Close(ctx) defer db.Close(ctx)
store := NewStore(db) store := NewStore(db)
store.ensureAuthIndexes(ctx)
go SeedAdmin(ctx, store) go SeedAdmin(ctx, store)
handler := NewHandler(store) secret := os.Getenv("SESSION_SECRET")
if secret == "" {
secret = "dev-secret-change-in-production-32x"
slog.Warn("SESSION_SECRET not set — using insecure default, set it before deploying")
}
handler := NewHandler(store, secret,
os.Getenv("GOOGLE_CLIENT_ID"),
os.Getenv("GOOGLE_CLIENT_SECRET"),
os.Getenv("BASE_URL"),
)
mux := http.NewServeMux() mux := http.NewServeMux()
handler.RegisterRoutes(mux) handler.RegisterRoutes(mux)

View File

@ -0,0 +1,25 @@
package main
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type AuthUser struct {
ID bson.ObjectID `bson:"_id,omitempty"`
Email string `bson:"email"`
Name string `bson:"name,omitempty"`
PasswordHash string `bson:"password_hash,omitempty"`
Provider string `bson:"provider,omitempty"` // "google" or ""
ProviderID string `bson:"provider_id,omitempty"` // provider's user sub/id
CreatedAt time.Time `bson:"created_at"`
}
type AuthSession struct {
ID bson.ObjectID `bson:"_id,omitempty"`
UserID bson.ObjectID `bson:"user_id"`
Email string `bson:"email"`
ExpiresAt time.Time `bson:"expires_at"`
CreatedAt time.Time `bson:"created_at"`
}

View File

@ -41,19 +41,26 @@ func SeedAdmin(ctx context.Context, store *Store) {
} }
} }
// lookupUserByEmailMongo queries the shared "users" collection directly, // lookupUserByEmailMongo resolves a user ID from email.
// avoiding any cross-service HTTP dependency. // Checks finance_users first (standalone/cloud deployment), then the shared
// "users" collection (Traefik forward-auth deployment).
func lookupUserByEmailMongo(ctx context.Context, store *Store, email string) (string, error) { func lookupUserByEmailMongo(ctx context.Context, store *Store, email string) (string, error) {
coll := store.db.Collection("users") // standalone auth
var result struct { var finUser struct {
ID bson.ObjectID `bson:"_id"`
}
if err := store.db.Collection("finance_users").FindOne(ctx, bson.M{"email": email}).Decode(&finUser); err == nil {
return finUser.ID.Hex(), nil
}
// legacy shared-auth fallback
var legacy struct {
ID string `bson:"_id"` ID string `bson:"_id"`
Email string `bson:"email"` Email string `bson:"email"`
} }
err := coll.FindOne(ctx, bson.M{"email": email}).Decode(&result) if err := store.db.Collection("users").FindOne(ctx, bson.M{"email": email}).Decode(&legacy); err != nil {
if err != nil {
return "", fmt.Errorf("user %q not found in mongo: %w", email, err) return "", fmt.Errorf("user %q not found in mongo: %w", email, err)
} }
return result.ID, nil return legacy.ID, nil
} }
func seedAll(ctx context.Context, store *Store, userID string) error { func seedAll(ctx context.Context, store *Store, userID string) error {

View File

@ -0,0 +1,78 @@
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) 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) 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),
})
}

View File

@ -0,0 +1,255 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in — Finance Hub</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #040609;
color: #eaf2f0;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* canvas bg */
canvas { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
.wrap {
position: relative;
z-index: 1;
width: 100%;
max-width: 420px;
padding: 24px 20px;
}
.logo-row {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 36px;
}
.logo-icon {
width: 38px; height: 38px;
border-radius: 11px;
background: linear-gradient(135deg, #00c9b8, #a855f7);
display: flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 800; color: #030609;
}
.logo-name { font-size: 20px; font-weight: 700; letter-spacing: -0.4px; }
.card {
background: rgba(10, 14, 22, 0.85);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 20px;
padding: 32px 28px;
backdrop-filter: blur(12px);
}
h1 {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 6px;
}
.sub {
font-size: 13px;
color: #7aada9;
margin-bottom: 28px;
line-height: 1.5;
}
.sub a { color: #00c9b8; text-decoration: none; }
.sub a:hover { text-decoration: underline; }
.error-box {
background: rgba(248,113,113,0.1);
border: 1px solid rgba(248,113,113,0.25);
border-radius: 10px;
padding: 11px 14px;
font-size: 13px;
color: #fca5a5;
margin-bottom: 20px;
}
.field { margin-bottom: 16px; }
label {
display: block;
font-size: 12px;
font-weight: 600;
color: #7aada9;
letter-spacing: 0.04em;
margin-bottom: 6px;
}
input[type="email"],
input[type="password"],
input[type="text"] {
width: 100%;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px;
padding: 11px 14px;
font-size: 14px;
color: #eaf2f0;
outline: none;
transition: border-color .2s;
}
input:focus { border-color: rgba(0,201,184,0.5); }
input::placeholder { color: #364e4c; }
.btn-primary {
width: 100%;
background: linear-gradient(135deg, #00c9b8, #33d9ca);
color: #030609;
font-size: 14px;
font-weight: 700;
border: none;
border-radius: 10px;
padding: 13px;
cursor: pointer;
transition: opacity .2s;
margin-top: 4px;
}
.btn-primary:hover { opacity: 0.88; }
.divider {
display: flex;
align-items: center;
gap: 12px;
margin: 22px 0;
font-size: 12px;
color: #364e4c;
}
.divider::before, .divider::after {
content: '';
flex: 1;
height: 1px;
background: rgba(255,255,255,0.07);
}
.btn-google {
width: 100%;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px;
padding: 11px;
font-size: 14px;
font-weight: 600;
color: #eaf2f0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
text-decoration: none;
transition: background .2s, border-color .2s;
}
.btn-google:hover { background: rgba(255,255,255,0.09); border-color: rgba(255,255,255,0.18); }
.btn-google svg { flex-shrink: 0; }
.footer-link {
text-align: center;
margin-top: 20px;
font-size: 12px;
color: #364e4c;
}
.footer-link a { color: #00c9b8; text-decoration: none; }
.footer-link a:hover { text-decoration: underline; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div class="wrap">
<div class="logo-row">
<div class="logo-icon"></div>
<span class="logo-name">Finance Hub</span>
</div>
<div class="card">
<h1>Welcome back</h1>
<p class="sub">Sign in to your account. No account? <a href="/auth/register">Create one →</a></p>
{{if .Error}}
<div class="error-box">{{.Error}}</div>
{{end}}
{{if eq (index . "Error") "oauth"}}
<div class="error-box">Google sign-in failed. Please try again.</div>
{{end}}
<form method="POST" action="/auth/login">
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" name="email" value="{{.Email}}" placeholder="you@example.com" required autofocus>
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="••••••••" required>
</div>
<button class="btn-primary" type="submit">Sign in →</button>
</form>
{{if .GoogleEnabled}}
<div class="divider">or</div>
<a class="btn-google" href="/auth/oauth/google">
<svg width="18" height="18" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Continue with Google
</a>
{{end}}
</div>
<div class="footer-link"><a href="/">← Back to home</a></div>
</div>
<script>
(function(){
const c = document.getElementById('c');
const ctx = c.getContext('2d');
let W, H, pts;
function resize() {
W = c.width = innerWidth; H = c.height = innerHeight;
pts = Array.from({length: 55}, () => ({
x: Math.random()*W, y: Math.random()*H,
vx: (Math.random()-.5)*.4, vy: (Math.random()-.5)*.4
}));
}
resize();
window.addEventListener('resize', resize);
function draw() {
ctx.clearRect(0,0,W,H);
for (let p of pts) {
p.x += p.vx; p.y += p.vy;
if (p.x<0||p.x>W) p.vx*=-1;
if (p.y<0||p.y>H) p.vy*=-1;
}
for (let i=0;i<pts.length;i++) for (let j=i+1;j<pts.length;j++) {
const d = Math.hypot(pts[i].x-pts[j].x, pts[i].y-pts[j].y);
if (d < 110) {
ctx.strokeStyle = `rgba(0,201,184,${(1-d/110)*0.18})`;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(pts[i].x,pts[i].y); ctx.lineTo(pts[j].x,pts[j].y); ctx.stroke();
}
}
for (let p of pts) {
ctx.fillStyle = 'rgba(0,201,184,0.35)';
ctx.beginPath(); ctx.arc(p.x,p.y,2,0,Math.PI*2); ctx.fill();
}
requestAnimationFrame(draw);
}
draw();
})();
</script>
</body>
</html>

View File

@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create account — Finance Hub</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #040609;
color: #eaf2f0;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
canvas { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
.wrap {
position: relative;
z-index: 1;
width: 100%;
max-width: 420px;
padding: 24px 20px;
}
.logo-row {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 36px;
}
.logo-icon {
width: 38px; height: 38px;
border-radius: 11px;
background: linear-gradient(135deg, #00c9b8, #a855f7);
display: flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 800; color: #030609;
}
.logo-name { font-size: 20px; font-weight: 700; letter-spacing: -0.4px; }
.card {
background: rgba(10, 14, 22, 0.85);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 20px;
padding: 32px 28px;
backdrop-filter: blur(12px);
}
h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.5px; margin-bottom: 6px; }
.sub { font-size: 13px; color: #7aada9; margin-bottom: 28px; line-height: 1.5; }
.sub a { color: #00c9b8; text-decoration: none; }
.sub a:hover { text-decoration: underline; }
.error-box {
background: rgba(248,113,113,0.1);
border: 1px solid rgba(248,113,113,0.25);
border-radius: 10px;
padding: 11px 14px;
font-size: 13px;
color: #fca5a5;
margin-bottom: 20px;
}
.field { margin-bottom: 16px; }
label { display: block; font-size: 12px; font-weight: 600; color: #7aada9; letter-spacing: 0.04em; margin-bottom: 6px; }
input[type="email"],
input[type="password"],
input[type="text"] {
width: 100%;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px;
padding: 11px 14px;
font-size: 14px;
color: #eaf2f0;
outline: none;
transition: border-color .2s;
}
input:focus { border-color: rgba(0,201,184,0.5); }
input::placeholder { color: #364e4c; }
.hint { font-size: 11px; color: #364e4c; margin-top: 5px; }
.btn-primary {
width: 100%;
background: linear-gradient(135deg, #00c9b8, #33d9ca);
color: #030609;
font-size: 14px;
font-weight: 700;
border: none;
border-radius: 10px;
padding: 13px;
cursor: pointer;
transition: opacity .2s;
margin-top: 4px;
}
.btn-primary:hover { opacity: 0.88; }
.divider {
display: flex; align-items: center; gap: 12px;
margin: 22px 0; font-size: 12px; color: #364e4c;
}
.divider::before, .divider::after {
content: ''; flex: 1; height: 1px; background: rgba(255,255,255,0.07);
}
.btn-google {
width: 100%;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px;
padding: 11px;
font-size: 14px;
font-weight: 600;
color: #eaf2f0;
cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 10px;
text-decoration: none;
transition: background .2s, border-color .2s;
}
.btn-google:hover { background: rgba(255,255,255,0.09); border-color: rgba(255,255,255,0.18); }
.footer-link { text-align: center; margin-top: 20px; font-size: 12px; color: #364e4c; }
.footer-link a { color: #00c9b8; text-decoration: none; }
.footer-link a:hover { text-decoration: underline; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div class="wrap">
<div class="logo-row">
<div class="logo-icon"></div>
<span class="logo-name">Finance Hub</span>
</div>
<div class="card">
<h1>Create your account</h1>
<p class="sub">Already have an account? <a href="/auth/login">Sign in →</a></p>
{{if .Error}}
<div class="error-box">{{.Error}}</div>
{{end}}
{{if .GoogleEnabled}}
<a class="btn-google" href="/auth/oauth/google">
<svg width="18" height="18" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Continue with Google
</a>
<div class="divider">or sign up with email</div>
{{end}}
<form method="POST" action="/auth/register">
<div class="field">
<label for="name">Name <span style="color:#364e4c;font-weight:400">(optional)</span></label>
<input type="text" id="name" name="name" value="{{.Name}}" placeholder="Your name">
</div>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" name="email" value="{{.Email}}" placeholder="you@example.com" required autofocus>
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="••••••••" required minlength="8">
<div class="hint">At least 8 characters</div>
</div>
<div class="field">
<label for="confirm">Confirm password</label>
<input type="password" id="confirm" name="confirm" placeholder="••••••••" required>
</div>
<button class="btn-primary" type="submit">Create account →</button>
</form>
</div>
<div class="footer-link"><a href="/">← Back to home</a></div>
</div>
<script>
(function(){
const c = document.getElementById('c');
const ctx = c.getContext('2d');
let W, H, pts;
function resize() {
W = c.width = innerWidth; H = c.height = innerHeight;
pts = Array.from({length: 55}, () => ({
x: Math.random()*W, y: Math.random()*H,
vx: (Math.random()-.5)*.4, vy: (Math.random()-.5)*.4
}));
}
resize();
window.addEventListener('resize', resize);
function draw() {
ctx.clearRect(0,0,W,H);
for (let p of pts) {
p.x += p.vx; p.y += p.vy;
if (p.x<0||p.x>W) p.vx*=-1;
if (p.y<0||p.y>H) p.vy*=-1;
}
for (let i=0;i<pts.length;i++) for (let j=i+1;j<pts.length;j++) {
const d = Math.hypot(pts[i].x-pts[j].x, pts[i].y-pts[j].y);
if (d < 110) {
ctx.strokeStyle = `rgba(0,201,184,${(1-d/110)*0.18})`;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(pts[i].x,pts[i].y); ctx.lineTo(pts[j].x,pts[j].y); ctx.stroke();
}
}
for (let p of pts) {
ctx.fillStyle = 'rgba(0,201,184,0.35)';
ctx.beginPath(); ctx.arc(p.x,p.y,2,0,Math.PI*2); ctx.fill();
}
requestAnimationFrame(draw);
}
draw();
})();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff