Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions orderly/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Orderly Authentication Demo

This repository contains a full-stack authentication and authorization playground
built with a Node.js/Express API, MongoDB, and a React client. It now also
includes a cross-platform Electron desktop shell and a React Native (Expo)
mobile app that consume the same endpoints as the web front-end without
duplicating UI logic.

## Projects

- `server/` – Express API with JWT auth, refresh tokens, MFA, RBAC, and audit logging.
- `client/` – React + Vite SPA used as the primary reference implementation.
- `desktop/` – Electron + React desktop application targeting Windows, macOS, and Linux.
- `mobile/` – React Native (Expo) client for iOS, Android, and the web.

## Desktop application

1. Install dependencies and start the desktop shell in development mode:

```bash
cd desktop
npm install
npm run dev
```

The script runs the Vite dev server on port `5173` and launches Electron once
the renderer is ready.

2. Copy `.env.example` to `.env` and adjust `VITE_API_URL` so the desktop client
can talk to your API server. Packaged builds will fall back to the preload
bridge (`window.orderlyDesktop.apiBaseUrl`).

3. Build installers for macOS, Windows, and Linux:

```bash
npm run build
```

Packages are emitted to `desktop/release/` via `electron-builder` (`dmg`,
`nsis`, `AppImage`, `deb`).

The renderer reuses the same auth flows and protected routes exported from the
web SPA so both surfaces stay aligned feature-for-feature.

## Mobile application (React Native / Expo)

1. Install dependencies and copy the example environment file:

```bash
cd mobile
npm install
cp .env.example .env
```

Ensure `EXPO_PUBLIC_API_URL` points at your API server (defaults to
`http://localhost:3000/api`).

2. Start the Expo dev server:

```bash
npm run start
```

Use the Expo interface to launch iOS simulators, Android emulators/devices,
or the web preview. Convenience scripts `npm run ios`, `npm run android`,
and `npm run web` are also available.

The mobile app mirrors the browser experience: it persists tokens in
`AsyncStorage`, reuses the API client with refresh-token retries, and exposes
profile, accounts, sales, and admin screens gated by the same permissions used
elsewhere in the project.
85 changes: 41 additions & 44 deletions orderly/client/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Route, Routes, Navigate, Link } from "react-router-dom";
import { Route, Routes, Navigate } from "react-router-dom";
import Login from "./pages/Login.jsx";
import ProtectedRoute from "./auth/ProtectedRoute.jsx";
import Profile from "./pages/Profile.jsx";
Expand All @@ -8,52 +8,49 @@ import SalesOrders from "./pages/SalesOrders";
import AdminUsers from './pages/AdminUsers';
import Nav from "./components/Nav.jsx";




function App() {
return (
<>
<Nav />

<Routes>
{/* public routes */}
<Route path="/login" element={<Login />} />

{/* protected routes group */}
<Route element={<ProtectedRoute />}>
<Route path="/profile" element={<Profile />} />
{/* later: accounts, sales, admin go here too */}
<Route
path="/accounts/invoices"
element={
<RequirePermission needed="invoices:read">
<AccountsInvoices />
</RequirePermission>
}
/>
<Route
path="/sales/orders"
element={
<RequirePermission needed="orders:read">
<SalesOrders />
</RequirePermission>
}
/>
<div className="app-shell">
<Nav />
<main className="app-content">
<Routes>
{/* public routes */}
<Route path="/login" element={<Login />} />

{/* protected routes group */}
<Route element={<ProtectedRoute />}>
<Route path="/profile" element={<Profile />} />
<Route
path="/admin"
element={
<RequirePermission needed="users:manage">
<AdminUsers />
</RequirePermission>
}
/>
</Route>

{/* fallback */}
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</>
path="/accounts/invoices"
element={
<RequirePermission needed="invoices:read">
<AccountsInvoices />
</RequirePermission>
}
/>
<Route
path="/sales/orders"
element={
<RequirePermission needed="orders:read">
<SalesOrders />
</RequirePermission>
}
/>
<Route
path="/admin"
element={
<RequirePermission needed="users:manage">
<AdminUsers />
</RequirePermission>
}
/>
</Route>

{/* fallback */}
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</main>
</div>
);
}

Expand Down
61 changes: 52 additions & 9 deletions orderly/client/src/api/api.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,64 @@
import axios from 'axios'

const fallbackBaseUrl =
(typeof window !== 'undefined' && window.orderlyDesktop?.apiBaseUrl) ||
'http://localhost:3000/api'

const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true, //for refresh cookie
baseURL: import.meta.env.VITE_API_URL || fallbackBaseUrl,
withCredentials: true, // for refresh cookie
})

//Attach access token per request (set by AuthProvider)
export function setAccessToken(token){
api.defaults.headers.common['Authorization'] = token ? `Bearer ${token}` : ' ';
// Attach access token per request (set by AuthProvider)
export function setAccessToken(token) {
if (token) {
api.defaults.headers.common.Authorization = `Bearer ${token}`
} else {
delete api.defaults.headers.common.Authorization
}
}

export async function fetchMe() {
const r = await api.get('/me')
return r.data
const r = await api.get('/me')
return r.data
}

//Response interceptor: 401, try refresh once
let refreshing = null

api.interceptors.response.use(
(response) => response,
async (error) => {
const { response, config } = error || {}
if (!response || response.status !== 401 || config?.__retried) {
throw error
}

try {
if (!refreshing) {
refreshing = api.post('/auth/refresh').finally(() => {
refreshing = null
})
}

const refreshResp = await refreshing
const newToken = refreshResp?.data?.accessToken
if (!newToken) {
throw error
}

setAccessToken(newToken)
config.__retried = true
config.headers = {
...(config.headers || {}),
Authorization: `Bearer ${newToken}`,
}

return api(config)
} catch (refreshError) {
setAccessToken(null)
throw refreshError
}
}
)

export default api
export default api
51 changes: 36 additions & 15 deletions orderly/client/src/components/Nav.jsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,59 @@
import { Link } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import useHasPermission from "../auth/useHasPermission";
import useHasPermission from "../auth/useHasPermission";
import ThemeToggle from "../theme/ThemeToggle";

export default function Nav() {
const { accessToken, logout } = useAuth();
const location = useLocation();

const canProfile = useHasPermission("profile:read");
const canAccounts = useHasPermission("invoices:read");
const canSales = useHasPermission("orders:read");
const canAdmin = useHasPermission("users:manage");

const isActive = (path) => location.pathname.startsWith(path);

return (
<nav style={{display:'flex',gap:12,padding:12,borderBottom:'1px solid #ddd'}}>
<nav className="top-nav">
{!accessToken && <Link to="/login">Login</Link>}

{canProfile && <Link to="/profile">Profile</Link>}
{canAccounts && <Link to="/accounts/invoices">Accounts</Link>}
{canSales && <Link to="/sales/orders">Sales</Link>}
{canAdmin && <Link to="/admin">Admin</Link>}
{canProfile && (
<Link to="/profile" aria-current={isActive('/profile') ? 'page' : undefined}>
Profile
</Link>
)}
{canAccounts && (
<Link
to="/accounts/invoices"
aria-current={isActive('/accounts') ? 'page' : undefined}
>
Accounts
</Link>
)}
{canSales && (
<Link to="/sales/orders" aria-current={isActive('/sales') ? 'page' : undefined}>
Sales
</Link>
)}
{canAdmin && (
<Link to="/admin" aria-current={isActive('/admin') ? 'page' : undefined}>
Admin
</Link>
)}

<div className="top-nav__spacer" />
<ThemeToggle />

{accessToken && (
<button
onClick={logout}
style={{
marginLeft: "auto",
background: "transparent",
border: "none",
color: "crimson",
cursor: "pointer"
}}
className="link-button danger"
type="button"
>
Logout
</button>
)}
</nav>
);
}
}
11 changes: 7 additions & 4 deletions orderly/client/src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,23 @@ api
.then((r) => console.log("API health:", r.data))
.catch(console.error);


import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from 'react-router-dom';
import {AuthProvider} from './auth/AuthContext.jsx'
import { ThemeProvider } from './theme/ThemeProvider.jsx'

import App from "./App.jsx";
import './styles/app.css'

createRoot(document.getElementById("root")).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
<ThemeProvider>
<AuthProvider>
<App />
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
</StrictMode>
);
Loading