elcrm 1.1.24 → 1.1.26

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 (72) hide show
  1. package/dist/index.js +4 -1
  2. package/package.json +4 -1
  3. package/templates/elcrm-docs/BUTTON.elCRM.md +2 -1
  4. package/templates/elcrm-docs/CLI.elCRM.md +1 -1
  5. package/templates/elcrm-docs/COMPONENTS.elCRM.md +6 -0
  6. package/templates/elcrm-docs/FORM.elCRM.md +9 -1
  7. package/templates/elcrm-docs/PASSKEY.elCRM.md +2 -0
  8. package/templates/orbit/api/src/index.ts +14 -0
  9. package/templates/orbit/api/src/lib/db.ts +11 -0
  10. package/templates/orbit/api/src/lib/passwordReset.ts +143 -0
  11. package/templates/orbit/api/src/routes/auth.ts +57 -6
  12. package/templates/orbit/api/src/routes/index.ts +3 -1
  13. package/templates/orbit/api/src/routes/passkey.ts +145 -0
  14. package/templates/orbit/web/index.html +6 -0
  15. package/templates/orbit/web/package.json +3 -2
  16. package/templates/orbit/web/public/logo.svg +4 -0
  17. package/templates/orbit/web/src/App.tsx +2 -2
  18. package/templates/orbit/web/src/auth/AuthFrame.tsx +53 -0
  19. package/templates/orbit/web/src/auth/AuthWallpaper.tsx +49 -0
  20. package/templates/orbit/web/src/auth/PasskeyLoginButton.tsx +48 -0
  21. package/templates/orbit/web/src/auth/goAfterLogin.ts +9 -0
  22. package/templates/orbit/web/src/auth/passkey.ts +135 -0
  23. package/templates/orbit/web/src/component/Nav.tsx +2 -0
  24. package/templates/orbit/web/src/icons.generated.tsx +9 -1
  25. package/templates/orbit/web/src/lib/notifications.css +15 -0
  26. package/templates/orbit/web/src/lib/notifications.tsx +161 -0
  27. package/templates/orbit/web/src/modules/Settings/Notifications.tsx +280 -0
  28. package/templates/orbit/web/src/modules/Settings/Security.tsx +268 -0
  29. package/templates/orbit/web/src/modules/Settings/index.tsx +8 -1
  30. package/templates/orbit/web/src/pages/Auth.css +164 -0
  31. package/templates/orbit/web/src/pages/Auth.tsx +54 -31
  32. package/templates/orbit/web/src/pages/Restore.tsx +76 -0
  33. package/templates/orbit/web/src/pages/RestoreConfirm.tsx +106 -0
  34. package/templates/orbit/web/src/router-data.d.ts +1 -0
  35. package/templates/orbit/web/src/vite-env.d.ts +1 -0
  36. package/templates/orbit/web/vite.config.ts +85 -6
  37. package/templates/panel/README.md +1 -1
  38. package/templates/panel/api/package.json +2 -2
  39. package/templates/panel/api/src/index.ts +14 -0
  40. package/templates/panel/api/src/lib/db.ts +11 -0
  41. package/templates/panel/api/src/lib/passwordReset.ts +143 -0
  42. package/templates/panel/api/src/routes/auth.ts +57 -6
  43. package/templates/panel/api/src/routes/index.ts +2 -0
  44. package/templates/panel/api/src/routes/passkey.ts +145 -0
  45. package/templates/panel/web/index.html +6 -0
  46. package/templates/panel/web/package.json +10 -8
  47. package/templates/panel/web/public/logo.svg +4 -0
  48. package/templates/panel/web/src/App.tsx +3 -3
  49. package/templates/panel/web/src/auth/AuthFrame.tsx +53 -0
  50. package/templates/panel/web/src/auth/AuthWallpaper.tsx +49 -0
  51. package/templates/panel/web/src/auth/PasskeyLoginButton.tsx +48 -0
  52. package/templates/panel/web/src/auth/goAfterLogin.ts +9 -0
  53. package/templates/panel/web/src/auth/passkey.ts +135 -0
  54. package/templates/panel/web/src/component/Header.tsx +40 -5
  55. package/templates/panel/web/src/icons.generated.tsx +64 -0
  56. package/templates/panel/web/src/lib/notifications.css +15 -0
  57. package/templates/panel/web/src/lib/notifications.tsx +162 -0
  58. package/templates/panel/web/src/modules/Account/index.tsx +1 -1
  59. package/templates/panel/web/src/modules/Account/router.sections.ts +1 -0
  60. package/templates/panel/web/src/modules/Account/sections/Security.tsx +268 -0
  61. package/templates/panel/web/src/modules/Home/index.tsx +6 -1
  62. package/templates/panel/web/src/modules/Settings/router.sections.ts +1 -0
  63. package/templates/panel/web/src/modules/Settings/sections/Notifications.tsx +280 -0
  64. package/templates/panel/web/src/pages/Auth.css +164 -0
  65. package/templates/panel/web/src/pages/Auth.tsx +50 -52
  66. package/templates/panel/web/src/pages/Restore.tsx +76 -0
  67. package/templates/panel/web/src/pages/RestoreConfirm.tsx +106 -0
  68. package/templates/panel/web/src/router-data.d.ts +2 -0
  69. package/templates/panel/web/src/style/theme-dark.css +3 -3
  70. package/templates/panel/web/src/style/theme-light.css +3 -3
  71. package/templates/panel/web/src/vite-env.d.ts +1 -0
  72. package/templates/panel/web/vite.config.ts +91 -3
package/dist/index.js CHANGED
@@ -1901,6 +1901,9 @@ async function migrateFormNative(cwd, dry) {
1901
1901
  const changes = [];
1902
1902
  const files = projectCodeRoots(cwd).flatMap((r) => walkCodeFiles(r)).filter((f) => /\.(tsx|jsx)$/.test(f));
1903
1903
  for (const file of files) {
1904
+ if (/(^|\/)pages\/Auth\.tsx$/.test(rel(cwd, file).replace(/\\/g, "/"))) {
1905
+ continue;
1906
+ }
1904
1907
  const raw = readFileSync14(file, "utf8");
1905
1908
  const { next, hits } = rewriteFormNative(raw);
1906
1909
  if (hits === 0)
@@ -2637,7 +2640,7 @@ function collectFrontIssues(cwd, pkg) {
2637
2640
  });
2638
2641
  }
2639
2642
  }
2640
- if (/(?:^|\s)native(?:\s|$|=|\/|>)/.test(line) && !/\bnavigator\b|\bnativeEvent\b|\bcreateNative/.test(line) && !/['"`]native['"`]/.test(line) && !(/\binstallMode\b/.test(line) && /\bnative\b/.test(line))) {
2643
+ if (!/(^|\/)pages\/Auth\.tsx$/.test(rel2.replace(/\\/g, "/")) && /(?:^|\s)native(?:\s|$|=|\/|>)/.test(line) && !/\bnavigator\b|\bnativeEvent\b|\bcreateNative/.test(line) && !/['"`]native['"`]/.test(line) && !(/\binstallMode\b/.test(line) && /\bnative\b/.test(line))) {
2641
2644
  if (pkg.name !== "@elcrm/form") {
2642
2645
  issues.push({
2643
2646
  level: "warn",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elcrm",
3
- "version": "1.1.24",
3
+ "version": "1.1.26",
4
4
  "description": "CLI @elcrm/*: doctor --fix (порядок проекта), update, css, docs, cursor, kit, migrate",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -53,6 +53,9 @@
53
53
  "dev": "bun run ./src/index.ts",
54
54
  "elcrm": "bun run ./src/index.ts",
55
55
  "test": "bun test src/lib/migrate src/lib/tidy.test.ts src/lib/kit src/vite",
56
+ "demo": "bun run scripts/dev-template.ts",
57
+ "demo:panel": "bun run scripts/dev-template.ts panel",
58
+ "demo:orbit": "bun run scripts/dev-template.ts orbit",
56
59
  "link:local": "bun run build && bun link",
57
60
  "prepublishOnly": "bun run test && bun run build",
58
61
  "publish:npm": "npm publish"
@@ -26,7 +26,8 @@ import { Button } from "@elcrm/button";
26
26
  - `onSend(setDisabled, event)`: синхронный `false` — без pending; промис/`async` — pending до settle; иначе снять через `setDisabled(false)`
27
27
  - Если задан `onClick` — `onSend` **не** вызывается
28
28
  - `buttonType`: `button` \| `submit` \| `reset`
29
- - Точечно: `@elcrm/button/Button`
29
+ - **`IconButton`** — только иконка: `icon` + обязательный `label` → `aria-label`. Имя иконки + tooltip — `@elcrm/components` `IconButton`.
30
+ - Точечно: `@elcrm/button/Button`, `@elcrm/button/IconButton`
30
31
 
31
32
  ## Нельзя
32
33
 
@@ -67,7 +67,7 @@ elcrm migrate front # пакет (то же, что doctor --fix без
67
67
  # form-tokens → components → css-sanitize →
68
68
  # size-sml → field-border → modal-create →
69
69
  # socket-server
70
- elcrm migrate form-native # снять prop native у @elcrm/form
70
+ elcrm migrate form-native # снять prop native у @elcrm/form (не трогает pages/Auth.tsx)
71
71
  elcrm migrate native-html # <input|textarea> → StringField/CheckField/…
72
72
  elcrm migrate css-sanitize # осиротевшие in srgb / transparent);
73
73
  elcrm migrate components # Block/Row → Stack, --page-* / --item-* → --shell-*
@@ -17,6 +17,7 @@ import {
17
17
  Card, Stat, ActionGroup, Toolbar, TextGroup, ItemColumn, List, Item, PageHead, PageShell, ChatList, ChatMessages, ChatSplit, Sidebar, Popover,
18
18
  Healthmap, PanelInfo,
19
19
  Loading, EmptyState, RadioGroup, PricingGroup, PricingTable,
20
+ Table, CodeBlock, SplitPane, Banner, IconButton,
20
21
  createLazyResolver,
21
22
  } from "@elcrm/components";
22
23
  import type { PricingPlan, HealthmapData } from "@elcrm/components";
@@ -43,6 +44,11 @@ import type { PricingPlan, HealthmapData } from "@elcrm/components";
43
44
  - **TextGroup** — две строки: `title` + `description` (без аватара; с аватаром — AvatarName).
44
45
  - **Item** — строка списка. `as` button/a/li, `active`. `variant="card"` — рамка и muted-фон (идеи / тикеты / inbox). Не дублировать `.desk-row` в приложении.
45
46
  - **ItemColumn** — внутри Item: `icon` слева, столбик `title` / `description` / `footer` или `meta`, `extra` — бейджи справа сверху, `aside` — колонка справа. `unread` — жирный title. Title: `--item-column-title-size` / `--item-column-title-line` (в console = `--button-size-s` / `--button-line-height`).
47
+ - **Table** — `<table>` в обёртке со скроллом. `layout="fixed"` для фиксированной сетки. Токены `--table-*`.
48
+ - **CodeBlock** — bar + `<pre>`, `onCopy`, `children` для подсветки. Токены `--code-block-*`.
49
+ - **SplitPane** — две панели + drag handle. `direction`, `ratio`, `onRatioChange`, `minFirst` / `minSecond`. Не `ChatSplit` (только чат). Токены `--split-handle-*`.
50
+ - **Banner** — статусная полоса (`tone`: info / warning / danger / success). Не toast — не `Notice`.
51
+ - **IconButton** — `name` (`Icons.Line`) + обязательный `label` + `Tooltip` по умолчанию. `tooltip={false}` без подсказки. Низкий уровень — `@elcrm/button/IconButton` (`icon` + `label`).
46
52
 
47
53
  ## Меню
48
54
 
@@ -14,6 +14,14 @@ const form = useForm({ login: "", password: "" });
14
14
  <Form.Init onNotice={(msg) => { /* тост */ }} />
15
15
  <StringField name="login" form={form} label="Логин" autoComplete="username" />
16
16
  <PasswordField name="password" form={form} label="Пароль" />
17
+
18
+ {/* Экран входа: native обязателен — autofill и Passkey (autocomplete="username webauthn") */}
19
+ <StringField
20
+ name="login"
21
+ form={form}
22
+ native
23
+ autoComplete="username webauthn"
24
+ />
17
25
  ```
18
26
 
19
27
  `Form.Init` — если нужны notice / модалки для `ModalField` / `OptionsField`.
@@ -40,7 +48,7 @@ const form = useForm({ login: "", password: "" });
40
48
  - Алиасы: `ProgressField` → `RangeField`, `ReadonlyField` → `DisplayField`, `OtpField` → `CodeField`, `SegmentField` → `TabsField`, `DragonDropField` → `DragDropField`. Чинит `elcrm migrate form-aliases`.
41
49
  - В форме: `Search` из `@elcrm/search` → `SearchField` (`form`+`name`). Хедер — `@elcrm/search`.
42
50
  - Токены `--search-*` → `--field-*` (`elcrm migrate form-tokens`).
43
- - Проп `native` на полях — `elcrm test` предупреждает; снять: `elcrm migrate form-native`. У `StringField` `native` исторически для autofill; у `PasswordField` `native` значит «не рендерить» — не путать.
51
+ - Проп `native` на обычных полях — `elcrm test` предупреждает; снять: `elcrm migrate form-native`. **Исключение — авторизация:** у логина на экране входа нужен `native` (браузерный autofill + WebAuthn / Passkey, `autoComplete="username webauthn"`). Без `native` менеджер паролей и Passkey не видят поле. У `PasswordField` `native` значит «не рендерить» — на пароле входа **не** ставить.
44
52
  - Legacy-токены: `--field-note-padding*`, `--control-inner-padding`, `--control-side-padding`, `--field-side-padding`, `--date-popup-shadow` → `elcrm migrate form-tokens`.
45
53
  - Пересоздавать `options` селекта на каждый рендер — константа модуля.
46
54
  - Серверные пакеты `@elcrm/db|jwt|cron|tb` на фронте.
@@ -19,6 +19,8 @@ await passkey.remove({ userId, credentialId });
19
19
 
20
20
  Ядро **без React**. Хуки — `@elcrm/passkey/react`. Ошибки: `PasskeyError.code`.
21
21
 
22
+ На экране входа у поля логина обязателен проп **`native`** у `StringField` и `autoComplete="username webauthn"` — иначе браузер не предлагает Passkey / autofill.
23
+
22
24
  ## Нельзя
23
25
 
24
26
  - `createPasskey` / `authenticateWithPasskey` — **не экспортируются** (`elcrm test` error).
@@ -2,6 +2,7 @@
2
2
  * orbit.api — createRouter + createServer; JWT/CORS/cron из конфига.
3
3
  */
4
4
  import { createServer, cors as serverCors } from "@elcrm/server";
5
+ import { createNotifications } from "@elcrm/server/notifications";
5
6
  import { DB_PATH, getCorsOrigin, PORT, TOKEN } from "./config";
6
7
  import { initDatabase } from "./lib/db";
7
8
  import { jwt } from "./lib/jwt";
@@ -13,9 +14,22 @@ async function start() {
13
14
  if (!process.env.TOKEN?.trim()) {
14
15
  process.env.TOKEN = TOKEN;
15
16
  }
17
+ if (!process.env.PASSKEY_RP_ID?.trim()) {
18
+ process.env.PASSKEY_RP_ID = "localhost";
19
+ }
20
+ if (!process.env.PASSKEY_ORIGIN?.trim()) {
21
+ process.env.PASSKEY_ORIGIN = "http://localhost:5173";
22
+ }
23
+ if (!process.env.PASSKEY_STORAGE_ADAPTER?.trim()) {
24
+ process.env.PASSKEY_STORAGE_ADAPTER = "sqlite";
25
+ }
16
26
 
17
27
  await initDatabase();
18
28
  registerRoutes();
29
+ await createNotifications({
30
+ defaultTenantId: "orbit",
31
+ vapidSubject: "mailto:admin@localhost",
32
+ });
19
33
 
20
34
  try {
21
35
  createServer({
@@ -36,6 +36,17 @@ async function migrate() {
36
36
  )
37
37
  `);
38
38
 
39
+ await db.run(`
40
+ CREATE TABLE IF NOT EXISTS password_resets (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ token_hash TEXT NOT NULL,
43
+ user_id INTEGER NOT NULL,
44
+ expires_at TEXT NOT NULL,
45
+ used INTEGER NOT NULL DEFAULT 0,
46
+ attempts INTEGER NOT NULL DEFAULT 0
47
+ )
48
+ `);
49
+
39
50
  const count = await db.get<{ n: number }>(
40
51
  "SELECT count(*) AS n FROM users",
41
52
  );
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Сброс пароля: 6-значный код. В демо код пишется в лог API.
3
+ */
4
+ import { createHash, randomInt } from "node:crypto";
5
+ import { db } from "./db";
6
+ import { jwt } from "./jwt";
7
+
8
+ const TTL_MS = 15 * 60 * 1000;
9
+ const MIN_PASSWORD = 6;
10
+ const MAX_ATTEMPTS = 5;
11
+
12
+ type ResetRow = {
13
+ id: number;
14
+ token_hash: string;
15
+ user_id: number;
16
+ expires_at: string;
17
+ used: number;
18
+ attempts: number;
19
+ };
20
+
21
+ type UserLite = {
22
+ id: number;
23
+ login: string;
24
+ email: string | null;
25
+ active: number | null;
26
+ };
27
+
28
+ function hashCode(code: string): string {
29
+ return createHash("sha256").update(code.trim()).digest("hex");
30
+ }
31
+
32
+ function generateCode(): string {
33
+ return String(randomInt(100_000, 1_000_000));
34
+ }
35
+
36
+ const hits = new Map<string, { n: number; until: number }>();
37
+
38
+ function rateOk(key: string): boolean {
39
+ const now = Date.now();
40
+ const row = hits.get(key);
41
+ if (!row || now > row.until) {
42
+ hits.set(key, { n: 1, until: now + 15 * 60 * 1000 });
43
+ return true;
44
+ }
45
+ if (row.n >= 5) return false;
46
+ row.n += 1;
47
+ return true;
48
+ }
49
+
50
+ /** Найти пользователя по логину или email */
51
+ export async function findUserForRestore(loginOrEmail: string) {
52
+ const q = loginOrEmail.trim();
53
+ if (!q) return null;
54
+ return db.get<UserLite>(
55
+ `SELECT id, login, email, active FROM users
56
+ WHERE lower(login) = lower(?) OR lower(ifnull(email, '')) = lower(?)
57
+ LIMIT 1`,
58
+ [q, q],
59
+ );
60
+ }
61
+
62
+ export async function requestPasswordReset(
63
+ loginOrEmail: string,
64
+ ip: string,
65
+ ): Promise<void> {
66
+ const q = loginOrEmail.trim();
67
+ if (!q) return;
68
+ if (!rateOk(`ip:${ip}`) || !rateOk(`login:${q.toLowerCase()}`)) {
69
+ return;
70
+ }
71
+
72
+ const user = await findUserForRestore(q);
73
+ if (!user || user.active === 0) return;
74
+
75
+ const code = generateCode();
76
+ const expires = new Date(Date.now() + TTL_MS).toISOString();
77
+
78
+ await db.run("DELETE FROM password_resets WHERE user_id = ?", [user.id]);
79
+ await db.run(
80
+ `INSERT INTO password_resets (token_hash, user_id, expires_at, used, attempts)
81
+ VALUES (?, ?, ?, 0, 0)`,
82
+ [hashCode(code), user.id, expires],
83
+ );
84
+
85
+ console.log(`[auth/restore] код для ${user.login}: ${code}`);
86
+ }
87
+
88
+ export async function confirmPasswordReset(
89
+ loginOrEmail: string,
90
+ code: string,
91
+ password: string,
92
+ ): Promise<{ ok: true } | { error: string }> {
93
+ const login = loginOrEmail.trim();
94
+ const digits = code.trim();
95
+ if (!login || !digits) return { error: "Укажите email, код и новый пароль" };
96
+ if (!/^\d{6}$/.test(digits)) {
97
+ return { error: "Код должен состоять из 6 цифр" };
98
+ }
99
+ if (password.length < MIN_PASSWORD) {
100
+ return { error: "Пароль не короче 6 символов" };
101
+ }
102
+
103
+ const user = await findUserForRestore(login);
104
+ if (!user || user.active === 0) {
105
+ return { error: "Неверный или истёкший код" };
106
+ }
107
+
108
+ const row = await db.get<ResetRow>(
109
+ `SELECT id, token_hash, user_id, expires_at, used, attempts
110
+ FROM password_resets WHERE user_id = ? AND used = 0
111
+ ORDER BY id DESC LIMIT 1`,
112
+ [user.id],
113
+ );
114
+ if (!row) return { error: "Неверный или истёкший код" };
115
+ if (new Date(row.expires_at).getTime() < Date.now()) {
116
+ await db.run("DELETE FROM password_resets WHERE id = ?", [row.id]);
117
+ return { error: "Неверный или истёкший код" };
118
+ }
119
+ const attempts = Number(row.attempts ?? 0);
120
+ if (attempts >= MAX_ATTEMPTS) {
121
+ await db.run("DELETE FROM password_resets WHERE id = ?", [row.id]);
122
+ return { error: "Слишком много попыток. Запросите новый код." };
123
+ }
124
+ if (hashCode(digits) !== row.token_hash) {
125
+ const next = attempts + 1;
126
+ await db.run("UPDATE password_resets SET attempts = ? WHERE id = ?", [
127
+ next,
128
+ row.id,
129
+ ]);
130
+ if (next >= MAX_ATTEMPTS) {
131
+ await db.run("DELETE FROM password_resets WHERE id = ?", [row.id]);
132
+ return { error: "Слишком много попыток. Запросите новый код." };
133
+ }
134
+ return { error: "Неверный или истёкший код" };
135
+ }
136
+
137
+ await db.run("UPDATE users SET password = ? WHERE id = ?", [
138
+ jwt.password(password),
139
+ user.id,
140
+ ]);
141
+ await db.run("UPDATE password_resets SET used = 1 WHERE id = ?", [row.id]);
142
+ return { ok: true };
143
+ }
@@ -7,6 +7,11 @@ import {
7
7
  } from "@elcrm/server";
8
8
  import { db } from "../lib/db";
9
9
  import { jwt } from "../lib/jwt";
10
+ import {
11
+ confirmPasswordReset,
12
+ findUserForRestore,
13
+ requestPasswordReset,
14
+ } from "../lib/passwordReset";
10
15
 
11
16
  type UserRow = {
12
17
  id: number;
@@ -43,8 +48,10 @@ function publicUser(
43
48
  * Не вызываем jwt.init повторно — только наличие user.
44
49
  * publicMethods: pass (логин без сессии).
45
50
  */
51
+ const PUBLIC = new Set(["pass", "restore", "restoreConfirm"]);
52
+
46
53
  const requireUser: Middleware = async ({ path, user }) => {
47
- if (path[1] === "pass") return;
54
+ if (PUBLIC.has(String(path[1] ?? ""))) return;
48
55
  if (!user?.id) return rpcLogout();
49
56
  };
50
57
 
@@ -52,17 +59,19 @@ export function registerAuthRoutes() {
52
59
  createRouter("auth")
53
60
  .use(requireUser)
54
61
  .use("pass", async ({ body, headers, jwt: jwtCtx }) => {
55
- const login = String(body?.login ?? "").trim();
62
+ const login = String(body?.login ?? body?.email ?? "").trim();
56
63
  const password = String(body?.password ?? "");
57
64
 
58
65
  if (!login || !password) {
59
66
  return rpcErr("Неверные данные", 400);
60
67
  }
61
68
 
62
- const data = await db.get<UserRow>(
63
- "SELECT * FROM users WHERE login = ?",
64
- [login],
65
- );
69
+ const found = await findUserForRestore(login);
70
+ const data = found
71
+ ? await db.get<UserRow>("SELECT * FROM users WHERE id = ?", [
72
+ found.id,
73
+ ])
74
+ : null;
66
75
 
67
76
  if (!data) return rpcErr("Пользователь не найден");
68
77
  if (data.active === 0) {
@@ -110,5 +119,47 @@ export function registerAuthRoutes() {
110
119
  );
111
120
  if (!row) return rpcErr("Пользователь не найден", 404);
112
121
  return rpcOk(publicUser(row));
122
+ })
123
+ .use("password", async ({ user, body }) => {
124
+ const password = String(body?.password ?? "");
125
+ if (password.length < 6) {
126
+ return rpcErr("Пароль не короче 6 символов", 400);
127
+ }
128
+ await db.run("UPDATE users SET password = ? WHERE id = ?", [
129
+ jwt.password(password),
130
+ user.id,
131
+ ]);
132
+ return rpcOk({ ok: true });
133
+ })
134
+ .use("restore", async ({ body, headers }) => {
135
+ const login = String(body?.login ?? body?.email ?? "").trim();
136
+ if (!login) return rpcErr("Укажите email или логин", 400);
137
+ try {
138
+ await requestPasswordReset(login, clientIp(headers));
139
+ } catch (e) {
140
+ console.error("auth/restore:", e);
141
+ return rpcErr("Не удалось отправить код", 500);
142
+ }
143
+ return rpcOk({ ok: true });
144
+ })
145
+ .use("restoreConfirm", async ({ body }) => {
146
+ const login = String(body?.login ?? body?.email ?? "").trim();
147
+ const code = String(body?.code ?? body?.token ?? "").trim();
148
+ const password = String(body?.password ?? "");
149
+ const result = await confirmPasswordReset(login, code, password);
150
+ if ("error" in result) return rpcErr(result.error, 400);
151
+ return rpcOk({ ok: true });
113
152
  });
114
153
  }
154
+
155
+ function clientIp(headers: unknown): string {
156
+ const h = headers as { get?: (k: string) => string | null } & Record<
157
+ string,
158
+ string | undefined
159
+ >;
160
+ const raw =
161
+ (typeof h?.get === "function"
162
+ ? h.get("x-forwarded-for") || h.get("x-real-ip")
163
+ : h?.["x-forwarded-for"] || h?.["x-real-ip"]) || "";
164
+ return String(raw).split(",")[0]?.trim() || "local";
165
+ }
@@ -1,10 +1,11 @@
1
1
  import { createRouter, rpcOk } from "@elcrm/server";
2
2
  import { registerAuthRoutes } from "./auth";
3
3
  import { registerDeployRoutes } from "./deploy";
4
+ import { registerPasskeyRoutes } from "./passkey";
4
5
 
5
6
  export function registerRoutes() {
6
7
  createRouter("health").use("ping", async () =>
7
- rpcOk({ ok: true, service: "panel.api" }),
8
+ rpcOk({ ok: true, service: "orbit.api" }),
8
9
  );
9
10
 
10
11
  createRouter("version").use("get", async () =>
@@ -12,5 +13,6 @@ export function registerRoutes() {
12
13
  );
13
14
 
14
15
  registerAuthRoutes();
16
+ registerPasskeyRoutes();
15
17
  registerDeployRoutes();
16
18
  }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * WebAuthn Passkey: регистрация / вход / удаление.
3
+ */
4
+ import { createRouter, rpcErr, rpcOk } from "@elcrm/server";
5
+ import {
6
+ createPasskey,
7
+ createPasskeyAuthentication,
8
+ handlePasskeyAuthVerify,
9
+ handlePasskeyRegisterVerify,
10
+ handlePasskeyRemove,
11
+ listPasskeyCredentials,
12
+ } from "@elcrm/server/passkey";
13
+ import { db } from "../lib/db";
14
+ import { jwt } from "../lib/jwt";
15
+ import { findUserForRestore } from "../lib/passwordReset";
16
+
17
+ type UserRow = {
18
+ id: number;
19
+ login: string;
20
+ active: number | null;
21
+ admin: number | null;
22
+ };
23
+
24
+ async function findUserByLogin(login: string) {
25
+ const found = await findUserForRestore(login);
26
+ if (!found) return null;
27
+ return db.get<UserRow>(
28
+ "SELECT id, login, active, admin FROM users WHERE id = ?",
29
+ [found.id],
30
+ );
31
+ }
32
+
33
+ export function registerPasskeyRoutes() {
34
+ createRouter("passkey")
35
+ .use("registerOptions", async ({ user, body }) => {
36
+ if (!user?.id) return { status: "logout", data: null };
37
+
38
+ const row = await db.get<UserRow>(
39
+ "SELECT id, login, active FROM users WHERE id = ?",
40
+ [user.id],
41
+ );
42
+ if (!row || row.active === 0) {
43
+ return { status: "logout", data: null };
44
+ }
45
+
46
+ const name = String(body?.username ?? row.login);
47
+ const options = await createPasskey({
48
+ id: String(row.id),
49
+ name,
50
+ displayName: name,
51
+ });
52
+ return rpcOk({ options });
53
+ })
54
+ .use("registerVerify", async ({ user, body }) => {
55
+ if (!user?.id) return { status: "logout", data: null };
56
+
57
+ const result = await handlePasskeyRegisterVerify({
58
+ userId: String(user.id),
59
+ credential: body?.credential,
60
+ });
61
+ if (!result.ok) {
62
+ return rpcErr(result.reason ?? result.code, result.status ?? 400);
63
+ }
64
+ return rpcOk(result);
65
+ })
66
+ .use("authOptions", async ({ body }) => {
67
+ const login = String(body?.login ?? body?.userId ?? "").trim();
68
+ if (!login) return rpcErr("login required", 400);
69
+
70
+ let userId = login;
71
+ if (!/^\d+$/.test(login)) {
72
+ const row = await findUserByLogin(login);
73
+ if (!row || row.active === 0) {
74
+ return rpcErr("Пользователь не найден");
75
+ }
76
+ userId = String(row.id);
77
+ }
78
+
79
+ const options = await createPasskeyAuthentication(userId);
80
+ return rpcOk({ options, userId });
81
+ })
82
+ .use("authVerify", async ({ body, headers, jwt: jwtCtx }) => {
83
+ const login = String(body?.login ?? body?.userId ?? "").trim();
84
+ if (!login) return rpcErr("login required", 400);
85
+
86
+ let row: UserRow | null = null;
87
+ if (/^\d+$/.test(login)) {
88
+ row = await db.get<UserRow>(
89
+ "SELECT id, login, active, admin FROM users WHERE id = ?",
90
+ [Number(login)],
91
+ );
92
+ } else {
93
+ row = await findUserByLogin(login);
94
+ }
95
+
96
+ if (!row || row.active === 0) {
97
+ return rpcErr("Пользователь не найден");
98
+ }
99
+
100
+ const result = await handlePasskeyAuthVerify({
101
+ userId: String(row.id),
102
+ credential: body?.credential,
103
+ });
104
+ if (!result.ok) {
105
+ return rpcErr(result.reason ?? result.code, result.status ?? 400);
106
+ }
107
+
108
+ const admin = Number(row.admin) === 1 ? 1 : 0;
109
+ const tokenApi = jwtCtx ?? jwt;
110
+ const ssid = await tokenApi.create(
111
+ { id: row.id, admin },
112
+ { headers },
113
+ );
114
+
115
+ return rpcOk({ id: row.id, login: row.login, admin }, { ssid });
116
+ })
117
+ .use("list", async ({ user }) => {
118
+ if (!user?.id) return { status: "logout", data: null };
119
+ try {
120
+ const rows = await listPasskeyCredentials(String(user.id));
121
+ return rpcOk(rows);
122
+ } catch (err) {
123
+ console.error("[passkey/list]", err);
124
+ return rpcErr("Не удалось загрузить Passkey", 500);
125
+ }
126
+ })
127
+ .use("remove", async ({ user, body }) => {
128
+ if (!user?.id) return { status: "logout", data: null };
129
+
130
+ const credentialId = String(body?.credentialId ?? "");
131
+ if (!credentialId) return rpcErr("credentialId required", 400);
132
+
133
+ const result = await handlePasskeyRemove({
134
+ userId: String(user.id),
135
+ credentialId,
136
+ });
137
+ if (!result.ok) {
138
+ return rpcErr(
139
+ result.reason ?? (result as { code?: string }).code,
140
+ result.status ?? 400,
141
+ );
142
+ }
143
+ return rpcOk(result);
144
+ });
145
+ }
@@ -3,6 +3,12 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="theme-color" content="#f6f6f4" />
7
+ <meta name="mobile-web-app-capable" content="yes" />
8
+ <meta name="apple-mobile-web-app-capable" content="yes" />
9
+ <meta name="apple-mobile-web-app-title" content="Orbit" />
10
+ <link rel="icon" href="/logo.svg" type="image/svg+xml" />
11
+ <link rel="apple-touch-icon" href="/logo.svg" />
6
12
  <title>Orbit</title>
7
13
  <script>
8
14
  try {
@@ -16,7 +16,7 @@
16
16
  "@elcrm/core": "0.1.3",
17
17
  "@elcrm/crypto": "0.0.2",
18
18
  "@elcrm/error-boundary": "0.0.2",
19
- "@elcrm/form": "0.1.12",
19
+ "@elcrm/form": "0.1.17",
20
20
  "@elcrm/icons": "^0.1.5",
21
21
  "@elcrm/modal": "0.0.34",
22
22
  "@elcrm/notice": "0.1.4",
@@ -35,6 +35,7 @@
35
35
  "@types/react-dom": "^18.2.25",
36
36
  "@vitejs/plugin-react": "^4.3.1",
37
37
  "typescript": "^5.8.2",
38
- "vite": "^5.4.2"
38
+ "vite": "^5.4.2",
39
+ "vite-plugin-pwa": "^0.21.2"
39
40
  }
40
41
  }
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2
+ <rect width="512" height="512" rx="96" fill="#1a1a1a"/>
3
+ <path fill="#fff" d="M156 140h200v48H220v56h120v48H220v80h-64z"/>
4
+ </svg>
@@ -1,9 +1,9 @@
1
1
  import { Alert } from "@elcrm/alert";
2
2
  import { Modal } from "@elcrm/modal";
3
3
  import { Notice } from "@elcrm/notice";
4
- import { Notifications } from "@elcrm/notifications";
5
4
  import { Form } from "@elcrm/form";
6
5
  import { LoadRoute } from "@elcrm/router";
6
+ import { AppNotificationsInit } from "./lib/notifications";
7
7
 
8
8
  /**
9
9
  * Корень Orbit: Init пакетов + страница.
@@ -14,7 +14,7 @@ export default function App() {
14
14
  <Alert.Init />
15
15
  <Modal.Init />
16
16
  <Notice.Init position="top-right" durationMs={2200} maxVisible={4} />
17
- <Notifications.Init appBadge={false} autoSync={false} />
17
+ <AppNotificationsInit />
18
18
  <Form.Init onNotice={(msg) => Notice.Remark(String(msg))} />
19
19
  <LoadRoute
20
20
  variant="page"