);
}
/* ------------------------------------------------ tick para relojes / códigos vivos */
function useTick(ms) {
const [, setN] = useState(0);
useEffect(() => { const id = setInterval(() => setN((n) => n + 1), ms || 1000); return () => clearInterval(id); }, [ms]);
}
/* ------------------------------------------------ botón (i) con explicación de sección */
function InfoButton({ title, children }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };
}, [open]);
return (
{open ? (
{title ?
{title}
: null}
{children}
) : null}
);
}
/* ------------------------------------------------ alta de 2FA por el propio usuario */
function randomSecret() {
const ch = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let s = ''; for (let i = 0; i < 16; i++) s += ch[Math.floor(Math.random() * ch.length)];
return s;
}
function TwoFASetup({ user, forced, onClose, onSaved }) {
const enabled = !!user.twofa_enabled;
const [secret] = useState(() => user.twofa_secret || randomSecret());
const [code, setCode] = useState('');
const [err, setErr] = useState(null);
const [busy, setBusy] = useState(false);
useTick(1000);
const demo = window.TRASCEND_BACKEND !== 'mysql';
const current = U.totpCode(secret);
const secsLeft = 30 - Math.floor((Date.now() / 1000) % 30);
const otpauth = 'otpauth://totp/Trascend:' + encodeURIComponent(user.email) + '?secret=' + secret + '&issuer=Trascend&period=30&digits=6';
const qrUrl = useMemo(() => {
try {
if (typeof window.qrcode !== 'function') return null;
const qr = window.qrcode(0, 'M'); qr.addData(otpauth); qr.make();
return qr.createDataURL(5, 4);
} catch (e) { return null; }
}, [otpauth]);
async function enable() {
if (String(code).trim().length !== 6) { setErr('Ingresá los 6 dígitos.'); return; }
if (demo && String(code).trim() !== current) { setErr('El código no coincide. Usá el que se muestra arriba.'); return; }
setBusy(true);
try { await TrascendAPI.users.update(user.id, { twofa_enabled: 1, twofa_secret: secret }); onSaved && onSaved(); onClose(); }
catch (e) { setErr(e.message || 'No se pudo activar.'); setBusy(false); }
}
async function disable() {
setBusy(true);
try { await TrascendAPI.users.update(user.id, { twofa_enabled: 0, twofa_secret: '' }); onSaved && onSaved(); onClose(); }
catch (e) { setErr(e.message || 'No se pudo desactivar.'); setBusy(false); }
}
return (
{!forced ? : null}
) : (
{!forced ? : null}
)}>
{enabled ? (
La verificación en 2 pasos está activa en tu cuenta. Al iniciar sesión te pediremos un código de 6 dígitos.
{demo ?
Tu código ahora (demo){current}{secsLeft}s
: null}
) : (
{forced ? 'Tu organización exige 2FA. Configurala para entrar. ' : 'Sumá una capa extra de seguridad. '}
Escaneá o ingresá esta clave en una app de autenticación (Google Authenticator, Authy, 1Password…).
{qrUrl
?
:
}
Escaneá el códigoAbrí tu app de autenticación y escaneá este QR para vincular la cuenta.
)}
);
}
/* ------------------------------------------------ banner global de error de API
Escucha 'trascend:apierror' (lo dispara data/db.js cuando falla un guardado)
y lo muestra en pantalla, así un error al guardar deja de fallar en silencio. */
function ApiErrorHost() {
const [msg, setMsg] = useState(null);
const timer = useRef(null);
useEffect(() => {
const onErr = (e) => {
const m = (e && e.detail && e.detail.message) || 'No se pudo guardar. Reintentá.';
setMsg(m);
clearTimeout(timer.current);
timer.current = setTimeout(() => setMsg(null), 8000);
};
window.addEventListener('trascend:apierror', onErr);
return () => { window.removeEventListener('trascend:apierror', onErr); clearTimeout(timer.current); };
}, []);
if (!msg) return null;
return (
{msg}
);
}
/* ------------------------------------------------ Importar / Exportar CSV
Botón + modal reutilizable. `table` es la clave whitelisted en api.php.
Al importar, muestra un resumen (creados / salteados). Al exportar,
descarga el CSV en una pestaña nueva. Acepta también .xlsx: si el
navegador selecciona uno, se carga SheetJS on-demand y se convierte a
CSV antes de enviarlo. */
let __sheetJSPromise = null;
function loadSheetJS() {
if (window.XLSX) return Promise.resolve(window.XLSX);
if (__sheetJSPromise) return __sheetJSPromise;
__sheetJSPromise = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://unpkg.com/xlsx@0.18.5/dist/xlsx.full.min.js';
s.onload = () => resolve(window.XLSX);
s.onerror = () => reject(new Error('No se pudo cargar el motor de Excel (revisá tu conexión).'));
document.head.appendChild(s);
});
return __sheetJSPromise;
}
function ImportExportCSV({ table, label, onImported }) {
const [open, setOpen] = React.useState(false);
const [csv, setCsv] = React.useState('');
const [file, setFile] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [result, setResult] = React.useState(null);
const [parsing, setParsing] = React.useState(false);
const [mode, setMode] = React.useState('skip');
async function pick(e) {
const f = e.target.files && e.target.files[0]; if (!f) return;
setFile(f); setResult(null);
const isExcel = /\.xlsx?$/i.test(f.name) || (f.type && f.type.indexOf('spreadsheet') !== -1);
if (isExcel) {
setParsing(true);
try {
const XLSX = await loadSheetJS();
const buf = await f.arrayBuffer();
const wb = XLSX.read(buf, { type: 'array' });
const sheetName = wb.SheetNames[0];
if (!sheetName) throw new Error('El Excel no tiene ninguna hoja.');
const csvOut = XLSX.utils.sheet_to_csv(wb.Sheets[sheetName], { FS: ',' });
setCsv(csvOut);
} catch (err) { setResult({ error: 'No pudimos leer el Excel: ' + err.message }); setCsv(''); }
setParsing(false);
} else {
const fr = new FileReader();
fr.onload = () => setCsv(String(fr.result || ''));
fr.readAsText(f, 'utf-8');
}
}
async function doImport() {
if (!csv.trim()) return;
setBusy(true);
try { const r = await TrascendAPI.csv.import(table, csv, mode); setResult(r); if (onImported) onImported(r); }
catch (e) { setResult({ error: e.message }); }
setBusy(false);
}
return (
{open ? (
setOpen(false)}
foot={}>
Podés subir .csv o .xlsx (Excel se convierte automáticamente). La cabecera debe usar los nombres de columna reales.
La plantilla con las columnas correctas la bajás desde Exportar (sale aunque la tabla esté vacía).