metric f ton
This commit is contained in:
@@ -1,31 +1,28 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useAuth } from "./contexts/AuthContext";
|
||||
import { useContext } from 'react'
|
||||
import Navbar from "./components/Navbar";
|
||||
import Login from "./pages/login/Login";
|
||||
import Signup from "./pages/Signup";
|
||||
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";
|
||||
import { AuthProvider } from "./ShopContext";
|
||||
import useAuth from "./ShopContext";
|
||||
|
||||
export default function App() {
|
||||
const { context } = useContext(ShopContext)
|
||||
const { isAuthenticated, isSubscribed, user } = useAuth();
|
||||
|
||||
if (!currentUser) {
|
||||
// Unauthenticated: show login + onboarding routes only
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/subscribe" element={<Subscribe />} />
|
||||
<Route path="*" element={<Navigate to="/login" />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticated but no active subscription: force through subscribe flow
|
||||
if (!isSubscribed) {
|
||||
return (
|
||||
<Routes>
|
||||
@@ -36,6 +33,7 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
// Fully authenticated + subscribed: main app
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
|
||||
27
frontend/src/api/auth.js
Normal file
27
frontend/src/api/auth.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { API_BASE } from "./config";
|
||||
|
||||
export async function loginUser(email, password) {
|
||||
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message || "Login failed");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function registerAfterCheckout(email, password, sessionId) {
|
||||
const res = await fetch(`${API_BASE}/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password, session_id: sessionId || "" }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.message || "Registration failed");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -1,46 +1,31 @@
|
||||
/**
|
||||
* Auth Headers Helper
|
||||
*
|
||||
* Provides authentication headers for API calls
|
||||
* Includes Firebase ID token in Authorization header
|
||||
* Provides authentication headers for API calls.
|
||||
* Reads the JWT from localStorage (set by AuthContext on login).
|
||||
*/
|
||||
|
||||
import { auth } from "../firebase/config";
|
||||
export function getAuthHeaders() {
|
||||
const token = localStorage.getItem("kp_token");
|
||||
|
||||
/**
|
||||
* Get headers with authentication token
|
||||
* @returns {Promise<Object>} Headers object with Authorization
|
||||
*/
|
||||
export async function getAuthHeaders() {
|
||||
const currentUser = auth.currentUser;
|
||||
if (!token) {
|
||||
throw new Error("No authenticated user");
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
throw new Error("No authenticated user");
|
||||
}
|
||||
|
||||
// Get Firebase ID token
|
||||
const token = await currentUser.getIdToken();
|
||||
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get headers for non-JSON requests (e.g., DELETE with no body)
|
||||
* @returns {Promise<Object>} Headers object with Authorization
|
||||
*/
|
||||
export async function getAuthHeadersSimple() {
|
||||
const currentUser = auth.currentUser;
|
||||
export function getAuthHeadersSimple() {
|
||||
const token = localStorage.getItem("kp_token");
|
||||
|
||||
if (!currentUser) {
|
||||
throw new Error("No authenticated user");
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error("No authenticated user");
|
||||
}
|
||||
|
||||
const token = await currentUser.getIdToken();
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ const navLinks = [
|
||||
];
|
||||
|
||||
export default function Navbar() {
|
||||
const { currentUser, logout } = useAuth();
|
||||
const { user, logout } = useAuth();
|
||||
const location = useLocation();
|
||||
const [isNavPanelOpen, setIsNavPanelOpen] = useState(false);
|
||||
|
||||
if (!currentUser) return null;
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -24,7 +24,7 @@ export default function Navbar() {
|
||||
<div className="navbar__brand-group">
|
||||
<span className="navbar__brand">Koin Ping</span>
|
||||
<Link to="/account" className="navbar__user-link navbar__user-link--mobile">
|
||||
{currentUser.email}
|
||||
{user.email}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="navbar__links">
|
||||
@@ -45,7 +45,7 @@ export default function Navbar() {
|
||||
|
||||
<div className="navbar__right">
|
||||
<Link to="/account" className="navbar__user navbar__user-link navbar__user-link--desktop">
|
||||
{currentUser.email}
|
||||
{user.email}
|
||||
</Link>
|
||||
<button onClick={logout} className="navbar__logout">
|
||||
Logout
|
||||
|
||||
@@ -1,54 +1,77 @@
|
||||
import { createContext, useReducer, useContext } from "react";
|
||||
import shopReducer, { initialState } from "./shopReducer";
|
||||
import { createContext, useReducer, useContext, useEffect } from "react";
|
||||
import authReducer, { initialState, ACTION_TYPES } from "../reducers/authReducer";
|
||||
import { loginUser, registerAfterCheckout } from "../api/auth";
|
||||
import { getAccount } from "../api/account";
|
||||
|
||||
const AuthContext = createContext(initialState);
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(shopReducer, initialState);
|
||||
export function AuthProvider({ children }) {
|
||||
const [state, dispatch] = useReducer(authReducer, initialState);
|
||||
|
||||
const addAuthUser = (args) => {
|
||||
const updatedUser = state.user.concat(product);
|
||||
updatePrice(updatedCart);
|
||||
dispatch({
|
||||
type: "ADD_AUTH_USER",
|
||||
payload: {
|
||||
args: newUser
|
||||
}
|
||||
});
|
||||
};
|
||||
// On mount, if we have a token in localStorage, hydrate the user object
|
||||
// by fetching the account from the backend.
|
||||
useEffect(() => {
|
||||
if (!state.token || state.user) return;
|
||||
|
||||
const logoutAuthUser = (args) => {
|
||||
const updatedUser = state.user.filter(
|
||||
(currentUser) => currentUser.name !== args.name
|
||||
);
|
||||
updateUser(updatedCart);
|
||||
getAccount()
|
||||
.then((account) => {
|
||||
dispatch({
|
||||
type: ACTION_TYPES.SET_USER,
|
||||
payload: {
|
||||
id: account.user_id,
|
||||
email: account.email,
|
||||
subscriptionStatus: account.subscription_status,
|
||||
subscriptionTier: account.subscription_tier,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch({ type: ACTION_TYPES.LOGOUT });
|
||||
});
|
||||
}, [state.token, state.user]);
|
||||
|
||||
dispatch({
|
||||
type: "REMOVE_AUTH_USER",
|
||||
payload: {
|
||||
args: updatedCart
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const value = {
|
||||
authUser: state.name,
|
||||
authUserTier: state.tier,
|
||||
addAuthUser,
|
||||
logoutAuthUser
|
||||
};
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
const useAuth = () => {
|
||||
const context = useContext(ShopContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error("authUser must be used within AuthContext");
|
||||
async function login(email, password) {
|
||||
const data = await loginUser(email, password);
|
||||
dispatch({ type: ACTION_TYPES.LOGIN_SUCCESS, payload: data });
|
||||
return data;
|
||||
}
|
||||
|
||||
async function register(email, password, sessionId) {
|
||||
const data = await registerAfterCheckout(email, password, sessionId);
|
||||
dispatch({ type: ACTION_TYPES.LOGIN_SUCCESS, payload: data });
|
||||
return data;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
dispatch({ type: ACTION_TYPES.LOGOUT });
|
||||
}
|
||||
|
||||
const isAuthenticated = state.isAuthenticated && !!state.token;
|
||||
const isSubscribed =
|
||||
state.user?.subscriptionStatus === "active" ||
|
||||
state.user?.subscriptionStatus === "trialing";
|
||||
|
||||
const value = {
|
||||
user: state.user,
|
||||
token: state.token,
|
||||
isAuthenticated,
|
||||
isSubscribed,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
dispatch,
|
||||
ACTION_TYPES,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
}
|
||||
|
||||
export default useAuth;
|
||||
|
||||
@@ -2,18 +2,15 @@ import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
import { UserPropertiesProvider } from "./contexts/UserPropertiesContext";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<UserPropertiesProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</UserPropertiesProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -27,7 +27,11 @@ export default function Login() {
|
||||
await login(email, password);
|
||||
navigate("/addresses");
|
||||
} catch (err) {
|
||||
setError("Failed to log in: " + err.message);
|
||||
if (err.message.includes("Invalid email or password")) {
|
||||
setError("Invalid email or password. Check your credentials or create a new account.");
|
||||
} else {
|
||||
setError("Failed to log in: " + err.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -80,7 +84,7 @@ export default function Login() {
|
||||
<div className="login-footer">
|
||||
<p className="text-muted">
|
||||
Don't have an account?{" "}
|
||||
<Link to="/signup" className="login-signup-link">
|
||||
<Link to="/subscribe" className="login-signup-link">
|
||||
Sign up here
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useUserProperties } from "../../contexts/UserPropertiesContext";
|
||||
import {
|
||||
createOnboardingCheckout,
|
||||
verifyCheckoutSession,
|
||||
activateFreeTier,
|
||||
} from "../../api/stripe";
|
||||
import { createOnboardingCheckout, activateFreeTier } from "../../api/stripe";
|
||||
import Input from "../../components/Input";
|
||||
import Button from "../../components/Button";
|
||||
import TierPicker from "../../components/TierPicker";
|
||||
@@ -15,13 +10,12 @@ import "./Subscribe.css";
|
||||
const STEPS = ["Create Account", "Choose Plan"];
|
||||
|
||||
export default function Subscribe() {
|
||||
const { currentUser, signup } = useAuth();
|
||||
const { state: userProps, dispatch, ACTION_TYPES } = useUserProperties();
|
||||
const { isAuthenticated, register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const hasPaymentReturn = searchParams.get("payment") === "success";
|
||||
const [step, setStep] = useState(hasPaymentReturn || currentUser ? 2 : 1);
|
||||
const [step, setStep] = useState(hasPaymentReturn || isAuthenticated ? 2 : 1);
|
||||
const [loading, setLoading] = useState(hasPaymentReturn);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -36,7 +30,7 @@ export default function Subscribe() {
|
||||
setData((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
// Handle Stripe payment returns
|
||||
// Handle return from Stripe checkout redirect
|
||||
useEffect(() => {
|
||||
const payment = searchParams.get("payment");
|
||||
const sessionId = searchParams.get("session_id");
|
||||
@@ -50,41 +44,33 @@ export default function Subscribe() {
|
||||
|
||||
if (payment !== "success" || !sessionId) return;
|
||||
|
||||
// Phase 1: no account yet — create it. Auth state change remounts the
|
||||
// component (App.jsx swaps route trees) and Phase 2 runs on the next mount.
|
||||
if (!currentUser) {
|
||||
if (!userProps.email || !userProps.password) {
|
||||
setSearchParams({}, { replace: true });
|
||||
setError("Session expired. Please start the signup process again.");
|
||||
setStep(1);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
signup(userProps.email, userProps.password).catch((err) => {
|
||||
setSearchParams({}, { replace: true });
|
||||
setError("Account creation failed: " + err.message);
|
||||
setStep(1);
|
||||
setLoading(false);
|
||||
});
|
||||
// Recover credentials stashed before the Stripe redirect
|
||||
const savedEmail = sessionStorage.getItem("kp_onboard_email");
|
||||
const savedPassword = sessionStorage.getItem("kp_onboard_password");
|
||||
|
||||
if (!savedEmail || !savedPassword) {
|
||||
setSearchParams({}, { replace: true });
|
||||
setError("Session expired. Please start the signup process again.");
|
||||
setStep(1);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: authenticated — verify the checkout and go to the dashboard.
|
||||
setSearchParams({}, { replace: true });
|
||||
setLoading(true);
|
||||
dispatch({ type: ACTION_TYPES.CLEAR_USER_PROPERTIES });
|
||||
|
||||
verifyCheckoutSession(sessionId)
|
||||
register(savedEmail, savedPassword, sessionId)
|
||||
.then(() => {
|
||||
sessionStorage.removeItem("kp_onboard_email");
|
||||
sessionStorage.removeItem("kp_onboard_password");
|
||||
navigate("/addresses", { replace: true });
|
||||
})
|
||||
.catch((err) => {
|
||||
setError("Payment verification failed: " + err.message);
|
||||
setStep(2);
|
||||
setError("Registration failed: " + err.message);
|
||||
setStep(1);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [currentUser, searchParams, setSearchParams, signup, navigate, userProps, dispatch, ACTION_TYPES]);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Step handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -114,31 +100,28 @@ export default function Subscribe() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Free tier: register immediately, then activate free tier
|
||||
if (data.selectedTier === "free") {
|
||||
if (!currentUser) {
|
||||
await signup(data.email, data.password);
|
||||
if (!isAuthenticated) {
|
||||
await register(data.email, data.password, "");
|
||||
}
|
||||
await activateFreeTier();
|
||||
navigate("/addresses", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: ACTION_TYPES.SET_USER_PROPERTIES,
|
||||
payload: { email: data.email, password: data.password },
|
||||
});
|
||||
// Paid tier: stash credentials in sessionStorage, then redirect to Stripe
|
||||
sessionStorage.setItem("kp_onboard_email", data.email);
|
||||
sessionStorage.setItem("kp_onboard_password", data.password);
|
||||
|
||||
const { url } = await createOnboardingCheckout(
|
||||
data.email,
|
||||
data.selectedTier,
|
||||
);
|
||||
window.location.href = url;
|
||||
} catch (err) {
|
||||
if (err.code === "auth/email-already-in-use") {
|
||||
setError("Email already in use. Try logging in instead.");
|
||||
} else if (err.code === "auth/invalid-email") {
|
||||
setError("Invalid email address");
|
||||
} else if (err.code === "auth/weak-password") {
|
||||
setError("Password is too weak");
|
||||
if (err.message.includes("already exists")) {
|
||||
setError("An account with this email already exists. Try logging in instead.");
|
||||
} else {
|
||||
setError("Failed to process plan selection: " + err.message);
|
||||
}
|
||||
@@ -269,7 +252,7 @@ export default function Subscribe() {
|
||||
return (
|
||||
<div className="subscribe__footer">
|
||||
<div>
|
||||
{step === 2 && (
|
||||
{step === 2 && !isAuthenticated && (
|
||||
<Button onClick={handleBack} disabled={loading} variant="ghost">
|
||||
← Back
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { updatePassword } from "firebase/auth";
|
||||
import { auth } from "../../firebase/config";
|
||||
import { getAccount, createPortalSession } from "../../api/account";
|
||||
import "./Account.css";
|
||||
|
||||
@@ -55,7 +53,8 @@ export default function Account() {
|
||||
|
||||
try {
|
||||
setChangingPassword(true);
|
||||
await updatePassword(auth.currentUser, newPassword);
|
||||
// TODO: implement password change endpoint on backend
|
||||
throw new Error("Password change not yet implemented");
|
||||
setPasswordMsg("Password updated successfully");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
|
||||
@@ -1,32 +1,56 @@
|
||||
import { useReducer } from "react"
|
||||
|
||||
const UserAuthContext = createContext();
|
||||
|
||||
export const initialState = {
|
||||
authUser: null,
|
||||
AuthTier: null
|
||||
export const ACTION_TYPES = {
|
||||
LOGIN_SUCCESS: "LOGIN_SUCCESS",
|
||||
SET_USER: "SET_USER",
|
||||
LOGOUT: "LOGOUT",
|
||||
AUTH_ERROR: "AUTH_ERROR",
|
||||
};
|
||||
|
||||
const authReducer = (state, action) => {
|
||||
const { type, payload = "FREE_TIER" } = action
|
||||
export const initialState = {
|
||||
user: null,
|
||||
token: localStorage.getItem("kp_token") || null,
|
||||
isAuthenticated: !!localStorage.getItem("kp_token"),
|
||||
error: null,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case "ADD_AUTH_USER":
|
||||
console.log("ADD_AUTH_USER", payload);
|
||||
export default function authReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case ACTION_TYPES.LOGIN_SUCCESS:
|
||||
localStorage.setItem("kp_token", action.payload.token);
|
||||
return {
|
||||
...state,
|
||||
auth: payload
|
||||
user: {
|
||||
id: action.payload.user_id,
|
||||
email: action.payload.email,
|
||||
subscriptionStatus: action.payload.subscription_status,
|
||||
subscriptionTier: action.payload.subscription_tier,
|
||||
},
|
||||
token: action.payload.token,
|
||||
isAuthenticated: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
case ACTION_TYPES.SET_USER:
|
||||
return {
|
||||
...state,
|
||||
user: action.payload,
|
||||
error: null,
|
||||
};
|
||||
|
||||
case ACTION_TYPES.LOGOUT:
|
||||
localStorage.removeItem("kp_token");
|
||||
return {
|
||||
...initialState,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
};
|
||||
|
||||
case ACTION_TYPES.AUTH_ERROR:
|
||||
return {
|
||||
...state,
|
||||
error: action.payload,
|
||||
};
|
||||
|
||||
default:
|
||||
throw new Error(`No case for type ${type} found.`);
|
||||
}
|
||||
switch (type) {
|
||||
case "DELETE_AUTH_USER":
|
||||
return {
|
||||
...state,
|
||||
users: state.users.filter(user => user.id !== action.userIdToDelete)
|
||||
};
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default shopReducer
|
||||
Reference in New Issue
Block a user