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
|
// 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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
@@ -150,9 +150,9 @@ router.put('/products/:pid', async (req, res) => {
|
|||||||
// 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`,
|
||||||
|
|||||||
+14
-9
@@ -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,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');
|
localStorage.removeItem('token');
|
||||||
sessionStorage.removeItem('isLoggedIn');
|
sessionStorage.removeItem('isLoggedIn');
|
||||||
|
|
||||||
@@ -77,19 +79,17 @@ function App() {
|
|||||||
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}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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