Add mobile dashboard, allow longer auth sessions

This commit is contained in:
2026-07-20 11:51:09 -04:00
parent d779228485
commit 6c973f0a9d
13 changed files with 1219 additions and 67 deletions
+16 -8
View File
@@ -12,13 +12,20 @@ export function createAuthRoutes({ pool }) {
// shared/auth/middleware.js and is used by downstream services. Auth-server's surface is // shared/auth/middleware.js and is used by downstream services. Auth-server's surface is
// small enough that a local copy is fine; the security boundary is the JWT verify step. // small enough that a local copy is fine; the security boundary is the JWT verify step.
async function authenticate(req, res, next) { async function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
const token = authHeader.split(' ')[1];
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
// DB failures are 500, not 401 — the frontend ends the session on 401,
// and a transient outage must not log users out.
try { try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const result = await pool.query( const result = await pool.query(
'SELECT id, username, email, is_admin, rocket_chat_user_id FROM users WHERE id = $1', 'SELECT id, username, email, is_admin, rocket_chat_user_id FROM users WHERE id = $1',
[decoded.userId] [decoded.userId]
@@ -29,7 +36,8 @@ export function createAuthRoutes({ pool }) {
req.user = result.rows[0]; req.user = result.rows[0];
next(); next();
} catch (error) { } catch (error) {
res.status(401).json({ error: 'Invalid token' }); console.error('Auth DB error:', error);
res.status(500).json({ error: 'Server error' });
} }
} }
@@ -58,7 +66,7 @@ export function createAuthRoutes({ pool }) {
const token = jwt.sign( const token = jwt.sign(
{ userId: user.id, username: user.username }, { userId: user.id, username: user.username },
process.env.JWT_SECRET, process.env.JWT_SECRET,
{ expiresIn: '8h' } { expiresIn: '7d' }
); );
const permissions = await getUserPermissions(user.id); const permissions = await getUserPermissions(user.id);
res.json({ res.json({
+5
View File
@@ -39,6 +39,11 @@ logger.info({
const app = express(); const app = express();
const port = Number(process.env.AUTH_PORT) || 3011; const port = Number(process.env.AUTH_PORT) || 3011;
// Behind Caddy on localhost: without this, req.ip is ::1 for every request and
// the rate limiters put ALL users in one shared bucket (10 logins/15min for
// the whole office). Same setting as inventory-server's server.js.
app.set('trust proxy', 'loopback');
const pool = new Pool({ const pool = new Pool({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
@@ -314,7 +314,6 @@ async function syncSettingsProductTable() {
lead_time_days, lead_time_days,
days_of_stock, days_of_stock,
safety_stock, safety_stock,
forecast_method,
exclude_from_forecast exclude_from_forecast
) )
SELECT SELECT
@@ -322,7 +321,6 @@ async function syncSettingsProductTable() {
CAST(NULL AS INTEGER), CAST(NULL AS INTEGER),
CAST(NULL AS INTEGER), CAST(NULL AS INTEGER),
COALESCE((SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_safety_stock_units'), 0), COALESCE((SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_safety_stock_units'), 0),
CAST(NULL AS VARCHAR),
FALSE FALSE
FROM FROM
public.products p public.products p
+7 -9
View File
@@ -136,7 +136,7 @@ router.put('/products/:pid', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
try { try {
const { pid } = req.params; const { pid } = req.params;
const { lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast } = req.body; const { lead_time_days, days_of_stock, safety_stock, exclude_from_forecast } = req.body;
console.log(`[Config Route] Updating product settings for ${pid}:`, req.body); console.log(`[Config Route] Updating product settings for ${pid}:`, req.body);
@@ -149,10 +149,10 @@ router.put('/products/:pid', async (req, res) => {
if (checkProduct.length === 0) { if (checkProduct.length === 0) {
// Insert if it doesn't exist // Insert if it doesn't exist
await pool.query( await pool.query(
`INSERT INTO settings_product `INSERT INTO settings_product
(pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast) (pid, lead_time_days, days_of_stock, safety_stock, exclude_from_forecast)
VALUES ($1, $2, $3, $4, $5, $6)`, VALUES ($1, $2, $3, $4, $5)`,
[pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast] [pid, lead_time_days, days_of_stock, safety_stock, exclude_from_forecast]
); );
} else { } else {
// Update if it exists // Update if it exists
@@ -161,11 +161,10 @@ router.put('/products/:pid', async (req, res) => {
SET lead_time_days = $2, SET lead_time_days = $2,
days_of_stock = $3, days_of_stock = $3,
safety_stock = $4, safety_stock = $4,
forecast_method = $5, exclude_from_forecast = $5,
exclude_from_forecast = $6,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE pid::text = $1`, WHERE pid::text = $1`,
[pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast] [pid, lead_time_days, days_of_stock, safety_stock, exclude_from_forecast]
); );
} }
@@ -190,7 +189,6 @@ router.post('/products/:pid/reset', async (req, res) => {
SET lead_time_days = NULL, SET lead_time_days = NULL,
days_of_stock = NULL, days_of_stock = NULL,
safety_stock = 0, safety_stock = 0,
forecast_method = NULL,
exclude_from_forecast = false, exclude_from_forecast = false,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE pid::text = $1`, WHERE pid::text = $1`,
+15 -10
View File
@@ -36,6 +36,7 @@ const RepeatOrders = lazy(() => import('./pages/RepeatOrders'));
// 2. Dashboard app - separate chunk // 2. Dashboard app - separate chunk
const Dashboard = lazy(() => import('./pages/Dashboard')); const Dashboard = lazy(() => import('./pages/Dashboard'));
const SmallDashboard = lazy(() => import('./pages/SmallDashboard')); const SmallDashboard = lazy(() => import('./pages/SmallDashboard'));
const MobileDashboard = lazy(() => import('./pages/MobileDashboard'));
// 3. Product import - separate chunk // 3. Product import - separate chunk
const Import = lazy(() => import('./pages/Import').then(module => ({ default: module.Import }))); const Import = lazy(() => import('./pages/Import').then(module => ({ default: module.Import })));
@@ -69,27 +70,26 @@ function App() {
}, },
}); });
if (!response.ok) { if (response.status === 401 || response.status === 403) {
// Token genuinely rejected — clear it and go to login
localStorage.removeItem('token'); localStorage.removeItem('token');
sessionStorage.removeItem('isLoggedIn'); sessionStorage.removeItem('isLoggedIn');
// Only navigate to login if we're not already there // Only navigate to login if we're not already there
if (!location.pathname.includes('/login')) { if (!location.pathname.includes('/login')) {
navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`); navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`);
} }
} else { } else if (response.ok) {
// If token is valid, set the login flag // If token is valid, set the login flag
sessionStorage.setItem('isLoggedIn', 'true'); sessionStorage.setItem('isLoggedIn', 'true');
} }
// Other statuses (500/429/etc.) are transient server issues —
// keep the token; AuthContext will retry on the next check.
} catch (error) { } catch (error) {
// Network error (server down, offline) — keep the token and let a
// later check decide. Logging out here would end valid sessions on
// every connection blip.
console.error('Token verification failed:', error); console.error('Token verification failed:', error);
localStorage.removeItem('token');
sessionStorage.removeItem('isLoggedIn');
// Only navigate to login if we're not already there
if (!location.pathname.includes('/login')) {
navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`);
}
} }
} }
}; };
@@ -109,6 +109,11 @@ function App() {
<SmallDashboard /> <SmallDashboard />
</Suspense> </Suspense>
} /> } />
<Route path="/mobile" element={
<Suspense fallback={<PageLoading />}>
<MobileDashboard />
</Suspense>
} />
<Route element={ <Route element={
<RequireAuth> <RequireAuth>
<MainLayout /> <MainLayout />
@@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { useNow } from "@/hooks/useNow";
const formatDate = (date) => const formatDate = (date) =>
date.toLocaleDateString("en-US", { date.toLocaleDateString("en-US", {
@@ -15,7 +16,7 @@ const greeting = (date) => {
}; };
const Header = ({ right = null }) => { const Header = ({ right = null }) => {
const now = new Date(); const now = useNow();
return ( return (
<header className="px-3 pt-5 sm:px-4 lg:px-5"> <header className="px-3 pt-5 sm:px-4 lg:px-5">
<div className="flex items-center justify-between gap-4 pb-1"> <div className="flex items-center justify-between gap-4 pb-1">
File diff suppressed because it is too large Load Diff
@@ -89,21 +89,6 @@ export function GlobalSettings() {
</SelectContent> </SelectContent>
</Select> </Select>
); );
} else if (setting.setting_key === 'default_forecast_method') {
return (
<Select
value={setting.setting_value}
onValueChange={(value) => updateSetting(setting.setting_key, value)}
>
<SelectTrigger>
<SelectValue placeholder="Select forecast method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">Standard</SelectItem>
<SelectItem value="seasonal">Seasonal</SelectItem>
</SelectContent>
</Select>
);
} else if (setting.setting_key.includes('threshold')) { } else if (setting.setting_key.includes('threshold')) {
// Percentage inputs // Percentage inputs
return ( return (
@@ -6,7 +6,6 @@ import { Input } from "@/components/ui/input";
import { toast } from "sonner"; import { toast } from "sonner";
import config from '../../config'; import config from '../../config';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
@@ -25,7 +24,6 @@ interface ProductSetting {
lead_time_days: number | null; lead_time_days: number | null;
days_of_stock: number | null; days_of_stock: number | null;
safety_stock: number; safety_stock: number;
forecast_method: string | null;
exclude_from_forecast: boolean; exclude_from_forecast: boolean;
updated_at: string; updated_at: string;
product_name?: string; // Added for display purposes product_name?: string; // Added for display purposes
@@ -86,7 +84,6 @@ export function ProductSettings() {
lead_time_days: setting.lead_time_days, lead_time_days: setting.lead_time_days,
days_of_stock: setting.days_of_stock, days_of_stock: setting.days_of_stock,
safety_stock: setting.safety_stock, safety_stock: setting.safety_stock,
forecast_method: setting.forecast_method,
exclude_from_forecast: setting.exclude_from_forecast exclude_from_forecast: setting.exclude_from_forecast
}) })
}); });
@@ -195,7 +192,6 @@ export function ProductSettings() {
<TableHead>Lead Time (days)</TableHead> <TableHead>Lead Time (days)</TableHead>
<TableHead>Days of Stock</TableHead> <TableHead>Days of Stock</TableHead>
<TableHead>Safety Stock</TableHead> <TableHead>Safety Stock</TableHead>
<TableHead>Forecast Method</TableHead>
<TableHead>Exclude</TableHead> <TableHead>Exclude</TableHead>
<TableHead>Actions</TableHead> <TableHead>Actions</TableHead>
</TableRow> </TableRow>
@@ -231,21 +227,6 @@ export function ProductSettings() {
onChange={(e) => updateSetting(setting.pid, 'safety_stock', parseInt(e.target.value) || 0)} onChange={(e) => updateSetting(setting.pid, 'safety_stock', parseInt(e.target.value) || 0)}
/> />
</TableCell> </TableCell>
<TableCell>
<Select
value={setting.forecast_method || 'default'}
onValueChange={(value) => updateSetting(setting.pid, 'forecast_method', value === 'default' ? null : value)}
>
<SelectTrigger className="w-28">
<SelectValue placeholder="Default" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="standard">Standard</SelectItem>
<SelectItem value="seasonal">Seasonal</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell> <TableCell>
<Switch <Switch
checked={setting.exclude_from_forecast} checked={setting.exclude_from_forecast}
+7 -2
View File
@@ -62,8 +62,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
console.error('Auth check failed:', response.status, errorData); console.error('Auth check failed:', response.status, errorData);
// Any failed /me response means the session is invalid — logout // Only a genuine auth rejection ends the session. Server errors and
logout(); // rate limits (500/429/etc.) are transient — keep the session.
if (response.status === 401 || response.status === 403) {
logout();
} else {
setError(errorData.error || `Auth check failed (${response.status})`);
}
return; return;
} }
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useState } from "react";
/**
* A Date that stays current: re-renders every `intervalMs` (default 1 min)
* and immediately when the tab becomes visible again — so greetings and
* date lines roll over even if the page sat open (or backgrounded on a
* phone) across a morning/afternoon/midnight boundary.
*/
export function useNow(intervalMs = 60_000): Date {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const update = () => setNow(new Date());
const id = setInterval(update, intervalMs);
const onVisibility = () => {
if (document.visibilityState === "visible") update();
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
clearInterval(id);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [intervalMs]);
return now;
}
+69
View File
@@ -0,0 +1,69 @@
import React, { useEffect, useState } from "react";
import { Navigate } from "react-router-dom";
import PinProtection from "@/components/dashboard/PinProtection";
import StudioMobile from "@/components/dashboard/mobile/StudioMobile";
import PageLoading from "@/components/ui/page-loading";
import { apiFetch } from "@/utils/api";
// Pin Protected Layout
const PinProtectedLayout = ({ children }: { children: React.ReactNode }) => {
const [isPinVerified, setIsPinVerified] = useState(() => {
return sessionStorage.getItem("pinVerified") === "true";
});
const handlePinSuccess = () => {
setIsPinVerified(true);
sessionStorage.setItem("pinVerified", "true");
};
if (!isPinVerified) {
return <PinProtection onSuccess={handlePinSuccess} />;
}
return <>{children}</>;
};
// Same three-way gate as /small: office IP gets PIN, authenticated users skip
// PIN, everyone else is bounced to login. Identity comes from
// /api/dashboard/whoami (behind Caddy's office-IP allowlist for the kiosk case).
type Identity = "probing" | "kiosk" | "authenticated" | "anonymous";
const AccessGate = ({ children }: { children: React.ReactNode }) => {
const [identity, setIdentity] = useState<Identity>("probing");
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await apiFetch("/api/dashboard/whoami");
if (cancelled) return;
if (res.status === 401) {
setIdentity("anonymous");
return;
}
const body = await res.json();
setIdentity(body.is_kiosk ? "kiosk" : "authenticated");
} catch {
if (!cancelled) setIdentity("anonymous");
}
})();
return () => {
cancelled = true;
};
}, []);
if (identity === "probing") return <PageLoading />;
if (identity === "anonymous") return <Navigate to="/login?redirect=/mobile" replace />;
if (identity === "kiosk") return <PinProtectedLayout>{children}</PinProtectedLayout>;
return <>{children}</>;
};
export function MobileDashboard() {
return (
<AccessGate>
<StudioMobile />
</AccessGate>
);
}
export default MobileDashboard;
File diff suppressed because one or more lines are too long