Compare commits

...

15 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
KS Jannette
0d7bc65995 adjust flow 2026-03-28 23:09:03 -04:00
KS Jannette
05453895b9 m 2026-03-28 22:54:52 -04:00
S Jannette
14ed0a23a6 Merge pull request #28 from kjannette/mega-blast
Mega blast
2026-03-28 22:34:56 -04:00
KS Jannette
40ed4f6afd m 2026-03-28 22:34:23 -04:00
KS Jannette
26324150d2 removed F up 2026-03-28 11:58:29 -04:00
KS Jannette
b0572451d3 but 2026-03-28 11:55:51 -04:00
S Jannette
d9c3bd1db5 Merge pull request #27 from kjannette/setup-free-tier
Setup free tier
2026-03-28 11:27:18 -04:00
37 changed files with 919 additions and 891 deletions

2
.gitignore vendored
View File

@@ -22,6 +22,8 @@ node_modules/
# Go build artifacts # Go build artifacts
backend/bin/ backend/bin/
backend/api
backend/poller
*.exe *.exe
*.exe~ *.exe~
*.dll *.dll

View File

@@ -1,12 +1,18 @@
Backend startup quickstarat: Backend startup quickstart:
-----------------------------> BEST
## 1. brew services start postgresql@15 ## 1. brew services start postgresql@15
OR OR
/opt/homebrew/opt/postgresql@15/bin/pg_ctl -D /opt/homebrew/var/postgresql@15 start /opt/homebrew/opt/postgresql@15/bin/pg_ctl -D /opt/homebrew/var/postgresql@15 start
## 2. From the backend directory, you have a few options: ## 2. ALLIN ONE:
Make dev-all — Runs both the API and poller concurrently.
-----------------------------> BEST
- OR -
## 3. Option 1: Single command (both API + poller) ## 3. Option 1: Single command (both API + poller)
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
@@ -25,5 +31,3 @@ Backend startup quickstarat:
make poller — Builds and runs the poller. make poller — Builds and runs the poller.
make poller-dev — Runs the poller with auto-reload. make poller-dev — Runs the poller with auto-reload.
## 6. ALLIN ONE:
Make dev-all — Runs both the API and poller concurrently.

Binary file not shown.

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

@@ -51,6 +51,7 @@ func main() {
defer database.Close() defer database.Close()
userModel := models.NewUserModel(pool)
addressModel := models.NewAddressModel(pool) addressModel := models.NewAddressModel(pool)
alertRuleModel := models.NewAlertRuleModel(pool) alertRuleModel := models.NewAlertRuleModel(pool)
alertEventModel := models.NewAlertEventModel(pool) alertEventModel := models.NewAlertEventModel(pool)
@@ -59,7 +60,7 @@ func main() {
observer := services.NewObserverService(eth, addressModel, checkpointModel) observer := services.NewObserverService(eth, addressModel, checkpointModel)
evaluator := services.NewEvaluatorService( evaluator := services.NewEvaluatorService(
eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel, eth, alertRuleModel, alertEventModel, addressModel, userModel, notifConfigModel,
) )
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel) digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)

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 {
@@ -94,6 +96,19 @@ func (c *Config) DSN() string {
) )
} }
// TierForPriceID maps a Stripe price ID back to the corresponding
// subscription tier. Returns empty string if the price is unrecognised.
func (c *Config) TierForPriceID(priceID string) string {
switch priceID {
case c.StripePriceIDPremium:
return "premium"
case c.StripePriceIDPro:
return "pro"
default:
return ""
}
}
func getEnv(key, fallback string) string { func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
return v return v

View File

@@ -0,0 +1,28 @@
package config
import "testing"
func TestTierForPriceID(t *testing.T) {
t.Parallel()
cfg := &Config{
StripePriceIDPremium: "price_premium_123",
StripePriceIDPro: "price_pro_456",
}
tests := []struct {
priceID string
want string
}{
{"price_premium_123", "premium"},
{"price_pro_456", "pro"},
{"price_unknown", ""},
{"", ""},
}
for _, tt := range tests {
if got := cfg.TierForPriceID(tt.priceID); got != tt.want {
t.Errorf("TierForPriceID(%q) = %q, want %q", tt.priceID, got, tt.want)
}
}
}

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,104 @@
package domain
import "testing"
func TestIsValidTier(t *testing.T) {
t.Parallel()
valid := []string{"free", "premium", "pro"}
for _, tier := range valid {
if !IsValidTier(tier) {
t.Errorf("expected %q to be valid", tier)
}
}
invalid := []string{"", "basic", "enterprise", "FREE", "Pro"}
for _, tier := range invalid {
if IsValidTier(tier) {
t.Errorf("expected %q to be invalid", tier)
}
}
}
func TestGetTierLimits_Free(t *testing.T) {
t.Parallel()
limits := GetTierLimits(TierFree)
if limits.MaxAddresses != 1 {
t.Errorf("free MaxAddresses = %d, want 1", limits.MaxAddresses)
}
if limits.MaxAlertTypes != 1 {
t.Errorf("free MaxAlertTypes = %d, want 1", limits.MaxAlertTypes)
}
if len(limits.AllowedChannels) != 1 || limits.AllowedChannels[0] != "email" {
t.Errorf("free AllowedChannels = %v, want [email]", limits.AllowedChannels)
}
if limits.IsUnlimitedAddresses() {
t.Error("free should not have unlimited addresses")
}
if limits.IsUnlimitedAlertTypes() {
t.Error("free should not have unlimited alert types")
}
}
func TestGetTierLimits_Premium(t *testing.T) {
t.Parallel()
limits := GetTierLimits(TierPremium)
if limits.MaxAddresses != 3 {
t.Errorf("premium MaxAddresses = %d, want 3", limits.MaxAddresses)
}
if limits.MaxAlertTypes != 2 {
t.Errorf("premium MaxAlertTypes = %d, want 2", limits.MaxAlertTypes)
}
if !limits.ChannelAllowed("email") {
t.Error("premium should allow email")
}
if !limits.ChannelAllowed("discord") {
t.Error("premium should allow discord")
}
if !limits.ChannelAllowed("telegram") {
t.Error("premium should allow telegram")
}
if limits.ChannelAllowed("slack") {
t.Error("premium should NOT allow slack")
}
}
func TestGetTierLimits_Pro(t *testing.T) {
t.Parallel()
limits := GetTierLimits(TierPro)
if !limits.IsUnlimitedAddresses() {
t.Error("pro should have unlimited addresses")
}
if !limits.IsUnlimitedAlertTypes() {
t.Error("pro should have unlimited alert types")
}
for _, ch := range []string{"email", "discord", "telegram", "slack"} {
if !limits.ChannelAllowed(ch) {
t.Errorf("pro should allow %s", ch)
}
}
}
func TestGetTierLimits_Unknown(t *testing.T) {
t.Parallel()
limits := GetTierLimits(SubscriptionTier("unknown"))
if limits.MaxAddresses != 1 {
t.Errorf("unknown tier should default to free limits, got MaxAddresses=%d", limits.MaxAddresses)
}
}
func TestChannelAllowed_NotInList(t *testing.T) {
t.Parallel()
limits := GetTierLimits(TierFree)
if limits.ChannelAllowed("discord") {
t.Error("free should not allow discord")
}
if limits.ChannelAllowed("nonexistent") {
t.Error("nonexistent channel should not be allowed")
}
}

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)
@@ -384,11 +394,26 @@ func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.
} }
status := string(sub.Status) status := string(sub.Status)
if err := h.users.UpdateSubscriptionStatus(r.Context(), customerID, status); err != nil {
log.Printf("Failed to update subscription status: %v", err) // Detect tier from the subscription's current price so that
// upgrades/downgrades via the Stripe portal are reflected.
tier := domain.TierPremium
if sub.Items != nil {
for _, item := range sub.Items.Data {
if item.Price != nil {
if t := h.cfg.TierForPriceID(item.Price.ID); t != "" {
tier = domain.SubscriptionTier(t)
break
}
}
}
} }
log.Printf("Subscription %s updated to %s for customer %s", sub.ID, status, customerID) if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status, tier); err != nil {
log.Printf("Failed to update subscription: %v", err)
}
log.Printf("Subscription %s updated to %s (tier %s) for customer %s", sub.ID, status, tier, customerID)
} }
func (h *StripeHandler) handleSubscriptionDeleted(r *http.Request, event stripe.Event) { func (h *StripeHandler) handleSubscriptionDeleted(r *http.Request, event stripe.Event) {

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

@@ -0,0 +1,60 @@
package middleware
import (
"context"
"testing"
)
func TestGetUserID_Empty(t *testing.T) {
t.Parallel()
ctx := context.Background()
if id := GetUserID(ctx); id != "" {
t.Errorf("expected empty user ID, got %q", id)
}
}
func TestGetUserID_Set(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), UserIDKey, "abc-123")
if id := GetUserID(ctx); id != "abc-123" {
t.Errorf("expected abc-123, got %q", id)
}
}
func TestGetUserEmail_Empty(t *testing.T) {
t.Parallel()
ctx := context.Background()
if email := GetUserEmail(ctx); email != "" {
t.Errorf("expected empty email, got %q", email)
}
}
func TestGetUserEmail_Set(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), UserEmailKey, "test@example.com")
if email := GetUserEmail(ctx); email != "test@example.com" {
t.Errorf("expected test@example.com, got %q", email)
}
}
func TestGetUserTier_Default(t *testing.T) {
t.Parallel()
ctx := context.Background()
if tier := GetUserTier(ctx); tier != "free" {
t.Errorf("expected default tier 'free', got %q", tier)
}
}
func TestGetUserTier_Set(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), UserTierKey, "pro")
if tier := GetUserTier(ctx); tier != "pro" {
t.Errorf("expected 'pro', got %q", tier)
}
}

View File

@@ -55,12 +55,14 @@ func (m *AddressModel) ListByUser(ctx context.Context, userID string) ([]domain.
return addresses, rows.Err() return addresses, rows.Err()
} }
// ListAll returns all addresses system-wide (used by the poller). // ListAll returns addresses for users with an active subscription (used by the poller).
func (m *AddressModel) ListAll(ctx context.Context) ([]domain.Address, error) { func (m *AddressModel) ListAll(ctx context.Context) ([]domain.Address, error) {
rows, err := m.pool.Query(ctx, rows, err := m.pool.Query(ctx,
`SELECT id, user_id, address, label, created_at `SELECT a.id, a.user_id, a.address, a.label, a.created_at
FROM addresses FROM addresses a
ORDER BY created_at DESC`, JOIN users u ON u.id = a.user_id
WHERE u.subscription_status IN ('active', 'trialing')
ORDER BY a.created_at DESC`,
) )
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -77,10 +77,12 @@ func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bo
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) { func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
rows, err := m.pool.Query(ctx, rows, err := m.pool.Query(ctx,
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token, `SELECT nc.user_id, nc.discord_webhook_url, nc.telegram_chat_id, nc.telegram_bot_token,
email, slack_webhook_url nc.email, nc.slack_webhook_url
FROM user_notification_configs FROM user_notification_configs nc
WHERE notification_enabled = TRUE`, JOIN users u ON u.id = nc.user_id
WHERE nc.notification_enabled = TRUE
AND u.subscription_status IN ('active', 'trialing')`,
) )
if err != nil { if err != nil {
return nil, err return nil, err

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

@@ -28,6 +28,7 @@ type EvaluatorService struct {
alertRules *models.AlertRuleModel alertRules *models.AlertRuleModel
alertEvents *models.AlertEventModel alertEvents *models.AlertEventModel
addresses *models.AddressModel addresses *models.AddressModel
users *models.UserModel
notifConfigs *models.NotificationConfigModel notifConfigs *models.NotificationConfigModel
notifSem *semaphore.Weighted notifSem *semaphore.Weighted
notifWg sync.WaitGroup notifWg sync.WaitGroup
@@ -38,6 +39,7 @@ func NewEvaluatorService(
alertRules *models.AlertRuleModel, alertRules *models.AlertRuleModel,
alertEvents *models.AlertEventModel, alertEvents *models.AlertEventModel,
addresses *models.AddressModel, addresses *models.AddressModel,
users *models.UserModel,
notifConfigs *models.NotificationConfigModel, notifConfigs *models.NotificationConfigModel,
) *EvaluatorService { ) *EvaluatorService {
return &EvaluatorService{ return &EvaluatorService{
@@ -45,6 +47,7 @@ func NewEvaluatorService(
alertRules: alertRules, alertRules: alertRules,
alertEvents: alertEvents, alertEvents: alertEvents,
addresses: addresses, addresses: addresses,
users: users,
notifConfigs: notifConfigs, notifConfigs: notifConfigs,
notifSem: semaphore.NewWeighted(maxConcurrentNotifications), notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
} }
@@ -248,14 +251,16 @@ func (s *EvaluatorService) WaitForNotifications() {
s.notifWg.Wait() s.notifWg.Wait()
} }
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier { func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig, limits domain.TierLimits) []notifications.Notifier {
var notifiers []notifications.Notifier var notifiers []notifications.Notifier
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" { if limits.ChannelAllowed("discord") &&
cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
notifiers = append(notifiers, &notifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL}) notifiers = append(notifiers, &notifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
} }
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" && if limits.ChannelAllowed("telegram") &&
cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" { cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
notifiers = append(notifiers, &notifications.TelegramNotifier{ notifiers = append(notifiers, &notifications.TelegramNotifier{
BotToken: *cfg.TelegramBotToken, BotToken: *cfg.TelegramBotToken,
@@ -263,7 +268,8 @@ func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []noti
}) })
} }
if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" { if limits.ChannelAllowed("slack") &&
cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
notifiers = append(notifiers, &notifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL}) notifiers = append(notifiers, &notifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
} }
@@ -313,6 +319,13 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
return return
} }
user, err := s.users.GetByID(ctx, userID)
if err != nil || user == nil {
log.Printf("Failed to load user %s for tier check in notification: %v", userID, err)
return
}
limits := domain.GetTierLimits(user.SubscriptionTier)
meta := notifications.AlertMetadata{ meta := notifications.AlertMetadata{
TxHash: obs.Hash, TxHash: obs.Hash,
AddressLabel: addressLabel, AddressLabel: addressLabel,
@@ -320,7 +333,7 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
Address: address, Address: address,
} }
for _, n := range s.buildNotifiers(notifConfig) { for _, n := range s.buildNotifiers(notifConfig, limits) {
if err := sendWithRetry(ctx, n, message, meta); err != nil { if err := sendWithRetry(ctx, n, message, meta); err != nil {
log.Printf("Notification channel failed for user %s after retries: %v", userID, err) log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
} else { } else {

Binary file not shown.

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,31 @@ 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 } = 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) {
return (
<Routes>
<Route path="/subscribe" element={<Subscribe />} />
<Route path="/account" element={<><Navbar /><Account /></>} />
<Route path="*" element={<Navigate to="/subscribe" />} />
</Routes>
);
}
// 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,45 +1,30 @@
/** /**
* 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
* @returns {Promise<Object>} Headers object with Authorization
*/
export async function getAuthHeaders() {
const currentUser = auth.currentUser;
if (!currentUser) {
throw new Error("No authenticated user"); throw new Error("No authenticated user");
} }
// Get Firebase ID token
const token = await currentUser.getIdToken();
return { return {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`, 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 { return {
Authorization: `Bearer ${token}`, 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

@@ -11,11 +11,7 @@ const TIERS = [
"Configure alerts to fire on trigger events", "Configure alerts to fire on trigger events",
"1 transaction alert type per trigger (email digest)", "1 transaction alert type per trigger (email digest)",
], ],
disabledFeatures: [ disabledFeatures: [],
"Real-time Discord alerts",
"Real-time Telegram alerts",
"Real-time Slack alerts",
],
}, },
{ {
id: "premium", id: "premium",
@@ -24,15 +20,12 @@ const TIERS = [
period: "/month", period: "/month",
features: [ features: [
"Monitor 3 blockchain addresses", "Monitor 3 blockchain addresses",
"Configure alerts to fire on trigger events", "Configure two types of rule-based alerts to fire on trigger events for each of the three addresses",
"Daily email digest alert", "Daily email digest alert",
"Real-time Discord alerts", "Real-time Discord alerts",
"Real-time Telegram alerts", "Real-time Telegram alerts",
"2 transaction alert types per monitored address",
],
disabledFeatures: [
"Real-time Slack alerts",
], ],
disabledFeatures: [],
highlighted: true, highlighted: true,
}, },
{ {
@@ -42,11 +35,11 @@ const TIERS = [
period: "/month", period: "/month",
features: [ features: [
"Monitor unlimited blockchain addresses", "Monitor unlimited blockchain addresses",
"Configure alerts to fire on trigger events", "Configure unlimited alert rules to fire on unlimited events on any address",
"Daily email digest alert", "Daily email digest alert",
"Real-time Discord alerts", "Real-time Discord alerts",
"Real-time Telegram alerts", "Real-time Telegram alerts",
"Real-time Slack alerts", "Real-time Slack alerts configurable for multiple Slack groups or channels",
"Unlimited transaction alert types per monitored address", "Unlimited transaction alert types per monitored address",
], ],
disabledFeatures: [], disabledFeatures: [],

View File

@@ -1,7 +1,7 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "./UpgradeBanner.css"; import "./UpgradeBanner.css";
export default function UpgradeBanner({ message, linkTo = "/account" }) { export default function UpgradeBanner({ message, linkTo = "/subscribe?upgrade=true" }) {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (

View File

@@ -1,132 +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 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);
} 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);
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 value = {
currentUser,
signup,
login,
logout,
error,
loading,
userTier,
tierLimits,
addressCount,
refreshAccount,
};
return (
<AuthContext.Provider value={value}>
{!loading && children}
</AuthContext.Provider>
);
}

View File

@@ -397,6 +397,14 @@ button {
} }
} }
/* Tier-locked: greyed out, non-interactive overlay for features above the user's plan */
.tier-locked {
opacity: 0.45;
pointer-events: none;
filter: grayscale(40%);
user-select: none;
}
/* Mobile */ /* Mobile */
@media (max-width: 480px) { @media (max-width: 480px) {
html { html {

View File

@@ -95,11 +95,9 @@ export default function Addresses() {
/> />
)} )}
{!atLimit && ( <div className={`mb-xl${atLimit ? " tier-locked" : ""}`}>
<div className="mb-xl">
<AddressForm onSubmit={handleAddressSubmit} /> <AddressForm onSubmit={handleAddressSubmit} />
</div> </div>
)}
<div> <div>
<h2>Existing Tracked Addresses</h2> <h2>Existing Tracked Addresses</h2>

View File

@@ -382,12 +382,10 @@ export default function Alerts() {
/> />
)} )}
{!atAlertLimit && ( <div className={`mb-xl${atAlertLimit ? " tier-locked" : ""}`}>
<div className="mb-xl">
<h3>Create New Alert</h3> <h3>Create New Alert</h3>
<AlertForm onSubmit={handleAlertSubmit} /> <AlertForm onSubmit={handleAlertSubmit} />
</div> </div>
)}
<div> <div>
<h3>Active Alert Rules</h3> <h3>Active Alert Rules</h3>

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) {
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); 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

@@ -111,52 +111,6 @@
color: #888; color: #888;
} }
/* Step 5 summary */
.subscribe__summary {
background-color: #1e2e1e;
border: 1px solid #2d5a2d;
border-radius: var(--radius-lg);
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
}
.subscribe__summary-title {
margin: 0 0 0.5rem;
color: #90ee90;
font-weight: bold;
}
.subscribe__summary-list {
margin: 0;
padding-left: 1.25rem;
color: var(--color-text-label);
line-height: 1.8;
}
/* Checkbox rows */
.checkbox-row {
margin-bottom: 1rem;
}
.checkbox-row__label {
display: flex;
align-items: center;
gap: 0.6rem;
cursor: pointer;
color: #ddd;
}
.checkbox-row__input {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
.checkbox-row__nested {
margin-top: 0.5rem;
margin-left: 1.75rem;
}
/* Footer navigation */ /* Footer navigation */
.subscribe__footer { .subscribe__footer {
display: flex; display: flex;
@@ -167,20 +121,6 @@
border-top: 1px solid var(--color-border-light); border-top: 1px solid var(--color-border-light);
} }
/* Test results */
.test-result {
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.test-result--success {
color: #90ee90;
}
.test-result--failure {
color: var(--color-error);
}
/* Step subtitle */ /* Step subtitle */
.subscribe__subtitle { .subscribe__subtitle {
color: #aaa; color: #aaa;
@@ -188,7 +128,7 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
/* Subscribe card (Step 2) */ /* Subscribe card (Step 2 tier cards) */
.subscribe-card { .subscribe-card {
background-color: var(--color-bg-card, #1a1a2e); background-color: var(--color-bg-card, #1a1a2e);
border: 1px solid var(--color-border-light); border: 1px solid var(--color-border-light);
@@ -241,29 +181,6 @@
font-style: italic; font-style: italic;
} }
/* Tier limit hints */
.subscribe__tier-hint {
font-size: 0.82rem;
color: #ffcc44;
margin-top: 0.5rem;
}
/* Disabled channel group */
.subscribe__channel-disabled {
opacity: 0.5;
pointer-events: none;
}
.subscribe__channel-disabled .subscribe__tier-hint {
pointer-events: auto;
opacity: 1;
}
/* Disabled checkbox row */
.checkbox-row--disabled {
opacity: 0.5;
}
/* ── Subscribe Responsive ────────────────────────────────── */ /* ── Subscribe Responsive ────────────────────────────────── */
@media (max-width: 480px) { @media (max-width: 480px) {

View File

@@ -1,85 +1,42 @@
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 { createAddress, getAddresses } from "../../api/addresses"; import { createOnboardingCheckout, activateFreeTier } from "../../api/stripe";
import { createAlert } from "../../api/alerts";
import {
updateNotificationConfig,
testNotificationChannels,
} from "../../api/notificationConfig";
import { 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";
import "./Subscribe.css"; import "./Subscribe.css";
const TIER_LIMITS = { const STEPS = ["Create Account", "Choose Plan"];
free: { maxAlertTypes: 1, channels: ["email"] },
premium: { maxAlertTypes: 2, channels: ["email", "discord", "telegram"] },
pro: { maxAlertTypes: 4, channels: ["email", "discord", "telegram", "slack"] },
};
const STEPS = [
"Create Account",
"Choose Plan",
"Add Wallet",
"Alert Rules",
"Notifications",
"Done",
];
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("");
const [skipWarning, setSkipWarning] = useState("");
const [testResults, setTestResults] = useState(null);
const [testLoading, setTestLoading] = useState(false);
const [data, setData] = useState({ const [data, setData] = useState({
selectedTier: "", selectedTier: "",
email: "", email: "",
password: "", password: "",
confirmPassword: "", confirmPassword: "",
walletAddress: "",
walletLabel: "",
createdAddressId: null,
alertIncomingTx: false,
alertOutgoingTx: false,
alertLargeTransfer: false,
largeTransferThreshold: "",
alertBalanceBelow: false,
balanceBelowThreshold: "",
discordWebhookUrl: "",
slackWebhookUrl: "",
notificationEmail: "",
alertsCreated: [],
notificationConfigured: false,
}); });
const registerCalledRef = useRef(false);
function set(field, value) { function set(field, value) {
setData((prev) => ({ ...prev, [field]: value })); setData((prev) => ({ ...prev, [field]: value }));
} }
const tierLimits = TIER_LIMITS[data.selectedTier] || TIER_LIMITS.free; // Handle return from Stripe checkout redirect.
// The ref guard prevents React StrictMode from double-firing this.
useEffect(() => { useEffect(() => {
if (!currentUser) return; if (registerCalledRef.current) return;
getAddresses()
.then((addresses) => {
if (addresses.length > 0) {
navigate("/addresses", { replace: true });
}
})
.catch(() => { });
}, [currentUser, navigate]);
useEffect(() => {
const payment = searchParams.get("payment"); const payment = searchParams.get("payment");
const sessionId = searchParams.get("session_id"); const sessionId = searchParams.get("session_id");
@@ -92,45 +49,34 @@ export default function Subscribe() {
if (payment !== "success" || !sessionId) return; if (payment !== "success" || !sessionId) return;
// Phase 1: no account yet — create it. This triggers an auth state change
// which remounts the component (App.jsx swaps route trees). The URL params
// are preserved so Phase 2 runs on the next mount.
if (!currentUser) {
const savedEmail = sessionStorage.getItem("kp_onboard_email"); const savedEmail = sessionStorage.getItem("kp_onboard_email");
const savedPw = sessionStorage.getItem("kp_onboard_pw"); const savedPassword = sessionStorage.getItem("kp_onboard_password");
if (!savedEmail || !savedPw) {
if (!savedEmail || !savedPassword) {
setSearchParams({}, { replace: true }); setSearchParams({}, { replace: true });
setError("Session expired. Please start the signup process again."); setError("Session expired. Please start the signup process again.");
setStep(1); setStep(1);
setLoading(false); setLoading(false);
return; return;
} }
setLoading(true);
signup(savedEmail, savedPw).catch((err) => {
setSearchParams({}, { replace: true });
setError("Account creation failed: " + err.message);
setStep(1);
setLoading(false);
});
return;
}
// Phase 2: authenticated — verify the checkout and activate subscription. 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(() => {
setStep(3); sessionStorage.removeItem("kp_onboard_email");
sessionStorage.removeItem("kp_onboard_password");
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]); }, []); // eslint-disable-line react-hooks/exhaustive-deps
// ── Step handlers ───────────────────────────────────────────────────────── // ── Step handlers ─────────────────────────────────────────────────────────
@@ -160,26 +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();
setStep(3); 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(data.email, data.selectedTier);
const { url } = await createOnboardingCheckout(
data.email,
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);
} }
@@ -188,125 +136,6 @@ export default function Subscribe() {
} }
} }
async function handleStep3() {
setError("");
if (!data.walletAddress) {
setError("Please enter a wallet address");
return;
}
if (!/^0x[0-9a-fA-F]{40}$/.test(data.walletAddress)) {
setError("Invalid ETH address (must be 0x followed by 40 hex characters)");
return;
}
try {
setLoading(true);
const created = await createAddress({
address: data.walletAddress,
label: data.walletLabel || undefined,
});
set("createdAddressId", created.id);
setStep(4);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleStep4() {
setError("");
const rules = [];
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
if (data.alertOutgoingTx) rules.push({ type: "outgoing_tx" });
if (data.alertLargeTransfer) {
if (!data.largeTransferThreshold) {
setError("Please enter a threshold for large transfers");
return;
}
rules.push({ type: "large_transfer", threshold: data.largeTransferThreshold });
}
if (data.alertBalanceBelow) {
if (!data.balanceBelowThreshold) {
setError("Please enter a threshold for balance below");
return;
}
rules.push({ type: "balance_below", threshold: data.balanceBelowThreshold });
}
if (rules.length === 0) {
setStep(5);
return;
}
try {
setLoading(true);
const created = [];
for (const rule of rules) {
const result = await createAlert(data.createdAddressId, rule);
created.push(result);
}
set("alertsCreated", created);
setStep(5);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleStep5() {
setError("");
const hasAny =
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
if (!hasAny) {
setStep(6);
return;
}
try {
setLoading(true);
await updateNotificationConfig({
notification_enabled: true,
discord_webhook_url: data.discordWebhookUrl || undefined,
slack_webhook_url: data.slackWebhookUrl || undefined,
email: data.notificationEmail || undefined,
});
set("notificationConfigured", true);
setStep(6);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleTestChannels() {
setTestLoading(true);
setTestResults(null);
try {
const results = await testNotificationChannels();
setTestResults(results);
} catch (err) {
setTestResults({ error: err.message });
} finally {
setTestLoading(false);
}
}
// ── Alert type limit helpers ──────────────────────────────────────────────
function countSelectedAlerts() {
let count = 0;
if (data.alertIncomingTx) count++;
if (data.alertOutgoingTx) count++;
if (data.alertLargeTransfer) count++;
if (data.alertBalanceBelow) count++;
return count;
}
function canSelectMoreAlerts() {
return tierLimits.maxAlertTypes > countSelectedAlerts();
}
// ── Progress bar ────────────────────────────────────────────────────────── // ── Progress bar ──────────────────────────────────────────────────────────
function ProgressBar() { function ProgressBar() {
@@ -326,7 +155,8 @@ export default function Subscribe() {
<div key={label} className="progress-bar__step"> <div key={label} className="progress-bar__step">
{i > 0 && ( {i > 0 && (
<div <div
className={`progress-bar__connector ${done || active className={`progress-bar__connector ${
done || active
? "progress-bar__connector--active" ? "progress-bar__connector--active"
: "progress-bar__connector--inactive" : "progress-bar__connector--inactive"
}`} }`}
@@ -337,7 +167,8 @@ export default function Subscribe() {
{done ? "\u2713" : stepNum} {done ? "\u2713" : stepNum}
</div> </div>
<div <div
className={`progress-bar__label ${active className={`progress-bar__label ${
active
? "progress-bar__label--active" ? "progress-bar__label--active"
: "progress-bar__label--inactive" : "progress-bar__label--inactive"
}`} }`}
@@ -402,344 +233,38 @@ export default function Subscribe() {
); );
} }
function StepAddWallet() {
return (
<>
<h2 className="mb-sm">Add a wallet address</h2>
<p className="subscribe__subtitle">
Enter the Ethereum address you want to monitor.
</p>
<Input
label="ETH Address"
value={data.walletAddress}
onChange={(v) => set("walletAddress", v)}
disabled={loading}
placeholder="0x..."
/>
<Input
label="Label (optional)"
value={data.walletLabel}
onChange={(v) => set("walletLabel", v)}
disabled={loading}
placeholder="e.g. My main wallet"
className="form-field--last"
/>
</>
);
}
function StepAlertRules() {
const atLimit = !canSelectMoreAlerts();
const maxTypes = tierLimits.maxAlertTypes;
return (
<>
<h2 className="mb-sm">Configure alert rules</h2>
<p className="subscribe__subtitle">
Choose which events trigger notifications ({countSelectedAlerts()}/{maxTypes} selected).
You can change these later.
</p>
<CheckboxRow
checked={data.alertIncomingTx}
onChange={(v) => set("alertIncomingTx", v)}
label="Incoming transaction"
disabled={!data.alertIncomingTx && atLimit}
/>
<CheckboxRow
checked={data.alertOutgoingTx}
onChange={(v) => set("alertOutgoingTx", v)}
label="Outgoing transaction"
disabled={!data.alertOutgoingTx && atLimit}
/>
<CheckboxRow
checked={data.alertLargeTransfer}
onChange={(v) => set("alertLargeTransfer", v)}
label="Large transfer"
disabled={!data.alertLargeTransfer && atLimit}
>
{data.alertLargeTransfer && (
<div className="checkbox-row__nested">
<Input
type="number"
label=""
value={data.largeTransferThreshold}
onChange={(v) => set("largeTransferThreshold", v)}
placeholder="Threshold (ETH)"
min="0"
step="0.01"
/>
</div>
)}
</CheckboxRow>
<CheckboxRow
checked={data.alertBalanceBelow}
onChange={(v) => set("alertBalanceBelow", v)}
label="Balance below"
disabled={!data.alertBalanceBelow && atLimit}
>
{data.alertBalanceBelow && (
<div className="checkbox-row__nested">
<Input
type="number"
label=""
value={data.balanceBelowThreshold}
onChange={(v) => set("balanceBelowThreshold", v)}
placeholder="Threshold (ETH)"
min="0"
step="0.01"
/>
</div>
)}
</CheckboxRow>
{atLimit && data.selectedTier !== "pro" && (
<p className="subscribe__tier-hint">
Your {data.selectedTier} plan allows {maxTypes} alert type{maxTypes !== 1 ? "s" : ""} per address. Upgrade for more.
</p>
)}
</>
);
}
function StepNotifications() {
const channels = tierLimits.channels;
const canDiscord = channels.includes("discord");
const canSlack = channels.includes("slack");
return (
<>
<h2 className="mb-sm">Set up notifications</h2>
<p className="subscribe__subtitle">
Add at least one channel so you receive alerts. All fields are optional.
</p>
<Input
label="Email address for alerts"
type="email"
value={data.notificationEmail}
onChange={(v) => set("notificationEmail", v)}
disabled={loading}
placeholder="you@example.com"
className="form-field--last"
/>
<div className={`mb-md${!canDiscord ? " subscribe__channel-disabled" : ""}`}>
<label className="form-label">
Discord Webhook URL{" "}
<a
href="https://support.discord.com/hc/en-us/articles/228383668"
target="_blank"
rel="noreferrer"
className="help-link"
>
(how to get one)
</a>
</label>
<Input
label=""
type="url"
value={data.discordWebhookUrl}
onChange={(v) => set("discordWebhookUrl", v)}
disabled={loading || !canDiscord}
placeholder="https://discord.com/api/webhooks/..."
/>
{!canDiscord && (
<p className="subscribe__tier-hint">Upgrade to Premium to enable Discord alerts</p>
)}
</div>
<div className={`mb-md${!canSlack ? " subscribe__channel-disabled" : ""}`}>
<label className="form-label">
Slack Webhook URL{" "}
<a
href="https://api.slack.com/messaging/webhooks"
target="_blank"
rel="noreferrer"
className="help-link"
>
(how to get one)
</a>
</label>
<Input
label=""
type="url"
value={data.slackWebhookUrl}
onChange={(v) => set("slackWebhookUrl", v)}
disabled={loading || !canSlack}
placeholder="https://hooks.slack.com/services/..."
/>
{!canSlack && (
<p className="subscribe__tier-hint">Upgrade to Pro to enable Slack alerts</p>
)}
</div>
</>
);
}
function StepDone() {
const alertCount = data.alertsCreated.length;
const hasNotif = data.notificationConfigured;
return (
<>
<h2 className="mb-md">You're all set!</h2>
<div className="subscribe__summary">
<p className="subscribe__summary-title">Summary</p>
<ul className="subscribe__summary-list">
<li>
Plan:{" "}
<span className="text-white">
{data.selectedTier === "pro" ? "Pro" : data.selectedTier === "premium" ? "Premium" : "Free Trial"}
</span>
</li>
<li>
Wallet address added:{" "}
<span className="text-mono text-white-sm">
{data.walletAddress}
</span>
{data.walletLabel && ` (${data.walletLabel})`}
</li>
<li>
Alert rules configured:{" "}
<span className="text-white">
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
</span>
</li>
<li>
Notification channels:{" "}
<span className="text-white">
{hasNotif ? "Configured" : "Not set up (skipped)"}
</span>
</li>
</ul>
</div>
{hasNotif && (
<div className="mb-lg">
<Button
onClick={handleTestChannels}
disabled={testLoading}
variant="ghost"
>
{testLoading ? "Testing..." : "Test All Channels"}
</Button>
{testResults && (
<div className="mt-md">
{testResults.error ? (
<p className="text-error">{testResults.error}</p>
) : (
<ul className="list-unstyled">
{Object.entries(testResults).map(([channel, result]) => (
<li
key={channel}
className={`test-result ${result.success ? "test-result--success" : "test-result--failure"}`}
>
{result.success ? "\u2713" : "\u2717"} {channel}:{" "}
{result.message || (result.success ? "OK" : "Failed")}
</li>
))}
</ul>
)}
</div>
)}
</div>
)}
<Button onClick={() => navigate("/addresses")} className="btn--lg text-bold">
Go to Dashboard
</Button>
</>
);
}
// ── Shared helpers ────────────────────────────────────────────────────────
function CheckboxRow({ checked, onChange, label, children, disabled }) {
return (
<div className={`checkbox-row${disabled ? " checkbox-row--disabled" : ""}`}>
<label className="checkbox-row__label">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="checkbox-row__input"
disabled={disabled}
/>
{label}
</label>
{children}
</div>
);
}
// ── Footer navigation ───────────────────────────────────────────────────── // ── Footer navigation ─────────────────────────────────────────────────────
function Footer() { function Footer() {
if (step === 6) return null;
const canSkip = step === 4 || step === 5;
const canBack = step > 1 && step <= 5;
async function handleNext() { async function handleNext() {
setSkipWarning("");
if (step === 1) handleStep1(); if (step === 1) handleStep1();
else if (step === 2) await handleStep2(); else if (step === 2) await handleStep2();
else if (step === 3) await handleStep3();
else if (step === 4) await handleStep4();
else if (step === 5) await handleStep5();
}
function handleSkip() {
setError("");
setSkipWarning("");
setStep((s) => s + 1);
} }
function handleBack() { function handleBack() {
setError(""); setError("");
setSkipWarning("");
setStep((s) => s - 1); setStep((s) => s - 1);
} }
const nextLabel = step === 1 const nextLabel =
step === 1
? "Create Account" ? "Create Account"
: step === 2 : !data.selectedTier
? !data.selectedTier
? "Continue" ? "Continue"
: data.selectedTier === "free" : data.selectedTier === "free"
? "Start Free Trial" ? "Start Free Trial"
: "Subscribe & Continue" : "Subscribe & Continue";
: step === 5
? "Finish"
: "Next →";
return ( return (
<div className="subscribe__footer"> <div className="subscribe__footer">
<div> <div>
{canBack && ( {step === 2 && !isAuthenticated && (
<Button <Button onClick={handleBack} disabled={loading} variant="ghost">
onClick={handleBack}
disabled={loading}
variant="ghost"
>
Back Back
</Button> </Button>
)} )}
</div> </div>
<div className="flex gap-md">
{canSkip && (
<Button
onClick={handleSkip}
disabled={loading}
variant="ghost"
>
Skip for now
</Button>
)}
<Button <Button
onClick={handleNext} onClick={handleNext}
disabled={loading || (step === 2 && !data.selectedTier)} disabled={loading || (step === 2 && !data.selectedTier)}
@@ -748,7 +273,6 @@ export default function Subscribe() {
{loading ? "Please wait..." : nextLabel} {loading ? "Please wait..." : nextLabel}
</Button> </Button>
</div> </div>
</div>
); );
} }
@@ -757,26 +281,18 @@ export default function Subscribe() {
const stepContent = { const stepContent = {
1: StepCreateAccount(), 1: StepCreateAccount(),
2: StepChoosePlan(), 2: StepChoosePlan(),
3: StepAddWallet(),
4: StepAlertRules(),
5: StepNotifications(),
6: StepDone(),
}; };
return ( return (
<div className="subscribe"> <div className="subscribe">
<div className={`subscribe__container${step === 2 ? " subscribe__container--wide" : ""}`}> <div
className={`subscribe__container${step === 2 ? " subscribe__container--wide" : ""}`}
>
<h1 className="subscribe__title">Koin Ping</h1> <h1 className="subscribe__title">Koin Ping</h1>
{ProgressBar()} {ProgressBar()}
{error && ( {error && <div className="alert alert--error">{error}</div>}
<div className="alert alert--error">{error}</div>
)}
{skipWarning && (
<div className="alert alert--warning">{skipWarning}</div>
)}
<div className="subscribe__card"> <div className="subscribe__card">
{stepContent[step]} {stepContent[step]}
@@ -785,8 +301,7 @@ export default function Subscribe() {
{step === 1 && ( {step === 1 && (
<p className="subscribe__login-link"> <p className="subscribe__login-link">
Already have an account?{" "} Already have an account? <a href="/login">Log in here</a>
<a href="/login">Log in here</a>
</p> </p>
)} )}
</div> </div>

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;
}
}

BIN
memberships.pdf Normal file

Binary file not shown.