Thank you for taking the time to contribute! This guide covers local setup, architecture conventions, and smoke testing for the React frontend.
We use pnpm for package management. The React app proxies API and WebSocket traffic to the backend service, so you will need the backend running separately (or via Docker Compose).
- Node.js (v20 or higher)
- pnpm
- Docker & Docker Compose (for running the full stack)
- node-postgres-api running in its container
git clone https://github.com
cd node-postgres-spa
pnpm installStart both the backend and the React dev server together:
docker compose up --buildThe UI will be available at http://localhost:3000 when running in a container, or at http://localhost:5173 when running the Vite dev server directly:
pnpm devThe Vite dev server proxies /oauth, /api, /secure-rest, and /ws to localhost:5000.
All contributions must follow these rules. They are enforced in code review.
TypeScript strict mode is enabled. Using any is prohibited.
- Use
unknownfor values whose types are not yet determined. - Use explicit interfaces for API response shapes.
All components must be functional. No class components.
- Destructure props at the function signature.
- Keep components under 200 lines. Split into smaller components or hooks when approaching this limit.
| Layer | Tool | When to use |
|---|---|---|
| Global app state | Redux Toolkit slice | Auth status, websocket status |
| Server data / caching | RTK Query (createApi) |
All API calls |
| Local UI state | useState |
Component-scoped toggles, form state |
Never reach around RTK Query for raw fetch calls to data endpoints.
All forms must use React Hook Form with a Zod schema wired via @hookform/resolvers/zod. No manual onChange state management for form fields.
Routes are declared in src/App.tsx. Protected routes are wrapped with the ProtectedRoute component, which redirects unauthenticated users to /login.
Current routes:
| Path | Component | Protected |
|---|---|---|
/login |
LoginPage |
No |
/callback |
CallbackPage |
No |
/logout |
LogoutRoute |
No |
/users |
UsersPage |
Yes |
/users/new |
UserFormPage |
Yes |
/users/:id/edit |
UserFormPage |
Yes |
Use the @/ path alias for all internal imports (resolves to src/). Do not use relative paths like ../../.
import { setAuth } from "@/app/authSlice";We use Vitest for unit tests.
# Run the full test suite once
pnpm test
# Run tests in watch mode during development
pnpm exec vitestAll test files live alongside the source they test (*.test.ts / *.test.tsx). Make sure all existing tests pass and write new tests for any added features.
Open http://localhost:3000 (container) or http://localhost:5173 (dev server) and log in with:
- Client ID:
demo-client-id - Client Secret:
demo-client-secret
On success you are redirected to /users, and the backend sets an HttpOnly session cookie. The client stores only auth status in Redux (no token persistence in browser storage).
Alternatively, establish a session directly with curl:
curl -X POST http://localhost:3000/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-c cookies.txt \
-d "grant_type=client_credentials" \
-d "client_id=demo-client-id" \
-d "client_secret=demo-client-secret"This captures session cookies into cookies.txt for later requests.
After logging in, the app automatically opens a WebSocket connection authenticated via the browser session cookie. The NotificationBanner component at the top of the page shows the current socket status (idle, connecting, open, closed, or error) and the latest pushed message.
To test the socket manually from the command line:
Using websocat with a session cookie copied from browser devtools:
websocat ws://localhost:3000/ws --header "Cookie: SESSION_COOKIE_NAME=SESSION_COOKIE_VALUE"Send a test event:
{ "event": "get_user", "payload": { "id": 1 } }Expected: the socket immediately returns user data. The NotificationBanner in the UI also updates with any server-pushed events.
Use the List Users, Add User, and Edit / Delete controls in the UI, then verify via curl in a second terminal window.
curl -X GET http://localhost:3000/api/users \
-b cookies.txt \
-w "\n"curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"name": "Realtime Dev", "email": "stream@example.com"}' \
-w "\n"Expected: 401 Unauthorized.
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"name": "Alice Developer", "email": "alice.dev@example.com"}' \
-w "\n"Expected: 201 Created. The active WebSocket listener will emit a user_created event.
curl -X GET http://localhost:3000/api/users/1 \
-b cookies.txt \
-w "\n"curl -X PUT http://localhost:3000/api/users/1 \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"name": "Alice Smith", "email": "alice.smith@example.com"}' \
-w "\n"curl -X DELETE http://localhost:3000/api/users/1 \
-b cookies.txt \
-w "\n"Click Logout in the navigation bar. The app dispatches clearAuth, calls POST /oauth/logout, and redirects to /login. The WebSocket connection closes automatically when auth becomes unauthenticated.
Verify the session is terminated:
curl -X GET http://localhost:3000/api/users \
-b cookies.txt \
-w "\n"Expected: 401 Unauthorized.
Before opening a PR, confirm every item below:
-
pnpm buildcompletes with zero TypeScript errors. - No
anytypes introduced. -
pnpm testpasses with all specs green. - All new API calls go through RTK Query (
src/app/api.ts). - All new forms use React Hook Form + Zod validation.
- New routes are added to
src/App.tsxand wrapped withProtectedRouteif they require authentication. - No hardcoded visual values — use MUI theming and
sx. - Local smoke tests pass against the running Docker stack.