Compare commits
11 Commits
mega-blast
...
refactor-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
965830fcde | ||
|
|
0412ea3e99 | ||
|
|
0920985a74 | ||
|
|
9d2a0678be | ||
|
|
1418e7f97c | ||
|
|
c0aaaedaf1 | ||
|
|
3a52e7afb3 | ||
|
|
33ec729631 | ||
|
|
0d7bc65995 | ||
|
|
05453895b9 | ||
|
|
14ed0a23a6 |
@@ -1,4 +1,5 @@
|
|||||||
Backend startup quickstarat:
|
Backend startup quickstart:
|
||||||
|
|
||||||
-----------------------------> BEST
|
-----------------------------> BEST
|
||||||
## 1. brew services start postgresql@15
|
## 1. brew services start postgresql@15
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
11
backend/infra/migrations/010_add_password_auth.sql
Normal file
11
backend/infra/migrations/010_add_password_auth.sql
Normal 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);
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
230
backend/internal/handlers/auth.go
Normal file
230
backend/internal/handlers/auth.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
27
frontend/src/api/auth.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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}`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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 = {
|
||||||
* Hook to access auth context
|
user: state.user,
|
||||||
* @returns {Object} Auth context value
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,86 +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 isUpgrade = searchParams.get("upgrade") === "true";
|
const [step, setStep] = useState(hasPaymentReturn || isAuthenticated ? 2 : 1);
|
||||||
const [step, setStep] = useState(hasPaymentReturn || currentUser ? 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 || isUpgrade) return;
|
if (registerCalledRef.current) return;
|
||||||
getAddresses()
|
|
||||||
.then((addresses) => {
|
|
||||||
if (addresses.length > 0) {
|
|
||||||
navigate("/addresses", { replace: true });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => { });
|
|
||||||
}, [currentUser, navigate, isUpgrade]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const payment = searchParams.get("payment");
|
const payment = searchParams.get("payment");
|
||||||
const sessionId = searchParams.get("session_id");
|
const sessionId = searchParams.get("session_id");
|
||||||
|
|
||||||
@@ -93,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 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -161,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);
|
||||||
}
|
}
|
||||||
@@ -189,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() {
|
||||||
@@ -327,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"
|
||||||
}`}
|
}`}
|
||||||
@@ -338,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"
|
||||||
}`}
|
}`}
|
||||||
@@ -403,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)}
|
||||||
@@ -749,7 +273,6 @@ export default function Subscribe() {
|
|||||||
{loading ? "Please wait..." : nextLabel}
|
{loading ? "Please wait..." : nextLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,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]}
|
||||||
@@ -786,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>
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
56
frontend/src/reducers/authReducer.jsx
Normal file
56
frontend/src/reducers/authReducer.jsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user