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:
parent
fc103c8534
commit
5a3d405ff4
|
|
@ -20,7 +20,7 @@ RUN npm config set registry https://package-mirror.liara.ir/repository/npm/ \
|
||||||
# Build the static bundle.
|
# Build the static bundle.
|
||||||
# VITE_PANEL_API is inlined into the bundle at build time (Vite reads it during
|
# 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.
|
# `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
|
ENV VITE_PANEL_API=$VITE_PANEL_API
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:7252b958029e1bbd72b2c774a2c02f9d423a4daa1be7926bb1208b020f702bb6
|
oid sha256:a6e2cb352e5bd25693baac943cc3aad44e6dc1595213bd64fc19f6a0230e57c8
|
||||||
size 32234368
|
size 30295491
|
||||||
|
|
|
||||||
|
|
@ -178,8 +178,8 @@ function LoginForm({ lang, login, navigate }: {
|
||||||
{!otpMode ? (
|
{!otpMode ? (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={submitPassword} style={{ display: 'flex', flexDirection: 'column', gap: '1.1rem' }}>
|
<form onSubmit={submitPassword} style={{ display: 'flex', flexDirection: 'column', gap: '1.1rem' }}>
|
||||||
<FField label={fa ? 'شماره موبایل' : 'Phone number'}>
|
<FField label={fa ? 'شماره موبایل یا ایمیل' : 'Phone or email'}>
|
||||||
<FInput type="tel" required value={phone} onChange={e => setPhone(e.target.value)} placeholder="09123456789" dir="ltr" icon={<PhoneIcon />} />
|
<FInput type="text" required value={phone} onChange={e => setPhone(e.target.value)} placeholder={fa ? 'شماره موبایل یا ایمیل' : 'Phone or email'} dir="ltr" icon={<PhoneIcon />} />
|
||||||
</FField>
|
</FField>
|
||||||
<FField label={fa ? 'رمز عبور' : 'Password'}>
|
<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)} />} />
|
<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)} />} />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
|
|
||||||
const GOLD = '#CD9E53'
|
const GOLD = '#CD9E53'
|
||||||
|
|
@ -7,8 +7,6 @@ const EASE = [0.22, 1, 0.36, 1] as const
|
||||||
|
|
||||||
export default function SpreadFeatures() {
|
export default function SpreadFeatures() {
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
const [videoSrc, setVideoSrc] = useState<string | null>(null)
|
|
||||||
const blobUrl = useRef<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia('(max-width: 767px)')
|
const mq = window.matchMedia('(max-width: 767px)')
|
||||||
|
|
@ -18,34 +16,15 @@ export default function SpreadFeatures() {
|
||||||
return () => mq.removeEventListener('change', update)
|
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 (
|
return (
|
||||||
<div style={{ height: '100vh', position: 'relative', marginTop: isMobile ? -60 : -68 }}>
|
<div style={{ height: '100vh', position: 'relative', marginTop: isMobile ? -60 : -68 }}>
|
||||||
{/* navy fallback — visible until video blob is ready */}
|
{/* navy fallback — visible until video blob is ready */}
|
||||||
<div style={{ position: 'sticky', top: 0, height: '100vh', overflow: 'hidden', background: NAVY }}>
|
<div style={{ position: 'sticky', top: 0, height: '100vh', overflow: 'hidden', background: NAVY }}>
|
||||||
|
|
||||||
{/* full-bleed video — src is a local blob URL, no range requests */}
|
{/* full-bleed video — native streaming via HTTP range requests */}
|
||||||
{videoSrc && (
|
|
||||||
<video
|
<video
|
||||||
src={videoSrc}
|
src="/hero-bg.mp4"
|
||||||
autoPlay muted loop playsInline
|
autoPlay muted loop playsInline preload="auto"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', inset: 0,
|
position: 'absolute', inset: 0,
|
||||||
width: '100%', height: '100%',
|
width: '100%', height: '100%',
|
||||||
|
|
@ -53,7 +32,6 @@ export default function SpreadFeatures() {
|
||||||
display: 'block', zIndex: 0,
|
display: 'block', zIndex: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* full-width gradient: solid navy on right → transparent on left */}
|
{/* full-width gradient: solid navy on right → transparent on left */}
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import { useState, useEffect } from 'react'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
import SnapshotBar from '@/pages/Home/sections/SnapshotBar'
|
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 = {
|
const STATS = {
|
||||||
fa: [
|
fa: [
|
||||||
{ label: 'تولید فولاد خام', value: '۳۱.۲', unit: 'میلیون تن', delta: '+۴.۱٪' },
|
{ label: 'تولید فولاد خام', value: '۳۱.۲', unit: 'میلیون تن', delta: '+۴.۱٪' },
|
||||||
|
|
@ -180,8 +183,7 @@ function PriceTable({ blurFrom, lang }: { blurFrom: number; lang: 'fa' | 'en' })
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
}}>
|
}}>
|
||||||
<a
|
<a
|
||||||
href="https://statista-didvan.liara.run/"
|
href={STATISTA_SSO}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{
|
style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { nanoid } from 'nanoid';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
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';
|
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));
|
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) {
|
function memberCookieOpts(req) {
|
||||||
const secure = cookieSecure(req);
|
const secure = cookieSecure(req);
|
||||||
return {
|
return {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure,
|
secure,
|
||||||
sameSite: secure ? 'none' : 'lax',
|
sameSite: COOKIE_DOMAIN ? 'lax' : (secure ? 'none' : 'lax'),
|
||||||
|
...(COOKIE_DOMAIN ? { domain: COOKIE_DOMAIN } : {}),
|
||||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||||
path: '/',
|
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 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);
|
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' });
|
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 }
|
// 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 id = Number(req.params.id);
|
||||||
const isActive = req.body?.isActive ? 1 : 0;
|
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);
|
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 });
|
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));
|
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' });
|
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
// admin: recent activity (page views + clicks) for one member
|
// 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(
|
const rows = await db.prepare(
|
||||||
'SELECT kind, path, label, created_at FROM member_activity WHERE member_id = ? ORDER BY created_at DESC LIMIT 200'
|
'SELECT kind, path, label, created_at FROM member_activity WHERE member_id = ? ORDER BY created_at DESC LIMIT 200'
|
||||||
).all(Number(req.params.id));
|
).all(Number(req.params.id));
|
||||||
|
|
@ -1648,6 +1655,30 @@ app.post('/api/members/logout', memberRequired, async (req, res) => {
|
||||||
res.json({ ok: true });
|
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) ──
|
/* ── OTP login/signup via Kavenegar (verify/lookup) ──
|
||||||
SFsignin → existing numbers, SFsignup → new numbers (auto-created on verify). */
|
SFsignin → existing numbers, SFsignup → new numbers (auto-created on verify). */
|
||||||
const otpStore = new Map(); // phone -> { code, expires, attempts }
|
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
|
// 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 || {};
|
const { articleId, amount = 0 } = req.body || {};
|
||||||
if (!articleId) return res.status(400).json({ error: 'articleId الزامی است' });
|
if (!articleId) return res.status(400).json({ error: 'articleId الزامی است' });
|
||||||
const member = await db.prepare('SELECT id FROM members WHERE id = ?').get(req.params.memberId);
|
const member = await db.prepare('SELECT id FROM members WHERE id = ?').get(req.params.memberId);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue