Compare commits

..

8 Commits

Author SHA1 Message Date
KS Jannette
965830fcde tweak 2026-03-29 08:17:11 -04:00
KS Jannette
0412ea3e99 metric f ton 2026-03-29 07:46:56 -04:00
KS Jannette
0920985a74 more 2026-03-29 07:04:25 -04:00
KS Jannette
9d2a0678be more 2026-03-29 07:02:20 -04:00
KS Jannette
1418e7f97c more 2026-03-29 01:48:55 -04:00
KS Jannette
c0aaaedaf1 more 2026-03-29 01:31:20 -04:00
KS Jannette
3a52e7afb3 more 2026-03-29 00:02:38 -04:00
S Jannette
33ec729631 Merge pull request #29 from kjannette/implement-tiers-end-to-end
Implement tiers end to end
2026-03-28 23:09:45 -04:00
20 changed files with 588 additions and 260 deletions

View File

@@ -1,4 +1,5 @@
Backend startup quickstarat: Backend startup quickstart:
-----------------------------> BEST -----------------------------> BEST
## 1. brew services start postgresql@15 ## 1. brew services start postgresql@15

View File

@@ -10,7 +10,6 @@ import (
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/kjannette/koin-ping/backend/internal/config" "github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/database" "github.com/kjannette/koin-ping/backend/internal/database"
"github.com/kjannette/koin-ping/backend/internal/firebase"
"github.com/kjannette/koin-ping/backend/internal/handlers" "github.com/kjannette/koin-ping/backend/internal/handlers"
"github.com/kjannette/koin-ping/backend/internal/middleware" "github.com/kjannette/koin-ping/backend/internal/middleware"
"github.com/kjannette/koin-ping/backend/internal/models" "github.com/kjannette/koin-ping/backend/internal/models"
@@ -38,10 +37,6 @@ func main() {
log.Fatalf("Failed to connect to database: %v", err) log.Fatalf("Failed to connect to database: %v", err)
} }
if err := firebase.Init(cfg.FirebaseProjectID); err != nil {
log.Fatalf("Failed to initialize Firebase: %v", err)
}
defer database.Close() defer database.Close()
userModel := models.NewUserModel(pool) userModel := models.NewUserModel(pool)
@@ -63,8 +58,9 @@ func main() {
statusHandler := handlers.NewStatusHandler(checkpointModel) statusHandler := handlers.NewStatusHandler(checkpointModel)
stripeHandler := handlers.NewStripeHandler(userModel, cfg) stripeHandler := handlers.NewStripeHandler(userModel, cfg)
accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg) accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg)
authHandler := handlers.NewAuthHandler(userModel, cfg)
authenticate := middleware.Authenticate(userModel) authenticate := middleware.Authenticate(userModel, cfg.JWTSecret)
requireSub := middleware.RequireSubscription(userModel) requireSub := middleware.RequireSubscription(userModel)
// authAndSub chains authentication + subscription check for protected routes. // authAndSub chains authentication + subscription check for protected routes.
@@ -79,6 +75,10 @@ func main() {
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck) mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus) mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus)
// Auth routes (public — no token required)
mux.HandleFunc("POST "+b+"/auth/login", authHandler.Login)
mux.HandleFunc("POST "+b+"/auth/register", authHandler.RegisterAfterCheckout)
// Stripe webhook (public — called by Stripe, not authenticated) // Stripe webhook (public — called by Stripe, not authenticated)
mux.HandleFunc("POST "+b+"/stripe/webhook", stripeHandler.HandleWebhook) mux.HandleFunc("POST "+b+"/stripe/webhook", stripeHandler.HandleWebhook)

View File

@@ -32,6 +32,7 @@ require (
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
@@ -54,12 +55,12 @@ require (
go.opentelemetry.io/otel/sdk v1.39.0 // indirect go.opentelemetry.io/otel/sdk v1.39.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.50.0 // indirect golang.org/x/net v0.51.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect

View File

@@ -61,6 +61,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
@@ -128,18 +130,24 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -147,6 +155,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -155,6 +165,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -0,0 +1,11 @@
-- Migration 010: Add password-based authentication
-- Adds password_hash column, makes firebase_uid optional, ensures email uniqueness.
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255);
ALTER TABLE users ALTER COLUMN firebase_uid DROP NOT NULL;
-- Ensure email is unique so it can serve as the login identifier.
-- Drop the old index first if it exists, then create a unique one.
DROP INDEX IF EXISTS idx_users_email_unique;
CREATE UNIQUE INDEX idx_users_email_unique ON users (email);

View File

@@ -38,6 +38,7 @@ type Config struct {
StripePriceIDPro string StripePriceIDPro string
StripePublishableKey string StripePublishableKey string
FrontendURL string FrontendURL string
JWTSecret string
} }
// Load reads configuration from environment variables and returns a Config. // Load reads configuration from environment variables and returns a Config.
@@ -64,6 +65,7 @@ func Load() (*Config, error) {
StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"), StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"),
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"), StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"), FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
} }
if cfg.PollIntervalMS < minPollIntervalMS { if cfg.PollIntervalMS < minPollIntervalMS {

View File

@@ -63,9 +63,10 @@ func (l TierLimits) ChannelAllowed(channel string) bool {
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
FirebaseUID string `json:"-"` FirebaseUID *string `json:"-"`
Email string `json:"email"` Email string `json:"email"`
DisplayName *string `json:"display_name"` //nolint:tagliatelle DisplayName *string `json:"display_name"` //nolint:tagliatelle
PasswordHash *string `json:"-"`
StripeCustomerID *string `json:"-"` StripeCustomerID *string `json:"-"`
StripeSubscriptionID *string `json:"-"` StripeSubscriptionID *string `json:"-"`
SubscriptionStatus string `json:"subscription_status"` //nolint:tagliatelle SubscriptionStatus string `json:"subscription_status"` //nolint:tagliatelle

View File

@@ -0,0 +1,230 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
"golang.org/x/crypto/bcrypt"
"github.com/kjannette/koin-ping/backend/internal/config"
"github.com/kjannette/koin-ping/backend/internal/domain"
"github.com/kjannette/koin-ping/backend/internal/models"
)
const (
bcryptCost = 12
jwtTTLHours = 72
minPasswordLen = 6
)
type AuthHandler struct {
users *models.UserModel
cfg *config.Config
}
func NewAuthHandler(users *models.UserModel, cfg *config.Config) *AuthHandler {
return &AuthHandler{users: users, cfg: cfg}
}
type authResponse struct {
Token string `json:"token"`
UserID string `json:"user_id"` //nolint:tagliatelle
Email string `json:"email"`
SubscriptionStatus string `json:"subscription_status"` //nolint:tagliatelle
SubscriptionTier string `json:"subscription_tier"` //nolint:tagliatelle
}
func (h *AuthHandler) issueJWT(userID, email string) (string, error) {
claims := jwt.MapClaims{
"sub": userID,
"email": email,
"iat": time.Now().Unix(),
"exp": time.Now().Add(jwtTTLHours * time.Hour).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(h.cfg.JWTSecret))
}
// Login authenticates an existing user by email + password.
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
return
}
if body.Email == "" || body.Password == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Email and password are required")
return
}
user, err := h.users.FindByEmail(r.Context(), body.Email)
if err != nil {
log.Printf("Login: DB error looking up %s: %v", body.Email, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Login failed")
return
}
if user == nil {
writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid email or password")
return
}
if user.PasswordHash == nil || *user.PasswordHash == "" {
writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid email or password")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(body.Password)); err != nil {
writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid email or password")
return
}
token, err := h.issueJWT(user.ID, user.Email)
if err != nil {
log.Printf("Login: failed to issue JWT for %s: %v", user.ID, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Login failed")
return
}
log.Printf("Login successful for user %s (%s)", user.ID, user.Email)
writeJSON(w, http.StatusOK, authResponse{
Token: token,
UserID: user.ID,
Email: user.Email,
SubscriptionStatus: user.SubscriptionStatus,
SubscriptionTier: string(user.SubscriptionTier),
})
}
// RegisterAfterCheckout creates a new user account after a successful Stripe
// checkout. It verifies the Stripe session was paid, hashes the password,
// inserts the user into Postgres, links the Stripe customer/subscription,
// and returns a JWT so the frontend is immediately authenticated.
func (h *AuthHandler) RegisterAfterCheckout(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
SessionID string `json:"session_id"` //nolint:tagliatelle
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid request body")
return
}
if body.Email == "" || body.Password == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Email and password are required")
return
}
if len(body.Password) < minPasswordLen {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Password must be at least 6 characters")
return
}
existing, err := h.users.FindByEmail(r.Context(), body.Email)
if err != nil {
log.Printf("Register: DB error looking up %s: %v", body.Email, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Registration failed")
return
}
if existing != nil {
writeError(w, http.StatusConflict, "EMAIL_IN_USE", "An account with this email already exists")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcryptCost)
if err != nil {
log.Printf("Register: bcrypt error: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Registration failed")
return
}
user, err := h.users.CreateWithPassword(r.Context(), body.Email, string(hash))
if err != nil {
log.Printf("Register: failed to create user %s: %v", body.Email, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Registration failed")
return
}
// If a Stripe session_id was provided (paid tier checkout), link the
// Stripe customer and activate the subscription immediately.
if body.SessionID != "" {
h.linkStripeSession(r, user.ID, body.SessionID)
}
// Re-fetch to pick up updated subscription fields after Stripe link.
user, err = h.users.GetByID(r.Context(), user.ID)
if err != nil || user == nil {
log.Printf("Register: failed to re-fetch user %s: %v", body.Email, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Registration failed")
return
}
token, err := h.issueJWT(user.ID, user.Email)
if err != nil {
log.Printf("Register: failed to issue JWT for %s: %v", user.ID, err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Registration failed")
return
}
log.Printf("Registration successful for user %s (%s)", user.ID, user.Email)
writeJSON(w, http.StatusOK, authResponse{
Token: token,
UserID: user.ID,
Email: user.Email,
SubscriptionStatus: user.SubscriptionStatus,
SubscriptionTier: string(user.SubscriptionTier),
})
}
// linkStripeSession retrieves the Stripe checkout session by ID, verifies
// payment, and writes the Stripe customer + subscription to the user record.
func (h *AuthHandler) linkStripeSession(r *http.Request, userID, sessionID string) {
s, err := checkoutsession.Get(sessionID, nil)
if err != nil {
log.Printf("linkStripeSession: failed to retrieve session %s: %v", sessionID, err)
return
}
fullJSON, _ := json.MarshalIndent(s, "", " ")
log.Printf("STRIPE REGISTER LINK — FULL SESSION:\n%s", string(fullJSON))
if s.PaymentStatus != "paid" {
log.Printf("linkStripeSession: session %s not paid (status=%s)", sessionID, s.PaymentStatus)
return
}
tier := domain.TierPremium
if t, ok := s.Metadata["tier"]; ok && domain.IsValidTier(t) {
tier = domain.SubscriptionTier(t)
}
customerID := ""
if s.Customer != nil {
customerID = s.Customer.ID
}
subscriptionID := ""
if s.Subscription != nil {
subscriptionID = s.Subscription.ID
}
if customerID != "" {
if err := h.users.UpdateStripeCustomer(r.Context(), userID, customerID); err != nil {
log.Printf("linkStripeSession: failed to save customer ID: %v", err)
}
}
if subscriptionID != "" && customerID != "" {
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active", tier); err != nil {
log.Printf("linkStripeSession: failed to activate subscription: %v", err)
}
}
log.Printf("linkStripeSession: linked user %s → customer %s, subscription %s, tier %s",
userID, customerID, subscriptionID, tier)
}

View File

@@ -145,6 +145,10 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
return return
} }
if fullJSON, marshalErr := json.MarshalIndent(s, "", " "); marshalErr == nil {
log.Printf("STRIPE VERIFY CHECKOUT — FULL SESSION:\n%s", string(fullJSON))
}
if s.ClientReferenceID != "" && s.ClientReferenceID != userID { if s.ClientReferenceID != "" && s.ClientReferenceID != userID {
writeError(w, http.StatusForbidden, "FORBIDDEN", "Session does not belong to this user") writeError(w, http.StatusForbidden, "FORBIDDEN", "Session does not belong to this user")
return return
@@ -260,6 +264,10 @@ func (h *StripeHandler) CreateOnboardingCheckout(w http.ResponseWriter, r *http.
return return
} }
if fullJSON, marshalErr := json.MarshalIndent(s, "", " "); marshalErr == nil {
log.Printf("STRIPE ONBOARDING CHECKOUT CREATED — FULL SESSION:\n%s", string(fullJSON))
}
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL}) writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
} }
@@ -327,6 +335,8 @@ func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
} }
func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Event) { func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Event) {
log.Printf("STRIPE CHECKOUT COMPLETED — FULL RAW PAYLOAD:\n%s", string(event.Data.Raw))
var session stripe.CheckoutSession var session stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &session); err != nil { if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
log.Printf("Error parsing checkout session: %v", err) log.Printf("Error parsing checkout session: %v", err)

View File

@@ -8,7 +8,8 @@ import (
"net/http" "net/http"
"strings" "strings"
fbauth "github.com/kjannette/koin-ping/backend/internal/firebase" "github.com/golang-jwt/jwt/v5"
"github.com/kjannette/koin-ping/backend/internal/models" "github.com/kjannette/koin-ping/backend/internal/models"
) )
@@ -31,10 +32,9 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) {
json.NewEncoder(w).Encode(v) //nolint:errcheck json.NewEncoder(w).Encode(v) //nolint:errcheck
} }
// Authenticate verifies the Firebase ID token and auto-provisions a local user // Authenticate verifies the JWT from the Authorization header, loads the local
// record. The local user UUID (not the Firebase UID) is placed into context so // user from Postgres, and injects the user UUID + email + tier into context.
// all downstream handlers use it as the canonical user identifier. func Authenticate(userModel *models.UserModel, jwtSecret string) func(http.Handler) http.Handler {
func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
@@ -47,9 +47,8 @@ func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
return return
} }
token := strings.TrimPrefix(authHeader, "Bearer ") rawToken := strings.TrimPrefix(authHeader, "Bearer ")
if token == "" { if rawToken == "" {
log.Println("Empty token")
writeJSON(w, http.StatusUnauthorized, errorResponse{ writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED", Error: "UNAUTHORIZED",
Message: "Invalid token format", Message: "Invalid token format",
@@ -57,12 +56,19 @@ func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
return return
} }
log.Println("Verifying Firebase token...") parsed, err := jwt.Parse(rawToken, func(t *jwt.Token) (interface{}, error) {
decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token) if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
if err != nil { return nil, jwt.ErrSignatureInvalid
log.Printf("Token verification failed: %v", err) }
return []byte(jwtSecret), nil
})
if err != nil || !parsed.Valid {
log.Printf("JWT verification failed: %v", err)
errMsg := err.Error() errMsg := ""
if err != nil {
errMsg = err.Error()
}
if strings.Contains(errMsg, "expired") { if strings.Contains(errMsg, "expired") {
writeJSON(w, http.StatusUnauthorized, errorResponse{ writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "TOKEN_EXPIRED", Error: "TOKEN_EXPIRED",
@@ -78,20 +84,37 @@ func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
return return
} }
firebaseUID := decoded.UID claims, ok := parsed.Claims.(jwt.MapClaims)
email, _ := decoded.Claims["email"].(string) if !ok {
writeJSON(w, http.StatusUnauthorized, errorResponse{
user, err := userModel.FindOrCreateByFirebaseUID(r.Context(), firebaseUID, email) Error: "UNAUTHORIZED",
if err != nil { Message: "Invalid token claims",
log.Printf("Failed to provision local user for Firebase UID %s: %v", firebaseUID, err)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "Failed to initialize user account",
}) })
return return
} }
log.Printf("Token verified! User UUID: %s, Email: %s", user.ID, email) userID, _ := claims["sub"].(string)
email, _ := claims["email"].(string)
if userID == "" {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "Invalid token: missing user ID",
})
return
}
user, err := userModel.GetByID(r.Context(), userID)
if err != nil || user == nil {
log.Printf("JWT auth: user %s not found in DB: %v", userID, err)
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "User account not found",
})
return
}
log.Printf("JWT verified — User UUID: %s, Email: %s", user.ID, email)
ctx := context.WithValue(r.Context(), UserIDKey, user.ID) ctx := context.WithValue(r.Context(), UserIDKey, user.ID)
ctx = context.WithValue(ctx, UserEmailKey, email) ctx = context.WithValue(ctx, UserEmailKey, email)

View File

@@ -17,14 +17,14 @@ func NewUserModel(pool *pgxpool.Pool) *UserModel {
return &UserModel{pool: pool} return &UserModel{pool: pool}
} }
const userColumns = `id, firebase_uid, email, display_name, const userColumns = `id, firebase_uid, email, display_name, password_hash,
stripe_customer_id, stripe_subscription_id, subscription_status, stripe_customer_id, stripe_subscription_id, subscription_status,
subscription_tier, subscription_created_at, created_at, updated_at` subscription_tier, subscription_created_at, created_at, updated_at`
func scanUser(row pgx.Row) (*domain.User, error) { func scanUser(row pgx.Row) (*domain.User, error) {
var u domain.User var u domain.User
err := row.Scan( err := row.Scan(
&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.PasswordHash,
&u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus, &u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
&u.SubscriptionTier, &u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt, &u.SubscriptionTier, &u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
) )
@@ -51,6 +51,23 @@ func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID,
return scanUser(row) return scanUser(row)
} }
func (m *UserModel) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
row := m.pool.QueryRow(ctx,
`SELECT `+userColumns+` FROM users WHERE email = $1`, email,
)
return scanUser(row)
}
func (m *UserModel) CreateWithPassword(ctx context.Context, email, passwordHash string) (*domain.User, error) {
row := m.pool.QueryRow(ctx,
`INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING `+userColumns,
email, passwordHash,
)
return scanUser(row)
}
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) { func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
row := m.pool.QueryRow(ctx, row := m.pool.QueryRow(ctx,
`SELECT `+userColumns+` FROM users WHERE id = $1`, id, `SELECT `+userColumns+` FROM users WHERE id = $1`, id,

View File

@@ -2,7 +2,6 @@ import { Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "./contexts/AuthContext"; import { useAuth } from "./contexts/AuthContext";
import Navbar from "./components/Navbar"; import Navbar from "./components/Navbar";
import Login from "./pages/login/Login"; import Login from "./pages/login/Login";
import Signup from "./pages/Signup";
import Subscribe from "./pages/subscribe/Subscribe"; import Subscribe from "./pages/subscribe/Subscribe";
import Addresses from "./pages/addresses/Addresses"; import Addresses from "./pages/addresses/Addresses";
import Alerts from "./pages/alerts/Alerts"; import Alerts from "./pages/alerts/Alerts";
@@ -10,19 +9,20 @@ import AlertHistory from "./pages/alertHistory/AlertHistory";
import Account from "./pages/user_account/Account"; import Account from "./pages/user_account/Account";
export default function App() { export default function App() {
const { currentUser, isSubscribed } = useAuth(); const { isAuthenticated, isSubscribed, user } = useAuth();
if (!currentUser) { // Unauthenticated: show login + onboarding routes only
if (!isAuthenticated) {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/subscribe" element={<Subscribe />} /> <Route path="/subscribe" element={<Subscribe />} />
<Route path="*" element={<Navigate to="/login" />} /> <Route path="*" element={<Navigate to="/login" />} />
</Routes> </Routes>
); );
} }
// Authenticated but no active subscription: force through subscribe flow
if (!isSubscribed) { if (!isSubscribed) {
return ( return (
<Routes> <Routes>
@@ -33,6 +33,7 @@ export default function App() {
); );
} }
// Fully authenticated + subscribed: main app
return ( return (
<div> <div>
<Navbar /> <Navbar />

27
frontend/src/api/auth.js Normal file
View File

@@ -0,0 +1,27 @@
import { API_BASE } from "./config";
export async function loginUser(email, password) {
const res = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || "Login failed");
}
return data;
}
export async function registerAfterCheckout(email, password, sessionId) {
const res = await fetch(`${API_BASE}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, session_id: sessionId || "" }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || "Registration failed");
}
return data;
}

View File

@@ -1,46 +1,31 @@
/** /**
* Auth Headers Helper * Auth Headers Helper
* *
* Provides authentication headers for API calls * Provides authentication headers for API calls.
* Includes Firebase ID token in Authorization header * Reads the JWT from localStorage (set by AuthContext on login).
*/ */
import { auth } from "../firebase/config"; export function getAuthHeaders() {
const token = localStorage.getItem("kp_token");
/** if (!token) {
* Get headers with authentication token throw new Error("No authenticated user");
* @returns {Promise<Object>} Headers object with Authorization }
*/
export async function getAuthHeaders() {
const currentUser = auth.currentUser;
if (!currentUser) { return {
throw new Error("No authenticated user"); "Content-Type": "application/json",
} Authorization: `Bearer ${token}`,
};
// Get Firebase ID token
const token = await currentUser.getIdToken();
return {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
} }
/** export function getAuthHeadersSimple() {
* Get headers for non-JSON requests (e.g., DELETE with no body) const token = localStorage.getItem("kp_token");
* @returns {Promise<Object>} Headers object with Authorization
*/
export async function getAuthHeadersSimple() {
const currentUser = auth.currentUser;
if (!currentUser) { if (!token) {
throw new Error("No authenticated user"); throw new Error("No authenticated user");
} }
const token = await currentUser.getIdToken(); return {
Authorization: `Bearer ${token}`,
return { };
Authorization: `Bearer ${token}`,
};
} }

View File

@@ -11,11 +11,11 @@ const navLinks = [
]; ];
export default function Navbar() { export default function Navbar() {
const { currentUser, logout } = useAuth(); const { user, logout } = useAuth();
const location = useLocation(); const location = useLocation();
const [isNavPanelOpen, setIsNavPanelOpen] = useState(false); const [isNavPanelOpen, setIsNavPanelOpen] = useState(false);
if (!currentUser) return null; if (!user) return null;
return ( return (
<> <>
@@ -24,7 +24,7 @@ export default function Navbar() {
<div className="navbar__brand-group"> <div className="navbar__brand-group">
<span className="navbar__brand">Koin Ping</span> <span className="navbar__brand">Koin Ping</span>
<Link to="/account" className="navbar__user-link navbar__user-link--mobile"> <Link to="/account" className="navbar__user-link navbar__user-link--mobile">
{currentUser.email} {user.email}
</Link> </Link>
</div> </div>
<div className="navbar__links"> <div className="navbar__links">
@@ -45,7 +45,7 @@ export default function Navbar() {
<div className="navbar__right"> <div className="navbar__right">
<Link to="/account" className="navbar__user navbar__user-link navbar__user-link--desktop"> <Link to="/account" className="navbar__user navbar__user-link navbar__user-link--desktop">
{currentUser.email} {user.email}
</Link> </Link>
<button onClick={logout} className="navbar__logout"> <button onClick={logout} className="navbar__logout">
Logout Logout

View File

@@ -1,140 +1,98 @@
/** import { createContext, useReducer, useContext, useEffect, useCallback, useRef } from "react";
* AuthContext - Firebase Authentication State Management import authReducer, { initialState, ACTION_TYPES } from "../reducers/authReducer";
* import { loginUser, registerAfterCheckout } from "../api/auth";
* Provides authentication state, tier info, and methods throughout the app
*/
import { createContext, useContext, useEffect, useState, useCallback } from "react";
import {
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signOut,
onAuthStateChanged,
} from "firebase/auth";
import { auth } from "../firebase/config";
import { getAccount } from "../api/account"; import { getAccount } from "../api/account";
const AuthContext = createContext(); const AuthContext = createContext(null);
const DEFAULT_TIER_LIMITS = { export function AuthProvider({ children }) {
const [state, dispatch] = useReducer(authReducer, initialState);
const fetchedRef = useRef(false);
const fetchAccount = useCallback(async () => {
try {
const account = await getAccount();
dispatch({
type: ACTION_TYPES.SET_USER,
payload: {
id: account.user_id,
email: account.email,
subscriptionStatus: account.subscription_status,
subscriptionTier: account.subscription_tier,
tierLimits: account.tier_limits,
addressCount: account.address_count,
},
});
} catch {
dispatch({ type: ACTION_TYPES.LOGOUT });
}
}, []);
// On mount, if we have a token in localStorage, hydrate user from backend.
useEffect(() => {
if (!state.token || fetchedRef.current) return;
fetchedRef.current = true;
fetchAccount();
}, [state.token, fetchAccount]);
async function login(email, password) {
const data = await loginUser(email, password);
dispatch({ type: ACTION_TYPES.LOGIN_SUCCESS, payload: data });
fetchedRef.current = false;
return data;
}
async function register(email, password, sessionId) {
const data = await registerAfterCheckout(email, password, sessionId);
dispatch({ type: ACTION_TYPES.LOGIN_SUCCESS, payload: data });
fetchedRef.current = false;
return data;
}
function logout() {
fetchedRef.current = false;
dispatch({ type: ACTION_TYPES.LOGOUT });
}
function refreshAccount() {
fetchAccount();
}
const isAuthenticated = state.isAuthenticated && !!state.token;
const isSubscribed =
state.user?.subscriptionStatus === "active" ||
state.user?.subscriptionStatus === "trialing";
const tierLimits = state.user?.tierLimits || {
max_addresses: 1, max_addresses: 1,
max_alert_types: 1, max_alert_types: 1,
allowed_channels: ["email"], allowed_channels: ["email"],
}; };
const value = {
user: state.user,
token: state.token,
isAuthenticated,
isSubscribed,
tierLimits,
userTier: state.user?.subscriptionTier || "free",
login,
register,
logout,
refreshAccount,
dispatch,
ACTION_TYPES,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
/**
* Hook to access auth context
* @returns {Object} Auth context value
*/
export function useAuth() { export function useAuth() {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (!context) { if (!context) {
throw new Error("useAuth must be used within AuthProvider"); throw new Error("useAuth must be used within an AuthProvider");
} }
return context; return context;
} }
/** export default useAuth;
* AuthProvider - Wraps app and provides auth state + tier info
*/
export function AuthProvider({ children }) {
const [currentUser, setCurrentUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [userTier, setUserTier] = useState("free");
const [tierLimits, setTierLimits] = useState(DEFAULT_TIER_LIMITS);
const [addressCount, setAddressCount] = useState(0);
const [subscriptionStatus, setSubscriptionStatus] = useState("none");
const refreshAccount = useCallback(async () => {
try {
const data = await getAccount();
setUserTier(data.subscription_tier || "free");
setTierLimits(data.tier_limits || DEFAULT_TIER_LIMITS);
setAddressCount(data.address_count || 0);
setSubscriptionStatus(data.subscription_status || "none");
} catch {
// account fetch can fail during onboarding before subscription is active
}
}, []);
async function signup(email, password) {
try {
setError(null);
const result = await createUserWithEmailAndPassword(
auth,
email,
password,
);
return result.user;
} catch (err) {
setError(err.message);
throw err;
}
}
async function login(email, password) {
try {
setError(null);
const result = await signInWithEmailAndPassword(
auth,
email,
password,
);
return result.user;
} catch (err) {
setError(err.message);
throw err;
}
}
async function logout() {
try {
setError(null);
setUserTier("free");
setTierLimits(DEFAULT_TIER_LIMITS);
setAddressCount(0);
setSubscriptionStatus("none");
await signOut(auth);
} catch (err) {
setError(err.message);
throw err;
}
}
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
setCurrentUser(user);
setLoading(false);
if (user) {
refreshAccount();
}
});
return unsubscribe;
}, [refreshAccount]);
const isSubscribed =
subscriptionStatus === "active" || subscriptionStatus === "trialing";
const value = {
currentUser,
signup,
login,
logout,
error,
loading,
userTier,
tierLimits,
addressCount,
subscriptionStatus,
isSubscribed,
refreshAccount,
};
return (
<AuthContext.Provider value={value}>
{!loading && children}
</AuthContext.Provider>
);
}

View File

@@ -27,7 +27,11 @@ export default function Login() {
await login(email, password); await login(email, password);
navigate("/addresses"); navigate("/addresses");
} catch (err) { } catch (err) {
setError("Failed to log in: " + err.message); if (err.message.includes("Invalid email or password")) {
setError("Invalid email or password. Check your credentials or create a new account.");
} else {
setError("Failed to log in: " + err.message);
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -80,7 +84,7 @@ export default function Login() {
<div className="login-footer"> <div className="login-footer">
<p className="text-muted"> <p className="text-muted">
Don't have an account?{" "} Don't have an account?{" "}
<Link to="/signup" className="login-signup-link"> <Link to="/subscribe" className="login-signup-link">
Sign up here Sign up here
</Link> </Link>
</p> </p>

View File

@@ -1,11 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
import { import { createOnboardingCheckout, activateFreeTier } from "../../api/stripe";
createOnboardingCheckout,
verifyCheckoutSession,
activateFreeTier,
} from "../../api/stripe";
import Input from "../../components/Input"; import Input from "../../components/Input";
import Button from "../../components/Button"; import Button from "../../components/Button";
import TierPicker from "../../components/TierPicker"; import TierPicker from "../../components/TierPicker";
@@ -14,12 +10,12 @@ import "./Subscribe.css";
const STEPS = ["Create Account", "Choose Plan"]; const STEPS = ["Create Account", "Choose Plan"];
export default function Subscribe() { export default function Subscribe() {
const { currentUser, signup } = useAuth(); const { isAuthenticated, register } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const hasPaymentReturn = searchParams.get("payment") === "success"; const hasPaymentReturn = searchParams.get("payment") === "success";
const [step, setStep] = useState(hasPaymentReturn || currentUser ? 2 : 1); const [step, setStep] = useState(hasPaymentReturn || isAuthenticated ? 2 : 1);
const [loading, setLoading] = useState(hasPaymentReturn); const [loading, setLoading] = useState(hasPaymentReturn);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -30,12 +26,17 @@ export default function Subscribe() {
confirmPassword: "", confirmPassword: "",
}); });
const registerCalledRef = useRef(false);
function set(field, value) { function set(field, value) {
setData((prev) => ({ ...prev, [field]: value })); setData((prev) => ({ ...prev, [field]: value }));
} }
// Handle Stripe payment returns // Handle return from Stripe checkout redirect.
// The ref guard prevents React StrictMode from double-firing this.
useEffect(() => { useEffect(() => {
if (registerCalledRef.current) return;
const payment = searchParams.get("payment"); const payment = searchParams.get("payment");
const sessionId = searchParams.get("session_id"); const sessionId = searchParams.get("session_id");
@@ -48,44 +49,34 @@ export default function Subscribe() {
if (payment !== "success" || !sessionId) return; if (payment !== "success" || !sessionId) return;
// Phase 1: no account yet — create it. Auth state change remounts the const savedEmail = sessionStorage.getItem("kp_onboard_email");
// component (App.jsx swaps route trees) and Phase 2 runs on the next mount. const savedPassword = sessionStorage.getItem("kp_onboard_password");
if (!currentUser) {
const savedEmail = sessionStorage.getItem("kp_onboard_email"); if (!savedEmail || !savedPassword) {
const savedPw = sessionStorage.getItem("kp_onboard_pw"); setSearchParams({}, { replace: true });
if (!savedEmail || !savedPw) { setError("Session expired. Please start the signup process again.");
setSearchParams({}, { replace: true }); setStep(1);
setError("Session expired. Please start the signup process again."); setLoading(false);
setStep(1);
setLoading(false);
return;
}
setLoading(true);
signup(savedEmail, savedPw).catch((err) => {
setSearchParams({}, { replace: true });
setError("Account creation failed: " + err.message);
setStep(1);
setLoading(false);
});
return; return;
} }
// Phase 2: authenticated — verify the checkout and go to the dashboard. registerCalledRef.current = true;
setSearchParams({}, { replace: true }); setSearchParams({}, { replace: true });
setLoading(true); setLoading(true);
sessionStorage.removeItem("kp_onboard_email");
sessionStorage.removeItem("kp_onboard_pw");
verifyCheckoutSession(sessionId) register(savedEmail, savedPassword, sessionId)
.then(() => { .then(() => {
sessionStorage.removeItem("kp_onboard_email");
sessionStorage.removeItem("kp_onboard_password");
navigate("/addresses", { replace: true }); navigate("/addresses", { replace: true });
}) })
.catch((err) => { .catch((err) => {
setError("Payment verification failed: " + err.message); registerCalledRef.current = false;
setStep(2); setError("Registration failed: " + err.message);
setStep(1);
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [currentUser, searchParams, setSearchParams, signup, navigate]); }, []); // eslint-disable-line react-hooks/exhaustive-deps
// ── Step handlers ───────────────────────────────────────────────────────── // ── Step handlers ─────────────────────────────────────────────────────────
@@ -115,29 +106,28 @@ export default function Subscribe() {
try { try {
setLoading(true); setLoading(true);
// Free tier: register immediately, then activate free tier
if (data.selectedTier === "free") { if (data.selectedTier === "free") {
if (!currentUser) { if (!isAuthenticated) {
await signup(data.email, data.password); await register(data.email, data.password, "");
} }
await activateFreeTier(); await activateFreeTier();
navigate("/addresses", { replace: true }); navigate("/addresses", { replace: true });
return; return;
} }
// Paid tier: stash credentials in sessionStorage, then redirect to Stripe
sessionStorage.setItem("kp_onboard_email", data.email); sessionStorage.setItem("kp_onboard_email", data.email);
sessionStorage.setItem("kp_onboard_pw", data.password); sessionStorage.setItem("kp_onboard_password", data.password);
const { url } = await createOnboardingCheckout( const { url } = await createOnboardingCheckout(
data.email, data.email,
data.selectedTier, data.selectedTier,
); );
window.location.href = url; window.location.href = url;
} catch (err) { } catch (err) {
if (err.code === "auth/email-already-in-use") { if (err.message.includes("already exists")) {
setError("Email already in use. Try logging in instead."); setError("An account with this email already exists. Try logging in instead.");
} else if (err.code === "auth/invalid-email") {
setError("Invalid email address");
} else if (err.code === "auth/weak-password") {
setError("Password is too weak");
} else { } else {
setError("Failed to process plan selection: " + err.message); setError("Failed to process plan selection: " + err.message);
} }
@@ -268,7 +258,7 @@ export default function Subscribe() {
return ( return (
<div className="subscribe__footer"> <div className="subscribe__footer">
<div> <div>
{step === 2 && ( {step === 2 && !isAuthenticated && (
<Button onClick={handleBack} disabled={loading} variant="ghost"> <Button onClick={handleBack} disabled={loading} variant="ghost">
Back Back
</Button> </Button>

View File

@@ -1,7 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { updatePassword } from "firebase/auth";
import { auth } from "../../firebase/config";
import { getAccount, createPortalSession } from "../../api/account"; import { getAccount, createPortalSession } from "../../api/account";
import "./Account.css"; import "./Account.css";
@@ -55,7 +53,8 @@ export default function Account() {
try { try {
setChangingPassword(true); setChangingPassword(true);
await updatePassword(auth.currentUser, newPassword); // TODO: implement password change endpoint on backend
throw new Error("Password change not yet implemented");
setPasswordMsg("Password updated successfully"); setPasswordMsg("Password updated successfully");
setNewPassword(""); setNewPassword("");
setConfirmPassword(""); setConfirmPassword("");

View File

@@ -0,0 +1,56 @@
export const ACTION_TYPES = {
LOGIN_SUCCESS: "LOGIN_SUCCESS",
SET_USER: "SET_USER",
LOGOUT: "LOGOUT",
AUTH_ERROR: "AUTH_ERROR",
};
export const initialState = {
user: null,
token: localStorage.getItem("kp_token") || null,
isAuthenticated: !!localStorage.getItem("kp_token"),
error: null,
};
export default function authReducer(state, action) {
switch (action.type) {
case ACTION_TYPES.LOGIN_SUCCESS:
localStorage.setItem("kp_token", action.payload.token);
return {
...state,
user: {
id: action.payload.user_id,
email: action.payload.email,
subscriptionStatus: action.payload.subscription_status,
subscriptionTier: action.payload.subscription_tier,
},
token: action.payload.token,
isAuthenticated: true,
error: null,
};
case ACTION_TYPES.SET_USER:
return {
...state,
user: action.payload,
error: null,
};
case ACTION_TYPES.LOGOUT:
localStorage.removeItem("kp_token");
return {
...initialState,
token: null,
isAuthenticated: false,
};
case ACTION_TYPES.AUTH_ERROR:
return {
...state,
error: action.payload,
};
default:
return state;
}
}