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