Add mobile dashboard, allow longer auth sessions
This commit is contained in:
@@ -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) {
|
||||
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);
|
||||
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 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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user