elcrm 1.1.16 → 1.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/index.js +4 -2
  2. package/dist/vite/discover-lib.js +3 -3
  3. package/dist/vite/plugin-css-scoped.js +2 -2
  4. package/package.json +1 -1
  5. package/templates/elcrm-cursor/rules/elcrm-ui.mdc +1 -1
  6. package/templates/elcrm-docs/COMPONENTS.elCRM.md +9 -3
  7. package/templates/elcrm-docs/MODAL.elCRM.md +22 -3
  8. package/templates/elcrm-docs/ROUTER.elCRM.md +9 -5
  9. package/templates/orbit/README.md +4 -3
  10. package/templates/orbit/api/package.json +18 -0
  11. package/templates/orbit/api/src/config.ts +49 -0
  12. package/templates/orbit/api/src/index.ts +72 -0
  13. package/templates/orbit/api/src/lib/cron.ts +13 -0
  14. package/templates/orbit/api/src/lib/db.ts +53 -0
  15. package/templates/orbit/api/src/lib/jwt.ts +19 -0
  16. package/templates/orbit/api/src/routes/auth.ts +114 -0
  17. package/templates/orbit/api/src/routes/deploy.ts +16 -0
  18. package/templates/orbit/api/src/routes/index.ts +16 -0
  19. package/templates/orbit/package.json +8 -30
  20. package/templates/orbit/scripts/dev.ts +33 -0
  21. package/templates/orbit/web/.env.example +3 -0
  22. package/templates/orbit/web/package.json +40 -0
  23. package/templates/orbit/{src → web/src}/App.tsx +1 -9
  24. package/templates/orbit/{src → web/src}/component/Nav.tsx +15 -15
  25. package/templates/orbit/web/src/icons.generated.tsx +76 -0
  26. package/templates/orbit/{src → web/src}/main.tsx +10 -7
  27. package/templates/orbit/{src → web/src}/modules/Home/index.tsx +9 -9
  28. package/templates/orbit/web/src/pages/Auth.tsx +71 -0
  29. package/templates/orbit/{src → web/src}/pages/Main.tsx +7 -10
  30. package/templates/orbit/web/src/state.ts +53 -0
  31. package/templates/orbit/{src → web/src}/style/theme-dark.css +5 -0
  32. package/templates/orbit/{src → web/src}/style/theme-light.css +5 -0
  33. package/templates/orbit/{src → web/src}/style/theme.css +9 -0
  34. package/templates/orbit/web/src/vite-env.d.ts +11 -0
  35. package/templates/orbit/web/vite.config.ts +23 -0
  36. package/templates/panel/web/package.json +1 -1
  37. package/templates/panel/web/src/App.tsx +1 -9
  38. package/templates/panel/web/src/main.tsx +1 -14
  39. package/templates/panel/web/src/pages/Main.tsx +1 -9
  40. package/templates/panel/web/src/style/theme-dark.css +10 -0
  41. package/templates/panel/web/src/style/theme-light.css +10 -0
  42. package/templates/panel/web/src/style/theme.css +20 -0
  43. package/templates/orbit/src/lazyRoute.ts +0 -4
  44. package/templates/orbit/src/lib/icon.tsx +0 -48
  45. package/templates/orbit/src/pages/Auth.tsx +0 -45
  46. package/templates/orbit/src/state.ts +0 -28
  47. package/templates/orbit/src/vite-env.d.ts +0 -1
  48. package/templates/orbit/vite.config.ts +0 -14
  49. package/templates/panel/web/src/lazyRoute.ts +0 -10
  50. /package/templates/orbit/{index.html → web/index.html} +0 -0
  51. /package/templates/orbit/{src → web/src}/lib/theme.ts +0 -0
  52. /package/templates/orbit/{src → web/src}/modules/Analytics/index.tsx +0 -0
  53. /package/templates/orbit/{src → web/src}/modules/Catalog/index.tsx +0 -0
  54. /package/templates/orbit/{src → web/src}/modules/Customers/index.tsx +0 -0
  55. /package/templates/orbit/{src → web/src}/modules/Finances/index.tsx +0 -0
  56. /package/templates/orbit/{src → web/src}/modules/Integrations/index.tsx +0 -0
  57. /package/templates/orbit/{src → web/src}/modules/Link/index.tsx +0 -0
  58. /package/templates/orbit/{src → web/src}/modules/Marketing/index.tsx +0 -0
  59. /package/templates/orbit/{src → web/src}/modules/Placeholder.tsx +0 -0
  60. /package/templates/orbit/{src → web/src}/modules/Settings/index.tsx +0 -0
  61. /package/templates/orbit/{src → web/src}/modules/Store/index.tsx +0 -0
  62. /package/templates/orbit/{src → web/src}/router-data.d.ts +0 -0
  63. /package/templates/orbit/{src → web/src}/style/elcrm.css +0 -0
  64. /package/templates/orbit/{tsconfig.json → web/tsconfig.json} +0 -0
package/dist/index.js CHANGED
@@ -3512,7 +3512,7 @@ function copyTemplate(templateName, targetDir, packageName) {
3512
3512
  cpSync(from, to, { recursive: true });
3513
3513
  }
3514
3514
  const name = packageName ?? folderToPackageName(targetDir);
3515
- if (templateName === "panel") {
3515
+ if (templateName === "panel" || templateName === "orbit") {
3516
3516
  finalizePanelNames(targetDir, name);
3517
3517
  return;
3518
3518
  }
@@ -3542,11 +3542,13 @@ function doneHint(template, cdPath) {
3542
3542
  ` : "";
3543
3543
  if (template === "orbit") {
3544
3544
  console.log(`
3545
- \u0413\u043E\u0442\u043E\u0432\u043E (orbit: \u0432\u0438\u0442\u0440\u0438\u043D\u0430 \u043D\u0430 @elcrm/components).
3545
+ \u0413\u043E\u0442\u043E\u0432\u043E (orbit: web + api).
3546
3546
 
3547
3547
  ${prefix} bun run dev
3548
3548
 
3549
3549
  Web \u2192 http://localhost:5173
3550
+ API \u2192 http://localhost:4545
3551
+ \u0412\u0445\u043E\u0434: admin / admin
3550
3552
  `);
3551
3553
  return;
3552
3554
  }
@@ -11,8 +11,8 @@ function assignLetters(names, overrides) {
11
11
  const used = new Set;
12
12
  const letters = {};
13
13
  for (const [name, letter] of Object.entries(overrides)) {
14
- if (!/^[a-z0-9]$/.test(letter)) {
15
- throw new Error(`[discover-lib] override "${name}" → "${letter}" (нужна a-z0-9)`);
14
+ if (!/^[a-z0-9A-Z]$/.test(letter)) {
15
+ throw new Error(`[discover-lib] override "${name}" → "${letter}" (нужна a-z0-9A-Z)`);
16
16
  }
17
17
  if (used.has(letter)) {
18
18
  throw new Error(`[discover-lib] дубль буквы "${letter}" в overrides`);
@@ -25,7 +25,7 @@ function assignLetters(names, overrides) {
25
25
  if (letters[name])
26
26
  continue;
27
27
  const base = name.replace(/[^a-zA-Z]/g, "").toLowerCase();
28
- let letter = [...base].find((c) => /[a-z]/.test(c) && !used.has(c)) ?? [..."abcdefghijklmnopqrstuvwxyz0123456789"].find((c) => !used.has(c));
28
+ let letter = [...base].find((c) => /[a-z]/.test(c) && !used.has(c)) ?? [..."abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"].find((c) => !used.has(c));
29
29
  if (!letter) {
30
30
  throw new Error(`[discover-lib] нет свободной буквы/цифры для "${name}"`);
31
31
  }
@@ -46,8 +46,8 @@ function createCssScoped(options) {
46
46
  if (!letter) {
47
47
  throw new Error(`[css-scoped] нет буквы для компонента "${component}". Добавьте в components.`);
48
48
  }
49
- if (!/^[a-z0-9]$/.test(letter)) {
50
- throw new Error(`[css-scoped] ключ компонента "${component}" должен быть a-z0-9: "${letter}"`);
49
+ if (!/^[a-z0-9A-Z]$/.test(letter)) {
50
+ throw new Error(`[css-scoped] ключ компонента "${component}" должен быть a-z0-9A-Z: "${letter}"`);
51
51
  }
52
52
  const scoped = `${prefix}${letter}${name}`;
53
53
  const owner = `${component}.${name}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elcrm",
3
- "version": "1.1.16",
3
+ "version": "1.1.17",
4
4
  "description": "CLI @elcrm/*: doctor --fix (порядок проекта), update, css, docs, cursor, migrate",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,7 @@ src/modules/Name/
25
25
  src/style/theme*.css + elcrm.css
26
26
  ```
27
27
 
28
- Orbit: `Layout sidebar={<Sidebar />}` вместо верхней `Header`. Дашборд — `PageShell` + `Stat` / `Card` / `Item`.
28
+ Orbit: `web/` + `api/`. UI: `Layout sidebar={<Sidebar />}` вместо верхней `Header`. Дашборд — `PageShell` + `Stat` / `Card` / `Item`.
29
29
 
30
30
  - Кнопки: `@elcrm/button` (`onSend`). Иконки: `Icons.Line`. Подсказки: `@elcrm/tooltip`.
31
31
  - HTTP: `Api.query("auth/pass", body)`. Роутер: `Router.setRouter` / `mergeRouter`, разные ключи вкладок.
@@ -15,10 +15,11 @@ import {
15
15
  Layout, Header, Footer, Brand, Avatar, AvatarName, AvatarGroup, Badge,
16
16
  Menu, Dropdown, Section, NavSections, TabSections, Breadcrumb, Stack,
17
17
  Card, Stat, ActionGroup, TextGroup, ItemColumn, List, Item, PageHead, PageShell, ChatList, ChatMessages, ChatSplit, Sidebar, Popover,
18
+ Healthmap, PanelInfo,
18
19
  Loading, EmptyState, RadioGroup, PricingGroup, PricingTable,
19
20
  createLazyResolver,
20
21
  } from "@elcrm/components";
21
- import type { PricingPlan } from "@elcrm/components";
22
+ import type { PricingPlan, HealthmapData } from "@elcrm/components";
22
23
  ```
23
24
 
24
25
  ## Роли layout
@@ -34,6 +35,8 @@ import type { PricingPlan } from "@elcrm/components";
34
35
  - **Stack** — ряд/колонка/сетка (`direction="row"|"column"|"grid"`). Не `Block` / `Row` / `Column`. Сетка: `auto-fit` + `--stack-grid-min`, либо `columns={4}` / `columns={["1fr", "100px", "auto"]}`. С `columns` ряды `stretch` (карточки не наезжают); в одну линию — `style={{ alignItems: "center" }}`. Не `height: 100%` у детей сетки.
35
36
  - **Card** — курсор: `--card-pointer` (`auto` / `pointer`). Сетка Stack: `--stack-grid-min`.
36
37
  - **Stat** — метрика: `label` + `value`, `tone` как у Badge, `labelMuted`. Не собирать из `span` в приложении.
38
+ - **Healthmap** — теплокарта дней (`data.days`). Данные грузит приложение; слоты `brandName` / `brandSub` / `unit` / `hint`. Токены `--healthmap-l0`…`l4`.
39
+ - **PanelInfo** — выезд справа (шапка / body / footer). На узком экране — оверлей. Слоты: `children`, `footer`, `headerExtra`, `close`. Токены `--panel-info-*`.
37
40
  - **ActionGroup** — тулбар: `label` + `items` (узлы) и/или `children`. Кнопки — `@elcrm/button`, не нативный `<button>`.
38
41
  - **TextGroup** — две строки: `title` + `description` (без аватара; с аватаром — AvatarName).
39
42
  - **Item** — строка списка. `as` button/a/li, `active`. `variant="card"` — рамка и muted-фон (идеи / тикеты / inbox). Не дублировать `.desk-row` в приложении.
@@ -42,8 +45,8 @@ import type { PricingPlan } from "@elcrm/components";
42
45
  ## Меню
43
46
 
44
47
  - **Menu** — inline-навигация в шапке. `items`: `value` + `label` + `badge` (`0` не показываем, иначе `Badge` size s). `variant="line"` — вкладки с чертой снизу (не ломать pill селекторами страницы).
45
- - **Dropdown** — выпадающее меню по `items`/`groups` (portal).
46
- - **Popover** — свой попап в portal (якорь + `children`). z-index: `acquireOverlayZIndex` (тот же стек, что у form). Не `@elcrm/overlay` `Menu`.
48
+ - **Dropdown** — выпадающее меню по `items`/`groups` (portal). `placement="auto"|"top"|"bottom"` — куда открывать панель (футер Sidebar — `top`).
49
+ - **Popover** — свой попап в portal (якорь + `children`). z-index: `acquireOverlayZIndex` (тот же стек, что у form). Не `@elcrm/overlay` `Menu`. `placement` как у Dropdown: позиция по **реальной** высоте панели, не по `maxHeight` 420.
47
50
 
48
51
  ## Avatar
49
52
 
@@ -59,6 +62,9 @@ import type { PricingPlan } from "@elcrm/components";
59
62
  ## Можно
60
63
 
61
64
  - `--layout-height` — высота каркаса `Layout` при `fixed` (по умолчанию `100dvh`).
65
+ - `--layout-padding` / `--layout-margin` / `--layout-max-width` — отступы и макс. ширина корня Layout (`0` / `none` = на весь вьюпорт).
66
+ - `--header-border` — рамка Header (shorthand: все стороны и толщина). Только снизу: `none` + линия через `--header-shadow`.
67
+ - `--header-nav-justify` — выравнивание `nav` в шапке (`flex-start` / `center` / `flex-end`).
62
68
  - Узкие токены (`--chat-*`, `--sidebar-*`, `--item-column-*`) по умолчанию = `--shell-*`. Переопределяй только отличие.
63
69
  - Задать `--shell-color` / `--shell-padding` / `--shell-radius` один раз — большинство компонентов подхватят.
64
70
  - `--popup-shadow` общий с `@elcrm/form`.
@@ -18,7 +18,8 @@ const { Open, Icon } = Modal.Create(EditModal, "edit-modal");
18
18
  Open({ id: 123 });
19
19
  ```
20
20
 
21
- Файлы модулей: `modules/<Type>/modal/<Name>.tsx` + `Modal.Open("Type", "Name", params)` или `import_modules` у Init.
21
+ Файлы модулей: `modules/<Type>/modal/<Name>.tsx` + `Modal.Open("Type", "Name", params)`.
22
+ Лоадер — тот же `glob` в `Router.createRouter` (не второй glob и не `importModules`). Свой лоадер — `import_modules` у `Init`.
22
23
 
23
24
  ## Состав
24
25
 
@@ -30,8 +31,26 @@ Open({ id: 123 });
30
31
  | `Modal.Close(name?)` | одно или все |
31
32
  | `Modal.Shake` | тряска |
32
33
 
33
- `size`: `a` auto · `s` 400 · `m` 500 · `l` 800 · `x` 1000 · `f` full.
34
- `escape` закрывает верхнее; клик по оверлею при `shake` (default true) трясёт, не закрывает; крестик закрывает всегда.
34
+ `size`: `a` auto · `s` 400 · `m` 500 · `l` 800 · `x` 1000 · `f` full · `p` page (весь экран поверх страницы).
35
+ `escape` закрывает верхнее; клик по оверлею при `shake` (default true) трясёт, не закрывает; крестик закрывает всегда. Для `size="p"` обычно `shake={false}`.
36
+
37
+ ## Раскрытие из элемента
38
+
39
+ `Create` возвращает `Open`, `Icon` и **`Trigger`**. По клику снимается `getBoundingClientRect`, окно анимируется FLIP из этой позиции (и обратно при закрытии).
40
+
41
+ ```tsx
42
+ const Card = Modal.Create(CardView, "card");
43
+
44
+ // Обёртка (предпочтительно)
45
+ <Card.Trigger params={{ id: 1 }}>
46
+ <Item as="li" variant="card">…</Item>
47
+ </Card.Trigger>
48
+
49
+ // Или вручную
50
+ Card.Open({ id: 1 }, { origin: event.currentTarget });
51
+ ```
52
+
53
+ `prefers-reduced-motion: reduce` — без анимации.
35
54
 
36
55
  ## Нельзя
37
56
 
@@ -13,22 +13,26 @@ Router.createRouter({
13
13
  glob: import.meta.glob("./{pages,modules}/**/*.{tsx,ts}", { eager: false }),
14
14
  });
15
15
 
16
- <LoadRoute variant="page" defaultName="Main" resolveLazyPage={resolvePage}>
17
- <span aria-hidden />
18
- </LoadRoute>
16
+ <LoadRoute variant="page" />
19
17
  ```
20
18
 
19
+ `page` → `pages/{Name}.tsx`. `module` → `modules/{Name}/index.tsx`.
20
+ Тот же glob: `Modal.Open("Home", "Demo")` → `modules/Home/modal/Demo.tsx`.
21
+
21
22
  ## Можно
22
23
 
23
24
  - Состояние: `useRouter`, `setRouter`, `mergeRouter`, `getRouter`, `resetRouter`, `matchesRouter`.
24
- - Типизация: `declare module "@elcrm/router" { interface RouterDataMap { page: "Auth"|"Main"; module: … } }`.
25
+ - Типизация (не обязательна для работы): `declare module "@elcrm/router" { interface RouterDataMap { page: "Auth"|"Main"; module: … } }`.
25
26
  - Секции модуля: отдельный ключ (`section` / `listTab` / `accountTab`) — **не один ключ на все вкладки**.
26
- - `createLazyResolver` + `import(\`./sections/${key}.tsx\`)` (`export default`).
27
+ - `createLazyResolver` + `import(\`./sections/${key}.tsx\`)` — только вкладки внутри модуля (`TabSections`), не страницы.
27
28
  - Persist sessionStorage (crypto), `urlTemplate` по желанию.
28
29
  - `GuardRoute`, `RouteLoader`, `RouterOutletStack`.
30
+ - `paths` / `resolveLazyPage` — если папки не `pages/` и `modules/`.
29
31
 
30
32
  ## Нельзя
31
33
 
32
34
  - `react-router` / `window.location` как основной роутер приложения.
35
+ - Второй `import.meta.glob` + `importModules` для модалок, если glob уже в `createRouter`.
36
+ - `resolveLazyPage` и заглушка `<span aria-hidden />` «чтобы завелось».
33
37
  - Смешивать ключи вкладок разных модулей (после смены таба откроется чужая lazy-секция).
34
38
  - Нормализовать регистр сегментов самостоятельно, если URL-слой включён — библиотека не нижний регистр.
@@ -1,12 +1,13 @@
1
1
  # Orbit
2
2
 
3
- Шаблон витрины на `@elcrm/components`: `Layout` + `Sidebar` + дашборд Home.
3
+ Витрина: `web/` (React + Vite) и `api/` (Bun + SQLite), как panel.
4
4
 
5
5
  ```bash
6
6
  elcrm create my-shop --template orbit
7
7
  bun run dev
8
8
  ```
9
9
 
10
- Web: http://localhost:5173
10
+ - Web: http://localhost:5173
11
+ - API: http://localhost:4545 (`/v1/...`)
11
12
 
12
- Демо-вход: любой логин/пароль (локальный store, API не обязателен).
13
+ Демо-вход: `admin` / `admin`
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "orbit-api",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "module": "src/index.ts",
7
+ "scripts": {
8
+ "dev": "bun run --watch src/index.ts",
9
+ "start": "bun run src/index.ts"
10
+ },
11
+ "dependencies": {
12
+ "@elcrm/server": "^0.6.2",
13
+ "@elcrm/deploy": "^0.0.10"
14
+ },
15
+ "devDependencies": {
16
+ "@types/bun": "latest"
17
+ }
18
+ }
@@ -0,0 +1,49 @@
1
+ /** Конфиг panel.api */
2
+
3
+ const isProd = process.env.NODE_ENV === "production";
4
+
5
+ /** Не 3535 — часто занят icons.api / другими сервисами elCRM */
6
+ export const PORT = Number(process.env.PORT ?? 4545);
7
+
8
+ function requireSecret(envName: string, devFallback: string): string {
9
+ const v = process.env[envName]?.trim();
10
+ if (v) return v;
11
+ if (isProd) {
12
+ console.error(`❌ ${envName} обязателен в production`);
13
+ process.exit(1);
14
+ }
15
+ console.warn(`⚠️ ${envName} не задан — dev-секрет (только локально)`);
16
+ return devFallback;
17
+ }
18
+
19
+ /** Секрет AES для ssid (@elcrm/jwt) */
20
+ export const TOKEN = requireSecret("TOKEN", "panel-dev-secret-change-me");
21
+
22
+ /** SQLite по умолчанию */
23
+ export const DB_PATH =
24
+ process.env.SQLITE_PATH?.trim() ||
25
+ process.env.DB_BASE?.trim() ||
26
+ "./data/panel.db";
27
+
28
+ /** Секрет hot-upload (@elcrm/deploy), опционально */
29
+ export const UPD = process.env.UPD?.trim() || "";
30
+
31
+ export function getCorsOrigin(): string | string[] {
32
+ const raw = process.env.CORS_ORIGINS?.trim();
33
+ if (!raw || raw === "*") {
34
+ if (!isProd) {
35
+ return [
36
+ "http://localhost:5173",
37
+ "http://localhost:3000",
38
+ "http://127.0.0.1:5173",
39
+ "http://127.0.0.1:3000",
40
+ ];
41
+ }
42
+ return raw === "*" ? ["http://localhost:5173"] : [];
43
+ }
44
+ const list = raw
45
+ .split(",")
46
+ .map((s) => s.trim())
47
+ .filter(Boolean);
48
+ return list.length === 1 ? list[0]! : list;
49
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * orbit.api — createRouter + createServer; JWT/CORS/cron из конфига.
3
+ */
4
+ import { createServer, cors as serverCors } from "@elcrm/server";
5
+ import { DB_PATH, getCorsOrigin, PORT, TOKEN } from "./config";
6
+ import { initDatabase } from "./lib/db";
7
+ import { jwt } from "./lib/jwt";
8
+ import "./lib/cron";
9
+ import { registerRoutes } from "./routes/index";
10
+
11
+ async function start() {
12
+ // TOKEN в env — createJWT/createServer видят один секрет
13
+ if (!process.env.TOKEN?.trim()) {
14
+ process.env.TOKEN = TOKEN;
15
+ }
16
+
17
+ await initDatabase();
18
+ registerRoutes();
19
+
20
+ try {
21
+ createServer({
22
+ port: PORT,
23
+ prefix: "/v1",
24
+ // alias + password hash — явный jwt; иначе хватило бы omit + TOKEN
25
+ jwt,
26
+ // auth в exclude: pass без ssid; me/profile — middleware requireUser (user уже из createServer)
27
+ jwtAuth: { bearer: false },
28
+ securityHeaders: true,
29
+ cron: true,
30
+ cors: serverCors({
31
+ origin: getCorsOrigin(),
32
+ credentials: true,
33
+ headers: [
34
+ "Content-Type",
35
+ "Authorization",
36
+ "ssid",
37
+ "user-agent",
38
+ "X-Request-Id",
39
+ ],
40
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
41
+ }),
42
+ onError: (err, ctx) => {
43
+ console.error(
44
+ "[orbit.api]",
45
+ ctx.requestId,
46
+ ctx.path.join("/"),
47
+ err instanceof Error ? err.message : err,
48
+ );
49
+ },
50
+ });
51
+ } catch (e) {
52
+ const msg = e instanceof Error ? e.message : String(e);
53
+ if (msg.includes("EADDRINUSE") || msg.includes("port")) {
54
+ console.error(
55
+ `❌ Порт ${PORT} занят. Освободите его или задайте другой:\n` +
56
+ ` PORT=4546 bun run dev\n` +
57
+ ` lsof -iTCP:${PORT} -sTCP:LISTEN`,
58
+ );
59
+ }
60
+ throw e;
61
+ }
62
+
63
+ console.log(`orbit.api: http://localhost:${PORT}`);
64
+ console.log(` path-RPC: /v1/{router}/{method}`);
65
+ console.log(` db: ${DB_PATH}`);
66
+ console.log(` demo: admin / admin`);
67
+ }
68
+
69
+ start().catch((e) => {
70
+ console.error(e instanceof Error ? e.message : e);
71
+ process.exit(1);
72
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Минимальный cron — heartbeat. createServer({ cron: true }) / CRON_AUTORUN=1
3
+ * подхватит cron.run() сам.
4
+ */
5
+ import { cron } from "@elcrm/server/cron";
6
+
7
+ cron.use(
8
+ () => {
9
+ console.log(`[cron] heartbeat ${new Date().toISOString()}`);
10
+ },
11
+ "0 */10 * * * *",
12
+ "heartbeat",
13
+ );
@@ -0,0 +1,53 @@
1
+ /**
2
+ * SQLite через @elcrm/server/db + схема users и демо-админ.
3
+ * Каталог для файла БД создаёт сам openSqlite (ensureDir) — node:fs не нужен.
4
+ */
5
+ import { db, initDB } from "@elcrm/server/db";
6
+ import { DB_PATH } from "../config";
7
+ import { jwt } from "./jwt";
8
+
9
+ let ready: Promise<typeof db> | null = null;
10
+
11
+ export async function initDatabase() {
12
+ if (!ready) {
13
+ ready = (async () => {
14
+ await initDB({ type: "sqlite", database: DB_PATH });
15
+ const ok = await db.ping();
16
+ if (!ok) throw new Error(`SQLite недоступна: ${DB_PATH}`);
17
+ await migrate();
18
+ console.log(`✅ DB (sqlite): ${DB_PATH}`);
19
+ return db;
20
+ })();
21
+ }
22
+ return ready;
23
+ }
24
+
25
+ async function migrate() {
26
+ await db.run(`
27
+ CREATE TABLE IF NOT EXISTS users (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ login TEXT NOT NULL UNIQUE,
30
+ password TEXT NOT NULL,
31
+ active INTEGER NOT NULL DEFAULT 1,
32
+ admin INTEGER NOT NULL DEFAULT 0,
33
+ display_name TEXT DEFAULT '',
34
+ email TEXT DEFAULT '',
35
+ avatar TEXT DEFAULT ''
36
+ )
37
+ `);
38
+
39
+ const count = await db.get<{ n: number }>(
40
+ "SELECT count(*) AS n FROM users",
41
+ );
42
+ if (!count?.n) {
43
+ const hash = jwt.password("admin");
44
+ await db.run(
45
+ `INSERT INTO users (login, password, active, admin, display_name, email)
46
+ VALUES (?, ?, 1, 1, ?, ?)`,
47
+ ["admin", hash, "Administrator", "admin@localhost"],
48
+ );
49
+ console.log("✅ seed: admin / admin");
50
+ }
51
+ }
52
+
53
+ export { db };
@@ -0,0 +1,19 @@
1
+ import { createJWT } from "@elcrm/server/jwt";
2
+ import { TOKEN } from "../config";
3
+
4
+ export const jwt = createJWT({
5
+ secret: TOKEN,
6
+ alias: [
7
+ ["i", "id"],
8
+ ["a", "admin"],
9
+ ],
10
+ validateAgent: true,
11
+ excludeRoutes: ["auth", "health", "version"],
12
+ });
13
+
14
+ export type JwtUser = {
15
+ id: number;
16
+ admin?: number;
17
+ agent?: string;
18
+ [key: string]: unknown;
19
+ };
@@ -0,0 +1,114 @@
1
+ import {
2
+ createRouter,
3
+ rpcErr,
4
+ rpcLogout,
5
+ rpcOk,
6
+ type Middleware,
7
+ } from "@elcrm/server";
8
+ import { db } from "../lib/db";
9
+ import { jwt } from "../lib/jwt";
10
+
11
+ type UserRow = {
12
+ id: number;
13
+ login: string;
14
+ password: string;
15
+ active: number | null;
16
+ admin: number | null;
17
+ display_name: string | null;
18
+ email: string | null;
19
+ avatar: string | null;
20
+ };
21
+
22
+ const USER_PUBLIC_COLS =
23
+ "id, login, active, admin, display_name, email, avatar";
24
+
25
+ function publicUser(
26
+ row: Pick<
27
+ UserRow,
28
+ "id" | "login" | "admin" | "display_name" | "email" | "avatar"
29
+ >,
30
+ ) {
31
+ return {
32
+ id: row.id,
33
+ login: row.login,
34
+ admin: Number(row.admin) === 1 ? 1 : 0,
35
+ display_name: String(row.display_name ?? ""),
36
+ email: String(row.email ?? ""),
37
+ avatar: String(row.avatar ?? ""),
38
+ };
39
+ }
40
+
41
+ /**
42
+ * createServer уже разобрал ssid → params.user.
43
+ * Не вызываем jwt.init повторно — только наличие user.
44
+ * publicMethods: pass (логин без сессии).
45
+ */
46
+ const requireUser: Middleware = async ({ path, user }) => {
47
+ if (path[1] === "pass") return;
48
+ if (!user?.id) return rpcLogout();
49
+ };
50
+
51
+ export function registerAuthRoutes() {
52
+ createRouter("auth")
53
+ .use(requireUser)
54
+ .use("pass", async ({ body, headers, jwt: jwtCtx }) => {
55
+ const login = String(body?.login ?? "").trim();
56
+ const password = String(body?.password ?? "");
57
+
58
+ if (!login || !password) {
59
+ return rpcErr("Неверные данные", 400);
60
+ }
61
+
62
+ const data = await db.get<UserRow>(
63
+ "SELECT * FROM users WHERE login = ?",
64
+ [login],
65
+ );
66
+
67
+ if (!data) return rpcErr("Пользователь не найден");
68
+ if (data.active === 0) {
69
+ return rpcErr("Аккаунт заблокирован", 403);
70
+ }
71
+ if (!data.password || data.password !== jwt.password(password)) {
72
+ return rpcErr("Данные не верны");
73
+ }
74
+
75
+ const pub = publicUser(data);
76
+ const tokenApi = jwtCtx ?? jwt;
77
+ const ssid = await tokenApi.create(
78
+ { id: pub.id, admin: pub.admin },
79
+ { headers },
80
+ );
81
+
82
+ return rpcOk(pub, { ssid });
83
+ })
84
+ .use("me", async ({ user }) => {
85
+ const row = await db.get<UserRow>(
86
+ `SELECT ${USER_PUBLIC_COLS} FROM users WHERE id = ?`,
87
+ [user.id],
88
+ );
89
+ if (!row || row.active === 0) return rpcLogout();
90
+ return rpcOk(publicUser(row));
91
+ })
92
+ .use("profile", async ({ user, body }) => {
93
+ const display_name = String(
94
+ body?.display_name ?? body?.name ?? "",
95
+ ).trim();
96
+ const email = String(body?.email ?? "").trim();
97
+
98
+ if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
99
+ return rpcErr("Некорректный email", 400);
100
+ }
101
+
102
+ await db.run(
103
+ `UPDATE users SET display_name = ?, email = ? WHERE id = ?`,
104
+ [display_name, email, user.id],
105
+ );
106
+
107
+ const row = await db.get<UserRow>(
108
+ `SELECT ${USER_PUBLIC_COLS} FROM users WHERE id = ?`,
109
+ [user.id],
110
+ );
111
+ if (!row) return rpcErr("Пользователь не найден", 404);
112
+ return rpcOk(publicUser(row));
113
+ });
114
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Минимальная точка для @elcrm/deploy (опциональный upload).
3
+ * Полный деплой — через CLI `elcrm build`; здесь — health зависимости.
4
+ */
5
+ import { createRouter, rpcOk } from "@elcrm/server";
6
+ import { UPD } from "../config";
7
+
8
+ export function registerDeployRoutes() {
9
+ createRouter("deploy").use("status", async () =>
10
+ rpcOk({
11
+ ok: true,
12
+ uploadEnabled: Boolean(UPD),
13
+ package: "@elcrm/deploy",
14
+ }),
15
+ );
16
+ }
@@ -0,0 +1,16 @@
1
+ import { createRouter, rpcOk } from "@elcrm/server";
2
+ import { registerAuthRoutes } from "./auth";
3
+ import { registerDeployRoutes } from "./deploy";
4
+
5
+ export function registerRoutes() {
6
+ createRouter("health").use("ping", async () =>
7
+ rpcOk({ ok: true, service: "panel.api" }),
8
+ );
9
+
10
+ createRouter("version").use("get", async () =>
11
+ rpcOk(process.env.APP_VERSION ?? "0.0.0"),
12
+ );
13
+
14
+ registerAuthRoutes();
15
+ registerDeployRoutes();
16
+ }
@@ -3,37 +3,15 @@
3
3
  "version": "0.0.0",
4
4
  "private": true,
5
5
  "type": "module",
6
+ "workspaces": ["web", "api"],
6
7
  "scripts": {
7
- "dev": "vite --port 5173",
8
- "build": "vite build",
9
- "preview": "vite preview"
8
+ "dev": "bun run scripts/dev.ts",
9
+ "dev:web": "bun --cwd web run dev",
10
+ "dev:api": "bun --cwd api run dev",
11
+ "build": "bun --cwd web run build",
12
+ "start": "bun --cwd api run start"
10
13
  },
11
- "dependencies": {
12
- "@elcrm/alert": "0.1.1",
13
- "@elcrm/api": "0.1.55",
14
- "@elcrm/button": "1.1.0",
15
- "@elcrm/components": "0.1.2",
16
- "@elcrm/core": "0.1.3",
17
- "@elcrm/crypto": "0.0.2",
18
- "@elcrm/error-boundary": "0.0.2",
19
- "@elcrm/form": "0.1.8",
20
- "@elcrm/modal": "0.0.34",
21
- "@elcrm/notice": "0.1.3",
22
- "@elcrm/notifications": "0.3.1",
23
- "@elcrm/passkey": "0.2.3",
24
- "@elcrm/router": "0.0.58",
25
- "@elcrm/search": "0.1.5",
26
- "@elcrm/setting": "0.0.6",
27
- "@elcrm/state": "0.0.7",
28
- "@elcrm/tooltip": "0.1.0",
29
- "react": "^18.2.0",
30
- "react-dom": "^18.2.0"
31
- },
32
- "devDependencies": {
33
- "@types/react": "^18.2.79",
34
- "@types/react-dom": "^18.2.25",
35
- "@vitejs/plugin-react": "^4.3.1",
36
- "typescript": "^5.8.2",
37
- "vite": "^5.4.2"
14
+ "engines": {
15
+ "node": ">=18"
38
16
  }
39
17
  }