From aa9488755f795184b5c3307a81e752d098b2a5ab Mon Sep 17 00:00:00 2001 From: Nico Date: Mon, 16 Feb 2026 01:49:03 -0800 Subject: [PATCH] feat: enable cookie auth flow and database url runtime config --- backend/.env.example | 11 ++++ backend/app.js | 79 ++++++++++++----------- backend/db/pool.js | 24 +++++-- frontend/src/api/auth.js | 13 ++-- frontend/src/api/axios.js | 23 +++++-- frontend/src/components/layout/Navbar.jsx | 17 ++++- frontend/src/context/AuthContext.jsx | 51 ++++++++------- 7 files changed, 139 insertions(+), 79 deletions(-) create mode 100644 backend/.env.example diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..acb7711 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,11 @@ +DATABASE_URL=postgres://username:password@db-host:5432/database_name +DB_USER= +DB_PASS= +DB_HOST= +DB_PORT=5432 +DB_NAME= +PORT=5000 +JWT_SECRET=change-me +ALLOWED_ORIGINS=http://localhost:3000 +SESSION_COOKIE_NAME=sid +SESSION_TTL_DAYS=30 diff --git a/backend/app.js b/backend/app.js index 180cc83..fa41196 100644 --- a/backend/app.js +++ b/backend/app.js @@ -8,51 +8,56 @@ const { sendError } = require("./utils/http"); const app = express(); app.use(requestIdMiddleware); app.use(express.json()); - -// Serve static files from public directory -app.use('/test', express.static(path.join(__dirname, 'public'))); - -const allowedOrigins = process.env.ALLOWED_ORIGINS.split(",").map(origin => origin.trim()); + +// Serve static files from public directory +app.use('/test', express.static(path.join(__dirname, 'public'))); + +const allowedOrigins = (process.env.ALLOWED_ORIGINS || "") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); app.use( cors({ - origin: function (origin, callback) { - if (!origin) return callback(null, true); - if (allowedOrigins.includes(origin)) return callback(null, true); - if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true); - if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true); - console.error(`🚫 CORS blocked origin: ${origin}`); - callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`)); - }, - methods: ["GET", "POST", "PUT", "DELETE", "PATCH"], - }) -); - + origin: function (origin, callback) { + if (!origin) return callback(null, true); + if (allowedOrigins.includes(origin)) return callback(null, true); + if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true); + if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true); + console.error(`CORS blocked origin: ${origin}`); + callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`)); + }, + methods: ["GET", "POST", "PUT", "DELETE", "PATCH"], + credentials: true, + exposedHeaders: ["X-Request-Id"], + }) +); + app.get('/', async (req, res) => { res.status(200).json({ message: "Grocery List API is running.", roles: Object.values(User.ROLES), }); }); - - -const authRoutes = require("./routes/auth.routes"); -app.use("/auth", authRoutes); - -const listRoutes = require("./routes/list.routes"); -app.use("/list", listRoutes); - -const adminRoutes = require("./routes/admin.routes"); -app.use("/admin", adminRoutes); - -const usersRoutes = require("./routes/users.routes"); -app.use("/users", usersRoutes); - -const configRoutes = require("./routes/config.routes"); -app.use("/config", configRoutes); - -const householdsRoutes = require("./routes/households.routes"); -app.use("/households", householdsRoutes); - + + +const authRoutes = require("./routes/auth.routes"); +app.use("/auth", authRoutes); + +const listRoutes = require("./routes/list.routes"); +app.use("/list", listRoutes); + +const adminRoutes = require("./routes/admin.routes"); +app.use("/admin", adminRoutes); + +const usersRoutes = require("./routes/users.routes"); +app.use("/users", usersRoutes); + +const configRoutes = require("./routes/config.routes"); +app.use("/config", configRoutes); + +const householdsRoutes = require("./routes/households.routes"); +app.use("/households", householdsRoutes); + const storesRoutes = require("./routes/stores.routes"); app.use("/stores", storesRoutes); diff --git a/backend/db/pool.js b/backend/db/pool.js index 38df9bf..38f0286 100644 --- a/backend/db/pool.js +++ b/backend/db/pool.js @@ -1,11 +1,21 @@ const { Pool } = require("pg"); -const pool = new Pool({ - user: process.env.DB_USER, - password: process.env.DB_PASS, - host: process.env.DB_HOST, - database: process.env.DB_NAME, - port: 5432, -}); +function buildPoolConfig() { + if (process.env.DATABASE_URL) { + return { + connectionString: process.env.DATABASE_URL, + }; + } + + return { + user: process.env.DB_USER, + password: process.env.DB_PASS, + host: process.env.DB_HOST, + database: process.env.DB_NAME, + port: Number(process.env.DB_PORT || 5432), + }; +} + +const pool = new Pool(buildPoolConfig()); module.exports = pool; diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js index 2b271c7..180302a 100644 --- a/frontend/src/api/auth.js +++ b/frontend/src/api/auth.js @@ -5,7 +5,12 @@ export const loginRequest = async (username, password) => { return res.data; }; -export const registerRequest = async (username, password, name) => { - const res = await api.post("/auth/register", { username, password, name }); - return res.data; -}; \ No newline at end of file +export const registerRequest = async (username, password, name) => { + const res = await api.post("/auth/register", { username, password, name }); + return res.data; +}; + +export const logoutRequest = async () => { + const res = await api.post("/auth/logout"); + return res.data; +}; diff --git a/frontend/src/api/axios.js b/frontend/src/api/axios.js index 3b302ad..f10c1c4 100644 --- a/frontend/src/api/axios.js +++ b/frontend/src/api/axios.js @@ -1,11 +1,12 @@ import axios from "axios"; import { API_BASE_URL } from "../config"; -const api = axios.create({ - baseURL: API_BASE_URL, - headers: { - "Content-Type": "application/json", - }, +const api = axios.create({ + baseURL: API_BASE_URL, + withCredentials: true, + headers: { + "Content-Type": "application/json", + }, }); api.interceptors.request.use((config => { @@ -40,8 +41,16 @@ api.interceptors.response.use( payload.message = payload.error.message; } - if (error.response?.status === 401 && - normalizedMessage === "Invalid or expired token") { + if ( + error.response?.status === 401 && + window.location.pathname !== "/login" && + window.location.pathname !== "/register" && + [ + "Invalid or expired token", + "Invalid or expired session", + "Missing authentication", + ].includes(normalizedMessage) + ) { localStorage.removeItem("token"); window.location.href = "/login"; alert("Your session has expired. Please log in again."); diff --git a/frontend/src/components/layout/Navbar.jsx b/frontend/src/components/layout/Navbar.jsx index 1a7c82f..cd2945a 100644 --- a/frontend/src/components/layout/Navbar.jsx +++ b/frontend/src/components/layout/Navbar.jsx @@ -2,6 +2,7 @@ import "../../styles/components/Navbar.css"; import { useContext, useState } from "react"; import { Link } from "react-router-dom"; +import { logoutRequest } from "../../api/auth"; import { AuthContext } from "../../context/AuthContext"; import HouseholdSwitcher from "../household/HouseholdSwitcher"; @@ -15,6 +16,18 @@ export default function Navbar() { setShowUserMenu(false); }; + const handleLogout = async () => { + try { + await logoutRequest(); + } catch (_) { + // Clear local auth state even if server logout fails. + } finally { + logout(); + closeMenus(); + window.location.href = "/login"; + } + }; + return ( ); -} \ No newline at end of file +} diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 046babd..ca3627c 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -9,28 +9,35 @@ export const AuthContext = createContext({ logout: () => { }, }); -export const AuthProvider = ({ children }) => { - const [token, setToken] = useState(localStorage.getItem('token') || null); - const [userId, setUserId] = useState(localStorage.getItem('userId') || null); - const [role, setRole] = useState(localStorage.getItem('role') || null); - const [username, setUsername] = useState(localStorage.getItem('username') || null); +export const AuthProvider = ({ children }) => { + const [token, setToken] = useState(localStorage.getItem('token') || null); + const [userId, setUserId] = useState(localStorage.getItem('userId') || null); + const [role, setRole] = useState(localStorage.getItem('role') || null); + const [username, setUsername] = useState(localStorage.getItem('username') || null); + + const clearAuthStorage = () => { + localStorage.removeItem("token"); + localStorage.removeItem("userId"); + localStorage.removeItem("role"); + localStorage.removeItem("username"); + }; - const login = (data) => { - localStorage.setItem('token', data.token); - localStorage.setItem('userId', data.userId); - localStorage.setItem('role', data.role); - localStorage.setItem('username', data.username); - setToken(data.token); - setUserId(data.userId); - setRole(data.role); - setUsername(data.username); - }; - - const logout = () => { - localStorage.clear(); - - setToken(null); - setUserId(null); + const login = (data) => { + localStorage.setItem('token', data.token); + localStorage.setItem('userId', data.userId); + localStorage.setItem('role', data.role); + localStorage.setItem('username', data.username); + setToken(data.token); + setUserId(data.userId); + setRole(data.role); + setUsername(data.username); + }; + + const logout = () => { + clearAuthStorage(); + + setToken(null); + setUserId(null); setRole(null); setUsername(null); }; @@ -49,4 +56,4 @@ export const AuthProvider = ({ children }) => { {children} ); -}; \ No newline at end of file +};