Compare commits
13 Commits
cleanup
...
account-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b77e7a4d4 | ||
|
|
8f1212813f | ||
|
|
95ec1a520a | ||
|
|
4b228d9b57 | ||
|
|
08bc3e1ea2 | ||
|
|
a091d39c08 | ||
|
|
35699a41bd | ||
|
|
2e96982d3a | ||
|
|
e48de2d6b6 | ||
|
|
9414af8108 | ||
|
|
068ca2f834 | ||
|
|
7f10bcb7de | ||
|
|
f91ca33752 |
@@ -6,12 +6,12 @@ last_modified: 2026-02-22
|
||||
This document covers repository structure, tooling, and workflow standards. Code
|
||||
style conventions are in separate documents:
|
||||
|
||||
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
|
||||
- [Code Styleguide](<link>)
|
||||
(general, bash, Docker)
|
||||
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
|
||||
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
||||
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
||||
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
||||
- [Go](<link>)
|
||||
- [JavaScript](<link>)
|
||||
- [Python](<link>)
|
||||
- [Go HTTP Server Conventions](<link>)
|
||||
|
||||
---
|
||||
|
||||
@@ -37,7 +37,7 @@ style conventions are in separate documents:
|
||||
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
|
||||
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
|
||||
`make hooks` (installs pre-commit hook). A model Makefile is at
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
||||
`(<link>)`.
|
||||
|
||||
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||
instead of invoking the underlying tools directly. The Makefile is the single
|
||||
@@ -95,7 +95,7 @@ style conventions are in separate documents:
|
||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
||||
Fetch the standard `.gitignore` from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||
`(<link>) when setting up
|
||||
a new repo.
|
||||
|
||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||
@@ -107,7 +107,7 @@ style conventions are in separate documents:
|
||||
|
||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
||||
manually by the user. Fetch from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
||||
`(<link>)`.
|
||||
|
||||
- When pinning images or packages by hash, add a comment above the reference
|
||||
with the version and date (YYYY-MM-DD).
|
||||
@@ -124,7 +124,7 @@ style conventions are in separate documents:
|
||||
- `README.md` is the primary documentation. Required sections:
|
||||
- **Description**: First line must include the project name, purpose,
|
||||
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
||||
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
||||
"µPaaS is an MIT-licensed Go web application by @sjdev that receives
|
||||
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||
- **Rationale**: Why does this exist?
|
||||
@@ -134,12 +134,11 @@ style conventions are in separate documents:
|
||||
left off.
|
||||
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
|
||||
`LICENSE` file in the repo root and a License section in the README.
|
||||
- **Author**: [@sneak](https://sneak.berlin).
|
||||
- **Author**: [@sjdev](https://sjdev.co).
|
||||
|
||||
- First commit of a new repo should contain only `README.md`.
|
||||
|
||||
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
|
||||
committing.
|
||||
- Always run `go mod tidy` before committing.
|
||||
|
||||
- Use SemVer.
|
||||
|
||||
@@ -175,7 +174,7 @@ style conventions are in separate documents:
|
||||
|
||||
- When setting up a new repo, files from the `prompts` repo may be used as
|
||||
templates. Fetch them from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
|
||||
`(<link>)<path>`.
|
||||
|
||||
- New repos must contain at minimum:
|
||||
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||
|
||||
@@ -62,6 +62,7 @@ func main() {
|
||||
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
|
||||
statusHandler := handlers.NewStatusHandler(checkpointModel)
|
||||
stripeHandler := handlers.NewStripeHandler(userModel, cfg)
|
||||
accountHandler := handlers.NewAccountHandler(userModel, cfg)
|
||||
|
||||
authenticate := middleware.Authenticate(userModel)
|
||||
requireSub := middleware.RequireSubscription(userModel)
|
||||
@@ -88,6 +89,12 @@ func main() {
|
||||
authenticate(http.HandlerFunc(stripeHandler.GetSubscriptionStatus)))
|
||||
mux.Handle("POST "+b+"/stripe/verify-checkout",
|
||||
authenticate(http.HandlerFunc(stripeHandler.VerifyCheckoutSession)))
|
||||
mux.Handle("POST "+b+"/stripe/create-portal-session",
|
||||
authenticate(http.HandlerFunc(stripeHandler.CreatePortalSession)))
|
||||
|
||||
// Account route (auth required, NO subscription required)
|
||||
mux.Handle("GET "+b+"/user/account",
|
||||
authenticate(http.HandlerFunc(accountHandler.GetAccount)))
|
||||
|
||||
// Authenticated + subscribed routes — addresses
|
||||
mux.Handle("POST "+b+"/addresses",
|
||||
|
||||
66
backend/internal/handlers/account.go
Normal file
66
backend/internal/handlers/account.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
type AccountHandler struct {
|
||||
users *models.UserModel
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewAccountHandler(users *models.UserModel, cfg *config.Config) *AccountHandler {
|
||||
return &AccountHandler{users: users, cfg: cfg}
|
||||
}
|
||||
|
||||
type accountResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
UserName string `json:"user_name"`
|
||||
SubscriptionStatus string `json:"subscription_status"`
|
||||
SubscriptionPlan string `json:"subscription_plan"`
|
||||
MemberSince *string `json:"member_since,omitempty"`
|
||||
NextBillingDate *string `json:"next_billing_date,omitempty"`
|
||||
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
||||
PeriodEndDate *string `json:"period_end_date,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
email := middleware.GetUserEmail(r.Context())
|
||||
|
||||
user, err := h.users.GetByID(r.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("Account: failed to get user %s: %v", userID, err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||
return
|
||||
}
|
||||
|
||||
resp := accountResponse{
|
||||
UserID: user.ID,
|
||||
Email: email,
|
||||
UserName: email,
|
||||
SubscriptionStatus: user.SubscriptionStatus,
|
||||
SubscriptionPlan: "monthly/$1.99",
|
||||
}
|
||||
|
||||
if user.SubscriptionCreatedAt != nil {
|
||||
t := user.SubscriptionCreatedAt.Format(time.DateOnly)
|
||||
resp.MemberSince = &t
|
||||
}
|
||||
|
||||
// MOCKED: NextBillingDate, CancelAtPeriodEnd, PeriodEndDate.
|
||||
// TODO: stripe-go v82 removed Subscription.CurrentPeriodEnd / CancelAtPeriodEnd.
|
||||
// Research SubscriptionItem.CurrentPeriodEnd or use Stripe REST API directly.
|
||||
resp.NextBillingDate = nil
|
||||
resp.CancelAtPeriodEnd = false
|
||||
resp.PeriodEndDate = nil
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/stripe/stripe-go/v82"
|
||||
portalsession "github.com/stripe/stripe-go/v82/billingportal/session"
|
||||
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
|
||||
"github.com/stripe/stripe-go/v82/webhook"
|
||||
|
||||
@@ -139,6 +140,38 @@ func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Req
|
||||
writeJSON(w, http.StatusOK, map[string]string{"subscription_status": "active"})
|
||||
}
|
||||
|
||||
// CreatePortalSession creates a Stripe Billing Portal session so the user can
|
||||
// manage their subscription (cancel, update payment method, view invoices).
|
||||
func (h *StripeHandler) CreatePortalSession(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
user, err := h.users.GetByID(r.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("Portal: failed to get user %s: %v", userID, err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||
return
|
||||
}
|
||||
|
||||
if user.StripeCustomerID == nil || *user.StripeCustomerID == "" {
|
||||
writeError(w, http.StatusBadRequest, "NO_CUSTOMER", "No Stripe customer on file")
|
||||
return
|
||||
}
|
||||
|
||||
params := &stripe.BillingPortalSessionParams{
|
||||
Customer: user.StripeCustomerID,
|
||||
ReturnURL: stripe.String(h.cfg.FrontendURL + "/account"),
|
||||
}
|
||||
|
||||
s, err := portalsession.New(params)
|
||||
if err != nil {
|
||||
log.Printf("Portal: failed to create portal session: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create portal session")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
|
||||
}
|
||||
|
||||
// HandleWebhook processes incoming Stripe webhook events.
|
||||
// This endpoint must NOT require authentication (Stripe calls it directly).
|
||||
func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -101,8 +101,12 @@ func SendDiscordNotification(webhookURL, message string, meta AlertMetadata) (bo
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
err := fmt.Errorf("discord webhook failed: HTTP %d", resp.StatusCode)
|
||||
log.Printf("Discord webhook failed: HTTP %d", resp.StatusCode)
|
||||
return false, fmt.Errorf("discord webhook failed: HTTP %d", resp.StatusCode)
|
||||
if isPermanentStatusCode(resp.StatusCode) {
|
||||
return false, &PermanentError{Err: err}
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
|
||||
@@ -97,8 +97,12 @@ func SendEmailNotification(apiKey, fromAddress, toAddress, message string, meta
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
err := fmt.Errorf("resend API failed: HTTP %d", resp.StatusCode)
|
||||
log.Printf("Resend API failed: HTTP %d", resp.StatusCode)
|
||||
return false, fmt.Errorf("resend API failed: HTTP %d", resp.StatusCode)
|
||||
if isPermanentStatusCode(resp.StatusCode) {
|
||||
return false, &PermanentError{Err: err}
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package notifications
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AlertMetadata holds context about the alert being sent.
|
||||
type AlertMetadata struct {
|
||||
@@ -13,3 +17,22 @@ type AlertMetadata struct {
|
||||
type Notifier interface {
|
||||
Send(ctx context.Context, message string, meta AlertMetadata) error
|
||||
}
|
||||
|
||||
// PermanentError wraps errors that should not be retried (e.g. 401, 403, 404).
|
||||
type PermanentError struct{ Err error }
|
||||
|
||||
func (e *PermanentError) Error() string { return e.Err.Error() }
|
||||
func (e *PermanentError) Unwrap() error { return e.Err }
|
||||
|
||||
func IsPermanent(err error) bool {
|
||||
var p *PermanentError
|
||||
return errors.As(err, &p)
|
||||
}
|
||||
|
||||
func isPermanentStatusCode(code int) bool {
|
||||
return code == http.StatusUnauthorized ||
|
||||
code == http.StatusForbidden ||
|
||||
code == http.StatusNotFound ||
|
||||
code == http.StatusMethodNotAllowed ||
|
||||
code == http.StatusGone
|
||||
}
|
||||
|
||||
@@ -87,8 +87,12 @@ func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
err := fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode)
|
||||
log.Printf("Slack webhook failed: HTTP %d", resp.StatusCode)
|
||||
return false, fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode)
|
||||
if isPermanentStatusCode(resp.StatusCode) {
|
||||
return false, &PermanentError{Err: err}
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
|
||||
@@ -63,8 +63,12 @@ func SendTelegramNotification(botToken, chatID, message string, meta AlertMetada
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
err := fmt.Errorf("telegram API failed: HTTP %d", resp.StatusCode)
|
||||
log.Printf("Telegram API failed: HTTP %d", resp.StatusCode)
|
||||
return false, fmt.Errorf("telegram API failed: HTTP %d", resp.StatusCode)
|
||||
if isPermanentStatusCode(resp.StatusCode) {
|
||||
return false, &PermanentError{Err: err}
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
|
||||
@@ -16,14 +16,16 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
rpcTimeoutMS = 30000
|
||||
rpcMaxRetries = 3
|
||||
rpcRetryBaseMS = 2000
|
||||
rpcTimeoutMS = 30000
|
||||
rpcMaxRetries = 3
|
||||
rpcRetryBaseMS = 2000
|
||||
rpcMinIntervalMS = 1000
|
||||
)
|
||||
|
||||
type JsonRpcEthereum struct {
|
||||
rpcURL string
|
||||
client *http.Client
|
||||
rpcURL string
|
||||
client *http.Client
|
||||
lastCallAt time.Time
|
||||
}
|
||||
|
||||
func NewJsonRpcEthereum(rpcURL string) (*JsonRpcEthereum, error) {
|
||||
@@ -58,6 +60,8 @@ type rpcError struct {
|
||||
}
|
||||
|
||||
func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...interface{}) (json.RawMessage, error) {
|
||||
j.throttle(ctx)
|
||||
|
||||
if params == nil {
|
||||
params = []interface{}{}
|
||||
}
|
||||
@@ -72,7 +76,25 @@ 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)
|
||||
result, callErr := j.callWithRetry(ctx, method, body)
|
||||
j.lastCallAt = time.Now()
|
||||
|
||||
return result, callErr
|
||||
}
|
||||
|
||||
func (j *JsonRpcEthereum) throttle(ctx context.Context) {
|
||||
if j.lastCallAt.IsZero() {
|
||||
return
|
||||
}
|
||||
minInterval := time.Duration(rpcMinIntervalMS) * time.Millisecond
|
||||
elapsed := time.Since(j.lastCallAt)
|
||||
if elapsed >= minInterval {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(minInterval - elapsed):
|
||||
}
|
||||
}
|
||||
|
||||
// callWithRetry executes a JSON-RPC POST with exponential backoff on transient errors.
|
||||
|
||||
@@ -261,8 +261,14 @@ func sendWithRetry(ctx context.Context, n notifications.Notifier, message string
|
||||
}
|
||||
|
||||
if err := n.Send(ctx, message, meta); err != nil {
|
||||
log.Printf("Notification attempt %d/%d failed: %v", attempt+1, notificationMaxRetries, err)
|
||||
lastErr = err
|
||||
|
||||
if notifications.IsPermanent(err) {
|
||||
log.Printf("Permanent notification failure, skipping retries: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Notification attempt %d/%d failed: %v", attempt+1, notificationMaxRetries, err)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
BIN
backend/poller
Executable file
BIN
backend/poller
Executable file
Binary file not shown.
BIN
frontend/public/koin_spin.mp4
Normal file
BIN
frontend/public/koin_spin.mp4
Normal file
Binary file not shown.
@@ -7,6 +7,7 @@ import Subscribe from "./pages/subscribe/subscribe";
|
||||
import Addresses from "./pages/addresses/Addresses";
|
||||
import Alerts from "./pages/alerts/Alerts";
|
||||
import AlertHistory from "./pages/alertHistory/AlertHistory";
|
||||
import Account from "./pages/user_account/Account";
|
||||
|
||||
export default function App() {
|
||||
const { currentUser } = useAuth();
|
||||
@@ -30,6 +31,7 @@ export default function App() {
|
||||
<Route path="/addresses" element={<Addresses />} />
|
||||
<Route path="/alerts" element={<Alerts />} />
|
||||
<Route path="/alertevents" element={<AlertHistory />} />
|
||||
<Route path="/account" element={<Account />} />
|
||||
<Route path="/subscribe" element={<Subscribe />} />
|
||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||
</Routes>
|
||||
|
||||
25
frontend/src/api/account.js
Normal file
25
frontend/src/api/account.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getAuthHeaders } from "./authHeaders";
|
||||
import { API_BASE } from "./config";
|
||||
|
||||
export async function getAccount() {
|
||||
const headers = await getAuthHeaders();
|
||||
const res = await fetch(`${API_BASE}/user/account`, { headers });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || "Failed to load account");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createPortalSession() {
|
||||
const headers = await getAuthHeaders();
|
||||
const res = await fetch(`${API_BASE}/stripe/create-portal-session`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || "Failed to create portal session");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -43,6 +43,16 @@
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.navbar__user-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.navbar__user-link:hover {
|
||||
color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.navbar__logout {
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 1.275rem;
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function Navbar() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex--center gap-lg">
|
||||
<span className="navbar__user">{currentUser.email}</span>
|
||||
<Link to="/account" className="navbar__user navbar__user-link">{currentUser.email}</Link>
|
||||
<button onClick={logout} className="navbar__logout">
|
||||
Logout
|
||||
</button>
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background-image: url(/ping.png);
|
||||
background-size: 67%;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
.login-bg-video {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
transform: translate(-50%, -50%);
|
||||
object-fit: cover;
|
||||
z-index: 0;
|
||||
filter: grayscale(85%);
|
||||
-webkit-filter: grayscale(85%);
|
||||
opacity: 0.05;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 400px;
|
||||
margin: 20px auto;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
padding-top: 8rem;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.login-heading {
|
||||
@@ -32,7 +51,6 @@
|
||||
padding: 0.75rem;
|
||||
font-size: 1.2rem;
|
||||
margin-top: 16px;
|
||||
;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
@@ -55,10 +73,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
.login-form-hidden {
|
||||
.login-card-fadein {
|
||||
opacity: 0;
|
||||
animation: fadeIn 2s ease-in-out forwards;
|
||||
animation-delay: 3s;
|
||||
}
|
||||
|
||||
.login-form-visible {
|
||||
animation: fadeIn 2s ease-in-out forwards;
|
||||
.login-tagline {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 630px;
|
||||
margin: 5rem auto 0;
|
||||
padding-left: 2rem;
|
||||
font-style: italic;
|
||||
color: #FFFFFF;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.login-interactive-fadein {
|
||||
opacity: 0;
|
||||
animation: fadeIn 2s ease-in-out forwards;
|
||||
animation-delay: 6s;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import Input from "../../components/Input";
|
||||
import "./Login.css";
|
||||
|
||||
export default function Login() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
@@ -36,12 +35,22 @@ export default function Login() {
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card" onMouseEnter={() => setIsVisible(true)}>
|
||||
<video
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className="login-bg-video"
|
||||
>
|
||||
<source src="/koin_spin.mp4" type="video/mp4" />
|
||||
</video>
|
||||
|
||||
<div className="login-card login-card-fadein">
|
||||
<h1 className="login-heading">
|
||||
<span className="login-brand">Koin Ping</span> - Login
|
||||
</h1>
|
||||
|
||||
<div className={isVisible ? "login-form-visible" : "login-form-hidden"}>
|
||||
<div className="login-interactive-fadein">
|
||||
{error && <div className="alert alert--error">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
@@ -78,6 +87,11 @@ export default function Login() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="login-tagline login-card-fadein">
|
||||
A lightweight, on-chain monitoring system giving users real-time
|
||||
situational awareness over blockchain addresses.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
77
frontend/src/pages/user_account/Account.css
Normal file
77
frontend/src/pages/user_account/Account.css
Normal file
@@ -0,0 +1,77 @@
|
||||
.account-page h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
.account__section-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 400;
|
||||
margin-bottom: 1.25rem;
|
||||
color: var(--color-text);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.account__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.6rem 0;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.account__row--last {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.account__label {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.account__value {
|
||||
color: var(--color-text);
|
||||
font-size: 1.1rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.account__uuid {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-dimmed);
|
||||
word-break: break-all;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.account__status {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.account__status--active {
|
||||
color: var(--color-success-text);
|
||||
}
|
||||
|
||||
.account__status--canceled {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.account__status--canceling {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.account__status--past_due {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.account__portal-section {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.account__portal-hint {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.account__password-form {
|
||||
max-width: 400px;
|
||||
}
|
||||
202
frontend/src/pages/user_account/Account.jsx
Normal file
202
frontend/src/pages/user_account/Account.jsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { updatePassword } from "firebase/auth";
|
||||
import { auth } from "../../firebase/config";
|
||||
import { getAccount, createPortalSession } from "../../api/account";
|
||||
import "./Account.css";
|
||||
|
||||
export default function Account() {
|
||||
const [account, setAccount] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [passwordMsg, setPasswordMsg] = useState(null);
|
||||
const [passwordErr, setPasswordErr] = useState(null);
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
|
||||
const [portalLoading, setPortalLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchAccount() {
|
||||
try {
|
||||
const data = await getAccount();
|
||||
setAccount(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchAccount();
|
||||
}, []);
|
||||
|
||||
async function handlePasswordChange(e) {
|
||||
e.preventDefault();
|
||||
setPasswordMsg(null);
|
||||
setPasswordErr(null);
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setPasswordErr("Password must be at least 6 characters");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordErr("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setChangingPassword(true);
|
||||
await updatePassword(auth.currentUser, newPassword);
|
||||
setPasswordMsg("Password updated successfully");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch (err) {
|
||||
setPasswordErr(err.message);
|
||||
} finally {
|
||||
setChangingPassword(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleManageSubscription() {
|
||||
try {
|
||||
setPortalLoading(true);
|
||||
const { url } = await createPortalSession();
|
||||
window.location.href = url;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setPortalLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="page">Loading...</div>;
|
||||
}
|
||||
|
||||
if (error && !account) {
|
||||
return <div className="page text-error">Error: {error}</div>;
|
||||
}
|
||||
|
||||
const isCanceling = account.cancel_at_period_end;
|
||||
const statusLabel = isCanceling
|
||||
? "Canceling"
|
||||
: account.subscription_status === "active"
|
||||
? "Active"
|
||||
: account.subscription_status.charAt(0).toUpperCase() +
|
||||
account.subscription_status.slice(1);
|
||||
|
||||
return (
|
||||
<div className="page account-page">
|
||||
<h1 className="mb-lg">Account</h1>
|
||||
|
||||
{error && <div className="alert alert--error mb-lg">{error}</div>}
|
||||
|
||||
{/* ── Profile Section ──────────────────────── */}
|
||||
<div className="section mb-lg">
|
||||
<h2 className="account__section-title">Profile</h2>
|
||||
<div className="account__row">
|
||||
<span className="account__label">Email</span>
|
||||
<span className="account__value">{account.email}</span>
|
||||
</div>
|
||||
<div className="account__row">
|
||||
<span className="account__label">User Name</span>
|
||||
<span className="account__value">{account.user_name}</span>
|
||||
</div>
|
||||
<div className="account__row account__row--last">
|
||||
<span className="account__label">User ID</span>
|
||||
<span className="account__value text-mono account__uuid">{account.user_id}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Subscription Section ─────────────────── */}
|
||||
<div className="section mb-lg">
|
||||
<h2 className="account__section-title">Subscription</h2>
|
||||
<div className="account__row">
|
||||
<span className="account__label">Plan</span>
|
||||
<span className="account__value">{account.subscription_plan}</span>
|
||||
</div>
|
||||
<div className="account__row">
|
||||
<span className="account__label">Status</span>
|
||||
<span className={`account__value account__status account__status--${isCanceling ? "canceling" : account.subscription_status}`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
{account.member_since && (
|
||||
<div className="account__row">
|
||||
<span className="account__label">Member Since</span>
|
||||
<span className="account__value">{account.member_since}</span>
|
||||
</div>
|
||||
)}
|
||||
{account.next_billing_date && !isCanceling && (
|
||||
<div className="account__row">
|
||||
<span className="account__label">Next Billing Date</span>
|
||||
<span className="account__value">{account.next_billing_date}</span>
|
||||
</div>
|
||||
)}
|
||||
{isCanceling && account.period_end_date && (
|
||||
<div className="account__row">
|
||||
<span className="account__label">Access Until</span>
|
||||
<span className="account__value">{account.period_end_date}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCanceling && (
|
||||
<div className="alert alert--warning mt-md">
|
||||
Your subscription has been canceled and will not renew.
|
||||
You have full access until {account.period_end_date}.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="account__portal-section">
|
||||
<button
|
||||
onClick={handleManageSubscription}
|
||||
disabled={portalLoading}
|
||||
className="btn btn--ghost"
|
||||
>
|
||||
{portalLoading ? "Redirecting..." : "Manage Subscription"}
|
||||
</button>
|
||||
<p className="text-dimmed text-sm account__portal-hint">
|
||||
Cancel subscription, update payment method, or view invoices via Stripe.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Change Password Section ──────────────── */}
|
||||
<div className="section">
|
||||
<h2 className="account__section-title">Change Password</h2>
|
||||
|
||||
{passwordMsg && <div className="alert alert--success mb-md">{passwordMsg}</div>}
|
||||
{passwordErr && <div className="alert alert--error mb-md">{passwordErr}</div>}
|
||||
|
||||
<form onSubmit={handlePasswordChange} className="account__password-form">
|
||||
<div className="form-field">
|
||||
<label className="form-label">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={changingPassword}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field form-field--last">
|
||||
<label className="form-label">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={changingPassword}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={changingPassword} className="btn btn--primary">
|
||||
{changingPassword ? "Updating..." : "Update Password"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user