From 8f1212813f0ef80b2e3f23477a449b3d6be9453f Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Thu, 5 Mar 2026 07:39:46 -0500 Subject: [PATCH 1/2] edits --- REPO_POLICIES.md | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md index a0abedf..8f43d99 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -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]() (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]() +- [JavaScript]() +- [Python]() +- [Go HTTP Server Conventions]() --- @@ -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`. + `()`. - 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 + `() 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`. + `()`. - 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/`. 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/`. + `()`. - New repos must contain at minimum: - `README.md`, `.git`, `.gitignore`, `.editorconfig` From 4b77e7a4d49453f8261951fd67f5739702e09bfe Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Thu, 5 Mar 2026 08:25:24 -0500 Subject: [PATCH 2/2] add user account page and associated functionaility to support it --- backend/cmd/api/main.go | 7 + backend/internal/handlers/account.go | 66 +++++++ backend/internal/handlers/stripe.go | 33 ++++ frontend/src/App.jsx | 2 + frontend/src/api/account.js | 25 +++ frontend/src/components/Navbar.css | 10 + frontend/src/components/Navbar.jsx | 2 +- frontend/src/pages/user_account/Account.css | 77 ++++++++ frontend/src/pages/user_account/Account.jsx | 202 ++++++++++++++++++++ 9 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 backend/internal/handlers/account.go create mode 100644 frontend/src/api/account.js create mode 100644 frontend/src/pages/user_account/Account.css create mode 100644 frontend/src/pages/user_account/Account.jsx diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 97be3be..84c6d4d 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -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", diff --git a/backend/internal/handlers/account.go b/backend/internal/handlers/account.go new file mode 100644 index 0000000..030a4a3 --- /dev/null +++ b/backend/internal/handlers/account.go @@ -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) +} diff --git a/backend/internal/handlers/stripe.go b/backend/internal/handlers/stripe.go index ac56290..de48e8f 100644 --- a/backend/internal/handlers/stripe.go +++ b/backend/internal/handlers/stripe.go @@ -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) { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 99d7b54..8eca37a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/account.js b/frontend/src/api/account.js new file mode 100644 index 0000000..8179d55 --- /dev/null +++ b/frontend/src/api/account.js @@ -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(); +} diff --git a/frontend/src/components/Navbar.css b/frontend/src/components/Navbar.css index 9f1578c..e9ac7ee 100644 --- a/frontend/src/components/Navbar.css +++ b/frontend/src/components/Navbar.css @@ -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; diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index bc4a110..57396bc 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -35,7 +35,7 @@ export default function Navbar() {
- {currentUser.email} + {currentUser.email} diff --git a/frontend/src/pages/user_account/Account.css b/frontend/src/pages/user_account/Account.css new file mode 100644 index 0000000..1364f73 --- /dev/null +++ b/frontend/src/pages/user_account/Account.css @@ -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; +} diff --git a/frontend/src/pages/user_account/Account.jsx b/frontend/src/pages/user_account/Account.jsx new file mode 100644 index 0000000..b3ae918 --- /dev/null +++ b/frontend/src/pages/user_account/Account.jsx @@ -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
Loading...
; + } + + if (error && !account) { + return
Error: {error}
; + } + + 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 ( +
+

Account

+ + {error &&
{error}
} + + {/* ── Profile Section ──────────────────────── */} +
+

Profile

+
+ Email + {account.email} +
+
+ User Name + {account.user_name} +
+
+ User ID + {account.user_id} +
+
+ + {/* ── Subscription Section ─────────────────── */} +
+

Subscription

+
+ Plan + {account.subscription_plan} +
+
+ Status + + {statusLabel} + +
+ {account.member_since && ( +
+ Member Since + {account.member_since} +
+ )} + {account.next_billing_date && !isCanceling && ( +
+ Next Billing Date + {account.next_billing_date} +
+ )} + {isCanceling && account.period_end_date && ( +
+ Access Until + {account.period_end_date} +
+ )} + + {isCanceling && ( +
+ Your subscription has been canceled and will not renew. + You have full access until {account.period_end_date}. +
+ )} + +
+ +

+ Cancel subscription, update payment method, or view invoices via Stripe. +

+
+
+ + {/* ── Change Password Section ──────────────── */} +
+

Change Password

+ + {passwordMsg &&
{passwordMsg}
} + {passwordErr &&
{passwordErr}
} + +
+
+ + setNewPassword(e.target.value)} + disabled={changingPassword} + required + /> +
+
+ + setConfirmPassword(e.target.value)} + disabled={changingPassword} + required + /> +
+ +
+
+
+ ); +}