feat: enable cookie auth flow and database url runtime config

This commit is contained in:
Nico 2026-02-16 01:49:03 -08:00
parent 119994b602
commit aa9488755f
7 changed files with 139 additions and 79 deletions

11
backend/.env.example Normal file
View File

@ -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

View File

@ -8,51 +8,56 @@ const { sendError } = require("./utils/http");
const app = express(); const app = express();
app.use(requestIdMiddleware); app.use(requestIdMiddleware);
app.use(express.json()); app.use(express.json());
// Serve static files from public directory // Serve static files from public directory
app.use('/test', express.static(path.join(__dirname, 'public'))); app.use('/test', express.static(path.join(__dirname, 'public')));
const allowedOrigins = process.env.ALLOWED_ORIGINS.split(",").map(origin => origin.trim()); const allowedOrigins = (process.env.ALLOWED_ORIGINS || "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
app.use( app.use(
cors({ cors({
origin: function (origin, callback) { origin: function (origin, callback) {
if (!origin) return callback(null, true); if (!origin) return callback(null, true);
if (allowedOrigins.includes(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 (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
if (/^https:\/\/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}`); console.error(`CORS blocked origin: ${origin}`);
callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`)); callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`));
}, },
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"], methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
}) credentials: true,
); exposedHeaders: ["X-Request-Id"],
})
);
app.get('/', async (req, res) => { app.get('/', async (req, res) => {
res.status(200).json({ res.status(200).json({
message: "Grocery List API is running.", message: "Grocery List API is running.",
roles: Object.values(User.ROLES), roles: Object.values(User.ROLES),
}); });
}); });
const authRoutes = require("./routes/auth.routes"); const authRoutes = require("./routes/auth.routes");
app.use("/auth", authRoutes); app.use("/auth", authRoutes);
const listRoutes = require("./routes/list.routes"); const listRoutes = require("./routes/list.routes");
app.use("/list", listRoutes); app.use("/list", listRoutes);
const adminRoutes = require("./routes/admin.routes"); const adminRoutes = require("./routes/admin.routes");
app.use("/admin", adminRoutes); app.use("/admin", adminRoutes);
const usersRoutes = require("./routes/users.routes"); const usersRoutes = require("./routes/users.routes");
app.use("/users", usersRoutes); app.use("/users", usersRoutes);
const configRoutes = require("./routes/config.routes"); const configRoutes = require("./routes/config.routes");
app.use("/config", configRoutes); app.use("/config", configRoutes);
const householdsRoutes = require("./routes/households.routes"); const householdsRoutes = require("./routes/households.routes");
app.use("/households", householdsRoutes); app.use("/households", householdsRoutes);
const storesRoutes = require("./routes/stores.routes"); const storesRoutes = require("./routes/stores.routes");
app.use("/stores", storesRoutes); app.use("/stores", storesRoutes);

View File

@ -1,11 +1,21 @@
const { Pool } = require("pg"); const { Pool } = require("pg");
const pool = new Pool({ function buildPoolConfig() {
user: process.env.DB_USER, if (process.env.DATABASE_URL) {
password: process.env.DB_PASS, return {
host: process.env.DB_HOST, connectionString: process.env.DATABASE_URL,
database: process.env.DB_NAME, };
port: 5432, }
});
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; module.exports = pool;

View File

@ -5,7 +5,12 @@ export const loginRequest = async (username, password) => {
return res.data; return res.data;
}; };
export const registerRequest = async (username, password, name) => { export const registerRequest = async (username, password, name) => {
const res = await api.post("/auth/register", { username, password, name }); const res = await api.post("/auth/register", { username, password, name });
return res.data; return res.data;
}; };
export const logoutRequest = async () => {
const res = await api.post("/auth/logout");
return res.data;
};

View File

@ -1,11 +1,12 @@
import axios from "axios"; import axios from "axios";
import { API_BASE_URL } from "../config"; import { API_BASE_URL } from "../config";
const api = axios.create({ const api = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
headers: { withCredentials: true,
"Content-Type": "application/json", headers: {
}, "Content-Type": "application/json",
},
}); });
api.interceptors.request.use((config => { api.interceptors.request.use((config => {
@ -40,8 +41,16 @@ api.interceptors.response.use(
payload.message = payload.error.message; payload.message = payload.error.message;
} }
if (error.response?.status === 401 && if (
normalizedMessage === "Invalid or expired token") { 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"); localStorage.removeItem("token");
window.location.href = "/login"; window.location.href = "/login";
alert("Your session has expired. Please log in again."); alert("Your session has expired. Please log in again.");

View File

@ -2,6 +2,7 @@ import "../../styles/components/Navbar.css";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { logoutRequest } from "../../api/auth";
import { AuthContext } from "../../context/AuthContext"; import { AuthContext } from "../../context/AuthContext";
import HouseholdSwitcher from "../household/HouseholdSwitcher"; import HouseholdSwitcher from "../household/HouseholdSwitcher";
@ -15,6 +16,18 @@ export default function Navbar() {
setShowUserMenu(false); 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 ( return (
<nav className="navbar"> <nav className="navbar">
{/* Left: Navigation Menu */} {/* Left: Navigation Menu */}
@ -72,7 +85,7 @@ export default function Navbar() {
<span className="user-dropdown-username">{username}</span> <span className="user-dropdown-username">{username}</span>
<span className="user-dropdown-role">{role}</span> <span className="user-dropdown-role">{role}</span>
</div> </div>
<button className="user-dropdown-logout" onClick={() => { logout(); closeMenus(); }}> <button className="user-dropdown-logout" onClick={handleLogout}>
Logout Logout
</button> </button>
</div> </div>
@ -81,4 +94,4 @@ export default function Navbar() {
</div> </div>
</nav> </nav>
); );
} }

View File

@ -9,28 +9,35 @@ export const AuthContext = createContext({
logout: () => { }, logout: () => { },
}); });
export const AuthProvider = ({ children }) => { export const AuthProvider = ({ children }) => {
const [token, setToken] = useState(localStorage.getItem('token') || null); const [token, setToken] = useState(localStorage.getItem('token') || null);
const [userId, setUserId] = useState(localStorage.getItem('userId') || null); const [userId, setUserId] = useState(localStorage.getItem('userId') || null);
const [role, setRole] = useState(localStorage.getItem('role') || null); const [role, setRole] = useState(localStorage.getItem('role') || null);
const [username, setUsername] = useState(localStorage.getItem('username') || 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) => { const login = (data) => {
localStorage.setItem('token', data.token); localStorage.setItem('token', data.token);
localStorage.setItem('userId', data.userId); localStorage.setItem('userId', data.userId);
localStorage.setItem('role', data.role); localStorage.setItem('role', data.role);
localStorage.setItem('username', data.username); localStorage.setItem('username', data.username);
setToken(data.token); setToken(data.token);
setUserId(data.userId); setUserId(data.userId);
setRole(data.role); setRole(data.role);
setUsername(data.username); setUsername(data.username);
}; };
const logout = () => { const logout = () => {
localStorage.clear(); clearAuthStorage();
setToken(null); setToken(null);
setUserId(null); setUserId(null);
setRole(null); setRole(null);
setUsername(null); setUsername(null);
}; };
@ -49,4 +56,4 @@ export const AuthProvider = ({ children }) => {
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
}; };