Compare commits
9 Commits
webhook-co
...
migrate-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3a08e8f25 | ||
|
|
8716be0201 | ||
|
|
647be874e5 | ||
|
|
c12c4fe742 | ||
|
|
5f5253d495 | ||
|
|
bbe2374703 | ||
|
|
205317c920 | ||
|
|
25850825b4 | ||
|
|
f01115de86 |
19
README.md
19
README.md
@@ -5,7 +5,6 @@ A lightweight on-chain monitoring and alerting system designed to give users sit
|
|||||||
|
|
||||||
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 observes on-chain activity and notifies users when predefined conditions are met. It does not execute transactions, manage wallets, or speculate on prices.
|
||||||
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -46,16 +45,16 @@ cd frontend && npm run dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
The API listens on `http://localhost:3001` and the frontend on
|
The API listens on `http://localhost:3001` and the frontend on
|
||||||
`http://localhost:3000` by default.
|
`http://localhost:3000` by default for development.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
Crypto users who hold or actively monitor addresses need a lightweight, reliable
|
Crypto users may actively monitor addresses with webhooks integrating popular
|
||||||
way to know when on-chain activity occurs without polling block explorers
|
messaging platforms: Discord, Slack, Telegram.
|
||||||
manually. Koin Ping fills that gap: it watches a set of Ethereum addresses,
|
This lightweight, reliable framework makes instant awareness of on-chain
|
||||||
evaluates configurable alert rules (incoming transactions, outgoing
|
activity trivial, without polling block explorers manually. Koin Ping watches
|
||||||
transactions, large transfers, balance thresholds), and notifies the user
|
addresses, evaluates configurable alert rules (incoming transactions, outgoing
|
||||||
through Discord webhooks.
|
transactions, "large" transfers, balance thresholds), and sends notifications.
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
@@ -132,7 +131,7 @@ To receive alerts via Telegram, you need to create a bot and get your chat ID.
|
|||||||
|
|
||||||
4. In the JSON response, find the `"chat"` object — the `"id"` field is your **Chat ID** (a numeric value).
|
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.
|
> **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
|
#### 3. Save in Koin Ping
|
||||||
|
|
||||||
@@ -153,3 +152,5 @@ MIT. See [LICENSE](LICENSE).
|
|||||||
## Author
|
## Author
|
||||||
|
|
||||||
Steven Jannette
|
Steven Jannette
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ Start DB:
|
|||||||
|
|
||||||
brew services start postgresql@15
|
brew services start postgresql@15
|
||||||
|
|
||||||
Run Backend:
|
|
||||||
|
|
||||||
cd /Users/kjannette/workspace/koin_ping/backend-go go run ./cmd/api
|
From the backend-go directory, you have a few options:
|
||||||
|
|
||||||
|
Option 1: Single command (both API + poller)
|
||||||
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gomake dev-all
|
||||||
|
|
||||||
|
Option 2: Two separate terminals
|
||||||
|
Terminal 1 (API server):
|
||||||
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/api
|
||||||
|
Terminal 2 (Poller):
|
||||||
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/poller
|
||||||
@@ -110,6 +110,17 @@ type NormalizedTx struct {
|
|||||||
Value string `json:"value"` // Wei as string for precision
|
Value string `json:"value"` // Wei as string for precision
|
||||||
BlockNumber int `json:"block_number"` //nolint:tagliatelle
|
BlockNumber int `json:"block_number"` //nolint:tagliatelle
|
||||||
BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle
|
BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle
|
||||||
|
|
||||||
|
// ERC-20 token transfer fields (nil for native ETH transfers)
|
||||||
|
TokenContract *string `json:"token_contract,omitempty"` //nolint:tagliatelle
|
||||||
|
TokenSymbol *string `json:"token_symbol,omitempty"` //nolint:tagliatelle
|
||||||
|
TokenDecimals *int `json:"token_decimals,omitempty"` //nolint:tagliatelle
|
||||||
|
TokenValue *string `json:"token_value,omitempty"` //nolint:tagliatelle
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTokenTransfer returns true if this transaction represents an ERC-20 token transfer.
|
||||||
|
func (tx NormalizedTx) IsTokenTransfer() bool {
|
||||||
|
return tx.TokenContract != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type Direction string
|
type Direction string
|
||||||
|
|||||||
@@ -242,6 +242,132 @@ func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (strin
|
|||||||
return hexToDecimalString(hexBalance), nil
|
return hexToDecimalString(hexBalance), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const erc20TransferTopic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
||||||
|
|
||||||
|
const minTransferTopics = 3
|
||||||
|
|
||||||
|
type rpcLog struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Topics []string `json:"topics"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
BlockNumber string `json:"blockNumber"`
|
||||||
|
TxHash string `json:"transactionHash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTokenTransfers fetches ERC-20 Transfer events for a given address
|
||||||
|
// across a block range, covering both incoming and outgoing transfers.
|
||||||
|
func (j *JsonRpcEthereum) GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error) {
|
||||||
|
paddedAddr := padAddress(address)
|
||||||
|
hexFrom := fmt.Sprintf("0x%x", fromBlock)
|
||||||
|
hexTo := fmt.Sprintf("0x%x", toBlock)
|
||||||
|
|
||||||
|
incomingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, "", paddedAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch incoming token transfers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outgoingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, paddedAddr, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch outgoing token transfers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var txs []domain.NormalizedTx
|
||||||
|
|
||||||
|
for _, entry := range append(incomingLogs, outgoingLogs...) {
|
||||||
|
key := entry.TxHash + "|" + entry.Address + "|" + entry.Data
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
|
||||||
|
tx, parseErr := parseTransferLog(entry)
|
||||||
|
if parseErr != nil {
|
||||||
|
log.Printf("Skipping unparseable transfer log in tx %s: %v", entry.TxHash, parseErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
txs = append(txs, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return txs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JsonRpcEthereum) getTransferLogs(ctx context.Context, fromBlock, toBlock, fromAddr, toAddr string) ([]rpcLog, error) {
|
||||||
|
topics := make([]interface{}, minTransferTopics)
|
||||||
|
topics[0] = erc20TransferTopic
|
||||||
|
|
||||||
|
if fromAddr != "" {
|
||||||
|
topics[1] = fromAddr
|
||||||
|
}
|
||||||
|
if toAddr != "" {
|
||||||
|
topics[2] = toAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := map[string]interface{}{
|
||||||
|
"fromBlock": fromBlock,
|
||||||
|
"toBlock": toBlock,
|
||||||
|
"topics": topics,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := j.callRPC(ctx, "eth_getLogs", filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var logs []rpcLog
|
||||||
|
if err := json.Unmarshal(result, &logs); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal logs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTransferLog(entry rpcLog) (domain.NormalizedTx, error) {
|
||||||
|
if len(entry.Topics) < minTransferTopics {
|
||||||
|
return domain.NormalizedTx{}, fmt.Errorf("transfer log has %d topics, expected >= 3", len(entry.Topics))
|
||||||
|
}
|
||||||
|
|
||||||
|
from := topicToAddress(entry.Topics[1])
|
||||||
|
to := topicToAddress(entry.Topics[2])
|
||||||
|
tokenValue := hexToDecimalString(entry.Data)
|
||||||
|
blockNumber, _ := hexToInt(entry.BlockNumber)
|
||||||
|
|
||||||
|
contractAddr := strings.ToLower(entry.Address)
|
||||||
|
tokenInfo, known := LookupToken(contractAddr)
|
||||||
|
|
||||||
|
tx := domain.NormalizedTx{
|
||||||
|
Hash: entry.TxHash,
|
||||||
|
From: from,
|
||||||
|
To: &to,
|
||||||
|
Value: "0",
|
||||||
|
BlockNumber: blockNumber,
|
||||||
|
TokenContract: &contractAddr,
|
||||||
|
TokenValue: &tokenValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
if known {
|
||||||
|
tx.TokenSymbol = &tokenInfo.Symbol
|
||||||
|
tx.TokenDecimals = &tokenInfo.Decimals
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func padAddress(addr string) string {
|
||||||
|
clean := strings.TrimPrefix(strings.ToLower(addr), "0x")
|
||||||
|
const addressHexLen = 64
|
||||||
|
return "0x" + strings.Repeat("0", addressHexLen-len(clean)) + clean
|
||||||
|
}
|
||||||
|
|
||||||
|
func topicToAddress(topic string) string {
|
||||||
|
clean := strings.TrimPrefix(topic, "0x")
|
||||||
|
const ethAddrLen = 40
|
||||||
|
if len(clean) > ethAddrLen {
|
||||||
|
clean = clean[len(clean)-ethAddrLen:]
|
||||||
|
}
|
||||||
|
return "0x" + strings.ToLower(clean)
|
||||||
|
}
|
||||||
|
|
||||||
func hexToInt(hex string) (int, error) {
|
func hexToInt(hex string) (int, error) {
|
||||||
hex = strings.TrimPrefix(hex, "0x")
|
hex = strings.TrimPrefix(hex, "0x")
|
||||||
n, ok := new(big.Int).SetString(hex, 16)
|
n, ok := new(big.Int).SetString(hex, 16)
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ type EthereumObserver interface {
|
|||||||
GetLatestBlockNumber(ctx context.Context) (int, error)
|
GetLatestBlockNumber(ctx context.Context) (int, error)
|
||||||
GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error)
|
GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error)
|
||||||
GetBalance(ctx context.Context, address string) (string, error)
|
GetBalance(ctx context.Context, address string) (string, error)
|
||||||
|
GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error)
|
||||||
}
|
}
|
||||||
|
|||||||
34
backend-go/internal/protocols/ethereum/tokens.go
Normal file
34
backend-go/internal/protocols/ethereum/tokens.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package ethereum
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// TokenInfo holds metadata for a known ERC-20 token contract.
|
||||||
|
type TokenInfo struct {
|
||||||
|
Symbol string
|
||||||
|
Decimals int
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gochecknoglobals
|
||||||
|
var wellKnownTokens = map[string]TokenInfo{
|
||||||
|
"0xdac17f958d2ee523a2206206994597c13d831ec7": {Symbol: "USDT", Decimals: 6},
|
||||||
|
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": {Symbol: "USDC", Decimals: 6},
|
||||||
|
"0x6b175474e89094c44da98b954eedeac495271d0f": {Symbol: "DAI", Decimals: 18},
|
||||||
|
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": {Symbol: "WETH", Decimals: 18},
|
||||||
|
"0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": {Symbol: "WBTC", Decimals: 8},
|
||||||
|
"0x514910771af9ca656af840dff83e8264ecf986ca": {Symbol: "LINK", Decimals: 18},
|
||||||
|
"0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": {Symbol: "UNI", Decimals: 18},
|
||||||
|
"0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9": {Symbol: "AAVE", Decimals: 18},
|
||||||
|
"0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce": {Symbol: "SHIB", Decimals: 18},
|
||||||
|
"0x6982508145454ce325ddbe47a25d4ec3d2311933": {Symbol: "PEPE", Decimals: 18},
|
||||||
|
"0xb8c77482e45f1f44de1745f52c74426c631bdd52": {Symbol: "BNB", Decimals: 18},
|
||||||
|
"0x4fabb145d64652a948d72533023f6e7a623c7c53": {Symbol: "BUSD", Decimals: 18},
|
||||||
|
"0x75231f58b43240c9718dd58b4967c5114342a86c": {Symbol: "OKB", Decimals: 18},
|
||||||
|
"0x582d872a1b094fc48f5de31d3b73f2d9be47def1": {Symbol: "TON", Decimals: 9},
|
||||||
|
"0x4d224452801aced8b2f0aebe155379bb5d594381": {Symbol: "APE", Decimals: 18},
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupToken returns metadata for a known token contract, if found.
|
||||||
|
func LookupToken(contractAddress string) (TokenInfo, bool) {
|
||||||
|
info, ok := wellKnownTokens[strings.ToLower(contractAddress)]
|
||||||
|
return info, ok
|
||||||
|
}
|
||||||
@@ -281,6 +281,10 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
|
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
|
||||||
|
if obs.IsTokenTransfer() {
|
||||||
|
return s.buildTokenMessage(rule, obs)
|
||||||
|
}
|
||||||
|
|
||||||
switch rule.Type {
|
switch rule.Type {
|
||||||
case domain.AlertIncomingTx:
|
case domain.AlertIncomingTx:
|
||||||
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
||||||
@@ -305,3 +309,36 @@ func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.Observ
|
|||||||
return "Alert triggered"
|
return "Alert triggered"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultTokenDecimals = 18
|
||||||
|
|
||||||
|
func (s *EvaluatorService) buildTokenMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
|
||||||
|
symbol := "tokens"
|
||||||
|
if obs.TokenSymbol != nil {
|
||||||
|
symbol = *obs.TokenSymbol
|
||||||
|
}
|
||||||
|
|
||||||
|
amount := "unknown"
|
||||||
|
if obs.TokenValue != nil {
|
||||||
|
decimals := defaultTokenDecimals
|
||||||
|
if obs.TokenDecimals != nil {
|
||||||
|
decimals = *obs.TokenDecimals
|
||||||
|
}
|
||||||
|
amount = wei.FormatTokenAmount(*obs.TokenValue, decimals)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rule.Type {
|
||||||
|
case domain.AlertIncomingTx:
|
||||||
|
return fmt.Sprintf("Incoming transfer: %s %s received", amount, symbol)
|
||||||
|
case domain.AlertOutgoingTx:
|
||||||
|
return fmt.Sprintf("Outgoing transfer: %s %s sent", amount, symbol)
|
||||||
|
case domain.AlertLargeTransfer:
|
||||||
|
threshold := float64(0)
|
||||||
|
if rule.Threshold != nil {
|
||||||
|
threshold = *rule.Threshold
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Large token transfer: %s %s (threshold: %g)", amount, symbol, threshold)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("Token transfer: %s %s", amount, symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,6 +80,15 @@ func (s *ObserverService) observeAddress(ctx context.Context, addr domain.Addres
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tokenTxs, err := s.eth.GetTokenTransfers(ctx, startBlock, endBlock, addr.Address)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error fetching token transfers for %s: %v", addr.Address, err)
|
||||||
|
} else {
|
||||||
|
for _, tx := range tokenTxs {
|
||||||
|
observations = append(observations, createObservedTx(tx, addr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil {
|
if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,3 +98,54 @@ func FormatAsEth(weiString string, decimals int) (string, error) {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%.*f ETH", decimals, eth), nil
|
return fmt.Sprintf("%.*f ETH", decimals, eth), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormatTokenAmount formats a raw token amount using the token's decimal places.
|
||||||
|
// For example, 1000000 USDT (6 decimals) becomes "1".
|
||||||
|
func FormatTokenAmount(rawValue string, tokenDecimals int) string {
|
||||||
|
if rawValue == "" || rawValue == "0" {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
n, ok := new(big.Int).SetString(rawValue, 10)
|
||||||
|
if !ok {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(tokenDecimals)), nil) //nolint:mnd
|
||||||
|
whole := new(big.Int).Div(n, divisor)
|
||||||
|
remainder := new(big.Int).Mod(n, divisor)
|
||||||
|
|
||||||
|
if remainder.Sign() == 0 {
|
||||||
|
return addThousandsSeparators(whole.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
fracStr := fmt.Sprintf("%0*s", tokenDecimals, remainder.String())
|
||||||
|
fracStr = strings.TrimRight(fracStr, "0")
|
||||||
|
const maxDisplayDecimals = 4
|
||||||
|
if len(fracStr) > maxDisplayDecimals {
|
||||||
|
fracStr = fracStr[:maxDisplayDecimals]
|
||||||
|
}
|
||||||
|
|
||||||
|
return addThousandsSeparators(whole.String()) + "." + fracStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func addThousandsSeparators(s string) string {
|
||||||
|
if len(s) <= 3 { //nolint:mnd
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
offset := len(s) % 3 //nolint:mnd
|
||||||
|
if offset > 0 {
|
||||||
|
result.WriteString(s[:offset])
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := offset; i < len(s); i += 3 { //nolint:mnd
|
||||||
|
if result.Len() > 0 {
|
||||||
|
result.WriteByte(',')
|
||||||
|
}
|
||||||
|
result.WriteString(s[i : i+3]) //nolint:mnd
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|||||||
BIN
frontend/public/ping.png
Normal file
BIN
frontend/public/ping.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -29,7 +29,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<Addresses />} />
|
<Route path="/" element={<Addresses />} />
|
||||||
<Route path="/addresses" element={<Addresses />} />
|
<Route path="/addresses" element={<Addresses />} />
|
||||||
<Route path="/alerts" element={<Alerts />} />
|
<Route path="/alerts" element={<Alerts />} />
|
||||||
<Route path="/history" element={<AlertHistory />} />
|
<Route path="/alertevents" element={<AlertHistory />} />
|
||||||
<Route path="/onboarding" element={<Onboarding />} />
|
<Route path="/onboarding" element={<Onboarding />} />
|
||||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useAuth } from "../contexts/AuthContext";
|
|||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ to: "/addresses", label: "Addresses" },
|
{ to: "/addresses", label: "Addresses" },
|
||||||
{ to: "/alerts", label: "Configure Alerts" },
|
{ to: "/alerts", label: "Configure Alerts" },
|
||||||
{ to: "/history", label: "Alert History" },
|
{ to: "/alertevents", label: "Alert Events" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
@@ -26,8 +26,8 @@ export default function Navbar() {
|
|||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontSize: "1.1rem",
|
fontSize: "1.4rem",
|
||||||
color: "#fff",
|
color: "#e62525",
|
||||||
marginRight: "2rem",
|
marginRight: "2rem",
|
||||||
letterSpacing: "0.5px",
|
letterSpacing: "0.5px",
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function AlertHistory() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
||||||
<h1>Recent Alerts</h1>
|
<h1>Recent Alert Events</h1>
|
||||||
|
|
||||||
{alertEvents.length === 0 ? (
|
{alertEvents.length === 0 ? (
|
||||||
<p style={{ color: "#808080" }}>No alerts yet</p>
|
<p style={{ color: "#808080" }}>No alerts yet</p>
|
||||||
|
|||||||
88
frontend/src/pages/Login.css
Normal file
88
frontend/src/pages/Login.css
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-image: url(/ping.png);
|
||||||
|
background-size: 67%;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 2rem;
|
||||||
|
padding-top: 8rem;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-heading {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
color: #e62525;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background-color: #ff000020;
|
||||||
|
border: 1px solid #ff0000;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #ff6666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field-last {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
background-color: #242424;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
background-color: #0066cc;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:disabled {
|
||||||
|
background-color: #333;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer-text {
|
||||||
|
color: #b3b3b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-signup-link {
|
||||||
|
color: #0066cc;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
@@ -1,146 +1,107 @@
|
|||||||
/**
|
|
||||||
* Login Page
|
|
||||||
*
|
|
||||||
* Allows existing users to sign in with email and password
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import "./Login.css";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [email, setEmail] = useState("");
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const [password, setPassword] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
setError("Please fill in all fields");
|
setError("Please fill in all fields");
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setError("");
|
|
||||||
setLoading(true);
|
|
||||||
await login(email, password);
|
|
||||||
navigate("/addresses"); // Redirect to main app
|
|
||||||
} catch (err) {
|
|
||||||
setError("Failed to log in: " + err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
try {
|
||||||
<div
|
setError("");
|
||||||
style={{
|
setLoading(true);
|
||||||
maxWidth: "400px",
|
await login(email, password);
|
||||||
margin: "4rem auto",
|
navigate("/addresses"); // Redirect to main app
|
||||||
padding: "2rem",
|
} catch (err) {
|
||||||
border: "1px solid #333",
|
setError("Failed to log in: " + err.message);
|
||||||
borderRadius: "8px",
|
} finally {
|
||||||
}}
|
setLoading(false);
|
||||||
>
|
}
|
||||||
<h1 style={{ marginBottom: "2rem", textAlign: "center" }}>
|
}
|
||||||
Koin Ping - Login
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{error && (
|
return (
|
||||||
<div
|
<div className="login-page">
|
||||||
style={{
|
<div
|
||||||
padding: "0.75rem",
|
className="login-card"
|
||||||
marginBottom: "1rem",
|
onMouseEnter={() => setIsVisible(true)}
|
||||||
backgroundColor: "#ff000020",
|
>
|
||||||
border: "1px solid #ff0000",
|
<h1 className="login-heading">
|
||||||
borderRadius: "4px",
|
<span className="login-brand">Koin Ping</span> - Login
|
||||||
color: "#ff6666",
|
</h1>
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<div style={{ visibility: isVisible ? 'visible' : 'hidden' }}>
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
{error && (
|
||||||
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
<div className="login-error">
|
||||||
Email
|
{error}
|
||||||
</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: "1.5rem" }}>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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 ? "Logging in..." : "Log In"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div style={{ marginTop: "1.5rem", textAlign: "center" }}>
|
|
||||||
<p style={{ color: "#b3b3b3" }}>
|
|
||||||
Don't have an account?{" "}
|
|
||||||
<Link
|
|
||||||
to="/signup"
|
|
||||||
style={{ color: "#0066cc", textDecoration: "none" }}
|
|
||||||
>
|
|
||||||
Sign up here
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="login-field">
|
||||||
|
<label className="login-label">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
className="login-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="login-field-last">
|
||||||
|
<label className="login-label">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
className="login-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="login-button"
|
||||||
|
>
|
||||||
|
{loading ? "Logging in..." : "Log In"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="login-footer">
|
||||||
|
<p className="login-footer-text">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link
|
||||||
|
to="/signup"
|
||||||
|
className="login-signup-link"
|
||||||
|
>
|
||||||
|
Sign up here
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user