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
// small enough that a local copy is fine; the security boundary is the JWT verify step.
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 {
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(
'SELECT id, username, email, is_admin, rocket_chat_user_id FROM users WHERE id = $1',
[decoded.userId]
@@ -29,7 +36,8 @@ export function createAuthRoutes({ pool }) {
req.user = result.rows[0];
next();
} 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(
{ userId: user.id, username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '8h' }
{ expiresIn: '7d' }
);
const permissions = await getUserPermissions(user.id);
res.json({
+5
View File
@@ -39,6 +39,11 @@ logger.info({
const app = express();
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({
host: process.env.DB_HOST,
user: process.env.DB_USER,
@@ -314,7 +314,6 @@ async function syncSettingsProductTable() {
lead_time_days,
days_of_stock,
safety_stock,
forecast_method,
exclude_from_forecast
)
SELECT
@@ -322,7 +321,6 @@ async function syncSettingsProductTable() {
CAST(NULL AS INTEGER),
CAST(NULL AS INTEGER),
COALESCE((SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_safety_stock_units'), 0),
CAST(NULL AS VARCHAR),
FALSE
FROM
public.products p
+6 -8
View File
@@ -136,7 +136,7 @@ router.put('/products/:pid', async (req, res) => {
const pool = req.app.locals.pool;
try {
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);
@@ -150,9 +150,9 @@ router.put('/products/:pid', async (req, res) => {
// Insert if it doesn't exist
await pool.query(
`INSERT INTO settings_product
(pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast)
VALUES ($1, $2, $3, $4, $5, $6)`,
[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)`,
[pid, lead_time_days, days_of_stock, safety_stock, exclude_from_forecast]
);
} else {
// Update if it exists
@@ -161,11 +161,10 @@ router.put('/products/:pid', async (req, res) => {
SET lead_time_days = $2,
days_of_stock = $3,
safety_stock = $4,
forecast_method = $5,
exclude_from_forecast = $6,
exclude_from_forecast = $5,
updated_at = CURRENT_TIMESTAMP
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,
days_of_stock = NULL,
safety_stock = 0,
forecast_method = NULL,
exclude_from_forecast = false,
updated_at = CURRENT_TIMESTAMP
WHERE pid::text = $1`,
+14 -9
View File
@@ -36,6 +36,7 @@ const RepeatOrders = lazy(() => import('./pages/RepeatOrders'));
// 2. Dashboard app - separate chunk
const Dashboard = lazy(() => import('./pages/Dashboard'));
const SmallDashboard = lazy(() => import('./pages/SmallDashboard'));
const MobileDashboard = lazy(() => import('./pages/MobileDashboard'));
// 3. Product import - separate chunk
const Import = lazy(() => import('./pages/Import').then(module => ({ default: module.Import })));
@@ -69,7 +70,8 @@ function App() {
},
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
// Token genuinely rejected — clear it and go to login
localStorage.removeItem('token');
sessionStorage.removeItem('isLoggedIn');
@@ -77,19 +79,17 @@ function App() {
if (!location.pathname.includes('/login')) {
navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`);
}
} else {
} else if (response.ok) {
// If token is valid, set the login flag
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) {
// 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);
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 />
</Suspense>
} />
<Route path="/mobile" element={
<Suspense fallback={<PageLoading />}>
<MobileDashboard />
</Suspense>
} />
<Route element={
<RequireAuth>
<MainLayout />
@@ -1,4 +1,5 @@
import React from "react";
import { useNow } from "@/hooks/useNow";
const formatDate = (date) =>
date.toLocaleDateString("en-US", {
@@ -15,7 +16,7 @@ const greeting = (date) => {
};
const Header = ({ right = null }) => {
const now = new Date();
const now = useNow();
return (
<header className="px-3 pt-5 sm:px-4 lg:px-5">
<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>
</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')) {
// Percentage inputs
return (
@@ -6,7 +6,6 @@ import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import config from '../../config';
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 { Switch } from "@/components/ui/switch";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -25,7 +24,6 @@ interface ProductSetting {
lead_time_days: number | null;
days_of_stock: number | null;
safety_stock: number;
forecast_method: string | null;
exclude_from_forecast: boolean;
updated_at: string;
product_name?: string; // Added for display purposes
@@ -86,7 +84,6 @@ export function ProductSettings() {
lead_time_days: setting.lead_time_days,
days_of_stock: setting.days_of_stock,
safety_stock: setting.safety_stock,
forecast_method: setting.forecast_method,
exclude_from_forecast: setting.exclude_from_forecast
})
});
@@ -195,7 +192,6 @@ export function ProductSettings() {
<TableHead>Lead Time (days)</TableHead>
<TableHead>Days of Stock</TableHead>
<TableHead>Safety Stock</TableHead>
<TableHead>Forecast Method</TableHead>
<TableHead>Exclude</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
@@ -231,21 +227,6 @@ export function ProductSettings() {
onChange={(e) => updateSetting(setting.pid, 'safety_stock', parseInt(e.target.value) || 0)}
/>
</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>
<Switch
checked={setting.exclude_from_forecast}
+7 -2
View File
@@ -62,8 +62,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('Auth check failed:', response.status, errorData);
// Any failed /me response means the session is invalid — logout
logout();
// Only a genuine auth rejection ends the session. Server errors and
// 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;
}
+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