Compare commits

...

5 Commits

Author SHA1 Message Date
KS Jannette
4b77e7a4d4 add user account page and associated functionaility to support it
Some checks are pending
check / check (push) Waiting to run
2026-03-05 08:25:24 -05:00
KS Jannette
8f1212813f edits 2026-03-05 07:39:46 -05:00
S Jannette
95ec1a520a Merge pull request #22 from kjannette/frontend-feature-roundup
style
2026-03-05 07:27:41 -05:00
KS Jannette
4b228d9b57 style
Some checks are pending
check / check (push) Waiting to run
2026-03-05 07:19:12 -05:00
S Jannette
08bc3e1ea2 Merge pull request #21 from kjannette/poller-refinements-3
adjust rpc call to eth block timing
2026-03-05 06:51:54 -05:00
12 changed files with 452 additions and 14 deletions

View File

@@ -6,12 +6,12 @@ last_modified: 2026-02-22
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents: 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) (general, bash, Docker)
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md) - [Go](<link>)
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md) - [JavaScript](<link>)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md) - [Python](<link>)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md) - [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 test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and `make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
`make hooks` (installs pre-commit hook). A model Makefile is at `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.) - Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single 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`), - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`. editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from 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. a new repo.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name. - 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 - `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from 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 - When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD). 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: - `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose, - **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example: 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." git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block. - **Getting Started**: Copy-pasteable install/usage code block.
- **Rationale**: Why does this exist? - **Rationale**: Why does this exist?
@@ -134,12 +134,11 @@ style conventions are in separate documents:
left off. left off.
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a - **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. `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`. - First commit of a new repo should contain only `README.md`.
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before - Always run `go mod tidy` before committing.
committing.
- Use SemVer. - 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 - When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from templates. Fetch them from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`. `(<link>)<path>`.
- New repos must contain at minimum: - New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig` - `README.md`, `.git`, `.gitignore`, `.editorconfig`

View File

@@ -62,6 +62,7 @@ func main() {
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel) emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
statusHandler := handlers.NewStatusHandler(checkpointModel) statusHandler := handlers.NewStatusHandler(checkpointModel)
stripeHandler := handlers.NewStripeHandler(userModel, cfg) stripeHandler := handlers.NewStripeHandler(userModel, cfg)
accountHandler := handlers.NewAccountHandler(userModel, cfg)
authenticate := middleware.Authenticate(userModel) authenticate := middleware.Authenticate(userModel)
requireSub := middleware.RequireSubscription(userModel) requireSub := middleware.RequireSubscription(userModel)
@@ -88,6 +89,12 @@ func main() {
authenticate(http.HandlerFunc(stripeHandler.GetSubscriptionStatus))) authenticate(http.HandlerFunc(stripeHandler.GetSubscriptionStatus)))
mux.Handle("POST "+b+"/stripe/verify-checkout", mux.Handle("POST "+b+"/stripe/verify-checkout",
authenticate(http.HandlerFunc(stripeHandler.VerifyCheckoutSession))) 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 // Authenticated + subscribed routes — addresses
mux.Handle("POST "+b+"/addresses", mux.Handle("POST "+b+"/addresses",

View 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)
}

View File

@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"github.com/stripe/stripe-go/v82" "github.com/stripe/stripe-go/v82"
portalsession "github.com/stripe/stripe-go/v82/billingportal/session"
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session" checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
"github.com/stripe/stripe-go/v82/webhook" "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"}) 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. // HandleWebhook processes incoming Stripe webhook events.
// This endpoint must NOT require authentication (Stripe calls it directly). // This endpoint must NOT require authentication (Stripe calls it directly).
func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {

View File

@@ -7,6 +7,7 @@ import Subscribe from "./pages/subscribe/subscribe";
import Addresses from "./pages/addresses/Addresses"; import Addresses from "./pages/addresses/Addresses";
import Alerts from "./pages/alerts/Alerts"; import Alerts from "./pages/alerts/Alerts";
import AlertHistory from "./pages/alertHistory/AlertHistory"; import AlertHistory from "./pages/alertHistory/AlertHistory";
import Account from "./pages/user_account/Account";
export default function App() { export default function App() {
const { currentUser } = useAuth(); const { currentUser } = useAuth();
@@ -30,6 +31,7 @@ export default function App() {
<Route path="/addresses" element={<Addresses />} /> <Route path="/addresses" element={<Addresses />} />
<Route path="/alerts" element={<Alerts />} /> <Route path="/alerts" element={<Alerts />} />
<Route path="/alertevents" element={<AlertHistory />} /> <Route path="/alertevents" element={<AlertHistory />} />
<Route path="/account" element={<Account />} />
<Route path="/subscribe" element={<Subscribe />} /> <Route path="/subscribe" element={<Subscribe />} />
<Route path="*" element={<Navigate to="/addresses" />} /> <Route path="*" element={<Navigate to="/addresses" />} />
</Routes> </Routes>

View 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();
}

View File

@@ -43,6 +43,16 @@
color: #777; 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 { .navbar__logout {
padding: 0.4rem 1rem; padding: 0.4rem 1rem;
font-size: 1.275rem; font-size: 1.275rem;

View File

@@ -35,7 +35,7 @@ export default function Navbar() {
</div> </div>
<div className="flex flex--center gap-lg"> <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"> <button onClick={logout} className="navbar__logout">
Logout Logout
</button> </button>

View File

@@ -79,6 +79,18 @@
animation-delay: 3s; animation-delay: 3s;
} }
.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 { .login-interactive-fadein {
opacity: 0; opacity: 0;
animation: fadeIn 2s ease-in-out forwards; animation: fadeIn 2s ease-in-out forwards;

View File

@@ -87,6 +87,11 @@ export default function Login() {
</div> </div>
</div> </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> </div>
); );
} }

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

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