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:
parent
541a1c3556
commit
fb6c839352
@ -3,8 +3,6 @@ kind: Ingress
|
||||
metadata:
|
||||
name: api
|
||||
namespace: finance
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: auth-forward-auth@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -150,8 +151,19 @@ func parseTmpl(files ...string) *template.Template {
|
||||
}).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 (
|
||||
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")
|
||||
dashboardTmpl = parseTmpl("templates/base.html", "templates/dashboard.html")
|
||||
txnsTmpl = parseTmpl("templates/base.html", "templates/transactions.html")
|
||||
@ -196,7 +208,12 @@ type authInfo struct {
|
||||
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{
|
||||
UserID: r.Header.Get("X-Auth-User-Id"),
|
||||
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
|
||||
getAttachments(ctx context.Context, requestID, orgID string) ([]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 {
|
||||
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 {
|
||||
return &Handler{store: store}
|
||||
func NewHandler(store *Store, secret, googleID, googleSecret, baseURL string) *Handler {
|
||||
return &Handler{
|
||||
store: store,
|
||||
secret: secret,
|
||||
googleID: googleID,
|
||||
googleSecret: googleSecret,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) authMW(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
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
|
||||
}
|
||||
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 {
|
||||
return h.authMW(func(w http.ResponseWriter, r *http.Request) {
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
ownerID := r.PathValue("user_id")
|
||||
if ownerID == "" {
|
||||
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) {
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
renderRaw(w, homepageTmpl, map[string]interface{}{
|
||||
"Email": a.Email,
|
||||
"UserID": a.UserID,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
|
||||
now := time.Now()
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
|
||||
var filter bson.M
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
|
||||
var body struct {
|
||||
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) {
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
accounts, _ := h.store.getAccounts(r.Context(), a.UserID)
|
||||
render(w, importTmpl, map[string]interface{}{
|
||||
"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) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
slog.Error("import preview multipart",
|
||||
@ -987,7 +1023,7 @@ func GenericMapping(data []byte) CSVColumnMapping {
|
||||
|
||||
func (h *Handler) ImportConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
id := r.PathValue("id")
|
||||
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
id := r.PathValue("id")
|
||||
if err := h.store.deleteTransaction(ctx, id, a.UserID); err != nil {
|
||||
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) {
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
|
||||
switch r.Method {
|
||||
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) {
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
|
||||
switch r.Method {
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
|
||||
txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{})
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
|
||||
txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{})
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
|
||||
trades, err := h.store.getTrades(ctx, a.UserID)
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
isin := strings.TrimSpace(r.FormValue("isin"))
|
||||
ticker := strings.TrimSpace(r.FormValue("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) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
|
||||
switch r.Method {
|
||||
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) {
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
q := r.URL.Query().Get("q")
|
||||
if q == "" || len(q) < 2 {
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
|
||||
txns, _ := h.store.getTransactions(ctx, a.UserID, bson.M{})
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
|
||||
txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{})
|
||||
if err != nil {
|
||||
@ -2097,7 +2133,7 @@ func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) {
|
||||
// ── Tax Summary ───────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) Tax(w http.ResponseWriter, r *http.Request) {
|
||||
auth := getAuth(r)
|
||||
auth := h.getAuth(r)
|
||||
ctx := r.Context()
|
||||
|
||||
// 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) {
|
||||
// Reuse Tax logic output as CSV — redirect with same year param
|
||||
auth := getAuth(r)
|
||||
auth := h.getAuth(r)
|
||||
ctx := r.Context()
|
||||
|
||||
yearStr := r.URL.Query().Get("year")
|
||||
@ -2312,7 +2348,7 @@ func (h *Handler) TaxExport(w http.ResponseWriter, r *http.Request) {
|
||||
// ── Household ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) Household(w http.ResponseWriter, r *http.Request) {
|
||||
auth := getAuth(r)
|
||||
auth := h.getAuth(r)
|
||||
ctx := r.Context()
|
||||
now := time.Now()
|
||||
|
||||
@ -2409,7 +2445,7 @@ func (h *Handler) Household(w http.ResponseWriter, r *http.Request) {
|
||||
// ── Auto Import ───────────────────────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
render(w, autoImportTmpl, &AutoImportData{
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
tab := r.URL.Query().Get("tab")
|
||||
if tab == "" {
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
tab := r.URL.Query().Get("tab")
|
||||
if tab == "" {
|
||||
tab = "accounts"
|
||||
@ -2574,6 +2610,15 @@ func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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 /dashboard", h.authMW(h.Dashboard))
|
||||
mux.HandleFunc("GET /transactions", h.Transactions)
|
||||
|
||||
404
apps/finance/services/api/main/handler_auth.go
Normal file
404
apps/finance/services/api/main/handler_auth.go
Normal 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
|
||||
}
|
||||
@ -1,9 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/csv"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"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 {
|
||||
return h.authMW(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
slug := r.PathValue("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) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
|
||||
orgs, err := h.store.getOrgsForUser(ctx, a.UserID)
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
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) {
|
||||
memberID := r.PathValue("member_id")
|
||||
// prevent removing yourself
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
if me.UserID == a.UserID && memberID == me.ID {
|
||||
http.Error(w, "cannot remove yourself", http.StatusBadRequest)
|
||||
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.
|
||||
func (h *Handler) OrgJoin(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
a := getAuth(r)
|
||||
a := h.getAuth(r)
|
||||
token := r.PathValue("token")
|
||||
|
||||
inv, err := h.store.getInviteByToken(ctx, token)
|
||||
@ -1967,12 +1965,6 @@ func (h *Handler) RegisterOrgRoutes(mux *http.ServeMux) {
|
||||
|
||||
// ── 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.
|
||||
func parseEuroAmount(s string) (int64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
@ -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) 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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
@ -1487,7 +1500,7 @@ func TestProjections_WithTransactions(t *testing.T) {
|
||||
func TestNewHandler(t *testing.T) {
|
||||
// NewHandler wraps a *Store into a Handler.
|
||||
// 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 {
|
||||
t.Fatal("NewHandler returned nil")
|
||||
}
|
||||
|
||||
@ -32,10 +32,20 @@ func main() {
|
||||
defer db.Close(ctx)
|
||||
|
||||
store := NewStore(db)
|
||||
store.ensureAuthIndexes(ctx)
|
||||
|
||||
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()
|
||||
handler.RegisterRoutes(mux)
|
||||
|
||||
25
apps/finance/services/api/main/models_auth.go
Normal file
25
apps/finance/services/api/main/models_auth.go
Normal 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"`
|
||||
}
|
||||
@ -41,19 +41,26 @@ func SeedAdmin(ctx context.Context, store *Store) {
|
||||
}
|
||||
}
|
||||
|
||||
// lookupUserByEmailMongo queries the shared "users" collection directly,
|
||||
// avoiding any cross-service HTTP dependency.
|
||||
// lookupUserByEmailMongo resolves a user ID from email.
|
||||
// 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) {
|
||||
coll := store.db.Collection("users")
|
||||
var result struct {
|
||||
// standalone auth
|
||||
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"`
|
||||
Email string `bson:"email"`
|
||||
}
|
||||
err := coll.FindOne(ctx, bson.M{"email": email}).Decode(&result)
|
||||
if err != nil {
|
||||
if err := store.db.Collection("users").FindOne(ctx, bson.M{"email": email}).Decode(&legacy); err != nil {
|
||||
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 {
|
||||
|
||||
78
apps/finance/services/api/main/store_auth.go
Normal file
78
apps/finance/services/api/main/store_auth.go
Normal 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),
|
||||
})
|
||||
}
|
||||
255
apps/finance/services/api/main/templates/auth_login.html
Normal file
255
apps/finance/services/api/main/templates/auth_login.html
Normal 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>
|
||||
216
apps/finance/services/api/main/templates/auth_register.html
Normal file
216
apps/finance/services/api/main/templates/auth_register.html
Normal 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
Loading…
x
Reference in New Issue
Block a user