Железный Егорин — форк Орбиты: тёплый стиль (amber), логин с odyssey WebGL-молнией справа, энергомониторинг+панель
Ребренд Мониторинг Орбита -> Железный Егорин; accent green->amber #F59E0B; LoginScreen правая половина = Lightning (WebGL, hue 35 тёплый) под стеклянной формой; левая панель — тёплый градиент. Доступ агентам: репо публичный, push german-токеном. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
# Шлюз. Адаптер активен только если задан источник; иначе раздел на демо-данных.
|
||||
PORT=4100
|
||||
JWT_SECRET=change-me-in-prod
|
||||
|
||||
# Wiren Board / FLEX-сервер (MQTT). В ingest-стеке = mqtt://mosquitto:1883
|
||||
MQTT_URL=mqtt://mosquitto:1883
|
||||
MQTT_BASE_TOPIC=navtelecom
|
||||
# MQTT_USER=
|
||||
# MQTT_PASS=
|
||||
# MQTT_PREFIX=/devices
|
||||
|
||||
# Traccar (когда стек развёрнут)
|
||||
# TRACCAR_URL=http://traccar:8082
|
||||
# TRACCAR_TOKEN=... (или TRACCAR_USER / TRACCAR_PASS)
|
||||
|
||||
# InfluxDB (энерго, когда стек развёрнут)
|
||||
# INFLUX_URL=http://influxdb:8086
|
||||
# INFLUX_TOKEN=...
|
||||
# INFLUX_ORG=servaki
|
||||
# INFLUX_BUCKET=energy
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY src ./src
|
||||
EXPOSE 4100
|
||||
CMD ["node", "src/server.js"]
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "servaki-monitor-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Бэкенд-шлюз Servaki Monitor: адаптеры Traccar / Wiren Board (MQTT) / InfluxDB → единая модель, REST + WebSocket, RBAC, мультиарендность",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@influxdata/influxdb-client": "^1.35.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"mqtt": "^5.7.0",
|
||||
"ws": "^8.17.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// InfluxDB-адаптер: энерго-ряд (PV/SOC/нагрузка) из бакета. Активен при INFLUX_URL.
|
||||
import { InfluxDB } from '@influxdata/influxdb-client';
|
||||
import { config } from '../config.js';
|
||||
import { setEnergySeries } from '../store.js';
|
||||
|
||||
export function startInflux() {
|
||||
if (!config.influx) return false;
|
||||
const q = new InfluxDB({ url: config.influx.url, token: config.influx.token }).getQueryApi(config.influx.org);
|
||||
const flux = `from(bucket:"${config.influx.bucket}")
|
||||
|> range(start:-24h)
|
||||
|> filter(fn:(r)=> r._measurement=="pv" or r._measurement=="soc" or r._measurement=="load")
|
||||
|> aggregateWindow(every:1h, fn:mean, createEmpty:false)`;
|
||||
async function poll() {
|
||||
const rows = {};
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
q.queryRows(flux, {
|
||||
next: (row, meta) => {
|
||||
const o = meta.toObject(row);
|
||||
const t = new Date(o._time).toISOString().slice(11, 16);
|
||||
rows[t] = rows[t] || { t };
|
||||
rows[t][o._measurement] = Math.round(o._value);
|
||||
},
|
||||
error: reject,
|
||||
complete: resolve,
|
||||
});
|
||||
});
|
||||
const series = Object.values(rows);
|
||||
if (series.length) setEnergySeries(series);
|
||||
} catch (e) {
|
||||
console.error('[influx]', e.message);
|
||||
}
|
||||
}
|
||||
poll();
|
||||
setInterval(poll, 60000);
|
||||
console.log('[influx] запросы', config.influx.url);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// MQTT-адаптер: приём позиций от FLEX-сервера (navtelecom/<imei>/state) +
|
||||
// состояние каналов Wiren Board (/devices/<dev>/controls/<ch>).
|
||||
import mqtt from 'mqtt';
|
||||
import { config } from '../config.js';
|
||||
import { addPosition, upsertEntity, updateChannel } from '../store.js';
|
||||
import { imeiOwner } from '../tenants.js';
|
||||
|
||||
const TELE_KEYS = ['pwr_ext', 'pwr_int', 'temp1', 'temp2', 'gsm', 'speed', 'sat', 'event_code'];
|
||||
|
||||
export function startMqtt() {
|
||||
if (!config.mqtt) return false;
|
||||
const c = mqtt.connect(config.mqtt.url, {
|
||||
username: config.mqtt.username,
|
||||
password: config.mqtt.password,
|
||||
reconnectPeriod: 5000,
|
||||
});
|
||||
c.on('connect', () => {
|
||||
console.log('[mqtt] подключён', config.mqtt.url);
|
||||
c.subscribe('navtelecom/+/state');
|
||||
c.subscribe(`${config.mqtt.prefix}/+/controls/+`);
|
||||
});
|
||||
c.on('message', (topic, buf) => {
|
||||
try {
|
||||
let m = topic.match(/^navtelecom\/([^/]+)\/state$/);
|
||||
if (m) {
|
||||
const imei = m[1];
|
||||
const p = JSON.parse(buf.toString());
|
||||
const lat = p.lat ?? p.latitude;
|
||||
const lon = p.lon ?? p.longitude;
|
||||
const telemetry = {};
|
||||
TELE_KEYS.forEach((k) => p[k] != null && (telemetry[k] = Number(p[k])));
|
||||
const id = `nt-${imei}`;
|
||||
const clientId = imeiOwner(imei) || 'unassigned';
|
||||
upsertEntity({ id, clientId, name: `Трекер ${imei}`, type: 'transport' }); // тег арендатора по IMEI
|
||||
if (lat != null && lon != null) addPosition(id, Number(lat), Number(lon), telemetry);
|
||||
else upsertEntity({ id, online: true, telemetry });
|
||||
return;
|
||||
}
|
||||
m = topic.match(new RegExp(`${config.mqtt.prefix}/([^/]+)/controls/([^/]+)$`));
|
||||
if (m) {
|
||||
const v = buf.toString();
|
||||
updateChannel(`${m[1]}/${m[2]}`, { on: v === '1' || v === 'true', ...(isNaN(+v) ? {} : { level: +v }) });
|
||||
}
|
||||
} catch {
|
||||
/* malformed payload — ignore */
|
||||
}
|
||||
});
|
||||
c.on('error', (e) => console.error('[mqtt]', e.message));
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Traccar-адаптер: опрос REST /api/devices + /api/positions → объекты/треки.
|
||||
// Активен при TRACCAR_URL. (Когда стек Traccar развёрнут — данные пойдут сюда.)
|
||||
import { config } from '../config.js';
|
||||
import { addPosition, upsertEntity } from '../store.js';
|
||||
|
||||
export function startTraccar() {
|
||||
if (!config.traccar) return false;
|
||||
const base = config.traccar.url.replace(/\/$/, '');
|
||||
const headers = {};
|
||||
if (config.traccar.token) headers.Authorization = `Bearer ${config.traccar.token}`;
|
||||
else if (config.traccar.user)
|
||||
headers.Authorization = 'Basic ' + Buffer.from(`${config.traccar.user}:${config.traccar.pass}`).toString('base64');
|
||||
|
||||
const devices = {};
|
||||
async function poll() {
|
||||
try {
|
||||
const d = await (await fetch(`${base}/api/devices`, { headers })).json();
|
||||
d.forEach((x) => {
|
||||
devices[x.id] = x;
|
||||
upsertEntity({ id: `tc-${x.id}`, clientId: 'traccar', name: x.name, type: 'transport', online: x.status === 'online' });
|
||||
});
|
||||
const pos = await (await fetch(`${base}/api/positions`, { headers })).json();
|
||||
pos.forEach((p) =>
|
||||
addPosition(`tc-${p.deviceId}`, p.latitude, p.longitude, { speed: Math.round((p.speed || 0) * 1.852), sat: p.attributes?.sat || 0 }, p.fixTime),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[traccar]', e.message);
|
||||
}
|
||||
}
|
||||
poll();
|
||||
setInterval(poll, 10000);
|
||||
console.log('[traccar] опрос', base);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// RBAC-зеркало фронта + простой HMAC-токен (без внешних зависимостей).
|
||||
import crypto from 'node:crypto';
|
||||
import { config } from './config.js';
|
||||
|
||||
export const USERS = [
|
||||
{
|
||||
id: 'admin',
|
||||
name: 'Администратор',
|
||||
role: 'Полный доступ',
|
||||
admin: true,
|
||||
access: {
|
||||
energy: { caps: ['view', 'edit', 'reports'], objects: 'all' },
|
||||
smart: { caps: ['view', 'edit', 'reports'], objects: 'all' },
|
||||
gps: { caps: ['view', 'edit', 'reports'], objects: 'all' },
|
||||
netinfra: { caps: ['view', 'edit', 'reports'], objects: 'all' },
|
||||
security: { caps: ['view', 'edit', 'reports'], objects: 'all' },
|
||||
},
|
||||
},
|
||||
{ id: 'pcn-operator', name: 'Дежурный ПЦН', role: 'Оператор пульта', access: { security: { caps: ['view', 'edit', 'reports'], objects: 'all' } } },
|
||||
{ id: 'energetik-ro', name: 'Сидоров А.', role: 'Энергетик · просмотр', access: { energy: { caps: ['view'], objects: ['cab-07'] } } },
|
||||
{ id: 'energetik-rw', name: 'Петров В.', role: 'Энергетик · правка', access: { energy: { caps: ['view', 'edit', 'reports'], objects: 'all' } } },
|
||||
{ id: 'logist', name: 'Кузнецова М.', role: 'Логист транспорта', access: { gps: { caps: ['view', 'edit', 'reports'], objects: 'all' } } },
|
||||
{ id: 'netadmin', name: 'Орлов Д.', role: 'Сетевой инженер', access: { netinfra: { caps: ['view', 'edit', 'reports'], objects: 'all' } } },
|
||||
{
|
||||
id: 'demo',
|
||||
name: 'Демо-доступ',
|
||||
role: 'Только просмотр',
|
||||
access: {
|
||||
energy: { caps: ['view'], objects: 'all' },
|
||||
smart: { caps: ['view'], objects: 'all' },
|
||||
gps: { caps: ['view'], objects: 'all' },
|
||||
netinfra: { caps: ['view'], objects: 'all' },
|
||||
security: { caps: ['view'], objects: 'all' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const TYPE_SECTION = { transport: 'gps', energy: 'energy', realty: 'smart', network: 'netinfra' };
|
||||
|
||||
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // срок жизни сессии — 30 дней
|
||||
const STATE_TTL_MS = 10 * 60 * 1000; // OAuth state (CSRF) — 10 минут
|
||||
|
||||
export function sign(user) {
|
||||
const body = Buffer.from(JSON.stringify({ id: user.id, t: Date.now() })).toString('base64url');
|
||||
const sig = crypto.createHmac('sha256', config.jwtSecret).update(body).digest('base64url');
|
||||
return `${body}.${sig}`;
|
||||
}
|
||||
|
||||
// Возвращает id пользователя из токена (сам пользователь резолвится в tenants). null если подпись/срок невалидны.
|
||||
export function verifyId(token) {
|
||||
if (!token) return null;
|
||||
const [body, sig] = token.split('.');
|
||||
if (!body || !sig) return null;
|
||||
const exp = crypto.createHmac('sha256', config.jwtSecret).update(body).digest('base64url');
|
||||
if (exp !== sig) return null;
|
||||
try {
|
||||
const p = JSON.parse(Buffer.from(body, 'base64url').toString());
|
||||
if (!p.t || Date.now() - p.t > TOKEN_TTL_MS) return null; // сессия просрочена
|
||||
return p.id || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// CSRF-state для OAuth: подписанный короткоживущий токен (stateless, без хранилища сессий)
|
||||
export function signState() {
|
||||
const body = Buffer.from(JSON.stringify({ n: crypto.randomBytes(9).toString('hex'), t: Date.now() })).toString('base64url');
|
||||
const sig = crypto.createHmac('sha256', config.jwtSecret).update('state:' + body).digest('base64url');
|
||||
return `${body}.${sig}`;
|
||||
}
|
||||
export function verifyState(state) {
|
||||
if (!state) return false;
|
||||
const [body, sig] = String(state).split('.');
|
||||
if (!body || !sig) return false;
|
||||
const exp = crypto.createHmac('sha256', config.jwtSecret).update('state:' + body).digest('base64url');
|
||||
if (exp !== sig) return false;
|
||||
try {
|
||||
const p = JSON.parse(Buffer.from(body, 'base64url').toString());
|
||||
return !!p.t && Date.now() - p.t <= STATE_TTL_MS;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const can = (user, section, cap) => !!user?.access?.[section]?.caps?.includes(cap);
|
||||
export function isObjectAllowed(user, section, id) {
|
||||
const o = user?.access?.[section]?.objects;
|
||||
return o === 'all' || (Array.isArray(o) && o.includes(id));
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Конфигурация шлюза из окружения. Адаптер активен только если задан его источник;
|
||||
// иначе раздел работает на демо-данных (fallback). Так шлюз поднимается сразу,
|
||||
// а реальные источники «зажигаются» по мере готовности их стеков.
|
||||
const env = process.env;
|
||||
|
||||
export const config = {
|
||||
port: Number(env.PORT || 4100),
|
||||
jwtSecret: env.JWT_SECRET || 'dev-secret-change-me',
|
||||
dataDir: env.DATA_DIR || '/data', // персист арендаторов/привязок устройств
|
||||
|
||||
traccar: env.TRACCAR_URL
|
||||
? { url: env.TRACCAR_URL, token: env.TRACCAR_TOKEN || '', user: env.TRACCAR_USER || '', pass: env.TRACCAR_PASS || '' }
|
||||
: null,
|
||||
|
||||
mqtt: env.MQTT_URL
|
||||
? { url: env.MQTT_URL, username: env.MQTT_USER || undefined, password: env.MQTT_PASS || undefined, prefix: env.MQTT_PREFIX || '/devices' }
|
||||
: null,
|
||||
|
||||
influx: env.INFLUX_URL
|
||||
? { url: env.INFLUX_URL, token: env.INFLUX_TOKEN || '', org: env.INFLUX_ORG || '', bucket: env.INFLUX_BUCKET || 'energy' }
|
||||
: null,
|
||||
|
||||
// Яндекс ID (OAuth). Активен при YANDEX_CLIENT_ID. Регистрация: oauth.yandex.ru.
|
||||
yandex: env.YANDEX_CLIENT_ID
|
||||
? {
|
||||
clientId: env.YANDEX_CLIENT_ID,
|
||||
secret: env.YANDEX_CLIENT_SECRET || '',
|
||||
redirect: env.YANDEX_REDIRECT || 'https://monitor.servaki.online/api/auth/yandex/callback',
|
||||
admins: (env.YANDEX_ADMINS || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
export const sources = {
|
||||
traccar: !!config.traccar,
|
||||
wirenboard: !!config.mqtt,
|
||||
influx: !!config.influx,
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
// Демо-данные (fallback, когда реальные источники не сконфигурированы).
|
||||
// Транспорт «двигается» — формируются живые треки, как от реальных устройств.
|
||||
import { upsertEntity, addPosition, setChannels, updateChannel, addEvent, setEnergySeries, setTrips } from './store.js';
|
||||
|
||||
export function seedDemo() {
|
||||
// Энерго-щит (стационар)
|
||||
upsertEntity({ id: 'cab-07', clientId: 'tempelhoff', name: 'Щит КТП-7', type: 'energy', online: false, lat: 44.08, lon: 43.0, telemetry: { pwr_ext: 0, pwr_int: 3.9, temp: 24 } });
|
||||
|
||||
// Сетевые узлы mesh2
|
||||
const nodes = [
|
||||
['mesh2-hub', 'finik-хаб', 44.072, 43.05, { latency: 1.2, uplink: 940, clients: 38, cpu: 17 }, { ip: '10.200.0.1', role: 'Центральный хаб mesh2' }],
|
||||
['mesh2-kn1', 'Keenetic · Склад', 44.064, 43.058, { latency: 3.4, uplink: 310, clients: 12, cpu: 22 }, { ip: '10.200.1.2', role: 'Спица · склад' }],
|
||||
['mesh2-kn2', 'Keenetic · Ферма', 44.081, 43.041, { latency: 5.1, uplink: 95, clients: 7, cpu: 19 }, { ip: '10.200.2.2', role: 'Спица · ферма' }],
|
||||
];
|
||||
nodes.forEach(([id, name, lat, lon, t, meta]) => upsertEntity({ id, clientId: 'tempelhoff', name, type: 'network', online: true, lat, lon, telemetry: t, meta }));
|
||||
|
||||
// Каналы Wiren Board
|
||||
setChannels([
|
||||
{ id: 'mr6c_214/K1', device: 'WB-MR6C #214', name: 'Освещение цех', kind: 'relay', on: true },
|
||||
{ id: 'mr6c_214/K2', device: 'WB-MR6C #214', name: 'Ворота', kind: 'relay', on: false },
|
||||
{ id: 'mdm3_30/CH1', device: 'WB-MDM3 #30', name: 'Свет офис', kind: 'dimmer', on: true, level: 65 },
|
||||
{ id: 'mr6c_214/IN1', device: 'WB-MR6C #214', name: 'Датчик двери', kind: 'input', on: false },
|
||||
]);
|
||||
|
||||
// Энерго-ряд за сутки
|
||||
setEnergySeries(
|
||||
Array.from({ length: 24 }, (_, h) => {
|
||||
const s = Math.max(0, Math.sin(((h - 6) / 12) * Math.PI));
|
||||
return { t: `${String(h).padStart(2, '0')}:00`, pv: Math.round(s * 4200), soc: Math.round(40 + s * 55), load: Math.round(600 + Math.sin((h / 24) * Math.PI * 2) * 250) };
|
||||
}),
|
||||
);
|
||||
|
||||
// Поездки (журнал)
|
||||
setTrips([
|
||||
{ id: 't1', entityId: 'veh-123', start: new Date(Date.now() - 6 * 3.6e6).toISOString(), end: new Date(Date.now() - 5.2 * 3.6e6).toISOString(), from: 'База', to: 'Пятигорск', distanceKm: 18.4, durationMin: 48, maxSpeed: 78, avgSpeed: 41 },
|
||||
{ id: 't2', entityId: 'veh-123', start: new Date(Date.now() - 2 * 3.6e6).toISOString(), end: new Date(Date.now() - 1.4 * 3.6e6).toISOString(), from: 'Ессентуки', to: 'База', distanceKm: 31.6, durationMin: 37, maxSpeed: 91, avgSpeed: 51 },
|
||||
]);
|
||||
|
||||
addEvent({ id: 'e1', entityId: 'cab-07', callsign: 'Щит КТП-7', severity: 'critical', type: 'power_loss', text: 'Пропадание сетевого питания, переход на ИБП.', ts: new Date().toISOString(), ackRequired: true });
|
||||
|
||||
// Подвижные объекты + стартовые позиции
|
||||
const moving = {
|
||||
'veh-123': { name: 'ГАЗель-123', lat: 44.05, lon: 43.06, hd: 0.4 },
|
||||
'veh-204': { name: 'МТЗ-82 трактор', lat: 44.02, lon: 43.11, hd: 1.2 },
|
||||
};
|
||||
Object.entries(moving).forEach(([id, m]) =>
|
||||
upsertEntity({ id, clientId: 'tempelhoff', name: m.name, type: 'transport', online: true, lat: m.lat, lon: m.lon, telemetry: { speed: 0, sat: 10, fuel: 55, voltage: 13.6 } }),
|
||||
);
|
||||
|
||||
// Тикер «движения» → живые треки (имитация приёма с устройств)
|
||||
setInterval(() => {
|
||||
for (const [id, m] of Object.entries(moving)) {
|
||||
m.hd += (Math.random() - 0.5) * 0.6;
|
||||
const speed = id === 'veh-204' ? Math.round(Math.random() * 12) : Math.round(35 + Math.random() * 45);
|
||||
const step = (speed / 3600) * 0.02;
|
||||
m.lat += Math.cos(m.hd) * step;
|
||||
m.lon += Math.sin(m.hd) * step;
|
||||
addPosition(id, +m.lat.toFixed(5), +m.lon.toFixed(5), { speed, sat: 9 + Math.round(Math.random() * 4), fuel: 50, voltage: 13.6 });
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// Датчик двери: периодическая смена статуса + событие — демонстрация live-обновления по WS
|
||||
let door = false;
|
||||
setInterval(() => {
|
||||
door = !door;
|
||||
updateChannel('mr6c_214/IN1', { on: door });
|
||||
addEvent({
|
||||
id: 'door-' + new Date().toISOString(),
|
||||
entityId: 'cab-07',
|
||||
callsign: 'Датчик двери · WB-MR6C #214',
|
||||
severity: door ? 'warning' : 'info',
|
||||
type: door ? 'door_open' : 'door_closed',
|
||||
text: door ? 'Дверь шкафа открыта' : 'Дверь шкафа закрыта',
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
}, 9000);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Метрики: онлайн-пользователи по разделам, журнал активности, нагрузка. In-memory.
|
||||
const seen = new Map(); // userId -> { ts, section, name }
|
||||
const activity = []; // {ts, userId, name, section, action}
|
||||
let reqTotal = 0;
|
||||
const reqTimes = []; // метки запросов за последнюю минуту
|
||||
|
||||
const ONLINE_MS = 120000; // онлайн = активность за 2 мин
|
||||
const wsByUser = new Map(); // userId -> { count, name }
|
||||
|
||||
export function wsConnect(user) {
|
||||
const e = wsByUser.get(user.id) || { count: 0, name: user.name };
|
||||
e.count++;
|
||||
wsByUser.set(user.id, e);
|
||||
}
|
||||
export function wsDisconnect(userId) {
|
||||
const e = wsByUser.get(userId);
|
||||
if (e && --e.count <= 0) wsByUser.delete(userId);
|
||||
}
|
||||
|
||||
export function touch(user, section = '—', action = 'запрос') {
|
||||
const now = Date.now();
|
||||
seen.set(user.id, { ts: now, section, name: user.name });
|
||||
reqTotal++;
|
||||
reqTimes.push(now);
|
||||
activity.unshift({ ts: new Date(now).toISOString(), userId: user.id, name: user.name, section, action });
|
||||
if (activity.length > 800) activity.length = 800;
|
||||
}
|
||||
|
||||
function onlineSet() {
|
||||
const now = Date.now();
|
||||
const out = new Map(); // id -> { name, section, secAgo }
|
||||
for (const [id, v] of seen) if (now - v.ts < ONLINE_MS) out.set(id, { name: v.name, section: v.section, secAgo: Math.round((now - v.ts) / 1000) });
|
||||
for (const [id, v] of wsByUser) if (!out.has(id)) out.set(id, { name: v.name, section: 'live', secAgo: 0 });
|
||||
return out;
|
||||
}
|
||||
|
||||
export function stats() {
|
||||
const now = Date.now();
|
||||
while (reqTimes.length && reqTimes[0] < now - 60000) reqTimes.shift();
|
||||
const on = onlineSet();
|
||||
const bySection = {};
|
||||
for (const [, v] of on) bySection[v.section] = (bySection[v.section] || 0) + 1;
|
||||
return {
|
||||
onlineTotal: on.size,
|
||||
bySection,
|
||||
reqPerMin: reqTimes.length,
|
||||
reqTotal,
|
||||
rssMb: Math.round(process.memoryUsage().rss / 1048576),
|
||||
uptimeSec: Math.round(process.uptime()),
|
||||
};
|
||||
}
|
||||
|
||||
export function onlineUsers() {
|
||||
return [...onlineSet().entries()].map(([id, v]) => ({ id, name: v.name, section: v.section, secAgo: v.secAgo }));
|
||||
}
|
||||
|
||||
export const getActivity = (limit = 150) => activity.slice(0, limit);
|
||||
|
||||
export function dailyByUser() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const m = {};
|
||||
for (const a of activity) {
|
||||
if (a.ts.slice(0, 10) !== today) continue;
|
||||
(m[a.userId] = m[a.userId] || { name: a.name, count: 0, last: a.ts }).count++;
|
||||
}
|
||||
return Object.entries(m).map(([id, v]) => ({ id, ...v }));
|
||||
}
|
||||
|
||||
// Раздел по пути API (для статистики онлайн-по-разделам)
|
||||
export function sectionOf(path) {
|
||||
if (path.includes('/entities') || path.includes('/tracks') || path.includes('/trips')) return 'GPS';
|
||||
if (path.includes('/channels')) return 'Смарт';
|
||||
if (path.includes('/energy')) return 'Энерго';
|
||||
if (path.includes('/devices')) return 'Устройства';
|
||||
if (path.includes('/admin')) return 'Админ';
|
||||
if (path.includes('/billing')) return 'Кабинет';
|
||||
return '—';
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import http from 'node:http';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { config } from './config.js';
|
||||
import { sign, verifyId, signState, verifyState, can, isObjectAllowed, TYPE_SECTION } from './auth.js';
|
||||
import * as tenants from './tenants.js';
|
||||
import * as metrics from './metrics.js';
|
||||
import * as store from './store.js';
|
||||
import { seedDemo } from './demo.js';
|
||||
import { startMqtt } from './adapters/mqtt.js';
|
||||
import { startTraccar } from './adapters/traccar.js';
|
||||
import { startInflux } from './adapters/influx.js';
|
||||
|
||||
seedDemo();
|
||||
const live = { wirenboard: startMqtt(), traccar: startTraccar(), influx: startInflux() };
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
function auth(req, res, next) {
|
||||
const u = tenants.getUser(verifyId((req.headers.authorization || '').replace(/^Bearer /, '')));
|
||||
if (!u) return res.status(401).json({ error: 'unauthorized' });
|
||||
if (tenants.isBlocked(u)) return res.status(403).json({ error: 'Доступ заблокирован', blocked: u.blocked });
|
||||
req.user = u;
|
||||
metrics.touch(u, metrics.sectionOf(req.path));
|
||||
next();
|
||||
}
|
||||
function adminOnly(req, res, next) {
|
||||
if (!req.user?.admin) return res.status(403).json({ error: 'forbidden' });
|
||||
next();
|
||||
}
|
||||
// Видимость: мультиарендность по clientId (admin/'all' видят всё) + RBAC раздела/объекта.
|
||||
const visible = (user, e) => {
|
||||
if (!user.admin && user.clientId !== 'all' && e.clientId !== user.clientId) return false;
|
||||
const sec = TYPE_SECTION[e.type];
|
||||
return can(user, sec, 'view') && isObjectAllowed(user, sec, e.id);
|
||||
};
|
||||
const filterEnt = (user, list) => list.filter((e) => visible(user, e));
|
||||
|
||||
app.get('/api/health', (_req, res) => res.json({ ok: true, sources: live }));
|
||||
// Вход по id — только для seed-ролей/demo (переключатель). Яндекс-арендаторы (yx-*) — только через OAuth.
|
||||
app.post('/api/login', (req, res) => {
|
||||
const id = String(req.body?.userId || 'demo');
|
||||
if (id.startsWith('yx-')) return res.status(403).json({ error: 'Для этого пользователя вход только через Яндекс ID' });
|
||||
const u = tenants.getUser(id) || tenants.getUser('demo');
|
||||
res.json({ token: sign(u), user: u });
|
||||
});
|
||||
app.get('/api/me', auth, (req, res) => res.json(req.user));
|
||||
|
||||
// Устройства арендатора (привязка трекеров по IMEI → clientId владельца)
|
||||
app.get('/api/devices', auth, (req, res) => res.json(tenants.devicesOf(req.user.id)));
|
||||
app.post('/api/devices/bind', auth, (req, res) => {
|
||||
const imei = String(req.body?.imei || '').trim();
|
||||
if (!/^\d{6,20}$/.test(imei)) return res.status(400).json({ error: 'Некорректный IMEI (только цифры)' });
|
||||
const device = tenants.bindDevice(req.user.id, imei, req.body?.name);
|
||||
const e = store.getEntity(`nt-${imei}`);
|
||||
if (e) store.upsertEntity({ id: e.id, clientId: req.user.clientId }); // пере-тег уже принятой сущности
|
||||
res.json({ ok: true, device });
|
||||
});
|
||||
app.delete('/api/devices/:imei', auth, (req, res) => {
|
||||
tenants.unbindDevice(req.user.id, req.params.imei);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.get('/api/entities', auth, (req, res) => {
|
||||
let l = filterEnt(req.user, store.getEntities());
|
||||
if (req.query.type) l = l.filter((e) => e.type === req.query.type);
|
||||
res.json(l);
|
||||
});
|
||||
app.get('/api/tracks/:id', auth, (req, res) => res.json(store.getTrack(req.params.id)));
|
||||
app.get('/api/trips', auth, (req, res) => res.json(store.getTrips(req.query.entityId)));
|
||||
app.get('/api/events', auth, (req, res) => res.json(store.getEvents()));
|
||||
app.get('/api/channels', auth, (req, res) => res.json(can(req.user, 'smart', 'view') ? store.getChannels() : []));
|
||||
app.get('/api/energy/series', auth, (req, res) => res.json(can(req.user, 'energy', 'view') ? store.getEnergySeries() : []));
|
||||
|
||||
// --- Админ-консоль (только admin) ---
|
||||
app.get('/api/admin/stats', auth, adminOnly, (_req, res) => res.json(metrics.stats()));
|
||||
app.get('/api/admin/online', auth, adminOnly, (_req, res) => res.json(metrics.onlineUsers()));
|
||||
app.get('/api/admin/activity', auth, adminOnly, (_req, res) => res.json(metrics.getActivity()));
|
||||
app.get('/api/admin/daily', auth, adminOnly, (_req, res) => res.json(metrics.dailyByUser()));
|
||||
app.get('/api/admin/users', auth, adminOnly, (_req, res) =>
|
||||
res.json(
|
||||
tenants.allUsers().map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
email: u.email || '',
|
||||
role: u.role || '',
|
||||
admin: !!u.admin,
|
||||
clientId: u.clientId,
|
||||
blocked: u.blocked || null,
|
||||
sections: Object.keys(u.access || {}),
|
||||
})),
|
||||
),
|
||||
);
|
||||
app.post('/api/admin/users', auth, adminOnly, (req, res) => res.json(tenants.addUser(req.body || {})));
|
||||
app.delete('/api/admin/users/:id', auth, adminOnly, (req, res) => res.json({ ok: tenants.deleteUser(req.params.id) }));
|
||||
app.post('/api/admin/users/:id/block', auth, adminOnly, (req, res) => res.json(tenants.setBlock(req.params.id, req.body || {}) || { error: 'not found' }));
|
||||
app.post('/api/admin/users/:id/unblock', auth, adminOnly, (req, res) => res.json(tenants.clearBlock(req.params.id) || { error: 'not found' }));
|
||||
|
||||
// --- Личный кабинет / биллинг (заглушка оплаты ЮMoney/Сбербанк) ---
|
||||
const balances = {};
|
||||
app.get('/api/billing', auth, (req, res) => {
|
||||
const cid = req.user.clientId;
|
||||
res.json({ clientId: cid, balance: balances[cid] ?? 0, currency: 'RUB', plan: req.user.admin ? 'Безлимит' : 'Базовый', devices: tenants.devicesOf(req.user.id).length });
|
||||
});
|
||||
app.post('/api/billing/topup', auth, (req, res) => {
|
||||
const amount = Math.max(0, Number(req.body?.amount) || 0);
|
||||
const method = String(req.body?.method || 'yoomoney');
|
||||
const order = `sm-${req.user.clientId}-${Date.now()}`;
|
||||
// ЗАГЛУШКА: реальная интеграция ЮMoney/Сбербанк — позже (см. план billing).
|
||||
const payUrl =
|
||||
method === 'sberbank'
|
||||
? `https://3dsec.sberbank.ru/payment/merchants/servaki/payment_ru.html?mdOrder=${order}`
|
||||
: `https://yoomoney.ru/quickpay/confirm.xml?receiver=4100100000000&quickpay-form=shop&targets=Servaki+Monitor&sum=${amount}&label=${order}`;
|
||||
res.json({ ok: true, demo: true, amount, method, order, payUrl, note: 'Демо-заглушка: оплата не списывается' });
|
||||
});
|
||||
|
||||
// Связанные аккаунты кабинета (общий clientId)
|
||||
app.get('/api/cabinet/links', auth, (req, res) => res.json(tenants.linksOf(req.user.id)));
|
||||
app.post('/api/cabinet/links', auth, (req, res) => res.json(tenants.addLink(req.user.id, req.body?.email)));
|
||||
app.delete('/api/cabinet/links/:email', auth, (req, res) => res.json(tenants.removeLink(req.user.id, decodeURIComponent(req.params.email))));
|
||||
|
||||
// --- Яндекс ID (OAuth) ---
|
||||
app.get('/api/auth/yandex/start', (_req, res) => {
|
||||
if (!config.yandex) return res.redirect('/?autherror=noyandex');
|
||||
const u = new URL('https://oauth.yandex.ru/authorize');
|
||||
u.searchParams.set('response_type', 'code');
|
||||
u.searchParams.set('client_id', config.yandex.clientId);
|
||||
u.searchParams.set('redirect_uri', config.yandex.redirect);
|
||||
u.searchParams.set('state', signState()); // CSRF-защита
|
||||
res.redirect(u.toString());
|
||||
});
|
||||
app.get('/api/auth/yandex/callback', async (req, res) => {
|
||||
if (!config.yandex) return res.redirect('/?autherror=noyandex');
|
||||
// Пользователь отклонил доступ / ошибка Яндекса
|
||||
if (req.query.error) return res.redirect(`/?autherror=${encodeURIComponent(String(req.query.error))}`);
|
||||
// Проверка state (CSRF)
|
||||
if (!verifyState(req.query.state)) return res.redirect('/?autherror=state');
|
||||
if (!req.query.code) return res.redirect('/?autherror=nocode');
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: String(req.query.code),
|
||||
client_id: config.yandex.clientId,
|
||||
client_secret: config.yandex.secret,
|
||||
});
|
||||
const tok = await (await fetch('https://oauth.yandex.ru/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body })).json();
|
||||
if (!tok.access_token) return res.redirect('/?autherror=token');
|
||||
const info = await (await fetch('https://login.yandex.ru/info?format=json', { headers: { Authorization: `OAuth ${tok.access_token}` } })).json();
|
||||
if (!info.id) return res.redirect('/?autherror=userinfo');
|
||||
const u = tenants.upsertYandex({
|
||||
email: String(info.default_email || ''),
|
||||
name: info.real_name || info.display_name || info.login,
|
||||
sub: String(info.id),
|
||||
});
|
||||
res.redirect(`/?token=${sign(u)}`);
|
||||
} catch {
|
||||
res.redirect('/?autherror=oauth');
|
||||
}
|
||||
});
|
||||
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/socket' });
|
||||
wss.on('connection', (ws, req) => {
|
||||
const user = tenants.getUser(verifyId(new URL(req.url, 'http://x').searchParams.get('token')));
|
||||
if (!user || tenants.isBlocked(user)) return ws.close();
|
||||
metrics.wsConnect(user);
|
||||
const send = (type, data) => {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type, data }));
|
||||
} catch {
|
||||
/* socket closed */
|
||||
}
|
||||
};
|
||||
send('snapshot', { entities: filterEnt(user, store.getEntities()) });
|
||||
const onPos = (p) => {
|
||||
const e = store.getEntity(p.id);
|
||||
if (e && visible(user, e)) send('position', p);
|
||||
};
|
||||
const onEnt = (e) => visible(user, e) && send('entity', e);
|
||||
const onEv = (ev) => send('event', ev);
|
||||
const onCh = (c) => can(user, 'smart', 'view') && send('channel', c);
|
||||
store.bus.on('position', onPos);
|
||||
store.bus.on('entity', onEnt);
|
||||
store.bus.on('event', onEv);
|
||||
store.bus.on('channel', onCh);
|
||||
ws.on('close', () => {
|
||||
metrics.wsDisconnect(user.id);
|
||||
store.bus.off('position', onPos);
|
||||
store.bus.off('entity', onEnt);
|
||||
store.bus.off('event', onEv);
|
||||
store.bus.off('channel', onCh);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(config.port, () => console.log(`[api] :${config.port} · источники`, live));
|
||||
@@ -0,0 +1,77 @@
|
||||
// Состояние в памяти + шина событий для WebSocket. Треки = история позиций по объекту.
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
export const bus = new EventEmitter();
|
||||
bus.setMaxListeners(0);
|
||||
|
||||
const MAX_TRACK = 1000;
|
||||
const state = {
|
||||
entities: new Map(),
|
||||
tracks: new Map(), // id -> [{lat,lon,ts}]
|
||||
events: [],
|
||||
channels: [],
|
||||
energySeries: [],
|
||||
trips: [],
|
||||
};
|
||||
|
||||
export function upsertEntity(e) {
|
||||
const prev = state.entities.get(e.id) || {};
|
||||
const merged = {
|
||||
clientId: 'default',
|
||||
type: 'transport',
|
||||
name: e.id,
|
||||
online: true,
|
||||
telemetry: {},
|
||||
...prev,
|
||||
...e,
|
||||
telemetry: { ...(prev.telemetry || {}), ...(e.telemetry || {}) },
|
||||
updatedAt: e.updatedAt || new Date().toISOString(),
|
||||
};
|
||||
state.entities.set(merged.id, merged);
|
||||
bus.emit('entity', merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function addPosition(id, lat, lon, telemetry = {}, ts) {
|
||||
const now = ts || new Date().toISOString();
|
||||
const e = upsertEntity({ id, lat, lon, telemetry, online: true, updatedAt: now });
|
||||
let t = state.tracks.get(id);
|
||||
if (!t) {
|
||||
t = [];
|
||||
state.tracks.set(id, t);
|
||||
}
|
||||
t.push({ lat, lon, ts: now });
|
||||
if (t.length > MAX_TRACK) t.shift();
|
||||
bus.emit('position', { id, lat, lon, ts: now });
|
||||
return e;
|
||||
}
|
||||
|
||||
export const setChannels = (chs) => {
|
||||
state.channels = chs;
|
||||
};
|
||||
export function updateChannel(id, patch) {
|
||||
const c = state.channels.find((x) => x.id === id);
|
||||
if (c) {
|
||||
Object.assign(c, patch);
|
||||
bus.emit('channel', c);
|
||||
}
|
||||
}
|
||||
export function addEvent(ev) {
|
||||
state.events.unshift(ev);
|
||||
if (state.events.length > 200) state.events.pop();
|
||||
bus.emit('event', ev);
|
||||
}
|
||||
export const setEnergySeries = (s) => {
|
||||
state.energySeries = s;
|
||||
};
|
||||
export const setTrips = (t) => {
|
||||
state.trips = t;
|
||||
};
|
||||
|
||||
export const getEntities = () => [...state.entities.values()];
|
||||
export const getEntity = (id) => state.entities.get(id);
|
||||
export const getTrack = (id) => state.tracks.get(id) || [];
|
||||
export const getEvents = () => state.events;
|
||||
export const getChannels = () => state.channels;
|
||||
export const getEnergySeries = () => state.energySeries;
|
||||
export const getTrips = (id) => (id ? state.trips.filter((t) => t.entityId === id) : state.trips);
|
||||
@@ -0,0 +1,180 @@
|
||||
// Арендаторы (мультиарендность). Seed-роли + динамические Яндекс-пользователи.
|
||||
// Привязка устройств по IMEI → clientId владельца. Персист в JSON-файл.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { USERS as SEED } from './auth.js';
|
||||
import { config } from './config.js';
|
||||
|
||||
const FILE = path.join(config.dataDir, 'tenants.json');
|
||||
let users = [];
|
||||
let bindings = {}; // imei -> { userId, clientId, name }
|
||||
|
||||
// Единый набор прав администратора (источник — seed admin). Включает все разделы,
|
||||
// в т.ч. новые (security/ПЦН) — чтобы добавление раздела доезжало без ручных правок tenants.json.
|
||||
const ADMIN_ACCESS = (SEED.find((s) => s.admin) || {}).access || {};
|
||||
|
||||
function persist() {
|
||||
try {
|
||||
fs.mkdirSync(config.dataDir, { recursive: true });
|
||||
fs.writeFileSync(FILE, JSON.stringify({ users, bindings }, null, 2));
|
||||
} catch (e) {
|
||||
console.error('[tenants] save', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
try {
|
||||
const j = JSON.parse(fs.readFileSync(FILE, 'utf8'));
|
||||
users = j.users || [];
|
||||
bindings = j.bindings || {};
|
||||
} catch {
|
||||
users = [];
|
||||
bindings = {};
|
||||
}
|
||||
for (const s of SEED) {
|
||||
const u = users.find((x) => x.id === s.id);
|
||||
if (!u) users.push({ ...s, clientId: s.admin ? 'all' : 'tempelhoff' });
|
||||
else u.access = s.access; // прокидываем изменения RBAC из seed (новые разделы, напр. security)
|
||||
}
|
||||
// Существующие Яндекс-админы — обновляем права до актуального админ-набора (идемпотентно).
|
||||
for (const u of users) if (u.admin && u.id.startsWith('yx-')) u.access = { ...ADMIN_ACCESS };
|
||||
persist();
|
||||
console.log(`[tenants] пользователей: ${users.length}, привязок устройств: ${Object.keys(bindings).length}`);
|
||||
}
|
||||
init();
|
||||
|
||||
export const getUser = (id) => users.find((u) => u.id === id) || null;
|
||||
export const allUsers = () => users;
|
||||
|
||||
export function upsertYandex({ email, name, sub }) {
|
||||
const id = 'yx-' + sub;
|
||||
const isAdmin = (config.yandex?.admins || []).includes(String(email || '').toLowerCase());
|
||||
let u = users.find((x) => x.id === id);
|
||||
if (!u) {
|
||||
// если email привязан к чужому кабинету — присоединяемся к нему (общий clientId)
|
||||
const linkCid = isAdmin ? null : linkedClientId(email);
|
||||
u = {
|
||||
id,
|
||||
email,
|
||||
name: name || email || 'Пользователь',
|
||||
role: isAdmin ? 'Администратор' : linkCid ? 'Связанный аккаунт' : 'Кабинет арендатора',
|
||||
admin: isAdmin,
|
||||
clientId: isAdmin ? 'all' : linkCid || 't-' + String(sub).slice(-6),
|
||||
// Новый арендатор — GPS-кабинет своих трекеров (+ «Мои устройства»).
|
||||
// Энерго/Смарт/Сеть — инфра-разделы, выдаёт админ при необходимости.
|
||||
access: isAdmin ? { ...ADMIN_ACCESS } : { gps: { caps: ['view', 'edit', 'reports'], objects: 'all' } },
|
||||
};
|
||||
users.push(u);
|
||||
persist();
|
||||
} else if (isAdmin && !u.admin) {
|
||||
u.admin = true;
|
||||
u.clientId = 'all';
|
||||
u.access = { ...ADMIN_ACCESS };
|
||||
persist();
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
// --- Привязка нескольких аккаунтов к одному кабинету (общий clientId) ---
|
||||
// Владелец кабинета добавляет email; когда тот входит через Яндекс — получает тот же clientId.
|
||||
export function linkedClientId(email) {
|
||||
const e = String(email || '').toLowerCase();
|
||||
if (!e) return null;
|
||||
const owner = users.find((u) => Array.isArray(u.linked) && u.linked.includes(e));
|
||||
return owner ? owner.clientId : null;
|
||||
}
|
||||
export function linksOf(userId) {
|
||||
const u = getUser(userId);
|
||||
return (u && u.linked) || [];
|
||||
}
|
||||
export function addLink(userId, email) {
|
||||
const u = getUser(userId);
|
||||
if (!u) return [];
|
||||
const e = String(email || '').trim().toLowerCase();
|
||||
u.linked = Array.isArray(u.linked) ? u.linked : [];
|
||||
if (e && !u.linked.includes(e)) {
|
||||
u.linked.push(e);
|
||||
// если такой пользователь уже входил и не админ — сразу присоединить к кабинету
|
||||
const existing = users.find((x) => String(x.email || '').toLowerCase() === e && x.id !== userId && !x.admin);
|
||||
if (existing) existing.clientId = u.clientId;
|
||||
persist();
|
||||
}
|
||||
return u.linked;
|
||||
}
|
||||
export function removeLink(userId, email) {
|
||||
const u = getUser(userId);
|
||||
if (!u || !Array.isArray(u.linked)) return [];
|
||||
u.linked = u.linked.filter((x) => x !== String(email || '').toLowerCase());
|
||||
persist();
|
||||
return u.linked;
|
||||
}
|
||||
|
||||
export function bindDevice(userId, imei, name) {
|
||||
const u = getUser(userId);
|
||||
if (!u) return null;
|
||||
bindings[imei] = { userId, clientId: u.clientId, name: name || 'Трекер ' + imei };
|
||||
persist();
|
||||
return bindings[imei];
|
||||
}
|
||||
export function unbindDevice(userId, imei) {
|
||||
if (bindings[imei] && bindings[imei].userId === userId) {
|
||||
delete bindings[imei];
|
||||
persist();
|
||||
}
|
||||
}
|
||||
export const devicesOf = (userId) =>
|
||||
Object.entries(bindings)
|
||||
.filter(([, b]) => b.userId === userId)
|
||||
.map(([imei, b]) => ({ imei, name: b.name }));
|
||||
export const imeiOwner = (imei) => (bindings[imei] ? bindings[imei].clientId : null);
|
||||
|
||||
// --- Админ: блокировки и CRUD ---
|
||||
// blocked = { from, until } (ISO|null). Блок активен, если now в окне.
|
||||
export function setBlock(id, { from = null, until = null } = {}) {
|
||||
const u = getUser(id);
|
||||
if (!u || u.admin) return null;
|
||||
u.blocked = { from: from || null, until: until || null };
|
||||
persist();
|
||||
return u;
|
||||
}
|
||||
export function clearBlock(id) {
|
||||
const u = getUser(id);
|
||||
if (!u) return null;
|
||||
delete u.blocked;
|
||||
persist();
|
||||
return u;
|
||||
}
|
||||
export function isBlocked(user, nowIso) {
|
||||
const b = user && user.blocked;
|
||||
if (!b) return false;
|
||||
const now = nowIso || new Date().toISOString();
|
||||
if (b.from && now < b.from) return false; // блок ещё не начался
|
||||
if (b.until && now > b.until) return false; // блок истёк / разморожен
|
||||
return true;
|
||||
}
|
||||
export function addUser({ email, name, clientId } = {}) {
|
||||
const base = (email || name || 'user').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 12) || 'user';
|
||||
let id = 'u-' + base;
|
||||
let n = 1;
|
||||
while (getUser(id)) id = `u-${base}${n++}`;
|
||||
const u = {
|
||||
id,
|
||||
email: email || '',
|
||||
name: name || email || id,
|
||||
role: 'Арендатор',
|
||||
clientId: clientId || 't-' + id.slice(-6),
|
||||
access: { gps: { caps: ['view', 'edit', 'reports'], objects: 'all' } },
|
||||
};
|
||||
users.push(u);
|
||||
persist();
|
||||
return u;
|
||||
}
|
||||
export function deleteUser(id) {
|
||||
const i = users.findIndex((u) => u.id === id);
|
||||
if (i >= 0 && !users[i].admin) {
|
||||
users.splice(i, 1);
|
||||
persist();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user