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.
- package/dist/index.js +4 -1
- package/package.json +4 -1
- package/templates/elcrm-docs/BUTTON.elCRM.md +2 -1
- package/templates/elcrm-docs/CLI.elCRM.md +1 -1
- package/templates/elcrm-docs/COMPONENTS.elCRM.md +6 -0
- package/templates/elcrm-docs/FORM.elCRM.md +9 -1
- package/templates/elcrm-docs/PASSKEY.elCRM.md +2 -0
- package/templates/orbit/api/src/index.ts +14 -0
- package/templates/orbit/api/src/lib/db.ts +11 -0
- package/templates/orbit/api/src/lib/passwordReset.ts +143 -0
- package/templates/orbit/api/src/routes/auth.ts +57 -6
- package/templates/orbit/api/src/routes/index.ts +3 -1
- package/templates/orbit/api/src/routes/passkey.ts +145 -0
- package/templates/orbit/web/index.html +6 -0
- package/templates/orbit/web/package.json +3 -2
- package/templates/orbit/web/public/logo.svg +4 -0
- package/templates/orbit/web/src/App.tsx +2 -2
- package/templates/orbit/web/src/auth/AuthFrame.tsx +53 -0
- package/templates/orbit/web/src/auth/AuthWallpaper.tsx +49 -0
- package/templates/orbit/web/src/auth/PasskeyLoginButton.tsx +48 -0
- package/templates/orbit/web/src/auth/goAfterLogin.ts +9 -0
- package/templates/orbit/web/src/auth/passkey.ts +135 -0
- package/templates/orbit/web/src/component/Nav.tsx +2 -0
- package/templates/orbit/web/src/icons.generated.tsx +9 -1
- package/templates/orbit/web/src/lib/notifications.css +15 -0
- package/templates/orbit/web/src/lib/notifications.tsx +161 -0
- package/templates/orbit/web/src/modules/Settings/Notifications.tsx +280 -0
- package/templates/orbit/web/src/modules/Settings/Security.tsx +268 -0
- package/templates/orbit/web/src/modules/Settings/index.tsx +8 -1
- package/templates/orbit/web/src/pages/Auth.css +164 -0
- package/templates/orbit/web/src/pages/Auth.tsx +54 -31
- package/templates/orbit/web/src/pages/Restore.tsx +76 -0
- package/templates/orbit/web/src/pages/RestoreConfirm.tsx +106 -0
- package/templates/orbit/web/src/router-data.d.ts +1 -0
- package/templates/orbit/web/src/vite-env.d.ts +1 -0
- package/templates/orbit/web/vite.config.ts +85 -6
- package/templates/panel/README.md +1 -1
- package/templates/panel/api/package.json +2 -2
- package/templates/panel/api/src/index.ts +14 -0
- package/templates/panel/api/src/lib/db.ts +11 -0
- package/templates/panel/api/src/lib/passwordReset.ts +143 -0
- package/templates/panel/api/src/routes/auth.ts +57 -6
- package/templates/panel/api/src/routes/index.ts +2 -0
- package/templates/panel/api/src/routes/passkey.ts +145 -0
- package/templates/panel/web/index.html +6 -0
- package/templates/panel/web/package.json +10 -8
- package/templates/panel/web/public/logo.svg +4 -0
- package/templates/panel/web/src/App.tsx +3 -3
- package/templates/panel/web/src/auth/AuthFrame.tsx +53 -0
- package/templates/panel/web/src/auth/AuthWallpaper.tsx +49 -0
- package/templates/panel/web/src/auth/PasskeyLoginButton.tsx +48 -0
- package/templates/panel/web/src/auth/goAfterLogin.ts +9 -0
- package/templates/panel/web/src/auth/passkey.ts +135 -0
- package/templates/panel/web/src/component/Header.tsx +40 -5
- package/templates/panel/web/src/icons.generated.tsx +64 -0
- package/templates/panel/web/src/lib/notifications.css +15 -0
- package/templates/panel/web/src/lib/notifications.tsx +162 -0
- package/templates/panel/web/src/modules/Account/index.tsx +1 -1
- package/templates/panel/web/src/modules/Account/router.sections.ts +1 -0
- package/templates/panel/web/src/modules/Account/sections/Security.tsx +268 -0
- package/templates/panel/web/src/modules/Home/index.tsx +6 -1
- package/templates/panel/web/src/modules/Settings/router.sections.ts +1 -0
- package/templates/panel/web/src/modules/Settings/sections/Notifications.tsx +280 -0
- package/templates/panel/web/src/pages/Auth.css +164 -0
- package/templates/panel/web/src/pages/Auth.tsx +50 -52
- package/templates/panel/web/src/pages/Restore.tsx +76 -0
- package/templates/panel/web/src/pages/RestoreConfirm.tsx +106 -0
- package/templates/panel/web/src/router-data.d.ts +2 -0
- package/templates/panel/web/src/style/theme-dark.css +3 -3
- package/templates/panel/web/src/style/theme-light.css +3 -3
- package/templates/panel/web/src/vite-env.d.ts +1 -0
- 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.
|
|
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
|
-
-
|
|
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`.
|
|
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]
|
|
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
|
|
63
|
-
|
|
64
|
-
[
|
|
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: "
|
|
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.
|
|
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
|
}
|
|
@@ -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
|
-
<
|
|
17
|
+
<AppNotificationsInit />
|
|
18
18
|
<Form.Init onNotice={(msg) => Notice.Remark(String(msg))} />
|
|
19
19
|
<LoadRoute
|
|
20
20
|
variant="page"
|