Author SHA1 Message Date
maenekoandClaude Opus 5 f4f645dcd3 ma7engine: ядро, шаблон панели, скаффолдер и доктор
Первый коммит существующего состояния. Ядро кладётся под git ради того,
чтобы половины разъезжались в продуктовые репозитории через git subtree,
а не копированием: subtree делает настоящий merge, хранит историю и умеет
отдавать правки обратно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
2026-08-26 20:39:37 +03:00
14 changed files with 541 additions and 1238 deletions
+86
View File
@@ -0,0 +1,86 @@
// Мидлвары авторизации: «пускать ли вообще» и «пускать ли к этому разделу».
//
// Проверка прав на фронте (permission в metadata.json вкладки) — это только
// UX: не показывать то, что всё равно отклонит сервер. Настоящая граница здесь,
// и роут без requirePermission открыт всем залогиненным независимо от того, как
// размечена вкладка.
//
// Откуда берутся права — развилка без единственно верного ответа, поэтому она
// вынесена в конфиг (loadPermissions):
// из клеймов токена — дёшево, но снятое право действует к истечению TTL;
// из БД на запрос — дороже, зато отзыв мгновенный.
import type { NextFunction, Request, Response } from 'express';
import type { SessionClaims, SessionStore } from './session';
export const OWNER_PERMISSION = '*';
// Ключ на объекте запроса. Symbol, а не req.session, чтобы не столкнуться с
// express-session и другими мидлварами, занимающими это имя.
const SESSION = Symbol.for('ma7engine.session');
export type Middleware = (req: Request, res: Response, next: NextFunction) => void;
export interface AuthOptions {
sessions: SessionStore;
/**
* Права на конкретный запрос. По умолчанию — из клеймов токена.
* Верни свежие права из БД, если нужен мгновенный отзыв.
*/
loadPermissions?: (claims: SessionClaims, req: Request) => string[] | Promise<string[]>;
/** Тексты отказов — панели отдают ошибки как {message}. */
messages?: { unauthorized?: string; forbidden?: string };
}
export interface Auth {
/** Пускает только с живым токеном; кладёт клеймы в запрос. */
requireAuth: Middleware;
/** Пускает только с указанным правом (или '*'). Ставится после requireAuth. */
requirePermission: (permission: string) => Middleware;
/** Клеймы текущего запроса. Осмысленны только после requireAuth. */
getSession: (req: Request) => SessionClaims;
/** Bearer-токен запроса — нужен, например, чтобы отозвать его на логауте. */
bearerToken: (req: Request) => string;
}
export function bearerToken(req: Request): string {
const header = req.headers.authorization ?? '';
return header.startsWith('Bearer ') ? header.slice(7) : '';
}
export function createAuth(options: AuthOptions): Auth {
const { sessions, loadPermissions } = options;
const unauthorized = options.messages?.unauthorized ?? 'Не авторизован';
const forbidden = options.messages?.forbidden ?? 'Недостаточно прав';
const getSession = (req: Request): SessionClaims =>
(req as Request & { [SESSION]?: SessionClaims })[SESSION] as SessionClaims;
const requireAuth: Middleware = (req, res, next) => {
const claims = sessions.verify(bearerToken(req));
if (!claims) {
res.status(401).json({ message: unauthorized });
return;
}
(req as Request & { [SESSION]?: SessionClaims })[SESSION] = claims;
next();
};
const requirePermission = (permission: string): Middleware => (req, res, next) => {
const claims = getSession(req);
if (!claims) {
res.status(401).json({ message: unauthorized });
return;
}
Promise.resolve(loadPermissions ? loadPermissions(claims, req) : claims.permissions)
.then((permissions) => {
if (permissions.includes(OWNER_PERMISSION) || permissions.includes(permission)) {
next();
return;
}
res.status(403).json({ message: forbidden });
})
.catch(next);
};
return { requireAuth, requirePermission, getSession, bearerToken };
}
-40
View File
@@ -1,40 +0,0 @@
// Слой сессии и прав. Опционален: панель без учёток и ролей (плоский список
// вкладок, доступных всем) обходится BasePageProps и в этот модуль не заходит.
// "*" в permissions — владелец, доступ ко всему в обход остальных проверок.
export const OWNER_PERMISSION = '*';
export function hasPermission(permissions: string[], perm: string): boolean {
return permissions.includes(OWNER_PERMISSION) || permissions.includes(perm);
}
// Что оболочка узнаёт о вошедшем. Отдаётся эндпоинтом сессии, адрес и форма
// ответа которого задаются в конфиге панели (loadSession).
export interface Session {
user: string | null;
userId: string | null;
role: string;
permissions: string[];
}
export const EMPTY_SESSION: Session = { user: null, userId: null, role: '', permissions: [] };
// Пропсы вкладки в панели без прав: вкладке нужен только токен и снэкбар.
export interface BasePageProps {
token: string;
showMsg: (text: string) => void;
}
// Пропсы вкладки в панели с сессией и правами.
export interface SessionPageProps extends BasePageProps {
username: string;
userId: string;
role: string;
permissions: string[];
// Все permission-строки, встречающиеся у вкладок — нужно только тем вкладкам,
// которые сами выдают права; остальные это поле игнорируют.
availablePermissions: string[];
// Переключить активную вкладку по её id из metadata.json. Роутера в панели
// нет — активная вкладка это состояние оболочки.
onNavigate: (tabId: string) => void;
}
+37
View File
@@ -0,0 +1,37 @@
// Проброс ошибок из вышестоящего API наружу.
//
// Панель, которая ходит за данными в основной API продукта, должна отдать
// клиенту его статус и текст, а не превратить всё в 500: «пользователь не
// найден» и «API лежит» — разные ситуации для оператора.
//
// Форма ошибки axios здесь проверяется по структуре, а не через
// axios.isAxiosError: ядру не нужна зависимость от axios ради одной проверки,
// а панель может ходить наверх и через fetch.
import type { Response } from 'express';
interface UpstreamError {
response?: {
status?: number;
data?: { message?: string; error?: string };
};
}
export interface ForwardErrorOptions {
/** Текст, когда вышестоящий API недоступен вовсе. */
unavailable?: string;
/** Текст, когда API ответил ошибкой без внятного сообщения. */
fallback?: string;
}
export function forwardError(res: Response, error: unknown, options: ForwardErrorOptions = {}) {
const unavailable = options.unavailable ?? 'Вышестоящий API недоступен';
const fallback = options.fallback ?? 'Ошибка API';
const response = (error as UpstreamError)?.response;
if (response && typeof response.status === 'number') {
const data = response.data;
res.status(response.status).json({ message: data?.message ?? data?.error ?? fallback });
return;
}
res.status(502).json({ message: unavailable });
}
-33
View File
@@ -1,33 +0,0 @@
// Единственная точка выхода вкладок в сеть. Вкладка не создаёт свой axios и не
// собирает заголовок авторизации руками — иначе обработка протухшего токена
// разъезжается по вкладкам.
import axios from 'axios';
// Что делать, когда сервер ответил 401. Ставит оболочка при старте: токен
// протух или отозван — надо разлогинить, а не показывать ошибку в снэкбаре.
// Само ядро про localStorage не знает — ключ хранения принадлежит приложению.
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: (() => void) | null) {
onUnauthorized = fn;
}
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
try {
const r = await axios({ method, url: path, headers: { Authorization: `Bearer ${token}` }, data: body });
return r.data;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 401) {
onUnauthorized?.();
}
throw e;
}
}
// Бэкенды панелей отдают ошибку по-разному: одни как {error}, другие как
// {message}. Вкладки читают через этот хелпер, а не разбирают форму ответа
// в каждом catch.
export function errText(e: unknown, fallback = 'Сервер недоступен'): string {
const data = (e as { response?: { data?: { error?: string; message?: string } } })?.response?.data;
return data?.error ?? data?.message ?? fallback;
}
-79
View File
@@ -1,79 +0,0 @@
// Иконки ядра — только те, что уже совпадали побайтово в панелях-потребителях.
// Продуктовые иконки живут в приложении; карта ICONS собирается там же:
// export const ICONS = { ...coreIcons, ...myIcons };
// Все иконки — инлайновый SVG на currentColor, без внешних зависимостей.
export const IcoMenu = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
);
export const IcoLogout = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
);
export const IcoSun = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
</svg>
);
export const IcoMoon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
);
export const IcoPlus = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
);
export const IcoTrash = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6M14 11v6M9 6V4h6v2"/>
</svg>
);
export const IcoRefresh = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
);
export const IcoCopy = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
);
export const IcoGlobe = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
);
export const IcoKey = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="7.5" cy="15.5" r="4.5"/>
<path d="M10.7 12.3 21 2"/><path d="M16 7l3 3"/><path d="M18 5l2 2"/>
</svg>
);
// Карта иконок ВКЛАДОК — по ней реестр резолвит поле "icon" из metadata.json.
// Сюда попадают только иконки, осмысленные как иконка раздела; хром оболочки
// (menu/logout/sun/moon) оболочка импортирует именованными экспортами и через
// карту никогда не ищет.
//
// Приложение расширяет карту своими и при совпадении ключа перекрывает:
// export const ICONS = { ...coreIcons, ...myIcons };
export const coreIcons: Record<string, () => JSX.Element> = {
key: IcoKey,
globe: IcoGlobe,
};
+128
View File
@@ -0,0 +1,128 @@
// Серверная половина ядра панели: логин, сессия, права, рейт-лимит и раздача
// собранного фронта. Всё, что во всех панелях было одинаковым, — здесь;
// продуктовые маршруты остаются в самой панели.
//
// Авторизация есть всегда и приезжает готовой: задай
// auth: { user, password | passwordHash, secret } — и панель сама выпускает и
// проверяет токен, монтирует /login, /logout и /api/session. Своей авторизации
// панель не пишет.
//
// Панель, у которой собственных учёток нет вовсе (логин форвардится в основной
// продукт, где живут настоящие учётки), createPanelServer не использует —
// она собирает express сама из кусков ядра: createLoginRateLimit на маршрут
// логина, свои прокси-маршруты, serveSpa последним. См. раздел про
// панель-прокси в README.
//
// Порядок монтирования фиксирован и потому не может быть нарушен снаружи:
// json → логин/сессия → маршруты панели → статика с fallback. Fallback ловит
// любой GET, поэтому маршрут после него уже не сработал бы.
import express, { type Express, type Request, type Response } from 'express';
import { createLoginRateLimit, type LoginRateLimit, type RateLimitOptions } from './ratelimit';
import { createPasswordCheck, type PasswordOptions } from './password';
import { createSessionStore, type SessionClaims, type SessionOptions, type SessionStore } from './session';
import { createAuth, type Auth } from './auth';
import { forwardError } from './errors';
import { serveSpa } from './spa';
const LOGIN_PATH = '/login';
const LOGOUT_PATH = '/logout';
const SESSION_PATH = '/api/session';
export interface LocalAuthOptions extends PasswordOptions, Omit<SessionOptions, 'secret'> {
/** Логин единственной учётки панели. */
user: string;
/** Секрет подписи сессий — свой, отдельный от секретов основного продукта. */
secret: string;
/**
* Клеймы, которые получит вошедший. По умолчанию — владелец: permissions
* ['*'], то есть доступ ко всем вкладкам. Появится несколько учёток —
* здесь и появится разметка.
*/
claims?: Partial<Omit<SessionClaims, 'user'>>;
}
export interface PanelServerOptions {
/** Учётка панели. Обязательна: панель без авторизации ядром не собирается. */
auth: LocalAuthOptions;
/** Каталог со сборкой фронта. Без него статика не монтируется. */
staticDir?: string;
rateLimit?: RateLimitOptions;
/** Маршруты самой панели. Монтируются после логина и до статики. */
mountRoutes?: (app: Express, ctx: PanelServerContext) => void;
}
export interface PanelServerContext {
app: Express;
rateLimit: LoginRateLimit;
sessions: SessionStore;
auth: Auth;
forwardError: typeof forwardError;
}
export function createPanelServer(options: PanelServerOptions): Express {
const app = express();
const cfg = options.auth;
app.use(express.json());
const rateLimit = createLoginRateLimit(options.rateLimit);
// Обе проверки бросают при негодной конфигурации — панель не поднимется
// с паролем по умолчанию или с известным секретом.
const password = createPasswordCheck(cfg);
const sessions = createSessionStore({
secret: cfg.secret,
ttl: cfg.ttl,
revocable: cfg.revocable,
revocationStore: cfg.revocationStore,
});
const auth = createAuth({ sessions });
app.post(LOGIN_PATH, rateLimit.middleware, (req: Request, res: Response) => {
const { user, pass } = (req.body ?? {}) as { user?: string; pass?: string };
// Пароль проверяется всегда, даже когда логин уже не совпал: иначе
// по времени ответа отличается «нет такого пользователя» от
// «пароль неверный».
const passOk = password.verify(pass ?? '');
const userOk = typeof user === 'string' && user === cfg.user;
if (!userOk || !passOk) {
res.status(401).json({ message: 'Неверный логин или пароль' });
return;
}
rateLimit.clear(req);
res.json({
token: sessions.issue({
user: cfg.user,
userId: cfg.claims?.userId ?? '1',
role: cfg.claims?.role ?? 'admin',
permissions: cfg.claims?.permissions ?? ['*'],
}),
});
});
app.post(LOGOUT_PATH, auth.requireAuth, (req: Request, res: Response) => {
sessions.revoke(auth.bearerToken(req));
res.status(204).end();
});
app.get(SESSION_PATH, auth.requireAuth, (req: Request, res: Response) => {
res.json(auth.getSession(req));
});
options.mountRoutes?.(app, { app, rateLimit, sessions, auth, forwardError });
if (options.staticDir) serveSpa(app, options.staticDir);
return app;
}
export { createLoginRateLimit, clientIp } from './ratelimit';
export { createPasswordCheck, hashPassword, timingSafeEquals } from './password';
export { createSessionStore } from './session';
export { createAuth, bearerToken, OWNER_PERMISSION } from './auth';
export { forwardError } from './errors';
export { serveSpa } from './spa';
export type { RateLimitOptions, LoginRateLimit } from './ratelimit';
export type { PasswordOptions, PasswordCheck } from './password';
export type { SessionClaims, SessionOptions, SessionStore, RevocationStore } from './session';
export type { Auth, AuthOptions, Middleware } from './auth';
+82
View File
@@ -0,0 +1,82 @@
// Проверка пароля единственной учётки панели.
//
// Две вещи, которые в панелях были сделаны по-разному, здесь сведены к одному
// поведению:
//
// 1. Сравнение. Везде было плоское `pass !== ADMIN_PASS` — время сравнения
// строк зависит от длины общего префикса. Здесь сравниваются SHA-256
// дайджесты через timingSafeEqual: одинаковая длина (иначе timingSafeEqual
// бросает) и постоянное время.
//
// 2. Ненастроенный пароль. Одна панель падала в открытую (дефолт "admin" и
// предупреждение в консоль), другая — в закрытую (вход запрещён). Ядро
// падает в закрытую и максимально рано: не на первом логине, а при старте
// процесса. Тихо работающая панель с паролем по умолчанию — худший из
// возможных исходов.
import crypto from 'node:crypto';
/** Сравнение за постоянное время. Строки произвольной длины. */
export function timingSafeEquals(a: string, b: string): boolean {
const ha = crypto.createHash('sha256').update(a).digest();
const hb = crypto.createHash('sha256').update(b).digest();
return crypto.timingSafeEqual(ha, hb);
}
export interface PasswordOptions {
/** Пароль открытым текстом (обычно из ADMIN_PASS). */
password?: string;
/**
* Либо хэш вида `scrypt:<salt-hex>:<key-hex>` (обычно из ADMIN_PASS_HASH) —
* тогда открытый пароль не хранится в окружении вовсе.
* Сгенерировать: `node -e "…"` — см. hashPassword ниже.
*/
passwordHash?: string;
}
export interface PasswordCheck {
verify: (input: string) => boolean;
/** Как настроен пароль — для строки в логе при старте. */
kind: 'plain' | 'scrypt';
}
const SCRYPT_PREFIX = 'scrypt:';
/** Готовый ADMIN_PASS_HASH для указанного пароля. */
export function hashPassword(password: string): string {
const salt = crypto.randomBytes(16);
const key = crypto.scryptSync(password, salt, 32);
return `${SCRYPT_PREFIX}${salt.toString('hex')}:${key.toString('hex')}`;
}
export function createPasswordCheck(options: PasswordOptions): PasswordCheck {
const { password, passwordHash } = options;
if (passwordHash) {
if (!passwordHash.startsWith(SCRYPT_PREFIX)) {
throw new Error('panel-server: passwordHash должен начинаться с "scrypt:" — см. hashPassword()');
}
const [, saltHex, keyHex] = passwordHash.split(':');
if (!saltHex || !keyHex) {
throw new Error('panel-server: passwordHash повреждён, ожидается scrypt:<salt-hex>:<key-hex>');
}
const salt = Buffer.from(saltHex, 'hex');
const expected = Buffer.from(keyHex, 'hex');
return {
kind: 'scrypt',
verify(input) {
const actual = crypto.scryptSync(input, salt, expected.length);
return crypto.timingSafeEqual(actual, expected);
},
};
}
if (!password) {
throw new Error(
'panel-server: пароль панели не задан.\n' +
' Укажи ADMIN_PASS (или ADMIN_PASS_HASH — тогда открытого пароля в окружении не будет).\n' +
' Значения по умолчанию здесь намеренно нет: панель с паролем "admin" опаснее, чем не поднявшаяся панель.',
);
}
return { kind: 'plain', verify: (input) => timingSafeEquals(input, password) };
}
+83
View File
@@ -0,0 +1,83 @@
// Ограничение перебора пароля на /login. Во всех трёх панелях этот код был
// скопирован дословно (те же 5 попыток за 15 минут), но в одной из них —
// с багом: IP брался как req.socket.remoteAddress без разбора X-Forwarded-For.
// За обратным прокси это адрес самого прокси, все клиенты схлопываются в один
// ключ, и пять неудачных попыток кем угодно блокируют вход всем.
//
// Счётчик — Map в памяти: он не переживает рестарт и не общий для нескольких
// инстансов за балансировщиком. Для защиты от перебора этого достаточно
// (атакующему всё равно приходится укладываться в лимит на каждом инстансе),
// но знать об этом надо — распределённый лимит требует общего хранилища.
import type { NextFunction, Request, Response } from 'express';
export interface RateLimitOptions {
/** Сколько неудачных попыток разрешено в окне. По умолчанию 5. */
limit?: number;
/** Длина окна в миллисекундах. По умолчанию 15 минут. */
windowMs?: number;
/**
* Доверять ли заголовку X-Forwarded-For. По умолчанию — только в проде:
* в деве перед сервером прокси нет, и подставить чужой IP заголовком мог
* бы кто угодно, кто достучался напрямую.
*/
trustProxy?: boolean;
/** Текст ответа. Получает количество секунд до сброса. */
message?: (retryAfter: number) => string;
}
interface RateEntry { count: number; resetAt: number }
type IpSource = Pick<Request, 'headers'> & { socket: { remoteAddress?: string } };
export function clientIp(req: IpSource, trustProxy: boolean): string {
if (trustProxy) {
const forwarded = req.headers['x-forwarded-for'];
const first = Array.isArray(forwarded) ? forwarded[0] : forwarded;
if (first) return first.split(',')[0].trim();
}
return req.socket.remoteAddress ?? 'unknown';
}
export interface LoginRateLimit {
/** Мидлвар на маршрут логина. */
middleware: (req: Request, res: Response, next: NextFunction) => void;
/**
* Снять счётчик после успешного входа — иначе три опечатки подряд перед
* удачным логином всё ещё считались бы попытками перебора.
*/
clear: (req: IpSource) => void;
/** IP запроса по тем же правилам, что использует лимитер. */
ipOf: (req: IpSource) => string;
}
export function createLoginRateLimit(options: RateLimitOptions = {}): LoginRateLimit {
const limit = options.limit ?? 5;
const windowMs = options.windowMs ?? 15 * 60 * 1000;
const trustProxy = options.trustProxy ?? process.env.NODE_ENV === 'production';
const message = options.message
?? ((retryAfter: number) => `Слишком много попыток. Повтори через ${retryAfter} сек.`);
const attempts = new Map<string, RateEntry>();
const ipOf = (req: IpSource) => clientIp(req, trustProxy);
return {
ipOf,
clear: (req) => { attempts.delete(ipOf(req)); },
middleware(req, res, next) {
const ip = ipOf(req);
const now = Date.now();
const entry = attempts.get(ip);
if (!entry || entry.resetAt < now) {
attempts.set(ip, { count: 1, resetAt: now + windowMs });
} else if (entry.count >= limit) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
res.setHeader('Retry-After', String(retryAfter));
res.status(429).json({ error: message(retryAfter) });
return;
} else {
entry.count++;
}
next();
},
};
}
+93
View File
@@ -0,0 +1,93 @@
// Сессия оператора панели: выпуск и проверка токена.
//
// Реализаций в панелях было две — подписанный JWT через jsonwebtoken и
// самописный HMAC на node:crypto. Ядро оставляет первую: алгоритм тот же, но
// краевые случаи (разбор exp/nbf, подмена alg) уже разобраны в библиотеке.
// jsonwebtoken объявлен peer-зависимостью — его надо поставить в бэкенд панели.
//
// Отзыв токена. JWT самодостаточен: сервер не хранит выданные токены и потому
// не может их отменить — логаут по умолчанию чисто клиентский. Опция
// `revocable` включает список отозванных подписей в памяти, и тогда логаут
// действительно убивает токен. Цена: список не переживает рестарт и не общий
// для нескольких инстансов за балансировщиком — после рестарта отозванный
// токен снова станет валидным до истечения TTL. Нужен настоящий отзыв на
// нескольких инстансах — нужно общее хранилище (Redis и т.п.); тогда передай
// свой store вместо встроенного.
import jwt from 'jsonwebtoken';
export interface SessionClaims {
user: string;
userId: string;
role: string;
permissions: string[];
}
/** Хранилище отозванных токенов. По умолчанию — Set в памяти. */
export interface RevocationStore {
revoke: (signature: string) => void;
isRevoked: (signature: string) => boolean;
}
export interface SessionOptions {
/** Секрет подписи. Свой, отдельный от секретов основного продукта. */
secret: string;
/** Время жизни токена. По умолчанию 12 часов. */
ttl?: string | number;
/** Включить отзыв токенов на логауте. По умолчанию выключено. */
revocable?: boolean;
/** Своё хранилище отозванных — например, общее на несколько инстансов. */
revocationStore?: RevocationStore;
}
export interface SessionStore {
issue: (claims: SessionClaims) => string;
/** Клеймы живого токена, либо null — протух, подделан или отозван. */
verify: (token: string) => SessionClaims | null;
/** Отозвать токен (если отзыв включён). */
revoke: (token: string) => void;
revocable: boolean;
}
function memoryRevocationStore(): RevocationStore {
const revoked = new Set<string>();
return {
revoke: (sig) => { revoked.add(sig); },
isRevoked: (sig) => revoked.has(sig),
};
}
// Подпись однозначно идентифицирует токен и короче его самого.
const signatureOf = (token: string) => token.split('.')[2] ?? token;
export function createSessionStore(options: SessionOptions): SessionStore {
const { secret } = options;
if (!secret) {
throw new Error(
'panel-server: не задан секрет подписи сессий (ADMIN_JWT_SECRET).\n' +
' Значения по умолчанию нет намеренно: с известным секретом токен подделывается кем угодно.',
);
}
const ttl = options.ttl ?? '12h';
const revocable = options.revocable ?? false;
const store = options.revocationStore ?? memoryRevocationStore();
return {
revocable,
issue: (claims) => jwt.sign(claims, secret, { expiresIn: ttl } as jwt.SignOptions),
verify(token) {
if (!token) return null;
if (revocable && store.isRevoked(signatureOf(token))) return null;
try {
// algorithms задан явно: без него подошёл бы токен, подписанный
// другим алгоритмом из заголовка самого токена.
const claims = jwt.verify(token, secret, { algorithms: ['HS256'] });
return claims as SessionClaims & jwt.JwtPayload;
} catch {
return null;
}
},
revoke(token) {
if (revocable && token) store.revoke(signatureOf(token));
},
};
}
-301
View File
@@ -1,301 +0,0 @@
// Оболочка панели: логин, тема, drawer/сайдбар, снэкбар и переключение вкладок.
// Про конкретные вкладки не знает ничего — их приносит реестр из конфига.
//
// Всё продуктовое вынесено в PanelConfig: имя и версия, ключи localStorage,
// адреса эндпоинтов, способ загрузки сессии и слоты для своего хрома. Панель
// без ролей (сессия отдаёт permissions: ['*']) и панель с правами — один и тот
// же код, отличаются только конфигом.
import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react';
import axios from 'axios';
import './styles.css';
import { IcoMenu, IcoLogout, IcoSun, IcoMoon } from '../icons';
import { setUnauthorizedHandler } from '../http';
import { EMPTY_SESSION, type Session } from '../auth';
import type { BuiltTabs, TabDef } from '../tabs';
// Контекст, который оболочка отдаёт слотам — чтобы свой хром мог ходить в сеть
// и показывать сообщения теми же средствами, что и вкладки.
export interface SlotContext {
token: string;
showMsg: (text: string) => void;
session: Session;
tab: TabDef | null;
}
export interface PanelConfig {
// Реестр вкладок: результат buildTabs(...) из приложения. Передаётся сюда,
// а не импортируется, потому что import.meta.glob обязан остаться в
// приложении — см. комментарий в tabs/index.ts.
tabs: BuiltTabs;
branding: { name: string; version?: string };
// Ключи localStorage. Разводятся по панелям, чтобы две панели на одном
// домене не затирали друг другу токен и тему.
storageKeys?: { token?: string; theme?: string };
endpoints?: { login?: string; logout?: string };
// Как узнать, жив ли токен, и получить роль с правами. По умолчанию —
// GET /api/session. Панель без ролей отдаёт отсюда permissions: ['*'].
loadSession?: (token: string) => Promise<Session>;
// Свой хром вокруг вкладки. Слот получает активную вкладку в ctx.tab и
// может вернуть null там, где рисоваться не должен.
slots?: {
aboveTab?: (ctx: SlotContext) => ReactNode;
headerExtra?: (ctx: SlotContext) => ReactNode;
};
}
export default function Panel({ config }: { config: PanelConfig }) {
const { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory } = config.tabs;
const TOKEN_KEY = config.storageKeys?.token ?? 'admin_token';
const THEME_KEY = config.storageKeys?.theme ?? 'admin_theme';
const LOGIN_URL = config.endpoints?.login ?? '/login';
const LOGOUT_URL = config.endpoints?.logout ?? '/logout';
const APP_NAME = config.branding.name;
const APP_VERSION = config.branding.version;
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? '');
const [loginUser, setLoginUser] = useState('');
const [loginPass, setLoginPass] = useState('');
const [loginError, setLoginError] = useState('');
const [msg, setMsg] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [activeTab, setActiveTab] = useState('');
const [session, setSession] = useState<Session>(EMPTY_SESSION);
const [theme, setTheme] = useState<'light' | 'dark'>(() =>
localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light');
const touchStartX = useRef(0);
const touchCurrentX = useRef(0);
const showMsg = useCallback((text: string) => {
setMsg(text);
setTimeout(() => setMsg(''), 3000);
}, []);
// Проверяет, жив ли токен, и заодно тянет роль/права — по ним фильтруется
// меню. Данные конкретных вкладок грузят сами вкладки.
const loadSession = config.loadSession;
const startSession = useCallback(async (tok: string) => {
const data = loadSession
? await loadSession(tok)
: (await axios.get<Session>('/api/session', { headers: { Authorization: `Bearer ${tok}` } })).data;
setSession(data);
return data;
}, [loadSession]);
const login = useCallback(async () => {
try {
const { data: auth } = await axios.post(LOGIN_URL, { user: loginUser, pass: loginPass });
const tok = auth.token as string;
await startSession(tok);
localStorage.setItem(TOKEN_KEY, tok);
setLoginError('');
setToken(tok);
} catch (e) {
// Все три причины отказа пишем под форму, а не в снэкбар: тот живёт
// 3 секунды и уезжает, а человек в этот момент смотрит на поля и
// перенабирает пароль. Особенно важно для 429 — там верный пароль
// тоже не пустит, и без текста это выглядит как «пароль не тот».
if (axios.isAxiosError(e) && e.response?.status === 401) {
setLoginError('Неверный логин или пароль');
} else if (axios.isAxiosError(e) && e.response?.status === 429) {
setLoginError(e.response.data?.error ?? 'Слишком много попыток');
} else {
setLoginError('Сервер недоступен');
}
}
}, [loginUser, loginPass, startSession, showMsg, LOGIN_URL, TOKEN_KEY]);
const logout = useCallback(async () => {
try {
await axios.post(LOGOUT_URL, null, { headers: { Authorization: `Bearer ${token}` } });
} catch { /* токен уже мог истечь */ }
localStorage.removeItem(TOKEN_KEY);
setToken('');
setSession(EMPTY_SESSION);
setActiveTab('');
setDrawerOpen(false);
}, [token, LOGOUT_URL, TOKEN_KEY]);
// Любой 401 из apiFetch вкладки означает, что токен протух или отозван —
// разлогиниваем сразу, а не показываем ошибку в снэкбаре и ждём, пока
// пользователь сам догадается перезайти.
useEffect(() => {
setUnauthorizedHandler(() => {
localStorage.removeItem(TOKEN_KEY);
setToken('');
setSession(EMPTY_SESSION);
setActiveTab('');
});
return () => setUnauthorizedHandler(null);
}, [TOKEN_KEY]);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
touchCurrentX.current = e.touches[0].clientX;
}, []);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
touchCurrentX.current = e.touches[0].clientX;
}, []);
const handleTouchEnd = useCallback(() => {
if (touchStartX.current - touchCurrentX.current > 40) setDrawerOpen(false);
}, []);
// Восстановить сессию из сохранённого токена
useEffect(() => {
const saved = localStorage.getItem(TOKEN_KEY);
if (saved) {
startSession(saved).catch(() => {
localStorage.removeItem(TOKEN_KEY);
setToken('');
});
}
}, [startSession, TOKEN_KEY]);
// Применять и сохранять тему
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(THEME_KEY, theme);
}, [theme, THEME_KEY]);
// Блокировать прокрутку фона когда drawer открыт
useEffect(() => {
document.body.style.overflow = drawerOpen ? 'hidden' : '';
return () => { document.body.style.overflow = ''; };
}, [drawerOpen]);
// Закрывать drawer по Escape
useEffect(() => {
if (!drawerOpen) return;
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') setDrawerOpen(false); };
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [drawerOpen]);
// Меню строится из вкладок, доступных правам текущей учётки, сгруппированных
// по категориям. Вкладок (или прав на них) может не быть вовсе — тогда
// вместо контента показываем заглушку.
const groups = visibleTabsByCategory(session.permissions);
const visible = groups.flatMap(g => g.tabs);
const tab = visible.find(t => t.id === activeTab) ?? visible[0] ?? null;
// Слот рисуется, если он задан в конфиге. Погасить его на конкретной вкладке
// решает сам слот: активная вкладка лежит в ctx.tab.
const slotCtx: SlotContext = { token, showMsg, session, tab };
const slot = (name: 'aboveTab' | 'headerExtra') => config.slots?.[name]?.(slotCtx) ?? null;
return (
<div className="app">
<button
className="theme-toggle"
aria-label={theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'}
onClick={() => setTheme(t => (t === 'dark' ? 'light' : 'dark'))}
>
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
</button>
{token && slot('headerExtra')}
{!token ? (
<div className="login-screen">
<div className="login-card">
<span className="logo-name">{APP_NAME}</span>
<p className="login-sub">Войдите, чтобы продолжить</p>
<input
className="field"
placeholder="Логин"
value={loginUser}
onChange={e => setLoginUser(e.target.value)}
autoFocus
/>
<input
className="field"
type="password"
placeholder="Пароль"
value={loginPass}
onChange={e => setLoginPass(e.target.value)}
onKeyDown={e => e.key === 'Enter' && login()}
/>
<button className="btn btn--primary btn--full" onClick={login}>
Войти
</button>
{loginError && <p className="login-error">{loginError}</p>}
</div>
</div>
) : (
<>
{/* Top bar — видим только на мобильном */}
<header className="top-bar">
<button
className="hamburger-btn"
aria-label="Открыть меню"
onClick={() => setDrawerOpen(true)}
>
<IcoMenu />
</button>
<span className="top-bar-title">{tab?.label ?? APP_NAME}</span>
</header>
{/* Затемнение под drawer */}
<div
className={`drawer-scrim${drawerOpen ? ' drawer-scrim--visible' : ''}`}
onClick={() => setDrawerOpen(false)}
/>
<div className="layout">
<aside
className={`sidebar${drawerOpen ? ' sidebar--open' : ''}`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div className="sidebar-logo">
<span className="logo-name">{APP_NAME}</span>
{APP_VERSION && <span className="sidebar-version">{APP_VERSION}</span>}
</div>
<hr className="drawer-divider" />
{groups.map(g => (
<div key={g.category} className="nav-group">
{/* Вкладки без category собраны в одну безымянную группу —
заголовка у неё нет, меню выглядит плоским списком. */}
{g.category && <div className="nav-category">{g.category}</div>}
{g.tabs.map(t => (
<div
key={t.id}
className={`nav-item${t.id === tab?.id ? ' nav-item--active' : ''}`}
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
>
<t.Icon /> {t.label}
</div>
))}
</div>
))}
<div className="sidebar-spacer" />
<button className="btn btn--danger" onClick={logout}>
<IcoLogout /> Выйти
</button>
</aside>
<main className="main">
<h2 className="page-title">{tab?.label ?? APP_NAME}</h2>
{slot('aboveTab')}
{tab
? <tab.Page token={token} showMsg={showMsg}
username={session.user ?? ''} userId={session.userId ?? ''}
role={session.role} permissions={session.permissions}
availablePermissions={ALL_TAB_PERMISSIONS}
onNavigate={setActiveTab} />
: <p className="placeholder">
{TABS.length === 0
? <>Вкладок пока нет — добавь папку в <code>src/tabs/</code></>
: 'Ни одна вкладка не доступна твоим правам'}
</p>}
</main>
</div>
</>
)}
{msg && <div className="snack">{msg}</div>}
</div>
);
}
-583
View File
@@ -1,583 +0,0 @@
/*
Дизайн-система панели: тёплая кремовая база + прохладный фиолетовый акцент
в ролях Material 3. Здесь только общий язык — стили конкретной вкладки живут
в её папке (tabs/<name>/<name>.css) и импортятся её же компонентом.
ПУБЛИЧНЫЙ КОНТРАКТ. На эти имена классов опирается код вкладок во всех
панелях-потребителях, поэтому переименование любого из них — ломающее
изменение, а не рефакторинг:
кнопки .btn .btn--primary .btn--tonal .btn--outline .btn--danger
.btn--full .btn--sq .btn-icon .btn-icon--danger .btn-icon--neutral
формы .field
таблица .table-card .td-mono .td-num .td-actions
прочее .chip (+--online/--offline/--error) .chip-dot
.toolbar .toolbar-btns .actions .empty
модалка .modal-backdrop .modal-card .modal-title .modal-actions .modal-hint
Всё остальное (.app, .sidebar, .drawer-*, .top-bar, .nav-*, .login-*, .snack,
.main, .page-title, .placeholder) принадлежит оболочке — вкладки этих имён
не используют.
Живая витрина всех этих классов — вкладка «Витрина» в frontend/ (шаблон панели).
Палитра задаётся токенами на :root и переопределяется на [data-theme="dark"];
цвета в правилах — только через var(--token), литералов в компонентах нет.
*/
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root { height: 100%; }
:root {
--primary: #6a45b8;
--on-primary: #ffffff;
--primary-container: #ebddff;
--on-primary-container: #5a2da8;
--surface: #faf6f0;
--surface-bright: #fdfaf6;
--surface-container: #f0e8dd;
--surface-hi: #ece2d3;
--on-surface: #3a2e22;
--outline: #d8cdbe;
--outline-variant: #ece2d3;
--success-bg: #c0dd97; --success-text: #2c4d0a; --success-dot: #639922;
--neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85;
--error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a;
--danger-border: #e6c4bc;
--danger-text: #b34a3a;
/* приглушённый «бровый» лейбл — заголовки таблицы, неактивная навигация */
--label: #5a4a3a;
/* state layers / тени — зависят от темы */
--state-primary: rgba(106, 69, 184, .10);
--state-row: rgba(106, 69, 184, .035);
--state-neutral: rgba(58, 46, 34, .08);
--scrim: rgba(58, 46, 34, .45);
--shadow: rgba(58, 46, 34, .15);
--font: system-ui, -apple-system, 'Inter', sans-serif;
--mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
--r-pill: 100px;
--r-card: 24px;
--r-table: 16px;
--r-logo: 9px;
}
:root[data-theme="dark"] {
--primary: #cbb3ff;
--on-primary: #381b6c;
--primary-container: #52389e;
--on-primary-container: #ebddff;
--surface: #1a1611;
--surface-bright: #2b251d;
--surface-container: #251f18;
--surface-hi: #302820;
--on-surface: #ede2d4;
--outline: #4d4334;
--outline-variant: #342c20;
--success-bg: #2d4413; --success-text: #c8e3a0; --success-dot: #8fc24f;
--neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085;
--error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449;
--danger-border: #6e4540;
--danger-text: #ffb4ab;
--label: #b6a994;
--state-primary: rgba(203, 179, 255, .14);
--state-row: rgba(203, 179, 255, .05);
--state-neutral: rgba(237, 226, 212, .08);
--scrim: rgba(0, 0, 0, .55);
--shadow: rgba(0, 0, 0, .45);
}
body {
font-family: var(--font);
font-size: 14px;
font-weight: 400;
color: var(--on-surface);
background: var(--surface);
-webkit-font-smoothing: antialiased;
transition: background 0.2s ease, color 0.2s ease;
}
.app { min-height: 100vh; display: flex; flex-direction: column; position: relative; }
.logo-name { font-size: 17px; font-weight: 500; color: var(--on-surface); }
.login-screen {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface);
}
.login-card {
width: 352px;
background: var(--surface-bright);
border-radius: var(--r-card);
border: 1px solid var(--outline-variant);
padding: 36px 32px 32px;
display: flex;
flex-direction: column;
gap: 12px;
}
.login-sub {
font-size: 14px;
color: var(--neutral-text);
margin-bottom: 4px;
}
.login-error {
font-size: 13px;
color: var(--danger-text);
text-align: center;
}
.field {
height: 44px;
padding: 0 16px;
border: 1.5px solid var(--outline);
border-radius: var(--r-pill);
background: var(--surface-bright);
font-family: var(--font);
font-size: 14px;
color: var(--on-surface);
outline: none;
transition: border-color 0.15s;
width: 100%;
}
.field:focus { border-color: var(--primary); }
.field::placeholder { color: var(--neutral-dot); }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 44px;
padding: 0 20px;
border-radius: var(--r-pill);
border: none;
font-family: var(--font);
font-size: 14px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.12s, background 0.12s;
flex-shrink: 0;
}
.btn:hover { opacity: 0.88; }
.btn:active { opacity: 0.74; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn--primary { background: var(--primary); color: var(--on-primary); }
.btn--tonal { background: var(--primary-container); color: var(--on-primary-container); border: none; }
.btn--outline {
background: transparent;
border: 1.5px solid var(--outline);
color: var(--primary);
}
.btn--danger {
background: transparent;
border: 1.5px solid var(--danger-border);
color: var(--danger-text);
}
.btn--full { width: 100%; }
.btn--sq { width: 44px; padding: 0; flex-shrink: 0; }
/* icon-only circular button */
.btn-icon {
width: 30px; height: 30px;
border-radius: 50%;
border: none;
background: transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--primary);
transition: background 0.12s;
flex-shrink: 0;
}
.btn-icon:hover { background: var(--state-primary); }
.btn-icon--danger { color: var(--danger-text); }
.btn-icon--danger:hover { background: rgba(179,74,58,.10); }
.btn-icon--neutral { color: var(--neutral-text); }
.btn-icon--neutral:hover { background: var(--state-neutral); }
.theme-toggle {
position: fixed;
top: 16px;
right: 16px;
z-index: 250;
width: 40px;
height: 40px;
border-radius: 50%;
border: 1.5px solid var(--outline);
background: var(--surface-bright);
color: var(--on-surface);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.theme-toggle:hover { border-color: var(--primary); color: var(--primary); }
.top-bar {
display: none;
align-items: center;
gap: 4px;
height: 56px;
padding: 0 8px 0 4px;
background: var(--surface);
flex-shrink: 0;
z-index: 10;
}
.hamburger-btn {
width: 48px; height: 48px;
border: none;
background: transparent;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--on-surface);
flex-shrink: 0;
-webkit-tap-highlight-color: transparent;
}
.hamburger-btn:active { background: var(--state-neutral); }
.top-bar-title {
font-size: 20px;
font-weight: 500;
color: var(--on-surface);
}
.drawer-scrim {
display: none;
position: fixed;
inset: 0;
background: var(--scrim);
z-index: 300;
opacity: 0;
pointer-events: none;
transition: opacity 0.27s cubic-bezier(0.2, 0, 0, 1);
}
.drawer-scrim--visible {
opacity: 1;
pointer-events: auto;
}
.layout {
flex: 1;
display: flex;
min-height: 100vh;
}
.sidebar {
width: 240px;
flex-shrink: 0;
background: var(--surface-container);
display: flex;
flex-direction: column;
padding: 24px 12px;
gap: 0;
}
.sidebar-logo {
padding: 0 8px 20px;
display: flex;
flex-direction: column;
gap: 2px;
}
.sidebar-version {
font-size: 11px;
color: var(--neutral-dot);
margin-top: 2px;
}
.drawer-divider {
border: none;
border-top: 0.5px solid var(--outline);
margin: 0 -12px 8px;
}
.nav-group + .nav-group { margin-top: 8px; }
.nav-category {
padding: 12px 12px 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--neutral-dot);
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--r-pill);
font-size: 14px;
font-weight: 400;
color: var(--label);
cursor: default;
user-select: none;
}
.nav-item--active {
background: var(--primary-container);
color: var(--on-primary-container);
font-weight: 500;
}
.sidebar-spacer { flex: 1; }
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: var(--r-pill);
font-size: 12px;
white-space: nowrap;
}
.chip-dot {
width: 6px; height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.chip--online { background: var(--success-bg); color: var(--success-text); }
.chip--online .chip-dot { background: var(--success-dot); }
.chip--offline { background: var(--neutral-bg); color: var(--neutral-text); }
.chip--offline .chip-dot { background: var(--neutral-dot); }
.chip--error { background: var(--error-bg); color: var(--error-text); }
.chip--error .chip-dot { background: var(--error-dot); }
.main {
flex: 1;
padding: 32px 36px;
display: flex;
flex-direction: column;
gap: 20px;
overflow: auto;
min-width: 0;
}
.page-title {
font-size: 20px;
font-weight: 500;
color: var(--on-surface);
}
/* Заглушка на месте контента — когда вкладок ещё нет */
.placeholder {
color: var(--neutral-text);
font-size: 14px;
}
.placeholder code {
font-family: var(--mono);
font-size: 13px;
background: var(--surface-container);
padding: 2px 6px;
border-radius: 6px;
}
/* Тултип без задержки: нативный title браузер показывает через ~500 мс */
.tip-wrap {
position: relative;
display: inline-flex;
}
.tip-wrap::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 7px);
left: 50%;
transform: translateX(-50%);
background: var(--on-surface);
color: var(--surface-bright);
font-size: 12px;
white-space: nowrap;
padding: 5px 12px;
border-radius: var(--r-pill);
pointer-events: none;
opacity: 0;
transition: opacity 0.12s;
}
.tip-wrap:hover::after { opacity: 1; }
.toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.toolbar .field {
width: 224px;
height: 44px;
flex-shrink: 0;
}
/* На десктопе кнопки участвуют в flex toolbar'а напрямую */
.toolbar-btns { display: contents; }
.table-card {
background: var(--surface-bright);
border-radius: var(--r-table);
border: 1px solid var(--outline-variant);
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
background: var(--surface-container);
color: var(--label);
font-weight: 500;
font-size: 13px;
text-align: left;
padding: 11px 14px;
}
thead th:first-child { padding-left: 20px; }
thead th:last-child { padding-right: 20px; }
tbody tr { border-top: 0.5px solid var(--outline-variant); }
tbody tr:hover { background: var(--state-row); }
tbody td {
padding: 11px 14px;
font-size: 13px;
color: var(--on-surface);
vertical-align: middle;
}
tbody td:first-child { padding-left: 20px; }
tbody td:last-child { padding-right: 20px; }
.td-num { font-size: 12px; color: var(--neutral-dot); width: 32px; }
.td-mono { font-family: var(--mono); font-size: 12px; }
.td-actions { width: 96px; }
.actions { display: flex; align-items: center; gap: 2px; }
.empty {
text-align: center;
color: var(--neutral-text);
padding: 48px 0 !important;
font-size: 14px;
}
/* Модалка — общий каркас (backdrop + карточка). Содержимое даёт вкладка. */
@keyframes backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes card-in {
from { opacity: 0; transform: scale(.92) translateY(12px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-backdrop {
position: fixed; inset: 0;
background: var(--scrim);
display: flex; align-items: center; justify-content: center;
z-index: 300;
animation: backdrop-in 0.18s ease;
}
.modal-card {
background: var(--surface-bright);
border-radius: var(--r-card);
border: 1px solid var(--outline-variant);
padding: 28px 28px 24px;
display: flex; flex-direction: column; align-items: center; gap: 14px;
width: 320px;
animation: card-in 0.22s cubic-bezier(.34,1.26,.64,1);
}
.modal-title { font-size: 16px; font-weight: 500; color: var(--on-surface); }
.modal-hint { font-size: 12px; color: var(--neutral-text); text-align: center; }
.modal-actions { display: flex; gap: 8px; width: 100%; }
.modal-actions .btn { flex: 1; }
.snack {
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
background: var(--on-surface);
color: var(--surface-bright);
font-size: 13px;
padding: 10px 22px;
border-radius: var(--r-pill);
z-index: 500;
white-space: nowrap;
pointer-events: none;
box-shadow: 0 4px 16px var(--shadow);
}
@media (max-width: 640px) {
/* Top bar */
.top-bar { display: flex; }
.page-title { display: none; }
/* Drawer scrim */
.drawer-scrim { display: block; }
/* Запретить горизонтальный скролл страницы */
body, .app { overflow-x: hidden; }
/* Sidebar → модальный drawer */
.layout {
min-height: 0; /* flex: 1 на .app справится сам */
overflow: visible;
}
/* main — не скролл-контейнер, пусть скроллит body */
.main { overflow: visible; }
.sidebar {
position: fixed;
top: 0; left: 0;
height: 100%;
width: 280px;
z-index: 400;
border-radius: 0 var(--r-card) var(--r-card) 0;
transform: translateX(-100%);
transition: transform 0.27s cubic-bezier(0.2, 0, 0, 1);
box-shadow: 4px 0 24px var(--shadow);
padding-top: 36px;
}
.sidebar--open { transform: translateX(0); }
/* Main: одна колонка, поля 16px */
.main {
padding: 16px;
gap: 12px;
}
/* Toolbar → вертикально */
.toolbar {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.toolbar .field { width: 100%; }
.toolbar-btns {
display: flex;
gap: 8px;
}
.toolbar-btns .btn { flex: 1; }
/* Таблицы на мобильном заменяются карточками — их даёт сама вкладка */
.table-card { display: none; }
/* Модалка — по ширине экрана */
.modal-card {
width: calc(100vw - 32px);
max-width: 360px;
padding: 24px 20px 20px;
}
}
+32
View File
@@ -0,0 +1,32 @@
// Раздача собранного фронта. Панель — один процесс и один образ: сервер сам
// отдаёт frontend/dist, отдельный nginx ей не нужен.
//
// Монтируется последним, после всех API-маршрутов: fallback отвечает на любой
// GET, поэтому маршрут, зарегистрированный после него, уже не сработает.
import express, { type Express, type Request, type Response } from 'express';
import fs from 'node:fs';
import path from 'node:path';
// Пути под этими префиксами получают 404, а не index.html: иначе опечатка в
// адресе API возвращает HTML со статусом 200, и клиент падает на разборе JSON
// вместо внятной ошибки.
const API_PREFIXES = ['/api'];
export function serveSpa(app: Express, dir: string) {
const root = path.resolve(dir);
if (!fs.existsSync(path.join(root, 'index.html'))) {
console.warn(`panel-server: в ${root} нет index.html — фронт не собран (npm run build) ` +
'или STATIC_DIR указывает не туда');
}
app.use(express.static(root));
app.get('*', (req: Request, res: Response) => {
if (API_PREFIXES.some(p => req.path.startsWith(p))) {
res.status(404).json({ message: 'Не найдено' });
return;
}
res.sendFile(path.join(root, 'index.html'));
});
}
-150
View File
@@ -1,150 +0,0 @@
// Реестр вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
// index.tsx — компонент + логика
// <name>.css — стили вкладки (импортит сам компонент)
// metadata.json — манифест: { id, label, icon, category?, permission?, order?, devOnly? }
//
// Сам поиск папок (import.meta.glob) остаётся в приложении и передаётся сюда
// аргументами — это compile-time фича Vite, её паттерн резолвится относительно
// файла, где она написана. Уехав в ядро, она сканировала бы ядро, а не
// src/tabs/ приложения.
//
// metadata.json НЕ проверяется компилятором (это JSON), поэтому содержимое
// валидируется здесь в рантайме — кривой манифест падает с внятной ошибкой при
// сборке TABS, а не «тихо» в UI.
import type { ComponentType } from 'react';
import { OWNER_PERMISSION } from '../auth';
// Сериализуемая часть из metadata.json. Обязательны только id, label и icon:
// панель без ролей и без группировки меню — базовый случай, а не урезанный.
export interface TabMeta {
id: string;
label: string;
icon: string; // имя иконки → карта ICONS
// Группа в меню. Без неё вкладка попадает в единственную безымянную группу,
// и сайдбар рисует плоский список.
category?: string;
// Право, нужное для доступа. Без него вкладка видна любому залогиненному.
permission?: string;
order?: number; // порядок в меню (по возрастанию; без него — 0)
// Вкладка, которой не должно быть в проде: её роут на бэкенде там всё равно
// отдаёт 404 независимо от прав, и вкладка вела бы в тупик.
devOnly?: boolean;
}
// Собранная вкладка: метадата + резолвнутые Icon и Page.
export interface TabDef extends TabMeta {
Icon: () => JSX.Element;
Page: ComponentType<any>;
}
export interface TabGroup {
category: string;
tabs: TabDef[];
}
export interface BuiltTabs {
TABS: TabDef[];
ALL_TAB_PERMISSIONS: string[];
visibleTabsByCategory: (permissions: string[]) => TabGroup[];
}
type MetaModules = Record<string, { default: unknown }>;
type PageModules = Record<string, { default: ComponentType<any> }>;
type IconMap = Record<string, () => JSX.Element>;
// './users/metadata.json' → 'users'
const folderOf = (path: string) => path.split('/')[1];
const REQUIRED_STRINGS = ['id', 'label', 'icon'] as const;
const OPTIONAL_STRINGS = ['category', 'permission'] as const;
// Валидация сырого metadata.json → типизированный TabMeta (или внятная ошибка).
function toMeta(name: string, raw: unknown): TabMeta {
const m = (raw ?? {}) as Record<string, unknown>;
const fail = (what: string): never => {
throw new Error(`tabs/${name}/metadata.json: ${what}`);
};
const nonEmpty = (v: unknown) => typeof v === 'string' && v.trim() !== '';
for (const key of REQUIRED_STRINGS) {
if (!nonEmpty(m[key])) fail(`поле "${key}" должно быть непустой строкой`);
}
for (const key of OPTIONAL_STRINGS) {
if (m[key] !== undefined && !nonEmpty(m[key])) fail(`"${key}" должно быть непустой строкой`);
}
if (m.order !== undefined && typeof m.order !== 'number') fail('"order" должно быть числом');
if (m.devOnly !== undefined && typeof m.devOnly !== 'boolean') fail('"devOnly" должно быть булевым');
return {
id: m.id as string,
label: m.label as string,
icon: m.icon as string,
category: m.category as string | undefined,
permission: m.permission as string | undefined,
order: m.order as number | undefined,
devOnly: m.devOnly as boolean | undefined,
};
}
// Собирает реестр вкладок из результатов import.meta.glob приложения:
//
// export const { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory } = buildTabs(
// import.meta.glob('./*/metadata.json', { eager: true }),
// import.meta.glob('./*/index.tsx', { eager: true }),
// ICONS,
// );
//
// Вкладок может не быть вовсе (чистый каркас) — тогда TABS пустой, и оболочка
// показывает заглушку вместо контента.
export function buildTabs(metas: MetaModules, pages: PageModules, icons: IconMap): BuiltTabs {
const TABS: TabDef[] = Object.entries(metas)
.map(([path, mod]) => {
const name = folderOf(path);
const meta = toMeta(name, mod.default);
const page = pages[`./${name}/index.tsx`]?.default;
if (!page) {
throw new Error(`tabs/${name}: нет index.tsx рядом с metadata.json`);
}
if (!icons[meta.icon]) {
throw new Error(`tabs/${name}: иконка "${meta.icon}" не зарегистрирована в ICONS`);
}
return { ...meta, Icon: icons[meta.icon], Page: page };
})
// import.meta.env.PROD — режим сборки этого фронта (vite build), а не
// окружения бэкенда, к которому он ходит.
.filter(t => !t.devOnly || !import.meta.env.PROD)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
// id вкладок используются как ключ активной вкладки — дубли ломают переключение.
const ids = TABS.map(t => t.id);
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
if (dup) {
throw new Error(`tabs: дублирующийся id "${dup}" — id вкладок должны быть уникальны`);
}
// Полный список прав, встречающихся у вкладок — по нему строится форма
// выдачи прав в панелях, где такая вкладка есть: появилась вкладка с новым
// permission — появился и чекбокс, без ручной синхронизации списков.
const ALL_TAB_PERMISSIONS = Array.from(
new Set(TABS.map(t => t.permission).filter((p): p is string => !!p)),
);
// Вкладки, доступные набору прав, сгруппированные по категориям в порядке
// первого появления (сами вкладки уже отсортированы по order). Вкладки без
// category собираются в одну безымянную группу — сайдбар рисует её без
// заголовка, то есть плоским списком.
function visibleTabsByCategory(permissions: string[]): TabGroup[] {
const owner = permissions.includes(OWNER_PERMISSION);
const visible = TABS.filter(t => !t.permission || owner || permissions.includes(t.permission));
const groups: TabGroup[] = [];
for (const tab of visible) {
const category = tab.category ?? '';
let group = groups.find(g => g.category === category);
if (!group) { group = { category, tabs: [] }; groups.push(group); }
group.tabs.push(tab);
}
return groups;
}
return { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory };
}
-52
View File
@@ -1,52 +0,0 @@
// Форматирование и работа с буфером/файлами. Чистые функции без зависимостей —
// то, что во всех панелях уже совпадало побайтово.
// ISO-дата → «2026-08-01 12:34». Без секунд и без часового пояса —
// формат для таблиц, где важна разница между записями, а не точность до сек.
export function fmtDateTime(iso: string | null | undefined): string {
if (!iso) return '—';
return iso.replace('T', ' ').slice(0, 16);
}
// unix-секунды → «5 мин» / «3 ч» (без суффикса «назад» — его добавляет вызывающий)
export function timeAgo(ts: number | undefined): string {
if (!ts) return '—';
const sec = Math.floor(Date.now() / 1000) - ts;
if (sec < 60) return sec + ' сек';
if (sec < 3600) return Math.floor(sec / 60) + ' мин';
return Math.floor(sec / 3600) + ' ч';
}
export function bytes(n: number | undefined): string {
if (!n) return '—';
if (n < 1024) return n + ' B';
if (n < 1_048_576) return (n / 1024).toFixed(1) + ' KB';
if (n < 1_073_741_824) return (n / 1_048_576).toFixed(1) + ' MB';
return (n / 1_073_741_824).toFixed(2) + ' GB';
}
export function downloadFile(filename: string, text: string) {
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
// navigator.clipboard недоступен вне https/localhost — отсюда фолбэк на execCommand.
export async function copyText(text: string) {
if (navigator.clipboard) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
}