fix: update VITE_PANEL_API URL and improve login form accessibility

refactor: streamline SpreadFeatures component by removing unused video handling
feat: implement SSO hand-off for Statista integration in server
This commit is contained in:
alireza 2026-06-29 14:23:24 +03:30
parent fc103c8534
commit 5a3d405ff4
6 changed files with 60 additions and 49 deletions

View File

@ -20,7 +20,7 @@ RUN npm config set registry https://package-mirror.liara.ir/repository/npm/ \
# Build the static bundle.
# VITE_PANEL_API is inlined into the bundle at build time (Vite reads it during
# `npm run build`), so it must be present as an env var here — not at runtime.
ARG VITE_PANEL_API=https://cms-steelforesight.liara.run
ARG VITE_PANEL_API=https://api.steelforesight.ir
ENV VITE_PANEL_API=$VITE_PANEL_API
COPY . .
RUN npm run build

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7252b958029e1bbd72b2c774a2c02f9d423a4daa1be7926bb1208b020f702bb6
size 32234368
oid sha256:a6e2cb352e5bd25693baac943cc3aad44e6dc1595213bd64fc19f6a0230e57c8
size 30295491

View File

@ -178,8 +178,8 @@ function LoginForm({ lang, login, navigate }: {
{!otpMode ? (
<>
<form onSubmit={submitPassword} style={{ display: 'flex', flexDirection: 'column', gap: '1.1rem' }}>
<FField label={fa ? 'شماره موبایل' : 'Phone number'}>
<FInput type="tel" required value={phone} onChange={e => setPhone(e.target.value)} placeholder="09123456789" dir="ltr" icon={<PhoneIcon />} />
<FField label={fa ? 'شماره موبایل یا ایمیل' : 'Phone or email'}>
<FInput type="text" required value={phone} onChange={e => setPhone(e.target.value)} placeholder={fa ? 'شماره موبایل یا ایمیل' : 'Phone or email'} dir="ltr" icon={<PhoneIcon />} />
</FField>
<FField label={fa ? 'رمز عبور' : 'Password'}>
<FInput type={showPass ? 'text' : 'password'} required value={password} onChange={e => setPassword(e.target.value)} dir="ltr" icon={<LockIcon />} suffix={<EyeToggle show={showPass} onToggle={() => setShowPass(s => !s)} />} />

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
const GOLD = '#CD9E53'
@ -7,8 +7,6 @@ const EASE = [0.22, 1, 0.36, 1] as const
export default function SpreadFeatures() {
const [isMobile, setIsMobile] = useState(false)
const [videoSrc, setVideoSrc] = useState<string | null>(null)
const blobUrl = useRef<string | null>(null)
useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)')
@ -18,42 +16,22 @@ export default function SpreadFeatures() {
return () => mq.removeEventListener('change', update)
}, [])
// Fetch video as blob to bypass range-request issues on tunnels/proxies
useEffect(() => {
let cancelled = false
fetch('/hero-bg.mp4')
.then(r => { if (!r.ok) throw new Error('fetch failed'); return r.blob() })
.then(blob => {
if (cancelled) return
const url = URL.createObjectURL(blob)
blobUrl.current = url
setVideoSrc(url)
})
.catch(() => { /* keep navy background */ })
return () => {
cancelled = true
if (blobUrl.current) URL.revokeObjectURL(blobUrl.current)
}
}, [])
return (
<div style={{ height: '100vh', position: 'relative', marginTop: isMobile ? -60 : -68 }}>
{/* navy fallback — visible until video blob is ready */}
<div style={{ position: 'sticky', top: 0, height: '100vh', overflow: 'hidden', background: NAVY }}>
{/* full-bleed video — src is a local blob URL, no range requests */}
{videoSrc && (
<video
src={videoSrc}
autoPlay muted loop playsInline
style={{
position: 'absolute', inset: 0,
width: '100%', height: '100%',
objectFit: 'cover', objectPosition: 'center',
display: 'block', zIndex: 0,
}}
/>
)}
{/* full-bleed video — native streaming via HTTP range requests */}
<video
src="/hero-bg.mp4"
autoPlay muted loop playsInline preload="auto"
style={{
position: 'absolute', inset: 0,
width: '100%', height: '100%',
objectFit: 'cover', objectPosition: 'center',
display: 'block', zIndex: 0,
}}
/>
{/* full-width gradient: solid navy on right → transparent on left */}
<div style={{

View File

@ -2,6 +2,9 @@ import { useState, useEffect } from 'react'
import { useLang } from '@/context/LangContext'
import SnapshotBar from '@/pages/Home/sections/SnapshotBar'
// SSO hand-off: routes through the panel, which silently logs members into statista.
const STATISTA_SSO = `${import.meta.env.VITE_PANEL_API || ''}/api/sso/statista`
const STATS = {
fa: [
{ label: 'تولید فولاد خام', value: '۳۱.۲', unit: 'میلیون تن', delta: '+۴.۱٪' },
@ -180,8 +183,7 @@ function PriceTable({ blurFrom, lang }: { blurFrom: number; lang: 'fa' | 'en' })
pointerEvents: 'none',
}}>
<a
href="https://statista-didvan.liara.run/"
target="_blank"
href={STATISTA_SSO}
rel="noopener noreferrer"
style={{
display: 'block',

View File

@ -14,7 +14,7 @@ import { nanoid } from 'nanoid';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { randomInt } from 'node:crypto';
import { randomInt, createHmac } from 'node:crypto';
import { db, rowToArticle, rowToRiskSignal, rowToPrice, rowToEvent, rowToTeamMember, rowToPlan, rowToBanner, rowToRadarItem, rowToRadarPage, rowToFactoryReport, rowToIntegration, rowToInstituteStat, rowToMarketPrice, rowToMarketChartPoint, rowToVisionItem, rowToAdvisoryMember } from './db.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -251,13 +251,18 @@ async function memberRequired(req, res, next) {
}
}
// member session cookie options — cross-site capable when served over HTTPS
// member session cookie options. When COOKIE_DOMAIN is set (e.g. ".steelforesight.ir"),
// the API is a same-site subdomain of the site, so the cookie is first-party and
// 'lax' survives third-party-cookie blocking (fixes "logged out in a new tab").
// Without it, fall back to the cross-site 'none' behaviour.
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || '';
function memberCookieOpts(req) {
const secure = cookieSecure(req);
return {
httpOnly: true,
secure,
sameSite: secure ? 'none' : 'lax',
sameSite: COOKIE_DOMAIN ? 'lax' : (secure ? 'none' : 'lax'),
...(COOKIE_DOMAIN ? { domain: COOKIE_DOMAIN } : {}),
maxAge: 30 * 24 * 60 * 60 * 1000,
path: '/',
};
@ -1394,7 +1399,9 @@ app.get('/api/members', authRequired, async (_req, res) => {
})));
});
app.get('/api/members/:id', authRequired, async (req, res) => {
// NOTE: :id is constrained to digits so "me" (and other word routes) fall through
// to the member-auth /api/members/me handler below instead of this admin route.
app.get('/api/members/:id(\\d+)', authRequired, async (req, res) => {
const id = Number(req.params.id);
const r = await db.prepare('SELECT id, name, email, phone, avatar, is_active, created_at, updated_at, last_login FROM members WHERE id = ?').get(id);
if (!r) return res.status(404).json({ error: 'not_found' });
@ -1411,7 +1418,7 @@ app.get('/api/members/:id', authRequired, async (req, res) => {
});
// block / unblock — body { isActive: boolean }
app.patch('/api/members/:id', authRequired, ownerRequired, jsonOnly, async (req, res) => {
app.patch('/api/members/:id(\\d+)', authRequired, ownerRequired, jsonOnly, async (req, res) => {
const id = Number(req.params.id);
const isActive = req.body?.isActive ? 1 : 0;
const info = await db.prepare('UPDATE members SET is_active = ?, updated_at = now() WHERE id = ?').run(isActive, id);
@ -1419,14 +1426,14 @@ app.patch('/api/members/:id', authRequired, ownerRequired, jsonOnly, async (req,
res.json({ id, isActive: !!isActive });
});
app.delete('/api/members/:id', authRequired, ownerRequired, async (req, res) => {
app.delete('/api/members/:id(\\d+)', authRequired, ownerRequired, async (req, res) => {
const info = await db.prepare('DELETE FROM members WHERE id = ?').run(Number(req.params.id));
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// admin: recent activity (page views + clicks) for one member
app.get('/api/members/:id/activity', authRequired, async (req, res) => {
app.get('/api/members/:id(\\d+)/activity', authRequired, async (req, res) => {
const rows = await db.prepare(
'SELECT kind, path, label, created_at FROM member_activity WHERE member_id = ? ORDER BY created_at DESC LIMIT 200'
).all(Number(req.params.id));
@ -1648,6 +1655,30 @@ app.post('/api/members/logout', memberRequired, async (req, res) => {
res.json({ ok: true });
});
// ── SSO hand-off to statista.steelforesight.ir ──
// The "ورود به سامانه جامع آمار و اطلاعات" button links here. Logged-in member →
// mint a 2-min HMAC token (shared SSO_SECRET) and bounce to statista's /api/sso,
// which logs them straight into the dashboard. Anyone else → statista landing.
const SSO_SECRET = process.env.SSO_SECRET;
const STATISTA_URL = (process.env.STATISTA_URL || 'https://statista.steelforesight.ir').replace(/\/+$/, '');
app.get('/api/sso/statista', async (req, res) => {
const fail = () => res.redirect(`${STATISTA_URL}/`); // not a member → public landing
if (!SSO_SECRET) return fail();
const token = req.cookies?.member_session;
if (!token) return fail();
let p;
try { p = jwt.verify(token, JWT_SECRET); } catch { return fail(); }
if (p.role !== 'member') return fail();
// honor deactivation / logout-revocation just like memberRequired does
const acct = await db.prepare('SELECT is_active, token_version FROM members WHERE id = ?').get(p.sub);
if (!acct || !acct.is_active || (acct.token_version || 0) !== (p.tv || 0)) return fail();
const body = Buffer.from(JSON.stringify({
email: p.email, name: p.name, exp: Math.floor(Date.now() / 1000) + 120,
})).toString('base64url');
const sig = createHmac('sha256', SSO_SECRET).update(body).digest('base64url');
res.redirect(`${STATISTA_URL}/api/sso?t=${body}.${sig}`);
});
/* OTP login/signup via Kavenegar (verify/lookup)
SFsignin existing numbers, SFsignup new numbers (auto-created on verify). */
const otpStore = new Map(); // phone -> { code, expires, attempts }
@ -1818,7 +1849,7 @@ app.get('/api/members/me/purchases', memberRequired, async (req, res) => {
});
// admin: grant purchase to a member
app.post('/api/members/:memberId/purchases', authRequired, async (req, res) => {
app.post('/api/members/:memberId(\\d+)/purchases', authRequired, async (req, res) => {
const { articleId, amount = 0 } = req.body || {};
if (!articleId) return res.status(400).json({ error: 'articleId الزامی است' });
const member = await db.prepare('SELECT id FROM members WHERE id = ?').get(req.params.memberId);