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.
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7252b958029e1bbd72b2c774a2c02f9d423a4daa1be7926bb1208b020f702bb6
|
||||
size 32234368
|
||||
oid sha256:a6e2cb352e5bd25693baac943cc3aad44e6dc1595213bd64fc19f6a0230e57c8
|
||||
size 30295491
|
||||
|
|
|
|||
|
|
@ -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)} />} />
|
||||
|
|
|
|||
|
|
@ -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,34 +16,15 @@ 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 && (
|
||||
{/* full-bleed video — native streaming via HTTP range requests */}
|
||||
<video
|
||||
src={videoSrc}
|
||||
autoPlay muted loop playsInline
|
||||
src="/hero-bg.mp4"
|
||||
autoPlay muted loop playsInline preload="auto"
|
||||
style={{
|
||||
position: 'absolute', inset: 0,
|
||||
width: '100%', height: '100%',
|
||||
|
|
@ -53,7 +32,6 @@ export default function SpreadFeatures() {
|
|||
display: 'block', zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* full-width gradient: solid navy on right → transparent on left */}
|
||||
<div style={{
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue