Compare commits
16 Commits
notifcatio
...
webhook-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6735000050 | ||
|
|
42a64fc043 | ||
|
|
9935817fa8 | ||
|
|
8d5716bdb7 | ||
|
|
aafe96d9a1 | ||
|
|
93168209b5 | ||
|
|
6840af1a47 | ||
|
|
7c36f3c214 | ||
|
|
c707b82f36 | ||
|
|
37ff644aa7 | ||
|
|
c80ed89c0a | ||
|
|
6590cbf4be | ||
|
|
61a8c5fe8b | ||
|
|
c79136a06e | ||
|
|
a5590a438f | ||
|
|
1c196cae9f |
65
README.md
65
README.md
@@ -1,9 +1,10 @@
|
||||
# Koin Ping
|
||||
A lightweight on-chain monitoring and alerting system designed to give users situational awareness over blockchain addresses they care about.
|
||||
|
||||
# Overview
|
||||
|
||||
Koin Ping observes on-chain activity and notifies users when predefined conditions are met. It does not execute transactions, manage wallets, or speculate on prices.
|
||||
|
||||
Koin Ping is an MIT-licensed blockchain monitoring system by Steven Jannette
|
||||
that polls Ethereum addresses for on-chain activity and delivers real-time
|
||||
alerts to users via a Go REST API backend and a React single-page application
|
||||
frontend.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -99,19 +100,51 @@ to the database, and dispatches Discord notifications.
|
||||
Firebase, communicates with the API via fetch, and renders the address/alert
|
||||
management UI.
|
||||
|
||||
## TODO
|
||||
## Setting Up Your Alert Platforms
|
||||
|
||||
- [ ] Switch frontend package manager from npm to yarn (per repo policy)
|
||||
- [ ] Rename `go.mod` module path from `github.com/kjannette/koin-ping` to
|
||||
`sneak.berlin/go/koin-ping` (per repo policy)
|
||||
- [ ] Move database migrations to `internal/db/migrations/` and embed them in
|
||||
the binary
|
||||
- [ ] Pin all Docker base images by `@sha256` in Dockerfile
|
||||
- [ ] Install `golangci-lint` locally and pass `make lint`
|
||||
- [ ] Add vitest unit tests for the React frontend
|
||||
- [ ] Add a `make db-reset` / `make migrate` target for schema management
|
||||
- [ ] Set HTTP server read/write timeouts (currently unbounded)
|
||||
- [ ] Replace `log.Printf` calls with structured `log/slog`
|
||||
Koin Ping can send real-time alerts to **Telegram**, **Discord**, **Slack**, and **Email**. Each channel is configured per-user through the **Notification Settings** panel on the Alerts page.
|
||||
|
||||
Below are step-by-step guides for setting up each platform.
|
||||
|
||||
---
|
||||
|
||||
### Telegram
|
||||
|
||||
To receive alerts via Telegram, you need to create a bot and get your chat ID.
|
||||
|
||||
#### 1. Create a Telegram Bot
|
||||
|
||||
1. Open Telegram and search for **@BotFather** (look for the blue verified checkmark).
|
||||
2. Open the conversation with BotFather and send: `/newbot`
|
||||
3. BotFather will ask for a **display name** — enter something like `Koin Ping Alerts`.
|
||||
4. BotFather will ask for a **username** — it must end in `bot`, e.g. `MyKoinPingBot`.
|
||||
5. BotFather will reply with your **Bot Token** — a string that looks like `123456789:ABCdefGHIjklMNOpqrSTUvwxYZ`. Copy it.
|
||||
|
||||
#### 2. Get Your Chat ID
|
||||
|
||||
1. In Telegram, search for the bot username you just created and open the chat.
|
||||
2. Tap **Start** or send any message (e.g. `hello`).
|
||||
3. Open the following URL in your browser, replacing `YOUR_BOT_TOKEN` with the token from step 1:
|
||||
|
||||
```
|
||||
https://api.telegram.org/botYOUR_BOT_TOKEN/getUpdates
|
||||
```
|
||||
|
||||
4. In the JSON response, find the `"chat"` object — the `"id"` field is your **Chat ID** (a numeric value).
|
||||
|
||||
> **Tip:** If the `"result"` array is empty, make sure you sent a message to your bot first, then refresh the page.
|
||||
|
||||
#### 3. Save in Koin Ping
|
||||
|
||||
1. Go to the **Alerts** page in Koin Ping.
|
||||
2. In the **Notification Settings** panel, find the **Telegram** section.
|
||||
3. Paste your **Bot Token** and **Chat ID** into the corresponding fields.
|
||||
4. Click **Save Settings**.
|
||||
5. Click **Test All Channels** to verify — you should receive a test message from your bot in Telegram.
|
||||
|
||||
---
|
||||
|
||||
*Guides for Discord, Slack, and Email coming soon.*
|
||||
|
||||
## License
|
||||
|
||||
|
||||
BIN
backend-go/api
Executable file
BIN
backend-go/api
Executable file
Binary file not shown.
@@ -18,9 +18,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// serverReadTimeoutSeconds is the maximum duration to read a request.
|
||||
// max duration to read a request.
|
||||
serverReadTimeoutSeconds = 5
|
||||
// serverWriteTimeoutSeconds is the maximum duration to write a response.
|
||||
// maxiduration to write a response.
|
||||
serverWriteTimeoutSeconds = 10
|
||||
)
|
||||
|
||||
@@ -47,6 +47,7 @@ func main() {
|
||||
addressModel := models.NewAddressModel(pool)
|
||||
alertRuleModel := models.NewAlertRuleModel(pool)
|
||||
alertEventModel := models.NewAlertEventModel(pool)
|
||||
checkpointModel := models.NewCheckpointModel(pool)
|
||||
notifConfigModel := models.NewNotificationConfigModel(pool)
|
||||
|
||||
emailDigestSvc := services.NewEmailDigestService(
|
||||
@@ -58,13 +59,14 @@ func main() {
|
||||
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
|
||||
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg)
|
||||
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
|
||||
statusHandler := handlers.NewStatusHandler(checkpointModel)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
b := cfg.APIBasePath // e.g. "/v1"
|
||||
|
||||
// Public routes
|
||||
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
|
||||
mux.HandleFunc("GET "+b+"/status", handlers.SystemStatus)
|
||||
mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus)
|
||||
|
||||
// Authenticated routes — addresses
|
||||
mux.Handle("POST "+b+"/addresses",
|
||||
@@ -73,8 +75,10 @@ func main() {
|
||||
middleware.Authenticate(http.HandlerFunc(addressHandler.List)))
|
||||
mux.Handle("DELETE "+b+"/addresses/{addressId}",
|
||||
middleware.Authenticate(http.HandlerFunc(addressHandler.Remove)))
|
||||
mux.Handle("PATCH "+b+"/addresses/{addressId}",
|
||||
middleware.Authenticate(http.HandlerFunc(addressHandler.UpdateLabel)))
|
||||
|
||||
// Authenticated routes — alert rules
|
||||
// Authenticated routes for alert rules
|
||||
mux.Handle("POST "+b+"/addresses/{addressId}/alerts",
|
||||
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create)))
|
||||
mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
|
||||
|
||||
@@ -62,6 +62,7 @@ func main() {
|
||||
eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel,
|
||||
cfg.ResendAPIKey, cfg.EmailFrom,
|
||||
)
|
||||
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
@@ -78,12 +79,14 @@ func main() {
|
||||
}()
|
||||
|
||||
interval := time.Duration(cfg.PollIntervalMS) * time.Millisecond
|
||||
digestInterval := time.Duration(cfg.DigestIntervalHours) * time.Hour
|
||||
|
||||
log.Println(strings.Repeat("=", separatorWidth))
|
||||
log.Println("Koin Ping Observer Poller Starting")
|
||||
log.Println(strings.Repeat("=", separatorWidth))
|
||||
log.Printf("RPC URL: %s", cfg.EthRPCURL)
|
||||
log.Printf("Poll Interval: %dms (%ds)", cfg.PollIntervalMS, cfg.PollIntervalMS/msPerSecond)
|
||||
log.Printf("Digest Interval: %dh", cfg.DigestIntervalHours)
|
||||
log.Println(strings.Repeat("=", separatorWidth))
|
||||
|
||||
runCycle(ctx, observer, evaluator)
|
||||
@@ -91,6 +94,9 @@ func main() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
digestTicker := time.NewTicker(digestInterval)
|
||||
defer digestTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -99,6 +105,13 @@ func main() {
|
||||
return
|
||||
case <-ticker.C:
|
||||
runCycle(ctx, observer, evaluator)
|
||||
case <-digestTicker.C:
|
||||
sent, digestErr := digestSvc.SendDigestsForAllUsers(ctx)
|
||||
if digestErr != nil {
|
||||
log.Printf("Email digest failed: %v", digestErr)
|
||||
} else {
|
||||
log.Printf("Sent %d email digests", sent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ require (
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/resend/resend-go/v3 v3.1.1 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
|
||||
|
||||
@@ -92,6 +92,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/resend/resend-go/v3 v3.1.1 h1:Uwpf/tZU+O/r/3nMWE6zUAMIG9dX/vTBS3wlQzYJKSw=
|
||||
github.com/resend/resend-go/v3 v3.1.1/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
||||
3
backend-go/infra/migrations/004_alert_event_dedup.sql
Normal file
3
backend-go/infra/migrations/004_alert_event_dedup.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_events_dedup
|
||||
ON alert_events (alert_rule_id, tx_hash)
|
||||
WHERE tx_hash IS NOT NULL;
|
||||
@@ -13,6 +13,7 @@ const (
|
||||
defaultDBPort = 5432
|
||||
defaultPollIntervalMS = 60000
|
||||
minPollIntervalMS = 1000
|
||||
defaultDigestIntervalHours = 24
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -30,6 +31,7 @@ type Config struct {
|
||||
NodeEnv string
|
||||
ResendAPIKey string
|
||||
EmailFrom string
|
||||
DigestIntervalHours int
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and returns a Config.
|
||||
@@ -49,6 +51,7 @@ func Load() (*Config, error) {
|
||||
NodeEnv: getEnv("NODE_ENV", "development"),
|
||||
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
||||
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
|
||||
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
|
||||
}
|
||||
|
||||
if cfg.PollIntervalMS < minPollIntervalMS {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package database manages PostgreSQL connection pools.
|
||||
package database
|
||||
|
||||
import (
|
||||
@@ -11,21 +10,21 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// maxConnIdleSeconds is the maximum idle time for a connection.
|
||||
|
||||
maxConnIdleSeconds = 30
|
||||
// maxConnLifetimeMinutes is the maximum lifetime for a connection.
|
||||
|
||||
maxConnLifetimeMinutes = 5
|
||||
// connectTimeoutSeconds is the timeout for initial connection.
|
||||
|
||||
connectTimeoutSeconds = 10
|
||||
// maxConns is the maximum number of connections in the pool.
|
||||
|
||||
maxConns = 20
|
||||
// minConns is the minimum number of connections in the pool.
|
||||
|
||||
minConns = 2
|
||||
)
|
||||
|
||||
var pool *pgxpool.Pool //nolint:gochecknoglobals
|
||||
|
||||
// Connect establishes a PostgreSQL connection pool using the given DSN.
|
||||
// establishPostgreSQL connection pool using the given DSN.
|
||||
func Connect(dsn string) (*pgxpool.Pool, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
@@ -57,12 +56,10 @@ func Connect(dsn string) (*pgxpool.Pool, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Pool returns the global connection pool.
|
||||
func Pool() *pgxpool.Pool {
|
||||
return pool
|
||||
}
|
||||
|
||||
// Close closes the global connection pool.
|
||||
func Close() {
|
||||
if pool != nil {
|
||||
pool.Close()
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// Package domain defines core domain types shared across the application.
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// Address represents a tracked Ethereum address.
|
||||
type Address struct {
|
||||
ID int `json:"id"`
|
||||
UserID string `json:"user_id"` //nolint:tagliatelle
|
||||
@@ -12,13 +10,10 @@ type Address struct {
|
||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
// AlertType identifies the kind of alert rule.
|
||||
type AlertType string
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (a AlertType) String() string { return string(a) }
|
||||
|
||||
// Alert type constants define the supported alert triggers.
|
||||
const (
|
||||
AlertIncomingTx AlertType = "incoming_tx"
|
||||
AlertOutgoingTx AlertType = "outgoing_tx"
|
||||
@@ -26,7 +21,6 @@ const (
|
||||
AlertBalanceBelow AlertType = "balance_below"
|
||||
)
|
||||
|
||||
// ValidAlertTypes lists all alert types accepted by the API.
|
||||
var ValidAlertTypes = []AlertType{ //nolint:gochecknoglobals
|
||||
AlertIncomingTx,
|
||||
AlertOutgoingTx,
|
||||
@@ -62,7 +56,6 @@ func IsThresholdRequired(t AlertType) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// AlertRule represents a user-defined alert rule for an address.
|
||||
type AlertRule struct {
|
||||
ID int `json:"id"`
|
||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||
@@ -72,7 +65,6 @@ type AlertRule struct {
|
||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
// AlertEvent represents a fired alert event stored for history.
|
||||
type AlertEvent struct {
|
||||
ID int `json:"id"`
|
||||
AlertRuleID int `json:"alert_rule_id"` //nolint:tagliatelle
|
||||
@@ -82,7 +74,6 @@ type AlertEvent struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// AddressCheckpoint tracks the last block checked for an address.
|
||||
type AddressCheckpoint struct {
|
||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||
LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
|
||||
@@ -121,19 +112,16 @@ type NormalizedTx struct {
|
||||
BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
// Direction indicates whether a transaction is incoming or outgoing.
|
||||
type Direction string
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (d Direction) String() string { return string(d) }
|
||||
|
||||
// Direction constants indicate the flow of a transaction relative to a watched address.
|
||||
const (
|
||||
DirectionIncoming Direction = "incoming"
|
||||
DirectionOutgoing Direction = "outgoing"
|
||||
)
|
||||
|
||||
// ObservedTx is a NormalizedTx enriched with address and direction context.
|
||||
type ObservedTx struct {
|
||||
NormalizedTx
|
||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package firebase provides Firebase authentication integration.
|
||||
package firebase
|
||||
|
||||
import (
|
||||
@@ -17,7 +16,6 @@ var ( //nolint:gochecknoglobals
|
||||
errInit error //nolint:gochecknoglobals
|
||||
)
|
||||
|
||||
// Init initializes the Firebase app and auth client using the given project ID.
|
||||
func Init(projectID string) error {
|
||||
once.Do(func() {
|
||||
ctx := context.Background()
|
||||
@@ -48,7 +46,6 @@ func Init(projectID string) error {
|
||||
return errInit
|
||||
}
|
||||
|
||||
// Auth returns the initialized Firebase auth client.
|
||||
func Auth() *auth.Client {
|
||||
return authClient
|
||||
}
|
||||
|
||||
@@ -15,17 +15,14 @@ import (
|
||||
|
||||
var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
|
||||
|
||||
// AddressHandler handles HTTP requests for address management.
|
||||
type AddressHandler struct {
|
||||
addresses *models.AddressModel
|
||||
}
|
||||
|
||||
// NewAddressHandler creates a new AddressHandler.
|
||||
func NewAddressHandler(addresses *models.AddressModel) *AddressHandler {
|
||||
return &AddressHandler{addresses: addresses}
|
||||
}
|
||||
|
||||
// Create handles POST requests to add a new tracked address.
|
||||
func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
@@ -92,6 +89,44 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, addresses)
|
||||
}
|
||||
|
||||
// handles PATCH requests to update an address label.
|
||||
func (h *AddressHandler) UpdateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
addressID, ok := parseIntParam(r.PathValue("addressId"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Label *string `json:"label"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("User %s updating label for address ID: %d", userID, addressID)
|
||||
|
||||
addr, err := h.addresses.UpdateLabel(r.Context(), addressID, userID, body.Label)
|
||||
if err != nil {
|
||||
log.Printf("Error updating address label: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update address")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if addr == nil {
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, addr)
|
||||
}
|
||||
|
||||
// Remove handles DELETE requests to remove a tracked address.
|
||||
func (h *AddressHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||
@@ -51,45 +50,9 @@ func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
log.Printf("Found %d alert events for user", len(events))
|
||||
|
||||
// MVP scaffolding: return mock data if DB is empty
|
||||
if len(events) == 0 {
|
||||
events = mockEvents(limit)
|
||||
if events == nil {
|
||||
events = []domain.AlertEvent{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, events)
|
||||
}
|
||||
|
||||
func mockEvents(limit int) []domain.AlertEvent {
|
||||
label1 := "Treasury Wallet"
|
||||
label2 := "Cold Storage"
|
||||
|
||||
mocks := []domain.AlertEvent{
|
||||
{
|
||||
ID: 1,
|
||||
AlertRuleID: 1,
|
||||
Message: "Incoming transaction detected: 5.5 ETH received",
|
||||
AddressLabel: &label1,
|
||||
Timestamp: time.Now().Add(-2 * time.Hour),
|
||||
},
|
||||
{
|
||||
ID: 2, //nolint:mnd
|
||||
AlertRuleID: 2, //nolint:mnd
|
||||
Message: "Balance dropped below threshold: Current balance 8.2 ETH",
|
||||
AddressLabel: &label1,
|
||||
Timestamp: time.Now().Add(-5 * time.Hour),
|
||||
},
|
||||
{
|
||||
ID: 3, //nolint:mnd
|
||||
AlertRuleID: 3, //nolint:mnd
|
||||
Message: "Outgoing transaction detected: 2.0 ETH sent",
|
||||
AddressLabel: &label2,
|
||||
Timestamp: time.Now().Add(-24 * time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
if limit < len(mocks) {
|
||||
return mocks[:limit]
|
||||
}
|
||||
|
||||
return mocks
|
||||
}
|
||||
|
||||
@@ -14,21 +14,17 @@ import (
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
)
|
||||
|
||||
// errThresholdFormat is returned when the threshold JSON cannot be decoded.
|
||||
var errThresholdFormat = errors.New("unsupported threshold format")
|
||||
|
||||
// AlertRuleHandler handles HTTP requests for alert rule management.
|
||||
type AlertRuleHandler struct {
|
||||
alertRules *models.AlertRuleModel
|
||||
addresses *models.AddressModel
|
||||
}
|
||||
|
||||
// NewAlertRuleHandler creates a new AlertRuleHandler.
|
||||
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler {
|
||||
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses}
|
||||
}
|
||||
|
||||
// Create handles POST requests to create a new alert rule for an address.
|
||||
func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
addressID, ok := parseIntParam(r.PathValue("addressId"))
|
||||
|
||||
@@ -1,10 +1,67 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
)
|
||||
|
||||
// StatusHandler handles the system status endpoint.
|
||||
type StatusHandler struct {
|
||||
checkpoints *models.CheckpointModel
|
||||
}
|
||||
|
||||
// NewStatusHandler creates a new StatusHandler.
|
||||
func NewStatusHandler(checkpoints *models.CheckpointModel) *StatusHandler {
|
||||
return &StatusHandler{checkpoints: checkpoints}
|
||||
}
|
||||
|
||||
// GetStatus returns real-time system status derived from checkpoint data.
|
||||
func (h *StatusHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
block, checkedAt, err := h.checkpoints.GetLatestBlock(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("Error querying latest block: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to get system status")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
latestBlock := 0
|
||||
lag := 0
|
||||
status := "starting"
|
||||
|
||||
if checkedAt != nil {
|
||||
lag = int(time.Since(*checkedAt).Seconds())
|
||||
if lag > 600 { //nolint:mnd
|
||||
status = "idle"
|
||||
} else {
|
||||
status = "active"
|
||||
}
|
||||
}
|
||||
|
||||
if block != nil {
|
||||
latestBlock = *block
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": status,
|
||||
"latestBlock": latestBlock,
|
||||
"lag": lag,
|
||||
"lastCheckedAt": checkedAtStr(checkedAt),
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func checkedAtStr(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "ok",
|
||||
@@ -12,12 +69,3 @@ func HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
"service": "koin-ping-backend",
|
||||
})
|
||||
}
|
||||
|
||||
func SystemStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"latestBlock": 0,
|
||||
"lag": 0,
|
||||
"status": "healthy",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,6 +107,24 @@ func (m *AddressModel) FindByID(ctx context.Context, id int, userID *string) (*d
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// UpdateLabel updates the label for an address owned by userID.
|
||||
// Returns nil, nil if no row matched (address not found or not owned by user).
|
||||
func (m *AddressModel) UpdateLabel(ctx context.Context, id int, userID string, label *string) (*domain.Address, error) {
|
||||
var a domain.Address
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`UPDATE addresses SET label = $3 WHERE id = $1 AND user_id = $2
|
||||
RETURNING id, user_id, address, label, created_at`,
|
||||
id, userID, label,
|
||||
).Scan(&a.ID, &a.UserID, &a.Address, &a.Label, &a.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (m *AddressModel) Remove(ctx context.Context, id int, userID string) (bool, error) {
|
||||
tag, err := m.pool.Exec(ctx,
|
||||
`DELETE FROM addresses WHERE id = $1 AND user_id = $2`,
|
||||
|
||||
@@ -2,7 +2,9 @@ package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
)
|
||||
@@ -71,10 +73,15 @@ func (m *AlertEventModel) Create(ctx context.Context, alertRuleID int, message s
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`INSERT INTO alert_events (alert_rule_id, message, address_label, tx_hash)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id, alert_rule_id, message, address_label, tx_hash, timestamp`,
|
||||
alertRuleID, message, addressLabel, txHash,
|
||||
).Scan(&e.ID, &e.AlertRuleID, &e.Message, &e.AddressLabel, &e.TxHash, &e.Timestamp)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Duplicate silently skipped by ON CONFLICT DO NOTHING
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
|
||||
@@ -3,6 +3,7 @@ package models
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -17,6 +18,20 @@ func NewCheckpointModel(pool *pgxpool.Pool) *CheckpointModel {
|
||||
return &CheckpointModel{pool: pool}
|
||||
}
|
||||
|
||||
// GetLatestBlock returns the highest last_checked_block and its timestamp across all addresses.
|
||||
// Returns nil, nil, nil when no checkpoints exist yet.
|
||||
func (m *CheckpointModel) GetLatestBlock(ctx context.Context) (*int, *time.Time, error) {
|
||||
var block *int
|
||||
var checkedAt *time.Time
|
||||
err := m.pool.QueryRow(ctx,
|
||||
`SELECT MAX(last_checked_block), MAX(last_checked_at) FROM address_checkpoints`,
|
||||
).Scan(&block, &checkedAt)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return block, checkedAt, nil
|
||||
}
|
||||
|
||||
// GetLastCheckedBlock returns the last checked block for an address, or -1 if never checked.
|
||||
func (m *CheckpointModel) GetLastCheckedBlock(ctx context.Context, addressID int) (int, bool, error) {
|
||||
var block int
|
||||
|
||||
@@ -45,11 +45,11 @@ func (m *NotificationConfigModel) UpsertConfig(ctx context.Context, userID strin
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET
|
||||
discord_webhook_url = COALESCE($2, user_notification_configs.discord_webhook_url),
|
||||
telegram_chat_id = COALESCE($3, user_notification_configs.telegram_chat_id),
|
||||
telegram_bot_token = COALESCE($4, user_notification_configs.telegram_bot_token),
|
||||
email = COALESCE($5, user_notification_configs.email),
|
||||
slack_webhook_url = COALESCE($6, user_notification_configs.slack_webhook_url),
|
||||
discord_webhook_url = $2,
|
||||
telegram_chat_id = $3,
|
||||
telegram_bot_token = $4,
|
||||
email = $5,
|
||||
slack_webhook_url = $6,
|
||||
notification_enabled = $7,
|
||||
updated_at = NOW()
|
||||
RETURNING user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
||||
|
||||
@@ -2,6 +2,7 @@ package notifications
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -25,11 +26,15 @@ var discordHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||
Timeout: discordHTTPTimeoutSeconds * time.Second,
|
||||
}
|
||||
|
||||
type AlertMetadata struct {
|
||||
TxHash string
|
||||
AddressLabel string
|
||||
AlertType string
|
||||
Address string
|
||||
// DiscordNotifier sends alert notifications via a Discord webhook.
|
||||
type DiscordNotifier struct {
|
||||
WebhookURL string
|
||||
}
|
||||
|
||||
// Send implements Notifier for Discord.
|
||||
func (d *DiscordNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||
_, err := SendDiscordNotification(d.WebhookURL, message, meta)
|
||||
return err
|
||||
}
|
||||
|
||||
type discordEmbed struct {
|
||||
|
||||
@@ -2,6 +2,7 @@ package notifications
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -9,6 +10,19 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// EmailNotifier sends alert notifications via email (Resend).
|
||||
type EmailNotifier struct {
|
||||
APIKey string
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
// Send implements Notifier for email.
|
||||
func (e *EmailNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||
_, err := SendEmailNotification(e.APIKey, e.From, e.To, message, meta)
|
||||
return err
|
||||
}
|
||||
|
||||
const emailHTTPTimeoutSeconds = 10
|
||||
|
||||
var emailHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||
|
||||
16
backend-go/internal/notifications/notifier.go
Normal file
16
backend-go/internal/notifications/notifier.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package notifications
|
||||
|
||||
import "context"
|
||||
|
||||
// AlertMetadata holds context about the alert being sent.
|
||||
type AlertMetadata struct {
|
||||
TxHash string
|
||||
AddressLabel string
|
||||
AlertType string
|
||||
Address string
|
||||
}
|
||||
|
||||
// Notifier is the interface implemented by all notification channels.
|
||||
type Notifier interface {
|
||||
Send(ctx context.Context, message string, meta AlertMetadata) error
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package notifications
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -9,6 +10,17 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SlackNotifier sends alert notifications via a Slack webhook.
|
||||
type SlackNotifier struct {
|
||||
WebhookURL string
|
||||
}
|
||||
|
||||
// Send implements Notifier for Slack.
|
||||
func (s *SlackNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||
_, err := SendSlackNotification(s.WebhookURL, message, meta)
|
||||
return err
|
||||
}
|
||||
|
||||
const slackHTTPTimeoutSeconds = 10
|
||||
|
||||
var slackHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||
|
||||
@@ -2,6 +2,7 @@ package notifications
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -9,6 +10,18 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TelegramNotifier sends alert notifications via Telegram.
|
||||
type TelegramNotifier struct {
|
||||
BotToken string
|
||||
ChatID string
|
||||
}
|
||||
|
||||
// Send implements Notifier for Telegram.
|
||||
func (t *TelegramNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||
_, err := SendTelegramNotification(t.BotToken, t.ChatID, message, meta)
|
||||
return err
|
||||
}
|
||||
|
||||
const telegramHTTPTimeoutSeconds = 10
|
||||
|
||||
var telegramHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -13,7 +15,11 @@ import (
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
)
|
||||
|
||||
const rpcTimeoutMS = 30000
|
||||
const (
|
||||
rpcTimeoutMS = 30000
|
||||
rpcMaxRetries = 3
|
||||
rpcRetryBaseMS = 1000
|
||||
)
|
||||
|
||||
type JsonRpcEthereum struct {
|
||||
rpcURL string
|
||||
@@ -66,6 +72,44 @@ func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...
|
||||
return nil, fmt.Errorf("marshal RPC request: %w", err)
|
||||
}
|
||||
|
||||
return j.callWithRetry(ctx, method, body)
|
||||
}
|
||||
|
||||
// callWithRetry executes a JSON-RPC POST with exponential backoff on transient errors.
|
||||
// It retries on network errors, HTTP 429, and HTTP 5xx. It does NOT retry on RPC-level
|
||||
// errors or other 4xx responses (those are permanent failures).
|
||||
func (j *JsonRpcEthereum) callWithRetry(ctx context.Context, method string, body []byte) (json.RawMessage, error) {
|
||||
var lastErr error
|
||||
for attempt := range rpcMaxRetries {
|
||||
if attempt > 0 {
|
||||
wait := time.Duration(rpcRetryBaseMS*(1<<(attempt-1))) * time.Millisecond
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
log.Printf("Retrying RPC call [%s] (attempt %d/%d)", method, attempt+1, rpcMaxRetries)
|
||||
}
|
||||
|
||||
result, err := j.doRPCCall(ctx, method, body)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// Permanent errors: do not retry
|
||||
if isPermanentRPCError(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("Transient RPC error [%s] (attempt %d/%d): %v", method, attempt+1, rpcMaxRetries, err)
|
||||
}
|
||||
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (j *JsonRpcEthereum) doRPCCall(ctx context.Context, method string, body []byte) (json.RawMessage, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.rpcURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create RPC request: %w", err)
|
||||
@@ -78,8 +122,13 @@ func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 429 and 5xx are transient; other non-200 are permanent.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d: %s for %s", resp.StatusCode, resp.Status, method)
|
||||
err := fmt.Errorf("HTTP %d: %s for %s", resp.StatusCode, resp.Status, method)
|
||||
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { //nolint:mnd
|
||||
return nil, err // transient — will be retried
|
||||
}
|
||||
return nil, &permanentRPCError{err}
|
||||
}
|
||||
|
||||
var rpcResp rpcResponse
|
||||
@@ -88,12 +137,25 @@ func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...
|
||||
}
|
||||
|
||||
if rpcResp.Error != nil {
|
||||
return nil, fmt.Errorf("RPC Error [%s]: %s (code: %d)", method, rpcResp.Error.Message, rpcResp.Error.Code)
|
||||
// RPC-level errors are permanent (bad params, unsupported method, etc.)
|
||||
return nil, &permanentRPCError{
|
||||
fmt.Errorf("RPC Error [%s]: %s (code: %d)", method, rpcResp.Error.Message, rpcResp.Error.Code),
|
||||
}
|
||||
}
|
||||
|
||||
return rpcResp.Result, nil
|
||||
}
|
||||
|
||||
type permanentRPCError struct{ cause error }
|
||||
|
||||
func (e *permanentRPCError) Error() string { return e.cause.Error() }
|
||||
func (e *permanentRPCError) Unwrap() error { return e.cause }
|
||||
|
||||
func isPermanentRPCError(err error) bool {
|
||||
var p *permanentRPCError
|
||||
return errors.As(err, &p)
|
||||
}
|
||||
|
||||
func (j *JsonRpcEthereum) GetLatestBlockNumber(ctx context.Context) (int, error) {
|
||||
result, err := j.callRPC(ctx, "eth_blockNumber")
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
@@ -12,6 +13,12 @@ import (
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/wei"
|
||||
)
|
||||
|
||||
const (
|
||||
notificationTimeout = 30 * time.Second
|
||||
notificationMaxRetries = 3
|
||||
notificationRetryBase = time.Second
|
||||
)
|
||||
|
||||
type EvaluatorService struct {
|
||||
eth ethereum.EthereumObserver
|
||||
alertRules *models.AlertRuleModel
|
||||
@@ -167,25 +174,85 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
|
||||
message := s.buildMessage(rule, obs)
|
||||
txHash := &obs.Hash
|
||||
|
||||
_, err = s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash)
|
||||
event, err := s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if event == nil {
|
||||
log.Printf("[ALERT DEDUP] Rule %d (%s) - duplicate event skipped for TX: %s", rule.ID, rule.Type, obs.Hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
|
||||
|
||||
// Send Discord notification (non-fatal on failure)
|
||||
if addr != nil {
|
||||
userID := addr.UserID
|
||||
address := addr.Address
|
||||
go func() {
|
||||
s.sendNotification(
|
||||
ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address,
|
||||
)
|
||||
notifCtx, cancel := context.WithTimeout(context.Background(), notificationTimeout)
|
||||
defer cancel()
|
||||
s.sendNotification(notifCtx, userID, message, obs, addressLabel, rule, address)
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier {
|
||||
var notifiers []notifications.Notifier
|
||||
|
||||
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
|
||||
notifiers = append(notifiers, ¬ifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
|
||||
}
|
||||
|
||||
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
|
||||
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
|
||||
notifiers = append(notifiers, ¬ifications.TelegramNotifier{
|
||||
BotToken: *cfg.TelegramBotToken,
|
||||
ChatID: *cfg.TelegramChatID,
|
||||
})
|
||||
}
|
||||
|
||||
if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
|
||||
notifiers = append(notifiers, ¬ifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
|
||||
}
|
||||
|
||||
if cfg.Email != nil && *cfg.Email != "" {
|
||||
notifiers = append(notifiers, ¬ifications.EmailNotifier{
|
||||
APIKey: s.resendAPIKey,
|
||||
From: s.emailFrom,
|
||||
To: *cfg.Email,
|
||||
})
|
||||
}
|
||||
|
||||
return notifiers
|
||||
}
|
||||
|
||||
func sendWithRetry(ctx context.Context, n notifications.Notifier, message string, meta notifications.AlertMetadata) error {
|
||||
var lastErr error
|
||||
for attempt := range notificationMaxRetries {
|
||||
if attempt > 0 {
|
||||
wait := notificationRetryBase * time.Duration(1<<(attempt-1))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
|
||||
if err := n.Send(ctx, message, meta); err != nil {
|
||||
log.Printf("Notification attempt %d/%d failed: %v", attempt+1, notificationMaxRetries, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) {
|
||||
notifConfig, err := s.notifConfigs.GetConfig(ctx, userID)
|
||||
if err != nil {
|
||||
@@ -204,44 +271,11 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
|
||||
Address: address,
|
||||
}
|
||||
|
||||
if notifConfig.DiscordWebhookURL != nil && *notifConfig.DiscordWebhookURL != "" {
|
||||
sent, sendErr := notifications.SendDiscordNotification(*notifConfig.DiscordWebhookURL, message, meta)
|
||||
if sendErr != nil || !sent {
|
||||
log.Printf("Discord notification failed for user %s: %v", userID, sendErr)
|
||||
for _, n := range s.buildNotifiers(notifConfig) {
|
||||
if err := sendWithRetry(ctx, n, message, meta); err != nil {
|
||||
log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
|
||||
} else {
|
||||
log.Printf("Discord notification sent to user %s", userID)
|
||||
}
|
||||
}
|
||||
|
||||
if notifConfig.TelegramBotToken != nil && *notifConfig.TelegramBotToken != "" &&
|
||||
notifConfig.TelegramChatID != nil && *notifConfig.TelegramChatID != "" {
|
||||
sent, sendErr := notifications.SendTelegramNotification(
|
||||
*notifConfig.TelegramBotToken, *notifConfig.TelegramChatID, message, meta,
|
||||
)
|
||||
if sendErr != nil || !sent {
|
||||
log.Printf("Telegram notification failed for user %s: %v", userID, sendErr)
|
||||
} else {
|
||||
log.Printf("Telegram notification sent to user %s", userID)
|
||||
}
|
||||
}
|
||||
|
||||
if notifConfig.SlackWebhookURL != nil && *notifConfig.SlackWebhookURL != "" {
|
||||
sent, sendErr := notifications.SendSlackNotification(*notifConfig.SlackWebhookURL, message, meta)
|
||||
if sendErr != nil || !sent {
|
||||
log.Printf("Slack notification failed for user %s: %v", userID, sendErr)
|
||||
} else {
|
||||
log.Printf("Slack notification sent to user %s", userID)
|
||||
}
|
||||
}
|
||||
|
||||
if notifConfig.Email != nil && *notifConfig.Email != "" {
|
||||
sent, sendErr := notifications.SendEmailNotification(
|
||||
s.resendAPIKey, s.emailFrom, *notifConfig.Email, message, meta,
|
||||
)
|
||||
if sendErr != nil || !sent {
|
||||
log.Printf("Email notification failed for user %s: %v", userID, sendErr)
|
||||
} else {
|
||||
log.Printf("Email notification sent to user %s", userID)
|
||||
log.Printf("Notification sent to user %s via %T", userID, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAuth } from "./contexts/AuthContext";
|
||||
import Navbar from "./components/Navbar";
|
||||
import Login from "./pages/Login";
|
||||
import Signup from "./pages/Signup";
|
||||
import Onboarding from "./pages/Onboarding";
|
||||
import Addresses from "./pages/Addresses";
|
||||
import Alerts from "./pages/Alerts";
|
||||
import AlertHistory from "./pages/AlertHistory";
|
||||
@@ -15,6 +16,7 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/onboarding" element={<Onboarding />} />
|
||||
<Route path="*" element={<Navigate to="/login" />} />
|
||||
</Routes>
|
||||
);
|
||||
@@ -28,6 +30,7 @@ export default function App() {
|
||||
<Route path="/addresses" element={<Addresses />} />
|
||||
<Route path="/alerts" element={<Alerts />} />
|
||||
<Route path="/history" element={<AlertHistory />} />
|
||||
<Route path="/onboarding" element={<Onboarding />} />
|
||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@@ -80,6 +80,41 @@ export async function getAddresses() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an address (e.g. change its label)
|
||||
* @param {number} addressId - Address ID to update
|
||||
* @param {Object} data - Fields to update (e.g. { label: "New Label" })
|
||||
* @returns {Promise<Object>} Updated address
|
||||
*/
|
||||
export async function updateAddress(addressId, data) {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
const response = await fetch(`${API_BASE}/addresses/${addressId}`, {
|
||||
method: "PATCH",
|
||||
headers: headers,
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = "Failed to update address";
|
||||
try {
|
||||
const error = await response.json();
|
||||
errorMessage = error.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error.message.includes("fetch")) {
|
||||
throw new Error("Cannot connect to server. Is the backend running?");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tracked address
|
||||
* @param {number} addressId - Address ID to delete
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import AddressForm from "../components/AddressForm";
|
||||
import { getAddresses, createAddress } from "../api/addresses";
|
||||
import { getAddresses, createAddress, deleteAddress, updateAddress } from "../api/addresses";
|
||||
|
||||
export default function Addresses() {
|
||||
const [addresses, setAddresses] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editLabel, setEditLabel] = useState("");
|
||||
|
||||
// Load addresses on mount
|
||||
useEffect(() => {
|
||||
@@ -29,15 +31,52 @@ export default function Addresses() {
|
||||
async function handleAddressSubmit(data) {
|
||||
try {
|
||||
const newAddress = await createAddress(data);
|
||||
// Append new address to state
|
||||
setAddresses((prev) => [...prev, newAddress]);
|
||||
setError(null); // Clear any previous errors
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to create address:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id, label) {
|
||||
const displayName = label || "this address";
|
||||
if (!window.confirm(`Remove "${displayName}"? This will also delete all associated alert rules.`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteAddress(id);
|
||||
setAddresses((prev) => prev.filter((a) => a.id !== id));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to delete address:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditStart(addr) {
|
||||
setEditingId(addr.id);
|
||||
setEditLabel(addr.label ?? "");
|
||||
}
|
||||
|
||||
async function handleEditSave(id) {
|
||||
try {
|
||||
const updated = await updateAddress(id, { label: editLabel || null });
|
||||
setAddresses((prev) => prev.map((a) => (a.id === id ? updated : a)));
|
||||
setEditingId(null);
|
||||
setEditLabel("");
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to update address:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditCancel() {
|
||||
setEditingId(null);
|
||||
setEditLabel("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
||||
<h1>Tracked Addresses</h1>
|
||||
@@ -68,14 +107,62 @@ export default function Addresses() {
|
||||
backgroundColor: "#333",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{editingId === addr.id ? (
|
||||
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginBottom: "0.25rem" }}>
|
||||
<input
|
||||
value={editLabel}
|
||||
onChange={(e) => setEditLabel(e.target.value)}
|
||||
placeholder="Label (optional)"
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
marginBottom: "0.25rem",
|
||||
background: "#444",
|
||||
border: "1px solid #666",
|
||||
borderRadius: "3px",
|
||||
color: "#fff",
|
||||
padding: "0.25rem 0.5rem",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleEditSave(addr.id);
|
||||
if (e.key === "Escape") handleEditCancel();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleEditSave(addr.id)}
|
||||
style={{ cursor: "pointer", padding: "0.25rem 0.6rem", fontSize: "0.85rem" }}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEditCancel}
|
||||
style={{ cursor: "pointer", padding: "0.25rem 0.6rem", fontSize: "0.85rem", background: "transparent", color: "#aaa", border: "1px solid #555" }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.25rem" }}>
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
{addr.label || "Unlabeled"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleEditStart(addr)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#6699cc",
|
||||
fontSize: "0.8rem",
|
||||
padding: "0",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
{addr.label || "Unlabeled"}
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "monospace",
|
||||
@@ -85,6 +172,24 @@ export default function Addresses() {
|
||||
>
|
||||
{addr.address}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(addr.id, addr.label)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
border: "1px solid #884444",
|
||||
color: "#cc6666",
|
||||
borderRadius: "3px",
|
||||
padding: "0.3rem 0.7rem",
|
||||
fontSize: "0.85rem",
|
||||
marginLeft: "1rem",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -65,6 +65,7 @@ export default function Alerts() {
|
||||
const [testingChannels, setTestingChannels] = useState(false);
|
||||
const [settingUpEmail, setSettingUpEmail] = useState(false);
|
||||
const [sendingDigest, setSendingDigest] = useState(false);
|
||||
const [hasExistingConfig, setHasExistingConfig] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
@@ -86,6 +87,14 @@ export default function Alerts() {
|
||||
setTelegramChatId(configData.telegram_chat_id || "");
|
||||
setEmail(configData.email || "");
|
||||
setSlackWebhookUrl(configData.slack_webhook_url || "");
|
||||
|
||||
const hasSaved =
|
||||
!!configData.discord_webhook_url ||
|
||||
!!configData.telegram_bot_token ||
|
||||
!!configData.telegram_chat_id ||
|
||||
!!configData.email ||
|
||||
!!configData.slack_webhook_url;
|
||||
setHasExistingConfig(hasSaved);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to fetch data:", err);
|
||||
@@ -155,6 +164,13 @@ export default function Alerts() {
|
||||
}
|
||||
|
||||
async function handleSaveNotificationConfig() {
|
||||
if (hasExistingConfig) {
|
||||
const confirmed = window.confirm(
|
||||
"This will overwrite your previously saved notification settings. Continue?",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
try {
|
||||
setNotificationLoading(true);
|
||||
setNotificationError(null);
|
||||
@@ -170,6 +186,7 @@ export default function Alerts() {
|
||||
};
|
||||
|
||||
await updateNotificationConfig(config);
|
||||
setHasExistingConfig(true);
|
||||
setNotificationSuccess("Notification settings saved!");
|
||||
setTimeout(() => setNotificationSuccess(null), 3000);
|
||||
} catch (err) {
|
||||
|
||||
834
frontend/src/pages/Onboarding.jsx
Normal file
834
frontend/src/pages/Onboarding.jsx
Normal file
@@ -0,0 +1,834 @@
|
||||
/**
|
||||
* Onboarding Wizard
|
||||
*
|
||||
* 5-step guided flow: Create Account → Add Wallet → Alert Rules → Notifications → Done
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { createAddress, getAddresses } from "../api/addresses";
|
||||
import { createAlert } from "../api/alerts";
|
||||
import {
|
||||
updateNotificationConfig,
|
||||
testNotificationChannels,
|
||||
} from "../api/notificationConfig";
|
||||
|
||||
const STEPS = [
|
||||
"Create Account",
|
||||
"Add Wallet",
|
||||
"Alert Rules",
|
||||
"Notifications",
|
||||
"Done",
|
||||
];
|
||||
|
||||
const inputStyle = {
|
||||
width: "100%",
|
||||
padding: "0.5rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: "#2a2a2a",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
color: "white",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const labelStyle = {
|
||||
display: "block",
|
||||
marginBottom: "0.4rem",
|
||||
color: "#ccc",
|
||||
fontSize: "0.9rem",
|
||||
};
|
||||
|
||||
export default function Onboarding() {
|
||||
const { currentUser, signup } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [skipWarning, setSkipWarning] = useState("");
|
||||
const [testResults, setTestResults] = useState(null);
|
||||
const [testLoading, setTestLoading] = useState(false);
|
||||
|
||||
// Wizard state
|
||||
const [data, setData] = useState({
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
walletAddress: "",
|
||||
walletLabel: "",
|
||||
createdAddressId: null,
|
||||
alertIncomingTx: false,
|
||||
alertOutgoingTx: false,
|
||||
alertLargeTransfer: false,
|
||||
largeTransferThreshold: "",
|
||||
alertBalanceBelow: false,
|
||||
balanceBelowThreshold: "",
|
||||
discordWebhookUrl: "",
|
||||
slackWebhookUrl: "",
|
||||
notificationEmail: "",
|
||||
// summary
|
||||
alertsCreated: [],
|
||||
notificationConfigured: false,
|
||||
});
|
||||
|
||||
function set(field, value) {
|
||||
setData((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
// On mount: if already fully onboarded, redirect away
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
getAddresses()
|
||||
.then((addresses) => {
|
||||
if (addresses.length > 0) {
|
||||
navigate("/addresses", { replace: true });
|
||||
}
|
||||
})
|
||||
.catch(() => {}); // ignore errors (e.g. mid-signup)
|
||||
}, [currentUser, navigate]);
|
||||
|
||||
// ── Step handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function handleStep1() {
|
||||
setError("");
|
||||
if (!data.email || !data.password || !data.confirmPassword) {
|
||||
setError("Please fill in all fields");
|
||||
return;
|
||||
}
|
||||
if (data.password !== data.confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (data.password.length < 6) {
|
||||
setError("Password must be at least 6 characters");
|
||||
return;
|
||||
}
|
||||
// If user already exists (browser-close-mid-wizard), skip signup
|
||||
if (!currentUser) {
|
||||
try {
|
||||
setLoading(true);
|
||||
await signup(data.email, data.password);
|
||||
} catch (err) {
|
||||
if (err.code === "auth/email-already-in-use") {
|
||||
setError("Email already in use. 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 {
|
||||
setError("Failed to create account: " + err.message);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
setStep(2);
|
||||
}
|
||||
|
||||
async function handleStep2() {
|
||||
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(3);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStep3() {
|
||||
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(4);
|
||||
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(4);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStep4() {
|
||||
setError("");
|
||||
const hasAny =
|
||||
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
|
||||
if (!hasAny) {
|
||||
setStep(5);
|
||||
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(5);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Progress bar ──────────────────────────────────────────────────────────
|
||||
|
||||
function ProgressBar() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: "2rem",
|
||||
}}
|
||||
>
|
||||
{STEPS.map((label, i) => {
|
||||
const stepNum = i + 1;
|
||||
const done = step > stepNum;
|
||||
const active = step === stepNum;
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
style={{ display: "flex", alignItems: "center" }}
|
||||
>
|
||||
{i > 0 && (
|
||||
<div
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "2px",
|
||||
backgroundColor: done || active ? "#0066cc" : "#444",
|
||||
margin: "0 4px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor:
|
||||
done ? "#0066cc" : active ? "#0066cc" : "#333",
|
||||
border: active ? "2px solid #4499ff" : "2px solid transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontWeight: "bold",
|
||||
fontSize: "0.85rem",
|
||||
color: "white",
|
||||
margin: "0 auto 4px",
|
||||
}}
|
||||
>
|
||||
{done ? "✓" : stepNum}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
color: active ? "white" : "#888",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step content ──────────────────────────────────────────────────────────
|
||||
|
||||
function Step1() {
|
||||
return (
|
||||
<>
|
||||
<h2 style={{ marginBottom: "1.5rem" }}>Create your account</h2>
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<label style={labelStyle}>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={data.email}
|
||||
onChange={(e) => set("email", e.target.value)}
|
||||
disabled={loading}
|
||||
style={inputStyle}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<label style={labelStyle}>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={data.password}
|
||||
onChange={(e) => set("password", e.target.value)}
|
||||
disabled={loading}
|
||||
style={inputStyle}
|
||||
placeholder="At least 6 characters"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<label style={labelStyle}>Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={data.confirmPassword}
|
||||
onChange={(e) => set("confirmPassword", e.target.value)}
|
||||
disabled={loading}
|
||||
style={inputStyle}
|
||||
placeholder="Repeat your password"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Step2() {
|
||||
return (
|
||||
<>
|
||||
<h2 style={{ marginBottom: "0.5rem" }}>Add a wallet address</h2>
|
||||
<p style={{ color: "#aaa", marginBottom: "1.5rem", fontSize: "0.9rem" }}>
|
||||
Enter the Ethereum address you want to monitor.
|
||||
</p>
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<label style={labelStyle}>ETH Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.walletAddress}
|
||||
onChange={(e) => set("walletAddress", e.target.value)}
|
||||
disabled={loading}
|
||||
style={inputStyle}
|
||||
placeholder="0x..."
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<label style={labelStyle}>Label (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.walletLabel}
|
||||
onChange={(e) => set("walletLabel", e.target.value)}
|
||||
disabled={loading}
|
||||
style={inputStyle}
|
||||
placeholder="e.g. My main wallet"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Step3() {
|
||||
return (
|
||||
<>
|
||||
<h2 style={{ marginBottom: "0.5rem" }}>Configure alert rules</h2>
|
||||
<p style={{ color: "#aaa", marginBottom: "1.5rem", fontSize: "0.9rem" }}>
|
||||
Choose which events trigger notifications. You can change these later.
|
||||
</p>
|
||||
|
||||
<CheckboxRow
|
||||
checked={data.alertIncomingTx}
|
||||
onChange={(v) => set("alertIncomingTx", v)}
|
||||
label="Incoming transaction"
|
||||
/>
|
||||
<CheckboxRow
|
||||
checked={data.alertOutgoingTx}
|
||||
onChange={(v) => set("alertOutgoingTx", v)}
|
||||
label="Outgoing transaction"
|
||||
/>
|
||||
<CheckboxRow
|
||||
checked={data.alertLargeTransfer}
|
||||
onChange={(v) => set("alertLargeTransfer", v)}
|
||||
label="Large transfer"
|
||||
>
|
||||
{data.alertLargeTransfer && (
|
||||
<div style={{ marginTop: "0.5rem", marginLeft: "1.75rem" }}>
|
||||
<input
|
||||
type="number"
|
||||
value={data.largeTransferThreshold}
|
||||
onChange={(e) => set("largeTransferThreshold", e.target.value)}
|
||||
style={{ ...inputStyle, width: "160px" }}
|
||||
placeholder="Threshold (ETH)"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CheckboxRow>
|
||||
<CheckboxRow
|
||||
checked={data.alertBalanceBelow}
|
||||
onChange={(v) => set("alertBalanceBelow", v)}
|
||||
label="Balance below"
|
||||
>
|
||||
{data.alertBalanceBelow && (
|
||||
<div style={{ marginTop: "0.5rem", marginLeft: "1.75rem" }}>
|
||||
<input
|
||||
type="number"
|
||||
value={data.balanceBelowThreshold}
|
||||
onChange={(e) => set("balanceBelowThreshold", e.target.value)}
|
||||
style={{ ...inputStyle, width: "160px" }}
|
||||
placeholder="Threshold (ETH)"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CheckboxRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Step4() {
|
||||
return (
|
||||
<>
|
||||
<h2 style={{ marginBottom: "0.5rem" }}>Set up notifications</h2>
|
||||
<p style={{ color: "#aaa", marginBottom: "1.5rem", fontSize: "0.9rem" }}>
|
||||
Add at least one channel so you receive alerts. All fields are optional.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: "1.25rem" }}>
|
||||
<label style={labelStyle}>
|
||||
Discord Webhook URL{" "}
|
||||
<a
|
||||
href="https://support.discord.com/hc/en-us/articles/228383668"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: "#4499ff", fontSize: "0.8rem" }}
|
||||
>
|
||||
(how to get one)
|
||||
</a>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={data.discordWebhookUrl}
|
||||
onChange={(e) => set("discordWebhookUrl", e.target.value)}
|
||||
disabled={loading}
|
||||
style={inputStyle}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "1.25rem" }}>
|
||||
<label style={labelStyle}>
|
||||
Slack Webhook URL{" "}
|
||||
<a
|
||||
href="https://api.slack.com/messaging/webhooks"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: "#4499ff", fontSize: "0.8rem" }}
|
||||
>
|
||||
(how to get one)
|
||||
</a>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={data.slackWebhookUrl}
|
||||
onChange={(e) => set("slackWebhookUrl", e.target.value)}
|
||||
disabled={loading}
|
||||
style={inputStyle}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<label style={labelStyle}>Email address for alerts</label>
|
||||
<input
|
||||
type="email"
|
||||
value={data.notificationEmail}
|
||||
onChange={(e) => set("notificationEmail", e.target.value)}
|
||||
disabled={loading}
|
||||
style={inputStyle}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Step5() {
|
||||
const alertCount = data.alertsCreated.length;
|
||||
const hasNotif = data.notificationConfigured;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 style={{ marginBottom: "1rem" }}>You're all set!</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#1e2e1e",
|
||||
border: "1px solid #2d5a2d",
|
||||
borderRadius: "6px",
|
||||
padding: "1rem 1.25rem",
|
||||
marginBottom: "1.5rem",
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: "0 0 0.5rem", color: "#90ee90", fontWeight: "bold" }}>
|
||||
Summary
|
||||
</p>
|
||||
<ul style={{ margin: 0, paddingLeft: "1.25rem", color: "#ccc", lineHeight: "1.8" }}>
|
||||
<li>
|
||||
Wallet address added:{" "}
|
||||
<span style={{ color: "white", fontFamily: "monospace", fontSize: "0.85rem" }}>
|
||||
{data.walletAddress}
|
||||
</span>
|
||||
{data.walletLabel && ` (${data.walletLabel})`}
|
||||
</li>
|
||||
<li>
|
||||
Alert rules configured:{" "}
|
||||
<span style={{ color: "white" }}>
|
||||
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
Notification channels:{" "}
|
||||
<span style={{ color: "white" }}>
|
||||
{hasNotif ? "Configured" : "Not set up (skipped)"}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{hasNotif && (
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<button
|
||||
onClick={handleTestChannels}
|
||||
disabled={testLoading}
|
||||
style={{
|
||||
padding: "0.6rem 1.25rem",
|
||||
backgroundColor: testLoading ? "#333" : "#1a4d80",
|
||||
color: "white",
|
||||
border: "1px solid #0066cc",
|
||||
borderRadius: "4px",
|
||||
cursor: testLoading ? "not-allowed" : "pointer",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
{testLoading ? "Testing..." : "Test All Channels"}
|
||||
</button>
|
||||
|
||||
{testResults && (
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
{testResults.error ? (
|
||||
<p style={{ color: "#ff6666" }}>{testResults.error}</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||
{Object.entries(testResults).map(([channel, result]) => (
|
||||
<li
|
||||
key={channel}
|
||||
style={{
|
||||
color: result.success ? "#90ee90" : "#ff6666",
|
||||
fontSize: "0.9rem",
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{result.success ? "✓" : "✗"} {channel}:{" "}
|
||||
{result.message || (result.success ? "OK" : "Failed")}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => navigate("/addresses")}
|
||||
style={{
|
||||
padding: "0.75rem 2rem",
|
||||
backgroundColor: "#0066cc",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "1rem",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
Go to Dashboard →
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function CheckboxRow({ checked, onChange, label, children }) {
|
||||
return (
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.6rem",
|
||||
cursor: "pointer",
|
||||
color: "#ddd",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
style={{ width: "16px", height: "16px", accentColor: "#0066cc" }}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Footer navigation ─────────────────────────────────────────────────────
|
||||
|
||||
function Footer() {
|
||||
if (step === 5) return null;
|
||||
|
||||
const canSkip = step === 3 || step === 4;
|
||||
const canBack = step > 1;
|
||||
|
||||
async function handleNext() {
|
||||
setSkipWarning("");
|
||||
if (step === 1) await handleStep1();
|
||||
else if (step === 2) await handleStep2();
|
||||
else if (step === 3) await handleStep3();
|
||||
else if (step === 4) await handleStep4();
|
||||
}
|
||||
|
||||
function handleSkip() {
|
||||
setError("");
|
||||
setSkipWarning("");
|
||||
setStep((s) => s + 1);
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
setError("");
|
||||
setSkipWarning("");
|
||||
setStep((s) => s - 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginTop: "1.5rem",
|
||||
paddingTop: "1rem",
|
||||
borderTop: "1px solid #333",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{canBack && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
backgroundColor: "transparent",
|
||||
color: "#aaa",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
cursor: loading ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||
{canSkip && (
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
backgroundColor: "transparent",
|
||||
color: "#aaa",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
cursor: loading ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: "0.5rem 1.25rem",
|
||||
backgroundColor: loading ? "#333" : "#0066cc",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: loading ? "not-allowed" : "pointer",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{loading ? "Please wait..." : step === 4 ? "Finish" : "Next →"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
const stepContent = {
|
||||
1: <Step1 />,
|
||||
2: <Step2 />,
|
||||
3: <Step3 />,
|
||||
4: <Step4 />,
|
||||
5: <Step5 />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
backgroundColor: "#1a1a1a",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
paddingTop: "3rem",
|
||||
paddingBottom: "3rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ width: "100%", maxWidth: "540px", padding: "0 1rem" }}>
|
||||
<h1
|
||||
style={{
|
||||
textAlign: "center",
|
||||
marginBottom: "2rem",
|
||||
color: "#0066cc",
|
||||
letterSpacing: "0.5px",
|
||||
}}
|
||||
>
|
||||
Koin Ping
|
||||
</h1>
|
||||
|
||||
<ProgressBar />
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
marginBottom: "1rem",
|
||||
backgroundColor: "#3a1a1a",
|
||||
border: "1px solid #cc3333",
|
||||
borderRadius: "4px",
|
||||
color: "#ff6666",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{skipWarning && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
marginBottom: "1rem",
|
||||
backgroundColor: "#3a2e00",
|
||||
border: "1px solid #aa7700",
|
||||
borderRadius: "4px",
|
||||
color: "#ffcc44",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
{skipWarning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#242424",
|
||||
border: "1px solid #333",
|
||||
borderRadius: "8px",
|
||||
padding: "2rem",
|
||||
}}
|
||||
>
|
||||
{stepContent[step]}
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<p
|
||||
style={{
|
||||
textAlign: "center",
|
||||
marginTop: "1.25rem",
|
||||
color: "#888",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
Already have an account?{" "}
|
||||
<a href="/login" style={{ color: "#0066cc" }}>
|
||||
Log in here
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +1,5 @@
|
||||
/**
|
||||
* Signup Page
|
||||
*
|
||||
* Allows new users to create an account with email and password
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
export default function Signup() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { signup } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Validation
|
||||
if (!email || !password || !confirmPassword) {
|
||||
setError("Please fill in all fields");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("Password must be at least 6 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
await signup(email, password);
|
||||
navigate("/addresses"); // Auto-login and redirect
|
||||
} catch (err) {
|
||||
// Firebase-specific error messages
|
||||
if (err.code === "auth/email-already-in-use") {
|
||||
setError("Email already in use. 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 {
|
||||
setError("Failed to create account: " + err.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "400px",
|
||||
margin: "4rem auto",
|
||||
padding: "2rem",
|
||||
border: "1px solid #333",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ marginBottom: "2rem", textAlign: "center" }}>
|
||||
Koin Ping - Sign Up
|
||||
</h1>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
marginBottom: "1rem",
|
||||
backgroundColor: "#ff000020",
|
||||
border: "1px solid #ff0000",
|
||||
borderRadius: "4px",
|
||||
color: "#ff6666",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.5rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: "#242424",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
color: "white",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.5rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: "#242424",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
color: "white",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.5rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: "#242424",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
color: "white",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.75rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: loading ? "#333" : "#0066cc",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: loading ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{loading ? "Creating account..." : "Sign Up"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: "1.5rem", textAlign: "center" }}>
|
||||
<p style={{ color: "#b3b3b3" }}>
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
to="/login"
|
||||
style={{ color: "#0066cc", textDecoration: "none" }}
|
||||
>
|
||||
Log in here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user