Compare commits
20
Commits
019f4a1b53
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4e48328ee | ||
|
|
b92d9801ff | ||
|
|
8d1f91187a | ||
|
|
93e17c37c7 | ||
|
|
d54107d722 | ||
|
|
ec3ab6a25f | ||
|
|
e42fd4697f | ||
|
|
f5e49b5c53 | ||
|
|
4d393aec91 | ||
|
|
99927aea0b | ||
|
|
d96e354e63 | ||
|
|
d9eaed41e0 | ||
|
|
1a31f72dd8 | ||
|
|
4ecb0e7698 | ||
|
|
95522fdedd | ||
|
|
1a09903326 | ||
|
|
cabcc0b1d4 | ||
|
|
b11ec711c5 | ||
|
|
5af360fd15 | ||
|
|
955fdeee41 |
No files matched your search
+10
@@ -22,6 +22,16 @@ COPY . .
|
|||||||
# Migrating first — as its own isolated, single-process step — means the
|
# Migrating first — as its own isolated, single-process step — means the
|
||||||
# workers only ever see an already-existing, already-stable file.
|
# workers only ever see an already-existing, already-stable file.
|
||||||
RUN mkdir -p data && npm run db:migrate
|
RUN mkdir -p data && npm run db:migrate
|
||||||
|
# A second, separate race: drizzle-kit's migrate command opens its own
|
||||||
|
# connection and leaves the file in SQLite's default (non-WAL) journal
|
||||||
|
# mode. The *first* pragma("journal_mode = WAL") call on a file is a real
|
||||||
|
# mode switch requiring a brief exclusive lock — and unlike ordinary
|
||||||
|
# statements, that specific switch doesn't reliably honor busy_timeout, so
|
||||||
|
# when all of next build's workers race to be the one performing it, one
|
||||||
|
# loses and throws SQLITE_BUSY regardless of the 30s timeout configured in
|
||||||
|
# lib/db/client.ts. Switching to WAL here, once, single-process, means
|
||||||
|
# every worker's own pragma call below is just a no-op mode check.
|
||||||
|
RUN node -e "require('better-sqlite3')('data/db.sqlite').pragma('journal_mode = WAL')"
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Explicit project name — without this, Compose derives it from the
|
||||||
|
# directory basename, which can collide with an unrelated sibling repo
|
||||||
|
# checked out under the same name on another host/path. Never omit this.
|
||||||
|
name: top-tickets
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
@@ -11,6 +16,21 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- data:/data
|
- data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
top_tickets_net:
|
||||||
|
ipv4_address: 10.201.0.10
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
|
|
||||||
|
# Explicit subnet — Docker's auto-assigned default bridge range (often
|
||||||
|
# 172.17-172.31.0.0/16) can land on the same block as a real LAN/VLAN,
|
||||||
|
# breaking routing to it from this host. Picking a private range nothing
|
||||||
|
# else here uses avoids that; see zportal (10.199.0.0/24) and kkpab
|
||||||
|
# (10.200.0.0/24) for the same pattern.
|
||||||
|
networks:
|
||||||
|
top_tickets_net:
|
||||||
|
driver: bridge
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 10.201.0.0/24
|
||||||
Generated
+586
-7
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argon2": "^0.45.1",
|
"argon2": "^0.45.1",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"framer-motion": "^12.4.7",
|
"framer-motion": "^12.4.7",
|
||||||
"html-to-text": "^10.0.0",
|
"html-to-text": "^10.0.0",
|
||||||
@@ -19,8 +20,11 @@
|
|||||||
"mailparser": "^3.9.14",
|
"mailparser": "^3.9.14",
|
||||||
"next": "16.2.12",
|
"next": "16.2.12",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
|
"otplib": "^13.4.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"sanitize-html": "^2.13.1",
|
||||||
"telegraf": "^4.16.3",
|
"telegraf": "^4.16.3",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -32,8 +36,10 @@
|
|||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/sanitize-html": "^2.16.1",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
@@ -2087,6 +2093,17 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -2131,6 +2148,56 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@otplib/core": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA=="
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/hotp": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "13.4.1",
|
||||||
|
"@otplib/uri": "13.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/plugin-base32-scure": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "13.4.1",
|
||||||
|
"@scure/base": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/plugin-crypto-noble": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "^2.2.0",
|
||||||
|
"@otplib/core": "13.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/totp": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "13.4.1",
|
||||||
|
"@otplib/hotp": "13.4.1",
|
||||||
|
"@otplib/uri": "13.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/uri": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "13.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@phc/format": {
|
"node_modules/@phc/format": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
||||||
@@ -2475,6 +2542,14 @@
|
|||||||
"integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
|
"integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@scure/base": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@selderee/plugin-htmlparser2": {
|
"node_modules/@selderee/plugin-htmlparser2": {
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
|
||||||
@@ -2861,6 +2936,15 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
@@ -2879,6 +2963,15 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/sanitize-html": {
|
||||||
|
"version": "2.16.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
|
||||||
|
"integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"htmlparser2": "^10.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/web-push": {
|
"node_modules/@types/web-push": {
|
||||||
"version": "3.6.4",
|
"version": "3.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||||
@@ -3647,11 +3740,18 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-styles": {
|
"node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
},
|
},
|
||||||
@@ -4011,6 +4111,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
||||||
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="
|
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/boolbase": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.16",
|
"version": "1.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||||
@@ -4184,6 +4289,14 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001806",
|
"version": "1.0.30001806",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||||
@@ -4244,6 +4357,46 @@
|
|||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cheerio": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
|
||||||
|
"dependencies": {
|
||||||
|
"cheerio-select": "^2.1.0",
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"encoding-sniffer": "^0.2.1",
|
||||||
|
"htmlparser2": "^10.1.0",
|
||||||
|
"parse5": "^7.3.0",
|
||||||
|
"parse5-htmlparser2-tree-adapter": "^7.1.0",
|
||||||
|
"parse5-parser-stream": "^7.1.2",
|
||||||
|
"undici": "^7.19.0",
|
||||||
|
"whatwg-mimetype": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio-select": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-select": "^5.1.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chownr": {
|
"node_modules/chownr": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||||
@@ -4254,11 +4407,20 @@
|
|||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
},
|
},
|
||||||
@@ -4269,8 +4431,7 @@
|
|||||||
"node_modules/color-name": {
|
"node_modules/color-name": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
@@ -4318,6 +4479,32 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-select": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"nth-check": "^2.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css-what": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -4397,6 +4584,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/decompress-response": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
@@ -4434,6 +4629,14 @@
|
|||||||
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/deepmerge": {
|
||||||
|
"version": "4.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
|
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/deepmerge-ts": {
|
"node_modules/deepmerge-ts": {
|
||||||
"version": "7.1.5",
|
"version": "7.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||||
@@ -4484,6 +4687,11 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
|
||||||
|
},
|
||||||
"node_modules/doctrine": {
|
"node_modules/doctrine": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||||
@@ -4728,6 +4936,29 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/encoding-sniffer": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
|
||||||
|
"dependencies": {
|
||||||
|
"iconv-lite": "^0.6.3",
|
||||||
|
"whatwg-encoding": "^3.1.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/encoding-sniffer/node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/end-of-stream": {
|
"node_modules/end-of-stream": {
|
||||||
"version": "1.4.5",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
@@ -5010,7 +5241,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
@@ -5684,6 +5914,14 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -6293,6 +6531,14 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-generator-function": {
|
"node_modules/is-generator-function": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||||
@@ -6373,6 +6619,14 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-plain-object": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-regex": {
|
"node_modules/is-regex": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||||
@@ -7404,6 +7658,17 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nth-check": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -7552,6 +7817,19 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/otplib": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "13.4.1",
|
||||||
|
"@otplib/hotp": "13.4.1",
|
||||||
|
"@otplib/plugin-base32-scure": "13.4.1",
|
||||||
|
"@otplib/plugin-crypto-noble": "13.4.1",
|
||||||
|
"@otplib/totp": "13.4.1",
|
||||||
|
"@otplib/uri": "13.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/own-keys": {
|
"node_modules/own-keys": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
|
||||||
@@ -7608,6 +7886,14 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -7620,6 +7906,56 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse-srcset": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="
|
||||||
|
},
|
||||||
|
"node_modules/parse5": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
|
||||||
|
"dependencies": {
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"parse5": "^7.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-parser-stream": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
|
||||||
|
"dependencies": {
|
||||||
|
"parse5": "^7.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5/node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseley": {
|
"node_modules/parseley": {
|
||||||
"version": "0.13.1",
|
"version": "0.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz",
|
||||||
@@ -7636,7 +7972,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -7729,6 +8064,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
|
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -7742,7 +8085,6 @@
|
|||||||
"version": "8.5.23",
|
"version": "8.5.23",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||||
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -7861,6 +8203,22 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -7996,6 +8354,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.7",
|
"version": "2.0.0-next.7",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||||
@@ -8214,6 +8585,37 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sanitize-html": {
|
||||||
|
"version": "2.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.13.1.tgz",
|
||||||
|
"integrity": "sha512-ZXtKq89oue4RP7abL9wp/9URJcqQNABB5GGJ2acW1sdO8JTVl92f4ygD7Yc9Ze09VAZhnt2zegeU0tbNsdcLYg==",
|
||||||
|
"dependencies": {
|
||||||
|
"deepmerge": "^4.2.2",
|
||||||
|
"escape-string-regexp": "^4.0.0",
|
||||||
|
"htmlparser2": "^8.0.0",
|
||||||
|
"is-plain-object": "^5.0.0",
|
||||||
|
"parse-srcset": "^1.0.2",
|
||||||
|
"postcss": "^8.3.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sanitize-html/node_modules/htmlparser2": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"entities": "^4.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -8239,6 +8641,11 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
|
||||||
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@@ -8585,6 +8992,24 @@
|
|||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
|
||||||
|
},
|
||||||
"node_modules/string.prototype.includes": {
|
"node_modules/string.prototype.includes": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||||
@@ -8693,6 +9118,17 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-bom": {
|
"node_modules/strip-bom": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||||
@@ -9641,6 +10077,14 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "7.29.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||||
|
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
@@ -9997,6 +10441,37 @@
|
|||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-encoding": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||||
|
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||||
|
"dependencies": {
|
||||||
|
"iconv-lite": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/whatwg-url": {
|
"node_modules/whatwg-url": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
@@ -10084,6 +10559,11 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
|
||||||
|
},
|
||||||
"node_modules/which-typed-array": {
|
"node_modules/which-typed-array": {
|
||||||
"version": "1.1.22",
|
"version": "1.1.22",
|
||||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
|
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
|
||||||
@@ -10130,17 +10610,116 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrappy": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argon2": "^0.45.1",
|
"argon2": "^0.45.1",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"framer-motion": "^12.4.7",
|
"framer-motion": "^12.4.7",
|
||||||
"html-to-text": "^10.0.0",
|
"html-to-text": "^10.0.0",
|
||||||
@@ -25,8 +26,11 @@
|
|||||||
"mailparser": "^3.9.14",
|
"mailparser": "^3.9.14",
|
||||||
"next": "16.2.12",
|
"next": "16.2.12",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
|
"otplib": "^13.4.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"sanitize-html": "^2.13.1",
|
||||||
"telegraf": "^4.16.3",
|
"telegraf": "^4.16.3",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -38,8 +42,10 @@
|
|||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/sanitize-html": "^2.16.1",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "top-tickets",
|
||||||
|
"short_name": "top-tickets",
|
||||||
|
"description": "Helpdesk for support@top-sysops.ru, Telegram, and the web.",
|
||||||
|
"start_url": "/dashboard",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f8f7fb",
|
||||||
|
"theme_color": "#6d28d9",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,23 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { X } from "lucide-react";
|
import { Paperclip, X } from "lucide-react";
|
||||||
|
|
||||||
export function NewTicketModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
export function NewTicketModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
const [subject, setSubject] = useState("");
|
const [subject, setSubject] = useState("");
|
||||||
const [customerName, setCustomerName] = useState("");
|
const [customerName, setCustomerName] = useState("");
|
||||||
const [customerEmail, setCustomerEmail] = useState("");
|
const [customerEmail, setCustomerEmail] = useState("");
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
setSubject("");
|
setSubject("");
|
||||||
setCustomerName("");
|
setCustomerName("");
|
||||||
setCustomerEmail("");
|
setCustomerEmail("");
|
||||||
setBody("");
|
setBody("");
|
||||||
|
setPendingFile(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
@@ -25,16 +29,14 @@ export function NewTicketModal({ open, onClose }: { open: boolean; onClose: () =
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await fetch("/api/tickets", {
|
const formData = new FormData();
|
||||||
method: "POST",
|
formData.set("subject", subject);
|
||||||
headers: { "Content-Type": "application/json" },
|
formData.set("customerName", customerName);
|
||||||
body: JSON.stringify({
|
if (customerEmail) formData.set("customerEmail", customerEmail);
|
||||||
subject,
|
formData.set("body", body);
|
||||||
customerName,
|
if (pendingFile) formData.set("file", pendingFile);
|
||||||
customerEmail: customerEmail || undefined,
|
|
||||||
body,
|
const res = await fetch("/api/tickets", { method: "POST", body: formData });
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -104,17 +106,56 @@ export function NewTicketModal({ open, onClose }: { open: boolean; onClose: () =
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="mb-4 block text-sm">
|
<label className="mb-1.5 block text-sm">
|
||||||
<span className="mb-1 block font-medium text-text-muted">Сообщение</span>
|
<span className="mb-1 block font-medium text-text-muted">Сообщение</span>
|
||||||
<textarea
|
<textarea
|
||||||
required
|
required
|
||||||
rows={4}
|
rows={4}
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
onPaste={(e) => {
|
||||||
|
// Same Ctrl+V screenshot support as the reply box on an
|
||||||
|
// existing ticket — see ticket-thread.tsx.
|
||||||
|
const item = Array.from(e.clipboardData.items).find((i) => i.kind === "file" && i.type.startsWith("image/"));
|
||||||
|
if (!item) return;
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (!file) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const extension = item.type.split("/")[1] ?? "png";
|
||||||
|
setPendingFile(new File([file], `screenshot-${Date.now()}.${extension}`, { type: item.type }));
|
||||||
|
}}
|
||||||
className="w-full resize-none rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
className="w-full resize-none rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => setPendingFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
{pendingFile ? (
|
||||||
|
<div className="flex flex-1 items-center gap-2 rounded-md border border-border bg-surface-hover px-2.5 py-1.5 text-xs">
|
||||||
|
<Paperclip size={12} />
|
||||||
|
<span className="flex-1 truncate">{pendingFile.name}</span>
|
||||||
|
<button type="button" onClick={() => setPendingFile(null)} className="text-text-muted hover:text-text">
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
title="Прикрепить файл"
|
||||||
|
>
|
||||||
|
<Paperclip size={14} />
|
||||||
|
Прикрепить файл
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
|
||||||
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
|
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getCurrentSession } from "@/lib/auth/session";
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
import { listTickets } from "@/lib/tickets/service";
|
import { listTickets, listTags } from "@/lib/tickets/service";
|
||||||
import { TicketBoard } from "./ticket-board";
|
import { TicketBoard } from "./ticket-board";
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
@@ -8,6 +8,6 @@ export default async function DashboardPage() {
|
|||||||
if (!session) redirect("/login");
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
const currentUser = { id: session.user.id, role: session.user.role };
|
const currentUser = { id: session.user.id, role: session.user.role };
|
||||||
const tickets = await listTickets(undefined, currentUser);
|
const [tickets, tags] = await Promise.all([listTickets(undefined, currentUser), listTags()]);
|
||||||
return <TicketBoard initialTickets={tickets} currentUser={currentUser} />;
|
return <TicketBoard initialTickets={tickets} allTags={tags} currentUser={currentUser} />;
|
||||||
}
|
}
|
||||||
@@ -3,10 +3,10 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { Send, Globe, UserRound, Mail, MessageSquare, Plus, Inbox, Search, X } from "lucide-react";
|
import { Send, Globe, UserRound, Mail, MessageSquare, Plus, Inbox, Search, X, Check } from "lucide-react";
|
||||||
import { useTicketEvents } from "@/lib/events/use-ticket-events";
|
import { useTicketEvents } from "@/lib/events/use-ticket-events";
|
||||||
import { formatRelativeTime } from "@/lib/format";
|
import { formatRelativeTime } from "@/lib/format";
|
||||||
import type { TicketDTO, TicketStatus } from "@/lib/tickets/types";
|
import type { TicketDTO, TicketStatus, TagDTO } from "@/lib/tickets/types";
|
||||||
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
|
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
|
||||||
import { NewTicketModal } from "./new-ticket-modal";
|
import { NewTicketModal } from "./new-ticket-modal";
|
||||||
import { Avatar } from "@/components/avatar";
|
import { Avatar } from "@/components/avatar";
|
||||||
@@ -45,9 +45,11 @@ const PRIORITY_COLOR: Record<TicketDTO["priority"], string> = {
|
|||||||
|
|
||||||
export function TicketBoard({
|
export function TicketBoard({
|
||||||
initialTickets,
|
initialTickets,
|
||||||
|
allTags,
|
||||||
currentUser,
|
currentUser,
|
||||||
}: {
|
}: {
|
||||||
initialTickets: TicketDTO[];
|
initialTickets: TicketDTO[];
|
||||||
|
allTags: TagDTO[];
|
||||||
currentUser: { id: string; role: "admin" | "agent" };
|
currentUser: { id: string; role: "admin" | "agent" };
|
||||||
}) {
|
}) {
|
||||||
const [tickets, setTickets] = useState<TicketDTO[]>(initialTickets);
|
const [tickets, setTickets] = useState<TicketDTO[]>(initialTickets);
|
||||||
@@ -56,6 +58,41 @@ export function TicketBoard({
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [searchResults, setSearchResults] = useState<TicketDTO[] | null>(null);
|
const [searchResults, setSearchResults] = useState<TicketDTO[] | null>(null);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [bulkUpdating, setBulkUpdating] = useState(false);
|
||||||
|
const [bulkError, setBulkError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const toggleSelected = (id: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelection = () => setSelected(new Set());
|
||||||
|
|
||||||
|
const applyBulkStatus = async (status: TicketStatus) => {
|
||||||
|
setBulkUpdating(true);
|
||||||
|
setBulkError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/tickets/bulk-status", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ticketIds: Array.from(selected), status }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
// The board's own SSE subscription (useTicketEvents above) already
|
||||||
|
// applies each updated ticket as its own ticket.updated event — no
|
||||||
|
// need to patch local state here too.
|
||||||
|
clearSelection();
|
||||||
|
} catch {
|
||||||
|
setBulkError("Не удалось изменить статус — попробуйте ещё раз");
|
||||||
|
} finally {
|
||||||
|
setBulkUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useTicketEvents("/api/events", (event) => {
|
useTicketEvents("/api/events", (event) => {
|
||||||
if (event.type === "ticket.created" || event.type === "ticket.updated") {
|
if (event.type === "ticket.created" || event.type === "ticket.updated") {
|
||||||
@@ -100,13 +137,11 @@ export function TicketBoard({
|
|||||||
|
|
||||||
const baseTickets = query.trim() ? (searchResults ?? tickets) : tickets;
|
const baseTickets = query.trim() ? (searchResults ?? tickets) : tickets;
|
||||||
|
|
||||||
const availableTags = useMemo(() => {
|
// Every tag that exists, not just ones already applied to a currently
|
||||||
const map = new Map<string, TicketDTO["tags"][number]>();
|
// loaded ticket — otherwise a freshly created tag has nothing to filter
|
||||||
for (const ticket of baseTickets) {
|
// by until someone happens to tag a ticket with it. allTags is already
|
||||||
for (const tag of ticket.tags) map.set(tag.id, tag);
|
// name-sorted server-side (listTags orders by tagsTable.name).
|
||||||
}
|
const availableTags = allTags;
|
||||||
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
}, [baseTickets]);
|
|
||||||
|
|
||||||
const filteredTickets = useMemo(() => {
|
const filteredTickets = useMemo(() => {
|
||||||
if (!activeTagId) return baseTickets;
|
if (!activeTagId) return baseTickets;
|
||||||
@@ -186,7 +221,12 @@ export function TicketBoard({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{byStatus[col.status].map((ticket) => (
|
{byStatus[col.status].map((ticket) => (
|
||||||
<TicketCard key={ticket.id} ticket={ticket} />
|
<TicketCard
|
||||||
|
key={ticket.id}
|
||||||
|
ticket={ticket}
|
||||||
|
selected={selected.has(ticket.id)}
|
||||||
|
onToggleSelect={toggleSelected}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
{byStatus[col.status].length === 0 && (
|
{byStatus[col.status].length === 0 && (
|
||||||
@@ -201,11 +241,64 @@ export function TicketBoard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NewTicketModal open={modalOpen} onClose={() => setModalOpen(false)} />
|
<NewTicketModal open={modalOpen} onClose={() => setModalOpen(false)} />
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 16 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="fixed inset-x-0 bottom-4 z-30 flex justify-center px-4"
|
||||||
|
>
|
||||||
|
<div className="card flex flex-wrap items-center gap-2 px-3 py-2 shadow-lg">
|
||||||
|
<span className="mr-1 text-sm font-medium">Выбрано: {selected.size}</span>
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-success-soft px-2.5 py-1.5 text-xs font-medium text-success-soft-text transition-opacity hover:opacity-80 disabled:opacity-50"
|
||||||
|
disabled={bulkUpdating}
|
||||||
|
onClick={() => applyBulkStatus("open")}
|
||||||
|
>
|
||||||
|
Открыть
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-warning-soft px-2.5 py-1.5 text-xs font-medium text-warning-soft-text transition-opacity hover:opacity-80 disabled:opacity-50"
|
||||||
|
disabled={bulkUpdating}
|
||||||
|
onClick={() => applyBulkStatus("pending")}
|
||||||
|
>
|
||||||
|
В ожидание
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-danger-soft px-2.5 py-1.5 text-xs font-medium text-danger-soft-text transition-opacity hover:opacity-80 disabled:opacity-50"
|
||||||
|
disabled={bulkUpdating}
|
||||||
|
onClick={() => applyBulkStatus("closed")}
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md px-2.5 py-1.5 text-xs font-medium text-text-faint hover:text-text disabled:opacity-50"
|
||||||
|
disabled={bulkUpdating}
|
||||||
|
onClick={clearSelection}
|
||||||
|
>
|
||||||
|
Снять выделение
|
||||||
|
</button>
|
||||||
|
{bulkError && <span className="w-full text-xs text-danger-soft-text">{bulkError}</span>}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TicketCard({ ticket }: { ticket: TicketDTO }) {
|
function TicketCard({
|
||||||
|
ticket,
|
||||||
|
selected,
|
||||||
|
onToggleSelect,
|
||||||
|
}: {
|
||||||
|
ticket: TicketDTO;
|
||||||
|
selected: boolean;
|
||||||
|
onToggleSelect: (id: string) => void;
|
||||||
|
}) {
|
||||||
const Icon = CHANNEL_ICON[ticket.channel];
|
const Icon = CHANNEL_ICON[ticket.channel];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -218,9 +311,27 @@ function TicketCard({ ticket }: { ticket: TicketDTO }) {
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/tickets/${ticket.id}`}
|
href={`/tickets/${ticket.id}`}
|
||||||
className="card block p-3 shadow-sm transition-shadow hover:shadow-md"
|
className={`card relative block p-3 pr-9 shadow-sm transition-shadow hover:shadow-md ${
|
||||||
|
selected ? "ring-2 ring-accent" : ""
|
||||||
|
}`}
|
||||||
style={{ borderLeft: `3px solid ${PRIORITY_COLOR[ticket.priority]}` }}
|
style={{ borderLeft: `3px solid ${PRIORITY_COLOR[ticket.priority]}` }}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleSelect(ticket.id);
|
||||||
|
}}
|
||||||
|
aria-label={selected ? "Снять выделение с заявки" : "Выбрать заявку"}
|
||||||
|
className={`absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full border transition-colors ${
|
||||||
|
selected
|
||||||
|
? "border-accent bg-accent text-white"
|
||||||
|
: "border-border bg-surface text-transparent hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Check size={12} />
|
||||||
|
</button>
|
||||||
<p className="mb-1 line-clamp-2 text-sm font-medium">{ticket.subject}</p>
|
<p className="mb-1 line-clamp-2 text-sm font-medium">{ticket.subject}</p>
|
||||||
{ticket.tags.length > 0 && (
|
{ticket.tags.length > 0 && (
|
||||||
<div className="mb-2 flex flex-wrap gap-1">
|
<div className="mb-2 flex flex-wrap gap-1">
|
||||||
@@ -243,6 +354,17 @@ function TicketCard({ ticket }: { ticket: TicketDTO }) {
|
|||||||
<span className="text-xs text-text-faint">{formatRelativeTime(ticket.lastMessageAt)}</span>
|
<span className="text-xs text-text-faint">{formatRelativeTime(ticket.lastMessageAt)}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 border-t border-border pt-1.5">
|
||||||
|
{ticket.assigneeName ? (
|
||||||
|
<>
|
||||||
|
<Avatar name={ticket.assigneeName} size={16} />
|
||||||
|
<span className="truncate text-xs text-text-muted">{ticket.assigneeName}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-text-faint">Не назначено</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
|||||||
import { Ticket } from "lucide-react";
|
import { Ticket } from "lucide-react";
|
||||||
import { getCurrentSession } from "@/lib/auth/session";
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
import { LogoutButton } from "./logout-button";
|
import { LogoutButton } from "./logout-button";
|
||||||
import { NavLinks } from "./nav-links";
|
import { NavShell } from "./nav-shell";
|
||||||
import { ThemeToggle } from "./theme-toggle";
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
import { SettingsMenu } from "./settings-menu";
|
import { SettingsMenu } from "./settings-menu";
|
||||||
import { NotificationToggle } from "./notification-toggle";
|
import { NotificationToggle } from "./notification-toggle";
|
||||||
@@ -21,18 +21,18 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-bg">
|
<div className="flex min-h-screen flex-col bg-bg">
|
||||||
<header className="border-b border-border bg-surface">
|
<header className="border-b border-border bg-surface pt-[env(safe-area-inset-top)]">
|
||||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3 sm:px-6">
|
<div className="mx-auto flex max-w-[1800px] items-center gap-3 px-4 py-2.5 sm:gap-6 sm:px-6 sm:py-3">
|
||||||
<Link href="/dashboard" className="flex items-center gap-2 font-display text-[15px] font-bold tracking-tight">
|
<Link href="/dashboard" className="flex shrink-0 items-center gap-2 font-display text-[15px] font-bold tracking-tight">
|
||||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-accent text-white">
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-accent text-white">
|
||||||
<Ticket size={15} strokeWidth={2.5} />
|
<Ticket size={15} strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
top-tickets
|
<span className="hidden sm:inline">top-tickets</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="order-last w-full overflow-x-auto sm:order-none sm:w-auto sm:flex-1 sm:overflow-visible sm:flex sm:justify-center">
|
<div className="flex flex-1 justify-center overflow-x-auto">
|
||||||
<NavLinks isAdmin={isAdmin} />
|
<NavShell isAdmin={isAdmin} />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-2 sm:ml-0 sm:gap-3">
|
<div className="ml-auto flex shrink-0 items-center gap-2 sm:ml-0 sm:gap-3">
|
||||||
<NotificationToggle />
|
<NotificationToggle />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{isAdmin && <SettingsMenu isAdmin={isAdmin} />}
|
{isAdmin && <SettingsMenu isAdmin={isAdmin} />}
|
||||||
@@ -41,7 +41,10 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-6 sm:px-6">{children}</main>
|
{/* pb: clears the fixed mobile bottom-tab bar (~52px + safe-area) so it never covers the last bit of scrolled content */}
|
||||||
|
<main className="mx-auto w-full max-w-[1800px] flex-1 px-4 py-6 pb-[calc(4.25rem+env(safe-area-inset-bottom))] sm:px-6 lg:pb-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
<DesktopNotifications currentUser={currentUser} />
|
<DesktopNotifications currentUser={currentUser} />
|
||||||
<TicketToasts currentUser={currentUser} />
|
<TicketToasts currentUser={currentUser} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { links } from "./nav-data";
|
||||||
|
|
||||||
|
/** Bottom tab bar for mobile — mirrors top-buh/top-finance's pattern. Only 2 links here, both fit as primary tabs, no overflow "Ещё" needed. */
|
||||||
|
export function MobileNav({ isAdmin }: { isAdmin: boolean }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const visibleLinks = links.filter((link) => !link.adminOnly || isAdmin);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className="fixed inset-x-0 bottom-0 z-30 flex border-t border-border bg-surface pb-[env(safe-area-inset-bottom)] lg:hidden"
|
||||||
|
aria-label="Основная навигация"
|
||||||
|
>
|
||||||
|
{visibleLinks.map(({ href, label, icon: Icon }) => {
|
||||||
|
const active = pathname.startsWith(href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={`flex flex-1 flex-col items-center gap-0.5 py-2 text-[11px] font-medium ${
|
||||||
|
active ? "text-accent" : "text-text-faint"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { LayoutDashboard, BarChart3 } from "lucide-react";
|
||||||
|
|
||||||
|
export interface NavLink {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: typeof LayoutDashboard;
|
||||||
|
adminOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const links: NavLink[] = [
|
||||||
|
{ href: "/dashboard", label: "Заявки", icon: LayoutDashboard, adminOnly: false },
|
||||||
|
{ href: "/stats", label: "Статистика", icon: BarChart3, adminOnly: true },
|
||||||
|
];
|
||||||
@@ -2,12 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { LayoutDashboard, BarChart3 } from "lucide-react";
|
import { links } from "./nav-data";
|
||||||
|
|
||||||
const links = [
|
|
||||||
{ href: "/dashboard", label: "Заявки", icon: LayoutDashboard, adminOnly: false },
|
|
||||||
{ href: "/stats", label: "Статистика", icon: BarChart3, adminOnly: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function NavLinks({ isAdmin }: { isAdmin: boolean }) {
|
export function NavLinks({ isAdmin }: { isAdmin: boolean }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { NavLinks } from "./nav-links";
|
||||||
|
import { MobileNav } from "./mobile-nav";
|
||||||
|
|
||||||
|
/** Desktop top nav (>=lg) + mobile bottom tab bar (<lg), same split as top-buh/top-finance/top-reservation. */
|
||||||
|
export function NavShell({ isAdmin }: { isAdmin: boolean }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="hidden lg:flex">
|
||||||
|
<NavLinks isAdmin={isAdmin} />
|
||||||
|
</div>
|
||||||
|
<MobileNav isAdmin={isAdmin} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { getCurrentSession } from "@/lib/auth/session";
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
import { ChangePasswordForm } from "./change-password-form";
|
import { ChangePasswordForm } from "./change-password-form";
|
||||||
|
import { TwoFactorSettings } from "./two-factor-settings";
|
||||||
|
import { PushSubscriptionsSettings } from "./push-subscriptions-settings";
|
||||||
|
|
||||||
export default async function AccountSettingsPage() {
|
export default async function AccountSettingsPage() {
|
||||||
const session = await getCurrentSession();
|
const session = await getCurrentSession();
|
||||||
@@ -9,6 +11,14 @@ export default async function AccountSettingsPage() {
|
|||||||
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Аккаунт</h1>
|
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Аккаунт</h1>
|
||||||
<p className="mb-6 text-sm text-text-muted">{session?.user.email}</p>
|
<p className="mb-6 text-sm text-text-muted">{session?.user.email}</p>
|
||||||
<ChangePasswordForm />
|
<ChangePasswordForm />
|
||||||
|
{session && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<TwoFactorSettings initialEnabled={session.user.totpEnabled} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-4">
|
||||||
|
<PushSubscriptionsSettings />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { BellOff, Loader2 } from "lucide-react";
|
||||||
|
import { formatRelativeTime } from "@/lib/format";
|
||||||
|
|
||||||
|
type Subscription = { id: string; userAgent: string | null; createdAt: string };
|
||||||
|
|
||||||
|
/** Best-effort "Chrome on Windows"-style label from a stored navigator.userAgent — purely cosmetic, never parsed for anything security-relevant. */
|
||||||
|
function describeUserAgent(userAgent: string | null): string {
|
||||||
|
if (!userAgent) return "Неизвестное устройство";
|
||||||
|
|
||||||
|
let browser = "Браузер";
|
||||||
|
if (/Edg\//.test(userAgent)) browser = "Edge";
|
||||||
|
else if (/OPR\//.test(userAgent)) browser = "Opera";
|
||||||
|
else if (/Chrome\//.test(userAgent)) browser = "Chrome";
|
||||||
|
else if (/Firefox\//.test(userAgent)) browser = "Firefox";
|
||||||
|
else if (/Safari\//.test(userAgent)) browser = "Safari";
|
||||||
|
|
||||||
|
let os = "";
|
||||||
|
if (/Windows/.test(userAgent)) os = "Windows";
|
||||||
|
else if (/Android/.test(userAgent)) os = "Android";
|
||||||
|
else if (/iPhone|iPad/.test(userAgent)) os = "iOS";
|
||||||
|
else if (/Macintosh|Mac OS X/.test(userAgent)) os = "macOS";
|
||||||
|
else if (/Linux/.test(userAgent)) os = "Linux";
|
||||||
|
|
||||||
|
return os ? `${browser} на ${os}` : browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PushSubscriptionsSettings() {
|
||||||
|
const [subscriptions, setSubscriptions] = useState<Subscription[] | null>(null);
|
||||||
|
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/push/subscriptions")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => setSubscriptions(data.subscriptions ?? []))
|
||||||
|
.catch(() => setError("Не удалось загрузить список устройств"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function unsubscribe(id: string) {
|
||||||
|
setRemovingId(id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/push/subscriptions/${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
setSubscriptions((prev) => (prev ? prev.filter((s) => s.id !== id) : prev));
|
||||||
|
} catch {
|
||||||
|
setError("Не удалось отписать устройство — попробуйте ещё раз");
|
||||||
|
} finally {
|
||||||
|
setRemovingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<p className="mb-1 text-sm font-semibold">Push-уведомления</p>
|
||||||
|
<p className="mb-3 text-sm text-text-muted">
|
||||||
|
Устройства/браузеры, подписанные на уведомления этого аккаунта. Если один и тот же браузер получает
|
||||||
|
уведомление дважды или трижды, скорее всего в нескольких браузерах на этом компьютере отдельно разрешены
|
||||||
|
уведомления для сайта — отпишите лишние здесь.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
|
||||||
|
{subscriptions === null ? (
|
||||||
|
<p className="flex items-center gap-1.5 text-sm text-text-faint">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
Загрузка…
|
||||||
|
</p>
|
||||||
|
) : subscriptions.length === 0 ? (
|
||||||
|
<p className="text-sm text-text-faint">Нет подписанных устройств.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{subscriptions.map((sub) => (
|
||||||
|
<li
|
||||||
|
key={sub.id}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md border border-border px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm">{describeUserAgent(sub.userAgent)}</p>
|
||||||
|
<p className="text-xs text-text-faint">Подписан {formatRelativeTime(new Date(sub.createdAt).getTime())}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={removingId === sub.id}
|
||||||
|
onClick={() => unsubscribe(sub.id)}
|
||||||
|
className="btn btn-ghost shrink-0 px-2.5 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<BellOff size={13} />
|
||||||
|
Отписать
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ShieldCheck, ShieldOff, Copy, Check } from "lucide-react";
|
||||||
|
|
||||||
|
type Stage = "idle" | "setting-up" | "recovery-codes" | "disabling";
|
||||||
|
|
||||||
|
export function TwoFactorSettings({ initialEnabled }: { initialEnabled: boolean }) {
|
||||||
|
const [enabled, setEnabled] = useState(initialEnabled);
|
||||||
|
const [stage, setStage] = useState<Stage>("idle");
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||||
|
const [secret, setSecret] = useState<string | null>(null);
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [disableCode, setDisableCode] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setStage("idle");
|
||||||
|
setQrDataUrl(null);
|
||||||
|
setSecret(null);
|
||||||
|
setCode("");
|
||||||
|
setDisableCode("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSetup() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch("/api/auth/totp/setup", { method: "POST" });
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось начать настройку 2FA");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setQrDataUrl(data.qrDataUrl);
|
||||||
|
setSecret(data.secret);
|
||||||
|
setStage("setting-up");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmSetup(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch("/api/auth/totp/confirm", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось подтвердить код");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setRecoveryCodes(data.recoveryCodes);
|
||||||
|
setEnabled(true);
|
||||||
|
setStage("recovery-codes");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disable2fa(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch("/api/auth/totp/disable", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code: disableCode }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось отключить 2FA");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEnabled(false);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyRecoveryCodes() {
|
||||||
|
if (!recoveryCodes) return;
|
||||||
|
navigator.clipboard.writeText(recoveryCodes.join("\n"));
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === "recovery-codes" && recoveryCodes) {
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<p className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-success-soft-text">
|
||||||
|
<ShieldCheck size={15} />
|
||||||
|
2FA включена
|
||||||
|
</p>
|
||||||
|
<p className="mb-3 text-sm text-text-muted">
|
||||||
|
Сохраните эти резервные коды в надёжном месте — каждый работает один раз, если телефон с приложением
|
||||||
|
потеряется. Второй раз их показать не получится.
|
||||||
|
</p>
|
||||||
|
<pre className="mb-3 whitespace-pre-wrap rounded-md border border-border bg-surface p-3 text-sm">
|
||||||
|
{recoveryCodes.join("\n")}
|
||||||
|
</pre>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={copyRecoveryCodes} className="btn btn-ghost">
|
||||||
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
{copied ? "Скопировано" : "Скопировать"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={reset} className="btn btn-primary flex-1 justify-center">
|
||||||
|
Готово
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === "setting-up") {
|
||||||
|
return (
|
||||||
|
<form onSubmit={confirmSetup} className="card p-4">
|
||||||
|
<p className="mb-3 text-sm font-semibold">Настройка 2FA</p>
|
||||||
|
<p className="mb-3 text-sm text-text-muted">
|
||||||
|
Отсканируйте QR-код в приложении-аутентификаторе (Google Authenticator, Authy и т.п.) и введите код, чтобы
|
||||||
|
подтвердить.
|
||||||
|
</p>
|
||||||
|
{qrDataUrl && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- a locally generated data: URI QR code, not worth next/image config
|
||||||
|
<img src={qrDataUrl} alt="QR-код для настройки 2FA" className="mb-3 h-40 w-40" />
|
||||||
|
)}
|
||||||
|
{secret && (
|
||||||
|
<p className="mb-3 break-all rounded-md border border-border bg-surface px-2 py-1.5 font-mono text-xs text-text-muted">
|
||||||
|
{secret}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Код из приложения</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="\d{6}"
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={reset} className="btn btn-ghost">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary flex-1 justify-center">
|
||||||
|
Подтвердить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === "disabling") {
|
||||||
|
return (
|
||||||
|
<form onSubmit={disable2fa} className="card p-4">
|
||||||
|
<p className="mb-3 text-sm font-semibold">Отключить 2FA</p>
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Код из приложения или резервный код</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={disableCode}
|
||||||
|
onChange={(e) => setDisableCode(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={reset} className="btn btn-ghost">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={loading} className="btn bg-danger text-white hover:brightness-95 flex-1 justify-center">
|
||||||
|
Отключить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<p className="mb-1 flex items-center gap-1.5 text-sm font-semibold">
|
||||||
|
{enabled ? <ShieldCheck size={15} className="text-success-soft-text" /> : <ShieldOff size={15} className="text-text-faint" />}
|
||||||
|
Двухфакторная аутентификация
|
||||||
|
</p>
|
||||||
|
<p className="mb-3 text-sm text-text-muted">
|
||||||
|
{enabled ? "Включена — при входе потребуется код из приложения." : "Не включена."}
|
||||||
|
</p>
|
||||||
|
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
{enabled ? (
|
||||||
|
<button type="button" onClick={() => setStage("disabling")} className="btn btn-ghost w-full justify-center">
|
||||||
|
Отключить
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={startSetup} disabled={loading} className="btn btn-primary w-full justify-center">
|
||||||
|
Включить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ interface Status {
|
|||||||
user: string | null;
|
user: string | null;
|
||||||
imapHost: string | null;
|
imapHost: string | null;
|
||||||
allowInsecureTls: boolean;
|
allowInsecureTls: boolean;
|
||||||
|
fromAddress: string | null;
|
||||||
verifiedAt: number | null;
|
verifiedAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }
|
|||||||
const [smtpPort, setSmtpPort] = useState("587");
|
const [smtpPort, setSmtpPort] = useState("587");
|
||||||
const [user, setUser] = useState(initialStatus.user ?? "");
|
const [user, setUser] = useState(initialStatus.user ?? "");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [fromAddress, setFromAddress] = useState(initialStatus.fromAddress ?? "");
|
||||||
const [allowInsecureTls, setAllowInsecureTls] = useState(true);
|
const [allowInsecureTls, setAllowInsecureTls] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -38,6 +40,7 @@ export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }
|
|||||||
user,
|
user,
|
||||||
password,
|
password,
|
||||||
allowInsecureTls,
|
allowInsecureTls,
|
||||||
|
fromAddress,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,7 +51,15 @@ export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus({ configured: true, enabled: true, user, imapHost: host, allowInsecureTls, verifiedAt: Date.now() });
|
setStatus({
|
||||||
|
configured: true,
|
||||||
|
enabled: true,
|
||||||
|
user,
|
||||||
|
imapHost: host,
|
||||||
|
allowInsecureTls,
|
||||||
|
fromAddress: fromAddress || null,
|
||||||
|
verifiedAt: Date.now(),
|
||||||
|
});
|
||||||
setPassword("");
|
setPassword("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +142,21 @@ export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Адрес отправителя (если отличается от логина)</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={fromAddress}
|
||||||
|
onChange={(e) => setFromAddress(e.target.value)}
|
||||||
|
placeholder="support@zaglushka.ru"
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<span className="mt-1 block text-xs text-text-faint">
|
||||||
|
Нужно, если логин — служебная учётка (напр. AD UPN), а письма клиенту должны уходить от другого адреса.
|
||||||
|
Оставьте пустым, если логин и есть нужный адрес.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="mb-4 flex items-center gap-2 text-sm text-text-muted">
|
<label className="mb-4 flex items-center gap-2 text-sm text-text-muted">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Star, Trash2 } from "lucide-react";
|
||||||
import { TAG_COLOR_CLASSES, TAG_COLORS, TAG_COLOR_LABELS } from "@/lib/tags/colors";
|
import { TAG_COLOR_CLASSES, TAG_COLORS, TAG_COLOR_LABELS } from "@/lib/tags/colors";
|
||||||
import type { TagDTO, TagColor } from "@/lib/tickets/types";
|
import type { TagDTO, TagColor } from "@/lib/tickets/types";
|
||||||
|
|
||||||
@@ -40,6 +40,18 @@ export function TagsManager({ initialTags }: { initialTags: TagDTO[] }) {
|
|||||||
setTags((prev) => prev.filter((t) => t.id !== id));
|
setTags((prev) => prev.filter((t) => t.id !== id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleToggleDefault(tag: TagDTO) {
|
||||||
|
const nextIsDefault = !tag.isDefault;
|
||||||
|
const res = await fetch(`/api/tags/${tag.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ isDefault: nextIsDefault }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const { tags: updated } = await res.json();
|
||||||
|
setTags(updated);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<form onSubmit={handleCreate} className="card flex flex-col gap-3 p-4 sm:flex-row sm:items-end">
|
<form onSubmit={handleCreate} className="card flex flex-col gap-3 p-4 sm:flex-row sm:items-end">
|
||||||
@@ -77,12 +89,23 @@ export function TagsManager({ initialTags }: { initialTags: TagDTO[] }) {
|
|||||||
|
|
||||||
{tags.length === 0 && <p className="text-sm text-text-muted">Тегов пока нет.</p>}
|
{tags.length === 0 && <p className="text-sm text-text-muted">Тегов пока нет.</p>}
|
||||||
|
|
||||||
|
<p className="text-xs text-text-faint">
|
||||||
|
Тег по умолчанию отмечается звёздочкой — он автоматически ставится на каждую новую заявку.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<span
|
<span
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className={`flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium ${TAG_COLOR_CLASSES[tag.color]}`}
|
className={`flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium ${TAG_COLOR_CLASSES[tag.color]}`}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleDefault(tag)}
|
||||||
|
className="opacity-60 hover:opacity-100"
|
||||||
|
title={tag.isDefault ? "Убрать как тег по умолчанию" : "Сделать тегом по умолчанию"}
|
||||||
|
>
|
||||||
|
<Star size={12} fill={tag.isDefault ? "currentColor" : "none"} />
|
||||||
|
</button>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
<button onClick={() => handleDelete(tag.id)} className="opacity-60 hover:opacity-100">
|
<button onClick={() => handleDelete(tag.id)} className="opacity-60 hover:opacity-100">
|
||||||
<Trash2 size={12} />
|
<Trash2 size={12} />
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function TicketToasts({ currentUser }: { currentUser: { id: string; role:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2">
|
<div className="pointer-events-none fixed bottom-[calc(4.25rem+env(safe-area-inset-bottom)+0.5rem)] right-4 z-50 flex w-full max-w-sm flex-col gap-2 lg:bottom-4">
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{toasts.map((toast) => (
|
{toasts.map((toast) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { ArrowLeft, Send, StickyNote, Paperclip, X, MessageSquareText } from "lucide-react";
|
import { ArrowLeft, Send, StickyNote, Paperclip, X, MessageSquareText, TriangleAlert } from "lucide-react";
|
||||||
import { useTicketEvents } from "@/lib/events/use-ticket-events";
|
import { useTicketEvents } from "@/lib/events/use-ticket-events";
|
||||||
import { formatRelativeTime } from "@/lib/format";
|
import { formatRelativeTime } from "@/lib/format";
|
||||||
import { Avatar } from "@/components/avatar";
|
import { Avatar } from "@/components/avatar";
|
||||||
import { AttachmentChip } from "@/components/attachment-chip";
|
import { AttachmentChip } from "@/components/attachment-chip";
|
||||||
|
import { MessageBodyHtml } from "@/components/message-body-html";
|
||||||
import { TagPicker } from "./tag-picker";
|
import { TagPicker } from "./tag-picker";
|
||||||
import type { TicketDTO, MessageDTO, TicketStatus } from "@/lib/tickets/types";
|
import type { TicketDTO, MessageDTO, TicketStatus } from "@/lib/tickets/types";
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export function TicketThread({
|
|||||||
const [visibility, setVisibility] = useState<"public" | "internal">("public");
|
const [visibility, setVisibility] = useState<"public" | "internal">("public");
|
||||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const [cannedResponses, setCannedResponses] = useState<{ id: string; title: string; body: string }[]>([]);
|
const [cannedResponses, setCannedResponses] = useState<{ id: string; title: string; body: string }[]>([]);
|
||||||
const [cannedOpen, setCannedOpen] = useState(false);
|
const [cannedOpen, setCannedOpen] = useState(false);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -81,6 +83,7 @@ export function TicketThread({
|
|||||||
async function sendReply() {
|
async function sendReply() {
|
||||||
if (!draft.trim() && !pendingFile) return;
|
if (!draft.trim() && !pendingFile) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
|
setSendError(null);
|
||||||
|
|
||||||
let res: Response;
|
let res: Response;
|
||||||
if (pendingFile) {
|
if (pendingFile) {
|
||||||
@@ -102,6 +105,9 @@ export function TicketThread({
|
|||||||
setDraft("");
|
setDraft("");
|
||||||
setPendingFile(null);
|
setPendingFile(null);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setSendError(data?.error ?? "Не удалось отправить сообщение");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +215,7 @@ export function TicketThread({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{sendError && <p className="mb-1.5 text-xs text-danger-soft-text">{sendError}</p>}
|
||||||
{pendingFile && (
|
{pendingFile && (
|
||||||
<div className="mb-1.5 flex items-center gap-2 rounded-md border border-border bg-surface-hover px-2.5 py-1.5 text-xs">
|
<div className="mb-1.5 flex items-center gap-2 rounded-md border border-border bg-surface-hover px-2.5 py-1.5 text-xs">
|
||||||
<Paperclip size={12} />
|
<Paperclip size={12} />
|
||||||
@@ -234,6 +241,19 @@ export function TicketThread({
|
|||||||
sendReply();
|
sendReply();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onPaste={(e) => {
|
||||||
|
// Ctrl+V a screenshot straight from the clipboard — no need to
|
||||||
|
// save it to disk first and click "attach file". Only kicks in
|
||||||
|
// when the clipboard actually holds image data; a normal text
|
||||||
|
// paste falls through untouched.
|
||||||
|
const item = Array.from(e.clipboardData.items).find((i) => i.kind === "file" && i.type.startsWith("image/"));
|
||||||
|
if (!item) return;
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (!file) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const extension = item.type.split("/")[1] ?? "png";
|
||||||
|
setPendingFile(new File([file], `screenshot-${Date.now()}.${extension}`, { type: item.type }));
|
||||||
|
}}
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder={visibility === "internal" ? "Заметка — клиент её не увидит…" : "Ответить клиенту…"}
|
placeholder={visibility === "internal" ? "Заметка — клиент её не увидит…" : "Ответить клиенту…"}
|
||||||
className={`flex-1 resize-none rounded-md border px-3 py-2 text-sm outline-none ${
|
className={`flex-1 resize-none rounded-md border px-3 py-2 text-sm outline-none ${
|
||||||
@@ -314,8 +334,14 @@ function MessageBubble({ message }: { message: MessageDTO }) {
|
|||||||
<StickyNote size={11} />
|
<StickyNote size={11} />
|
||||||
Внутренняя заметка
|
Внутренняя заметка
|
||||||
</p>
|
</p>
|
||||||
|
{message.bodyHtml ? (
|
||||||
|
<MessageBodyHtml html={message.bodyHtml} className="mb-1" />
|
||||||
|
) : (
|
||||||
<p className="mb-1 whitespace-pre-wrap text-sm">{message.body}</p>
|
<p className="mb-1 whitespace-pre-wrap text-sm">{message.body}</p>
|
||||||
{message.attachments.map((a) => (
|
)}
|
||||||
|
{message.attachments
|
||||||
|
.filter((a) => !a.isInline)
|
||||||
|
.map((a) => (
|
||||||
<AttachmentChip key={a.id} attachment={a} />
|
<AttachmentChip key={a.id} attachment={a} />
|
||||||
))}
|
))}
|
||||||
<p className="text-[11px] text-warning-soft-text/80">
|
<p className="text-[11px] text-warning-soft-text/80">
|
||||||
@@ -334,13 +360,28 @@ function MessageBubble({ message }: { message: MessageDTO }) {
|
|||||||
className={`mb-3 flex ${isAgent ? "justify-end" : "justify-start"}`}
|
className={`mb-3 flex ${isAgent ? "justify-end" : "justify-start"}`}
|
||||||
>
|
>
|
||||||
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${isAgent ? "bg-accent text-white" : "bg-surface-hover"}`}>
|
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${isAgent ? "bg-accent text-white" : "bg-surface-hover"}`}>
|
||||||
|
{message.bodyHtml ? (
|
||||||
|
<MessageBodyHtml html={message.bodyHtml} className="mb-1" />
|
||||||
|
) : (
|
||||||
<p className="mb-1 whitespace-pre-wrap">{message.body}</p>
|
<p className="mb-1 whitespace-pre-wrap">{message.body}</p>
|
||||||
{message.attachments.map((a) => (
|
)}
|
||||||
|
{message.attachments
|
||||||
|
.filter((a) => !a.isInline)
|
||||||
|
.map((a) => (
|
||||||
<AttachmentChip key={a.id} attachment={a} />
|
<AttachmentChip key={a.id} attachment={a} />
|
||||||
))}
|
))}
|
||||||
<p className={`text-[11px] ${isAgent ? "text-white/70" : "text-text-faint"}`}>
|
<p className={`text-[11px] ${isAgent ? "text-white/70" : "text-text-faint"}`}>
|
||||||
{message.authorName} · {formatRelativeTime(message.createdAt)}
|
{message.authorName} · {formatRelativeTime(message.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
|
{message.deliveryFailed && (
|
||||||
|
<p
|
||||||
|
className="mt-1 flex items-center gap-1 text-[11px] font-medium text-danger"
|
||||||
|
title="Сообщение сохранено, но не дошло до клиента — проверьте настройки почты/Telegram"
|
||||||
|
>
|
||||||
|
<TriangleAlert size={11} />
|
||||||
|
Не доставлено клиенту
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Ticket, LogIn } from "lucide-react";
|
import { Ticket, LogIn, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [totpRequired, setTotpRequired] = useState(false);
|
||||||
|
const [code, setCode] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -30,6 +32,36 @@ export default function LoginPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (data.totpRequired) {
|
||||||
|
setTotpRequired(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerifyTotp(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch("/api/auth/verify-totp", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось войти");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
@@ -41,6 +73,51 @@ export default function LoginPage() {
|
|||||||
background: "radial-gradient(ellipse 60% 50% at 50% -10%, var(--accent-soft), var(--bg) 70%)",
|
background: "radial-gradient(ellipse 60% 50% at 50% -10%, var(--accent-soft), var(--bg) 70%)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{totpRequired ? (
|
||||||
|
<motion.form
|
||||||
|
onSubmit={handleVerifyTotp}
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||||
|
className="card w-full max-w-sm p-8"
|
||||||
|
>
|
||||||
|
<div className="mb-6 flex items-center gap-2">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-accent text-white">
|
||||||
|
<ShieldCheck size={18} strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<span className="font-display text-lg font-bold tracking-tight">top-tickets</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="mb-1 font-display text-xl font-semibold">Двухфакторная аутентификация</h1>
|
||||||
|
<p className="mb-6 text-sm text-text-muted">Введите код из приложения-аутентификатора или резервный код.</p>
|
||||||
|
|
||||||
|
<label className="mb-5 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Код</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="mb-4 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
|
||||||
|
<LogIn size={16} />
|
||||||
|
{loading ? "Проверяем…" : "Войти"}
|
||||||
|
</button>
|
||||||
|
</motion.form>
|
||||||
|
) : (
|
||||||
<motion.form
|
<motion.form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -96,6 +173,7 @@ export default function LoginPage() {
|
|||||||
{loading ? "Входим…" : "Войти"}
|
{loading ? "Входим…" : "Войти"}
|
||||||
</button>
|
</button>
|
||||||
</motion.form>
|
</motion.form>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import { ArrowLeft, Send, Paperclip, X } from "lucide-react";
|
|||||||
import { useTicketEvents } from "@/lib/events/use-ticket-events";
|
import { useTicketEvents } from "@/lib/events/use-ticket-events";
|
||||||
import { formatRelativeTime } from "@/lib/format";
|
import { formatRelativeTime } from "@/lib/format";
|
||||||
import { AttachmentChip } from "@/components/attachment-chip";
|
import { AttachmentChip } from "@/components/attachment-chip";
|
||||||
|
import { MessageBodyHtml } from "@/components/message-body-html";
|
||||||
import type { TicketDTO, MessageDTO } from "@/lib/tickets/types";
|
import type { TicketDTO, MessageDTO } from "@/lib/tickets/types";
|
||||||
|
|
||||||
export function PortalThread({
|
export function PortalThread({
|
||||||
@@ -153,8 +154,14 @@ function MessageBubble({
|
|||||||
className={`mb-3 flex ${isMine ? "justify-end" : "justify-start"}`}
|
className={`mb-3 flex ${isMine ? "justify-end" : "justify-start"}`}
|
||||||
>
|
>
|
||||||
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${isMine ? "bg-accent text-white" : "bg-surface-hover"}`}>
|
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${isMine ? "bg-accent text-white" : "bg-surface-hover"}`}>
|
||||||
|
{message.bodyHtml ? (
|
||||||
|
<MessageBodyHtml html={message.bodyHtml} className="mb-1" />
|
||||||
|
) : (
|
||||||
<p className="mb-1 whitespace-pre-wrap">{message.body}</p>
|
<p className="mb-1 whitespace-pre-wrap">{message.body}</p>
|
||||||
{message.attachments.map((a) => (
|
)}
|
||||||
|
{message.attachments
|
||||||
|
.filter((a) => !a.isInline)
|
||||||
|
.map((a) => (
|
||||||
<AttachmentChip key={a.id} attachment={a} token={token} />
|
<AttachmentChip key={a.id} attachment={a} token={token} />
|
||||||
))}
|
))}
|
||||||
<p className={`text-[11px] ${isMine ? "text-white/70" : "text-text-faint"}`}>
|
<p className={`text-[11px] ${isMine ? "text-white/70" : "text-text-faint"}`}>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { verifyPassword } from "@/lib/auth/password";
|
import { verifyPassword } from "@/lib/auth/password";
|
||||||
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||||
|
import { createLoginChallenge, setLoginChallengeCookie } from "@/lib/auth/login-challenge";
|
||||||
import { authenticateLdapUser } from "@/lib/ldap/client";
|
import { authenticateLdapUser } from "@/lib/ldap/client";
|
||||||
import { findOrCreateUserFromLdap } from "@/lib/auth/users";
|
import { findOrCreateUserFromLdap } from "@/lib/auth/users";
|
||||||
import { getLdapSettings } from "@/lib/auth/ldap-config";
|
import { getLdapSettings } from "@/lib/auth/ldap-config";
|
||||||
@@ -56,6 +57,15 @@ export async function POST(request: Request) {
|
|||||||
defaultRole: ldapSettings?.defaultRole ?? "agent",
|
defaultRole: ldapSettings?.defaultRole ?? "agent",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// App-level 2FA is independent of whatever the directory itself does —
|
||||||
|
// useful when the directory doesn't enforce MFA, or as defense in depth
|
||||||
|
// when it does. Same challenge flow as the local-password branch below.
|
||||||
|
if (user.totpEnabled) {
|
||||||
|
const challengeToken = await createLoginChallenge(user.id);
|
||||||
|
await setLoginChallengeCookie(challengeToken);
|
||||||
|
return NextResponse.json({ ok: true, totpRequired: true });
|
||||||
|
}
|
||||||
|
|
||||||
const token = await createSession(user.id);
|
const token = await createSession(user.id);
|
||||||
await setSessionCookie(token);
|
await setSessionCookie(token);
|
||||||
|
|
||||||
@@ -78,6 +88,12 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
|
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.totpEnabled) {
|
||||||
|
const challengeToken = await createLoginChallenge(user.id);
|
||||||
|
await setLoginChallengeCookie(challengeToken);
|
||||||
|
return NextResponse.json({ ok: true, totpRequired: true });
|
||||||
|
}
|
||||||
|
|
||||||
const token = await createSession(user.id);
|
const token = await createSession(user.id);
|
||||||
await setSessionCookie(token);
|
await setSessionCookie(token);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { users, totpRecoveryCodes } from "@/lib/db/schema";
|
||||||
|
import { decryptTotpSecret, verifyTotpCode, generateRecoveryCodes, hashRecoveryCode } from "@/lib/auth/totp";
|
||||||
|
|
||||||
|
const schema = z.object({ code: z.string().min(6).max(6) });
|
||||||
|
|
||||||
|
/** Flips totpEnabled on after the user proves they can generate a live code, and issues recovery codes — shown once, never retrievable again. */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(await request.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Введите 6-значный код" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({ where: eq(users.id, session.user.id) });
|
||||||
|
if (!user?.totpSecret) {
|
||||||
|
return NextResponse.json({ error: "Сначала начните настройку 2FA" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await verifyTotpCode(decryptTotpSecret(user.totpSecret), parsed.data.code);
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json({ error: "Неверный код" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(users).set({ totpEnabled: true }).where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
// Replace any codes from a previous enable/disable cycle.
|
||||||
|
await db.delete(totpRecoveryCodes).where(eq(totpRecoveryCodes.userId, user.id));
|
||||||
|
const recoveryCodes = generateRecoveryCodes();
|
||||||
|
await db.insert(totpRecoveryCodes).values(recoveryCodes.map((code) => ({ userId: user.id, codeHash: hashRecoveryCode(code) })));
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, recoveryCodes });
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { eq, and, isNull } from "drizzle-orm";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { users, totpRecoveryCodes } from "@/lib/db/schema";
|
||||||
|
import { decryptTotpSecret, verifyTotpCode, hashRecoveryCode } from "@/lib/auth/totp";
|
||||||
|
|
||||||
|
const schema = z.object({ code: z.string().min(6).max(16) });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requires a live TOTP code or a recovery code — not the account password —
|
||||||
|
* so this works the same for local and LDAP-authenticated accounts (an
|
||||||
|
* LDAP account has no passwordHash to check here at all). Proving you still
|
||||||
|
* hold the second factor is what stops a stolen live session alone from
|
||||||
|
* turning 2FA off; a recovery code works too since losing your authenticator
|
||||||
|
* app shouldn't permanently lock you out of disabling it.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(await request.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({ where: eq(users.id, session.user.id) });
|
||||||
|
if (!user?.totpEnabled || !user.totpSecret) {
|
||||||
|
return NextResponse.json({ error: "2FA не включена" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = parsed.data.code.trim();
|
||||||
|
let verified = /^\d{6}$/.test(code) && (await verifyTotpCode(decryptTotpSecret(user.totpSecret), code));
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
const codeHash = hashRecoveryCode(code);
|
||||||
|
const match = await db.query.totpRecoveryCodes.findFirst({
|
||||||
|
where: and(eq(totpRecoveryCodes.userId, user.id), eq(totpRecoveryCodes.codeHash, codeHash), isNull(totpRecoveryCodes.usedAt)),
|
||||||
|
});
|
||||||
|
verified = Boolean(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return NextResponse.json({ error: "Неверный код" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(users).set({ totpSecret: null, totpEnabled: false }).where(eq(users.id, user.id));
|
||||||
|
await db.delete(totpRecoveryCodes).where(eq(totpRecoveryCodes.userId, user.id));
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { users } from "@/lib/db/schema";
|
||||||
|
import { generateTotpSecret, encryptTotpSecret, buildTotpQrCode } from "@/lib/auth/totp";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts (or restarts) 2FA setup — generates a fresh secret and stores it
|
||||||
|
* encrypted, but leaves totpEnabled false until /confirm proves the user
|
||||||
|
* actually scanned it (see users.totpEnabled comment in schema.ts). Safe to
|
||||||
|
* call again if the user abandons setup partway — it just overwrites the
|
||||||
|
* unconfirmed secret with a new one.
|
||||||
|
*/
|
||||||
|
export async function POST() {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({ where: eq(users.id, session.user.id) });
|
||||||
|
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const secret = generateTotpSecret();
|
||||||
|
await db.update(users).set({ totpSecret: encryptTotpSecret(secret), totpEnabled: false }).where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
const qrDataUrl = await buildTotpQrCode(secret, user.email);
|
||||||
|
return NextResponse.json({ secret, qrDataUrl });
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { eq, and, isNull } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { users, totpRecoveryCodes } from "@/lib/db/schema";
|
||||||
|
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||||
|
import { consumeLoginChallengeAttempt, deleteLoginChallenge, clearLoginChallengeCookie } from "@/lib/auth/login-challenge";
|
||||||
|
import { decryptTotpSecret, verifyTotpCode, hashRecoveryCode } from "@/lib/auth/totp";
|
||||||
|
|
||||||
|
const schema = z.object({ code: z.string().min(6).max(16) });
|
||||||
|
|
||||||
|
// Second step of login for a 2FA account — reads the pending-login cookie
|
||||||
|
// set by /api/auth/login, so the client never has to carry the challenge
|
||||||
|
// token itself. Accepts either a live 6-digit TOTP code or a recovery code.
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const parsed = schema.safeParse(await request.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Burns one attempt regardless of whether the code below turns out to be
|
||||||
|
// right — the whole point is capping brute-force *tries*, not just wrong ones.
|
||||||
|
const challenge = await consumeLoginChallengeAttempt();
|
||||||
|
if (!challenge) {
|
||||||
|
return NextResponse.json({ error: "Сессия входа истекла — войдите заново" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({ where: eq(users.id, challenge.userId) });
|
||||||
|
if (!user || !user.totpEnabled || !user.totpSecret) {
|
||||||
|
await deleteLoginChallenge(challenge.tokenHash);
|
||||||
|
return NextResponse.json({ error: "2FA не настроена для этого аккаунта" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = parsed.data.code.trim();
|
||||||
|
let verified = /^\d{6}$/.test(code) && (await verifyTotpCode(decryptTotpSecret(user.totpSecret), code));
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
const codeHash = hashRecoveryCode(code);
|
||||||
|
const match = await db.query.totpRecoveryCodes.findFirst({
|
||||||
|
where: and(eq(totpRecoveryCodes.userId, user.id), eq(totpRecoveryCodes.codeHash, codeHash), isNull(totpRecoveryCodes.usedAt)),
|
||||||
|
});
|
||||||
|
if (match) {
|
||||||
|
await db.update(totpRecoveryCodes).set({ usedAt: new Date() }).where(eq(totpRecoveryCodes.id, match.id));
|
||||||
|
verified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return NextResponse.json({ error: "Неверный код" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteLoginChallenge(challenge.tokenHash);
|
||||||
|
await clearLoginChallengeCookie();
|
||||||
|
|
||||||
|
const token = await createSession(user.id);
|
||||||
|
await setSessionCookie(token);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, user: { id: user.id, name: user.name, role: user.role } });
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ const configureSchema = z.object({
|
|||||||
user: z.string().email(),
|
user: z.string().email(),
|
||||||
password: z.string().min(1),
|
password: z.string().min(1),
|
||||||
allowInsecureTls: z.boolean(),
|
allowInsecureTls: z.boolean(),
|
||||||
|
fromAddress: z.string().email().optional().or(z.literal("")),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@@ -41,6 +42,7 @@ export async function POST(request: Request) {
|
|||||||
user: parsed.data.user,
|
user: parsed.data.user,
|
||||||
password: parsed.data.password,
|
password: parsed.data.password,
|
||||||
allowInsecureTls: parsed.data.allowInsecureTls,
|
allowInsecureTls: parsed.data.allowInsecureTls,
|
||||||
|
fromAddress: parsed.data.fromAddress || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const subscribeSchema = z.object({
|
|||||||
p256dh: z.string().min(1),
|
p256dh: z.string().min(1),
|
||||||
auth: z.string().min(1),
|
auth: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
|
userAgent: z.string().max(500).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@@ -23,7 +24,7 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await savePushSubscription(session.user.id, parsed.data);
|
await savePushSubscription(session.user.id, parsed.data, parsed.data.userAgent);
|
||||||
return NextResponse.json({ ok: true }, { status: 201 });
|
return NextResponse.json({ ok: true }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { deleteOwnPushSubscription } from "@/lib/push/service";
|
||||||
|
|
||||||
|
/** Unsubscribes one of the current user's own devices/browsers — scoped by userId, so a user can never remove someone else's. */
|
||||||
|
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
await deleteOwnPushSubscription(session.user.id, id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { listPushSubscriptionsForUser } from "@/lib/push/service";
|
||||||
|
|
||||||
|
/** The current user's own subscribed devices/browsers, for the notification settings list. */
|
||||||
|
export async function GET() {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const subscriptions = await listPushSubscriptionsForUser(session.user.id);
|
||||||
|
return NextResponse.json({ subscriptions });
|
||||||
|
}
|
||||||
@@ -1,8 +1,25 @@
|
|||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
import { requireSession } from "@/lib/auth/require";
|
import { requireSession } from "@/lib/auth/require";
|
||||||
import { deleteTag } from "@/lib/tickets/service";
|
import { deleteTag, setDefaultTag } from "@/lib/tickets/service";
|
||||||
|
|
||||||
|
const patchSchema = z.object({ isDefault: z.boolean() });
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const parsed = patchSchema.safeParse(await request.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = await setDefaultTag(parsed.data.isDefault ? id : null);
|
||||||
|
return NextResponse.json({ tags });
|
||||||
|
}
|
||||||
|
|
||||||
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSession();
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json(result, { status: 201 });
|
return NextResponse.json(result, { status: 201 });
|
||||||
} catch {
|
} catch (err) {
|
||||||
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
|
const message = err instanceof Error ? err.message : "Не удалось отправить вложение";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
|||||||
visibility,
|
visibility,
|
||||||
});
|
});
|
||||||
return NextResponse.json(result, { status: 201 });
|
return NextResponse.json(result, { status: 201 });
|
||||||
} catch {
|
} catch (err) {
|
||||||
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
|
const message = err instanceof Error ? err.message : "Не удалось отправить сообщение";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { bulkSetTicketStatus } from "@/lib/tickets/service";
|
||||||
|
|
||||||
|
const bulkSchema = z.object({
|
||||||
|
ticketIds: z.array(z.string()).min(1).max(200),
|
||||||
|
status: z.enum(["new", "open", "pending", "closed"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = bulkSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedIds = await bulkSetTicketStatus(parsed.data.ticketIds, parsed.data.status, {
|
||||||
|
id: session.user.id,
|
||||||
|
role: session.user.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ updatedIds });
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireSession } from "@/lib/auth/require";
|
import { requireSession } from "@/lib/auth/require";
|
||||||
import { listTickets, createManualTicket } from "@/lib/tickets/service";
|
import { listTickets, createManualTicket } from "@/lib/tickets/service";
|
||||||
|
import { saveAttachment, AttachmentTooLargeError, MAX_ATTACHMENT_BYTES } from "@/lib/attachments/storage";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSession();
|
||||||
@@ -18,19 +19,56 @@ const createSchema = z.object({
|
|||||||
subject: z.string().min(1).max(200),
|
subject: z.string().min(1).max(200),
|
||||||
body: z.string().min(1).max(10_000),
|
body: z.string().min(1).max(10_000),
|
||||||
customerName: z.string().min(1).max(200),
|
customerName: z.string().min(1).max(200),
|
||||||
customerEmail: z.string().email().optional(),
|
customerEmail: z.string().email().optional().or(z.literal("")),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// multipart/form-data rather than JSON — the modal supports an optional
|
||||||
|
// attachment (incl. Ctrl+V screenshot paste), same as replying on an
|
||||||
|
// existing ticket.
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSession();
|
||||||
if (!session) return response;
|
if (!session) return response;
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
const formData = await request.formData().catch(() => null);
|
||||||
const parsed = createSchema.safeParse(body);
|
if (!formData) return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
|
|
||||||
|
const parsed = createSchema.safeParse({
|
||||||
|
subject: formData.get("subject"),
|
||||||
|
body: formData.get("body"),
|
||||||
|
customerName: formData.get("customerName"),
|
||||||
|
customerEmail: formData.get("customerEmail") ?? undefined,
|
||||||
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await createManualTicket(parsed.data);
|
const file = formData.get("file");
|
||||||
|
let attachment: { filename: string; mimeType: string; sizeBytes: number; storageKey: string } | undefined;
|
||||||
|
if (file instanceof File) {
|
||||||
|
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||||
|
return NextResponse.json({ error: "File too large" }, { status: 413 });
|
||||||
|
}
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
try {
|
||||||
|
const saved = await saveAttachment(buffer);
|
||||||
|
attachment = {
|
||||||
|
filename: file.name || "file",
|
||||||
|
mimeType: file.type || "application/octet-stream",
|
||||||
|
sizeBytes: saved.sizeBytes,
|
||||||
|
storageKey: saved.storageKey,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AttachmentTooLargeError) {
|
||||||
|
return NextResponse.json({ error: "File too large" }, { status: 413 });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createManualTicket({
|
||||||
|
...parsed.data,
|
||||||
|
customerEmail: parsed.data.customerEmail || undefined,
|
||||||
|
attachment,
|
||||||
|
});
|
||||||
return NextResponse.json(result, { status: 201 });
|
return NextResponse.json(result, { status: 201 });
|
||||||
}
|
}
|
||||||
@@ -203,6 +203,26 @@
|
|||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inbound email HTML intentionally keeps its own inline colors (see the
|
||||||
|
sanitizeEmailHtml comment in lib/mail/sanitize-html.ts — full fidelity,
|
||||||
|
"exactly as the person sent it") — but a sender's hardcoded dark text,
|
||||||
|
the common case, becomes unreadable once the surrounding chrome goes
|
||||||
|
dark. Force every descendant in a message bubble back to the theme's
|
||||||
|
own readable text color in dark mode only; light mode is untouched and
|
||||||
|
keeps full fidelity as designed. */
|
||||||
|
:root[data-theme="dark"] .message-html,
|
||||||
|
:root[data-theme="dark"] .message-html * {
|
||||||
|
color: var(--text) !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) .message-html,
|
||||||
|
:root:not([data-theme="light"]) .message-html * {
|
||||||
|
color: var(--text) !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Theme-toggle icon: both render always, CSS picks which shows — same
|
/* Theme-toggle icon: both render always, CSS picks which shows — same
|
||||||
data-theme cascade the palette above uses, so it never needs JS state. */
|
data-theme cascade the palette above uses, so it never needs JS state. */
|
||||||
.theme-toggle-btn .theme-icon-sun {
|
.theme-toggle-btn .theme-icon-sun {
|
||||||
@@ -233,6 +253,7 @@
|
|||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
+18
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Onest, Unbounded, JetBrains_Mono } from "next/font/google";
|
import { Onest, Unbounded, JetBrains_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -21,6 +21,23 @@ const mono = JetBrains_Mono({
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "top-tickets",
|
title: "top-tickets",
|
||||||
description: "Helpdesk for support@top-sysops.ru, Telegram, and the web.",
|
description: "Helpdesk for support@top-sysops.ru, Telegram, and the web.",
|
||||||
|
manifest: "/manifest.webmanifest",
|
||||||
|
appleWebApp: {
|
||||||
|
// iOS ignores the web manifest's `display: standalone` — this pair of
|
||||||
|
// meta tags is the separate (older, Apple-specific) mechanism it
|
||||||
|
// actually reads for "hide Safari's chrome when launched from the
|
||||||
|
// home screen icon".
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "black-translucent",
|
||||||
|
title: "top-tickets",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: "cover",
|
||||||
|
themeColor: "#6d28d9",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reads the persisted theme before hydration so there's no flash of the
|
// Reads the persisted theme before hydration so there's no flash of the
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// message.bodyHtml is already sanitized server-side (see
|
||||||
|
// lib/mail/sanitize-html.ts), including wrapping bare images in a link to
|
||||||
|
// their own full-size src — this only adds layout containment so an
|
||||||
|
// email's inline styles/tables can't blow out the message bubble's width.
|
||||||
|
export function MessageBodyHtml({ html, className = "" }: { html: string; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`message-html max-w-full overflow-x-auto text-sm break-words [&_img]:max-w-full [&_img]:cursor-zoom-in [&_table]:max-w-full [&_td]:break-words [&_th]:break-words ${className}`}
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { loginChallenges } from "@/lib/db/schema";
|
||||||
|
|
||||||
|
// The "you passed the password check, now prove the TOTP code" state for a
|
||||||
|
// 2FA account — separate from the real `sessions` table (see schema.ts) so
|
||||||
|
// nothing can mistake a pending challenge for an authenticated session.
|
||||||
|
const CHALLENGE_COOKIE = "pending_login";
|
||||||
|
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const MAX_ATTEMPTS = 6;
|
||||||
|
|
||||||
|
function hashToken(token: string): string {
|
||||||
|
return crypto.createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLoginChallenge(userId: string): Promise<string> {
|
||||||
|
const token = crypto.randomBytes(32).toString("base64url");
|
||||||
|
const tokenHash = hashToken(token);
|
||||||
|
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS);
|
||||||
|
await db.insert(loginChallenges).values({ tokenHash, userId, expiresAt });
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLoginChallengeCookie(token: string): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(CHALLENGE_COOKIE, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production" && process.env.COOKIE_ALLOW_INSECURE !== "true",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: CHALLENGE_TTL_MS / 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearLoginChallengeCookie(): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.delete(CHALLENGE_COOKIE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the pending-login cookie and burns one attempt against it —
|
||||||
|
* called once per verify-totp POST, before the code itself is even checked,
|
||||||
|
* so a guessing script can't rack up unlimited tries by never calling this.
|
||||||
|
* Returns null (nothing to resume) if the cookie is missing, the challenge
|
||||||
|
* doesn't exist, it's expired, or attempts are exhausted — the last two
|
||||||
|
* also delete the row so it can't be retried after the fact.
|
||||||
|
*/
|
||||||
|
export async function consumeLoginChallengeAttempt(): Promise<{ userId: string; tokenHash: string } | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(CHALLENGE_COOKIE)?.value;
|
||||||
|
if (!token) return null;
|
||||||
|
const tokenHash = hashToken(token);
|
||||||
|
|
||||||
|
const challenge = await db.query.loginChallenges.findFirst({ where: eq(loginChallenges.tokenHash, tokenHash) });
|
||||||
|
if (!challenge) return null;
|
||||||
|
|
||||||
|
if (challenge.expiresAt.getTime() < Date.now() || challenge.attempts >= MAX_ATTEMPTS) {
|
||||||
|
await db.delete(loginChallenges).where(eq(loginChallenges.tokenHash, tokenHash));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(loginChallenges).set({ attempts: challenge.attempts + 1 }).where(eq(loginChallenges.tokenHash, tokenHash));
|
||||||
|
return { userId: challenge.userId, tokenHash };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLoginChallenge(tokenHash: string): Promise<void> {
|
||||||
|
await db.delete(loginChallenges).where(eq(loginChallenges.tokenHash, tokenHash));
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it, beforeAll } from "vitest";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// encryptTotpSecret/decryptTotpSecret delegate to lib/crypto/credentials.ts,
|
||||||
|
// which reads this lazily (only when actually encrypting/decrypting) — a
|
||||||
|
// throwaway key here keeps the test independent of .env.
|
||||||
|
process.env.CREDENTIALS_ENCRYPTION_KEY = crypto.randomBytes(32).toString("base64");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("totp", () => {
|
||||||
|
it("generates a code that verifies against its own secret", async () => {
|
||||||
|
const { generateTotpSecret, verifyTotpCode } = await import("./totp");
|
||||||
|
const { generate } = await import("otplib");
|
||||||
|
|
||||||
|
const secret = generateTotpSecret();
|
||||||
|
const code = await generate({ secret });
|
||||||
|
expect(await verifyTotpCode(secret, code)).toBe(true);
|
||||||
|
expect(await verifyTotpCode(secret, "000000")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a secret through encryption", async () => {
|
||||||
|
const { generateTotpSecret, encryptTotpSecret, decryptTotpSecret } = await import("./totp");
|
||||||
|
const secret = generateTotpSecret();
|
||||||
|
const encrypted = encryptTotpSecret(secret);
|
||||||
|
expect(encrypted).not.toContain(secret);
|
||||||
|
expect(decryptTotpSecret(encrypted)).toBe(secret);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates unique, human-typeable recovery codes and hashes deterministically", async () => {
|
||||||
|
const { generateRecoveryCodes, hashRecoveryCode } = await import("./totp");
|
||||||
|
const codes = generateRecoveryCodes(8);
|
||||||
|
expect(codes).toHaveLength(8);
|
||||||
|
expect(new Set(codes).size).toBe(8);
|
||||||
|
for (const code of codes) expect(code).toMatch(/^[0-9a-f]{5}-[0-9a-f]{5}$/);
|
||||||
|
|
||||||
|
expect(hashRecoveryCode(codes[0])).toBe(hashRecoveryCode(codes[0]));
|
||||||
|
expect(hashRecoveryCode(codes[0])).toBe(hashRecoveryCode(codes[0].toUpperCase()));
|
||||||
|
expect(hashRecoveryCode(codes[0])).not.toBe(hashRecoveryCode(codes[1]));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { generateSecret, verify, generateURI } from "otplib";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
import { encryptCredential, decryptCredential } from "@/lib/crypto/credentials";
|
||||||
|
|
||||||
|
const ISSUER = "top-tickets";
|
||||||
|
const RECOVERY_CODE_COUNT = 8;
|
||||||
|
|
||||||
|
export function generateTotpSecret(): string {
|
||||||
|
return generateSecret();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptTotpSecret(secret: string): string {
|
||||||
|
return encryptCredential(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptTotpSecret(encrypted: string): string {
|
||||||
|
return decryptCredential(encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildTotpQrCode(secret: string, email: string): Promise<string> {
|
||||||
|
const uri = generateURI({ issuer: ISSUER, label: email, secret });
|
||||||
|
return QRCode.toDataURL(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyTotpCode(secret: string, code: string): Promise<boolean> {
|
||||||
|
const result = await verify({ secret, token: code.trim() });
|
||||||
|
return result.valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-typeable: groups of 5 lowercase hex chars, e.g. "a1b2c-d3e4f". */
|
||||||
|
function formatRecoveryCode(): string {
|
||||||
|
const raw = crypto.randomBytes(5).toString("hex");
|
||||||
|
return `${raw.slice(0, 5)}-${raw.slice(5, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRecoveryCodes(count = RECOVERY_CODE_COUNT): string[] {
|
||||||
|
return Array.from({ length: count }, formatRecoveryCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashRecoveryCode(code: string): string {
|
||||||
|
return crypto.createHash("sha256").update(code.trim().toLowerCase()).digest("hex");
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `attachments` ADD `content_id` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `messages` ADD `body_html` text;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `tags` ADD `is_default` integer DEFAULT false NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `attachments` ADD `is_inline` integer DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
UPDATE `attachments` SET `is_inline` = 1 WHERE `content_id` IS NOT NULL;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE `login_challenges` (
|
||||||
|
`token_hash` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`attempts` integer DEFAULT 0 NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `totp_recovery_codes` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`code_hash` text NOT NULL,
|
||||||
|
`used_at` integer,
|
||||||
|
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `totp_secret` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `totp_enabled` integer DEFAULT false NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `push_subscriptions` ADD `user_agent` text;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `messages` ADD `delivery_failed` integer DEFAULT false NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `mailbox_config` ADD `from_address` text;
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -50,6 +50,55 @@
|
|||||||
"when": 1786130418783,
|
"when": 1786130418783,
|
||||||
"tag": "0006_big_daredevil",
|
"tag": "0006_big_daredevil",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787124609684,
|
||||||
|
"tag": "0007_neat_archangel",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787126651618,
|
||||||
|
"tag": "0008_quick_ultragirl",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787129904542,
|
||||||
|
"tag": "0009_little_natasha_romanoff",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787213219014,
|
||||||
|
"tag": "0010_curved_jack_murdock",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788763637424,
|
||||||
|
"tag": "0011_cute_boomer",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1791361267938,
|
||||||
|
"tag": "0012_flawless_lord_tyger",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1791367080545,
|
||||||
|
"tag": "0013_military_ezekiel_stane",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,13 @@ export const users = sqliteTable("users", {
|
|||||||
authSource: text("auth_source", { enum: ["local", "ldap"] })
|
authSource: text("auth_source", { enum: ["local", "ldap"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("local"),
|
.default("local"),
|
||||||
|
// Opt-in TOTP 2FA — local accounts only (see api/auth/totp/setup). The
|
||||||
|
// secret is AES-256-GCM encrypted at rest via lib/crypto/credentials.ts,
|
||||||
|
// same as every other stored credential in this app. Set as soon as
|
||||||
|
// "setup" generates a secret, but totpEnabled stays false until the user
|
||||||
|
// proves they scanned it right by confirming one live code.
|
||||||
|
totpSecret: text("totp_secret"),
|
||||||
|
totpEnabled: integer("totp_enabled", { mode: "boolean" }).notNull().default(false),
|
||||||
createdAt: timestamps.createdAt,
|
createdAt: timestamps.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,6 +51,41 @@ export const sessions = sqliteTable("sessions", {
|
|||||||
createdAt: timestamps.createdAt,
|
createdAt: timestamps.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time recovery codes, issued when 2FA is confirmed — SHA-256 hashed
|
||||||
|
* (they're low-entropy compared to a password, but only ever checked
|
||||||
|
* against a rate-limited login-challenge attempt counter, same as a TOTP
|
||||||
|
* code guess would be). usedAt marks a code as spent, not deleted, so the
|
||||||
|
* user can see in principle how many they've burned through — nothing
|
||||||
|
* currently surfaces that, but there's no reason to throw the row away.
|
||||||
|
*/
|
||||||
|
export const totpRecoveryCodes = sqliteTable("totp_recovery_codes", {
|
||||||
|
id: id(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
codeHash: text("code_hash").notNull(),
|
||||||
|
usedAt: integer("used_at", { mode: "timestamp_ms" }),
|
||||||
|
createdAt: timestamps.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "you passed step 1 (password), now prove step 2 (TOTP)" state for a
|
||||||
|
* 2FA-enabled account — deliberately not the same table as `sessions`,
|
||||||
|
* since a login challenge must never be usable as a real session no matter
|
||||||
|
* what bug might otherwise conflate the two. Short TTL (5 min) and a capped
|
||||||
|
* attempt counter so it can't be brute-forced; see lib/auth/login-challenge.ts.
|
||||||
|
*/
|
||||||
|
export const loginChallenges = sqliteTable("login_challenges", {
|
||||||
|
tokenHash: text("token_hash").primaryKey(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
attempts: integer("attempts").notNull().default(0),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
createdAt: timestamps.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
/** People who file tickets — identified by whichever channel they came in on. */
|
/** People who file tickets — identified by whichever channel they came in on. */
|
||||||
export const customers = sqliteTable("customers", {
|
export const customers = sqliteTable("customers", {
|
||||||
id: id(),
|
id: id(),
|
||||||
@@ -89,6 +131,11 @@ export const messages = sqliteTable("messages", {
|
|||||||
authorId: text("author_id"),
|
authorId: text("author_id"),
|
||||||
authorName: text("author_name").notNull(),
|
authorName: text("author_name").notNull(),
|
||||||
body: text("body").notNull(),
|
body: text("body").notNull(),
|
||||||
|
// Sanitized HTML, set only for inbound email messages that had an HTML
|
||||||
|
// part — preserves the sender's original formatting (fonts, layout,
|
||||||
|
// inline images) instead of the flattened plain-text `body`. Null for
|
||||||
|
// every other channel/direction, which keeps rendering the plain body.
|
||||||
|
bodyHtml: text("body_html"),
|
||||||
direction: text("direction", { enum: ["in", "out"] }).notNull(),
|
direction: text("direction", { enum: ["in", "out"] }).notNull(),
|
||||||
// "internal" notes are agent-only — never sent to the customer over any
|
// "internal" notes are agent-only — never sent to the customer over any
|
||||||
// channel, and filtered out of every customer-facing read path (portal,
|
// channel, and filtered out of every customer-facing read path (portal,
|
||||||
@@ -100,6 +147,11 @@ export const messages = sqliteTable("messages", {
|
|||||||
// email channel — lets a customer's future reply be matched back to the
|
// email channel — lets a customer's future reply be matched back to the
|
||||||
// exact ticket via In-Reply-To/References, even if they have other tickets.
|
// exact ticket via In-Reply-To/References, even if they have other tickets.
|
||||||
emailMessageId: text("email_message_id"),
|
emailMessageId: text("email_message_id"),
|
||||||
|
// Set when this is an outbound agent reply whose channel delivery
|
||||||
|
// (email send / Telegram send) failed — the reply itself is always
|
||||||
|
// recorded regardless (see deliverAgentMessage), but the agent needs a
|
||||||
|
// way to tell "saved" from "the customer actually got this".
|
||||||
|
deliveryFailed: integer("delivery_failed", { mode: "boolean" }).notNull().default(false),
|
||||||
createdAt: timestamps.createdAt,
|
createdAt: timestamps.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,6 +174,13 @@ export const mailboxConfig = sqliteTable("mailbox_config", {
|
|||||||
smtpPort: integer("smtp_port").notNull().default(587),
|
smtpPort: integer("smtp_port").notNull().default(587),
|
||||||
user: text("user").notNull(),
|
user: text("user").notNull(),
|
||||||
passwordEnc: text("password_enc").notNull(),
|
passwordEnc: text("password_enc").notNull(),
|
||||||
|
// Null unless the SMTP AUTH identity can't send as itself — e.g. an
|
||||||
|
// on-prem/hybrid M365 setup where `user` is the AD UPN used to log in
|
||||||
|
// (like elsup@zag.lan) but the mailbox's actual permitted "From" address
|
||||||
|
// is a different, customer-facing domain. Exchange Online rejects a
|
||||||
|
// mismatched From with "550 5.7.60 ... does not have permissions to send
|
||||||
|
// as this sender" — see sendTicketReplyEmail. Defaults to `user` when unset.
|
||||||
|
fromAddress: text("from_address"),
|
||||||
// Both IMAP and SMTP presented an expired cert on support@top-sysops.ru at
|
// Both IMAP and SMTP presented an expired cert on support@top-sysops.ru at
|
||||||
// setup time. This is a LAN-only mailbox (never exposed to the internet),
|
// setup time. This is a LAN-only mailbox (never exposed to the internet),
|
||||||
// so skipping verification is an accepted risk — not a default for
|
// so skipping verification is an accepted risk — not a default for
|
||||||
@@ -177,6 +236,16 @@ export const attachments = sqliteTable("attachments", {
|
|||||||
sizeBytes: integer("size_bytes").notNull(),
|
sizeBytes: integer("size_bytes").notNull(),
|
||||||
// Relative path under DATA_DIR/attachments/ — see src/lib/attachments/storage.ts.
|
// Relative path under DATA_DIR/attachments/ — see src/lib/attachments/storage.ts.
|
||||||
storageKey: text("storage_key").notNull(),
|
storageKey: text("storage_key").notNull(),
|
||||||
|
// The email's Content-ID header (no angle brackets) for a cid:-referenced
|
||||||
|
// inline image — used to rewrite `cid:` references in messages.bodyHtml
|
||||||
|
// to this attachment's serving URL. Null for regular attachments *and*
|
||||||
|
// for inline images that came from a data: URI instead (see isInline).
|
||||||
|
contentId: text("content_id"),
|
||||||
|
// True for any attachment whose file is already shown inside the
|
||||||
|
// message's bodyHtml (a cid: image, or one extracted from a data: URI —
|
||||||
|
// see lib/mail/imap.ts) — the UI uses this to skip showing it a second
|
||||||
|
// time as a separate chip below the already-rendered body.
|
||||||
|
isInline: integer("is_inline", { mode: "boolean" }).notNull().default(false),
|
||||||
createdAt: timestamps.createdAt,
|
createdAt: timestamps.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,6 +264,10 @@ export const tags = sqliteTable("tags", {
|
|||||||
color: text("color", { enum: ["accent", "success", "warning", "info", "rose", "danger"] })
|
color: text("color", { enum: ["accent", "success", "warning", "info", "rose", "danger"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("accent"),
|
.default("accent"),
|
||||||
|
// At most one tag has this set (enforced in service.ts's setDefaultTag,
|
||||||
|
// not at the schema level) — auto-attached to every ticket the moment
|
||||||
|
// it's created, across every channel (email/telegram/widget/manual).
|
||||||
|
isDefault: integer("is_default", { mode: "boolean" }).notNull().default(false),
|
||||||
createdAt: timestamps.createdAt,
|
createdAt: timestamps.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -226,5 +299,10 @@ export const pushSubscriptions = sqliteTable("push_subscriptions", {
|
|||||||
endpoint: text("endpoint").notNull().unique(),
|
endpoint: text("endpoint").notNull().unique(),
|
||||||
p256dh: text("p256dh").notNull(),
|
p256dh: text("p256dh").notNull(),
|
||||||
auth: text("auth").notNull(),
|
auth: text("auth").notNull(),
|
||||||
|
// Raw navigator.userAgent at subscribe time, purely for display — lets a
|
||||||
|
// user tell their two subscribed browsers apart when unsubscribing one
|
||||||
|
// (e.g. Windows + both Chrome and Edge granted notification permission,
|
||||||
|
// each getting its own OS toast for the same event).
|
||||||
|
userAgent: text("user_agent"),
|
||||||
createdAt: timestamps.createdAt,
|
createdAt: timestamps.createdAt,
|
||||||
});
|
});
|
||||||
@@ -119,11 +119,20 @@ async function ldapSearchCli(
|
|||||||
filter: string,
|
filter: string,
|
||||||
): Promise<{ dn: string; attributes: Record<string, string> }[]> {
|
): Promise<{ dn: string; attributes: Record<string, string> }[]> {
|
||||||
const protocol = settings.useTls ? "ldaps" : "ldap";
|
const protocol = settings.useTls ? "ldaps" : "ldap";
|
||||||
|
try {
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync(
|
||||||
"ldapsearch",
|
"ldapsearch",
|
||||||
[
|
[
|
||||||
"-x",
|
"-x",
|
||||||
"-LLL",
|
"-LLL",
|
||||||
|
// Simple Paged Results (RFC 2696): without it, a base DN with more
|
||||||
|
// entries than the server's admin-configured sizeLimit (AD default:
|
||||||
|
// 1000, but often set lower) fails the whole search outright —
|
||||||
|
// "Size limit exceeded (4)" — even though every individual page is
|
||||||
|
// under the limit. ldapsearch transparently walks all pages and
|
||||||
|
// concatenates the LDIF output, so parseLdif below needs no changes.
|
||||||
|
"-E",
|
||||||
|
"pr=500/noprompt",
|
||||||
"-H",
|
"-H",
|
||||||
`${protocol}://${settings.host}:${settings.port}`,
|
`${protocol}://${settings.host}:${settings.port}`,
|
||||||
"-D",
|
"-D",
|
||||||
@@ -143,6 +152,16 @@ async function ldapSearchCli(
|
|||||||
{ maxBuffer: 20 * 1024 * 1024, timeout: 15_000 },
|
{ maxBuffer: 20 * 1024 * 1024, timeout: 15_000 },
|
||||||
);
|
);
|
||||||
return parseLdif(stdout);
|
return parseLdif(stdout);
|
||||||
|
} catch (err) {
|
||||||
|
// execFile's rejection .message is "Command failed: <full argv>\n<stderr>"
|
||||||
|
// — the full argv includes -w <bindPassword> verbatim, which would
|
||||||
|
// otherwise reach callers (and, via the admin API's error responses,
|
||||||
|
// the browser) in plain text. Only stderr — ldapsearch's own diagnostic
|
||||||
|
// text, which never echoes the arguments it was invoked with — is safe
|
||||||
|
// to surface.
|
||||||
|
const stderr = (err as { stderr?: string })?.stderr?.trim();
|
||||||
|
throw new Error(stderr || "ldapsearch failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Escapes an untrusted value for safe interpolation into an LDAP search filter (RFC 4515). */
|
/** Escapes an untrusted value for safe interpolation into an LDAP search filter (RFC 4515). */
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export interface MailboxSettings {
|
|||||||
user: string;
|
user: string;
|
||||||
password: string;
|
password: string;
|
||||||
allowInsecureTls: boolean;
|
allowInsecureTls: boolean;
|
||||||
|
// See the schema comment on mailboxConfig.fromAddress — undefined means
|
||||||
|
// "same as user", the common case.
|
||||||
|
fromAddress?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings for the running listener/sender — null if never configured or disabled. */
|
/** Settings for the running listener/sender — null if never configured or disabled. */
|
||||||
@@ -26,6 +29,7 @@ export async function getMailboxSettings(): Promise<MailboxSettings | null> {
|
|||||||
user: config.user,
|
user: config.user,
|
||||||
password: decryptCredential(config.passwordEnc),
|
password: decryptCredential(config.passwordEnc),
|
||||||
allowInsecureTls: config.allowInsecureTls,
|
allowInsecureTls: config.allowInsecureTls,
|
||||||
|
fromAddress: config.fromAddress ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +42,7 @@ export async function saveMailboxSettings(settings: MailboxSettings): Promise<vo
|
|||||||
user: settings.user,
|
user: settings.user,
|
||||||
passwordEnc: encryptCredential(settings.password),
|
passwordEnc: encryptCredential(settings.password),
|
||||||
allowInsecureTls: settings.allowInsecureTls,
|
allowInsecureTls: settings.allowInsecureTls,
|
||||||
|
fromAddress: settings.fromAddress || null,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
verifiedAt: new Date(),
|
verifiedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -65,6 +70,7 @@ export async function getMailboxStatus() {
|
|||||||
user: config?.user ?? null,
|
user: config?.user ?? null,
|
||||||
imapHost: config?.imapHost ?? null,
|
imapHost: config?.imapHost ?? null,
|
||||||
allowInsecureTls: config?.allowInsecureTls ?? false,
|
allowInsecureTls: config?.allowInsecureTls ?? false,
|
||||||
|
fromAddress: config?.fromAddress ?? null,
|
||||||
verifiedAt: config?.verifiedAt?.getTime() ?? null,
|
verifiedAt: config?.verifiedAt?.getTime() ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { extractDataUriImages } from "./imap";
|
||||||
|
|
||||||
|
const TINY_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
|
||||||
|
|
||||||
|
describe("extractDataUriImages", () => {
|
||||||
|
it("extracts a data:image src into a real attachment and rewrites the HTML to reference it", async () => {
|
||||||
|
const html = `<p>see</p><img src="data:image/png;base64,${TINY_PNG_B64}" alt="shot">`;
|
||||||
|
const { html: out, attachments } = await extractDataUriImages(html);
|
||||||
|
|
||||||
|
expect(attachments).toHaveLength(1);
|
||||||
|
expect(attachments[0].mimeType).toBe("image/png");
|
||||||
|
expect(attachments[0].isInline).toBe(true);
|
||||||
|
expect(attachments[0].contentId).toBeNull();
|
||||||
|
expect(out).toContain(`/api/attachments/${attachments[0].id}`);
|
||||||
|
expect(out).not.toContain("base64");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes the same image embedded twice into a single attachment", async () => {
|
||||||
|
const src = `data:image/png;base64,${TINY_PNG_B64}`;
|
||||||
|
const html = `<img src="${src}"><p>middle</p><img src="${src}">`;
|
||||||
|
const { html: out, attachments } = await extractDataUriImages(html);
|
||||||
|
|
||||||
|
expect(attachments).toHaveLength(1);
|
||||||
|
const occurrences = out.split(`/api/attachments/${attachments[0].id}`).length - 1;
|
||||||
|
expect(occurrences).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves HTML with no data: images untouched", async () => {
|
||||||
|
const html = '<p>hi</p><img src="https://example.com/x.png">';
|
||||||
|
const { html: out, attachments } = await extractDataUriImages(html);
|
||||||
|
expect(attachments).toHaveLength(0);
|
||||||
|
expect(out).toBe(html);
|
||||||
|
});
|
||||||
|
});
|
||||||
+160
-12
@@ -1,9 +1,11 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
import { ImapFlow } from "imapflow";
|
import { ImapFlow } from "imapflow";
|
||||||
import { simpleParser, type ParsedMail } from "mailparser";
|
import { simpleParser, type ParsedMail } from "mailparser";
|
||||||
import { htmlToText } from "html-to-text";
|
import { htmlToText } from "html-to-text";
|
||||||
import { getMailboxSettings, saveMailboxSettings, disableMailboxConfig, type MailboxSettings } from "@/lib/mail/config";
|
import { getMailboxSettings, saveMailboxSettings, disableMailboxConfig, type MailboxSettings } from "@/lib/mail/config";
|
||||||
import { recordEmailInboundMessage } from "@/lib/tickets/service";
|
import { recordEmailInboundMessage } from "@/lib/tickets/service";
|
||||||
import { saveAttachment } from "@/lib/attachments/storage";
|
import { saveAttachment } from "@/lib/attachments/storage";
|
||||||
|
import { sanitizeEmailHtml } from "@/lib/mail/sanitize-html";
|
||||||
|
|
||||||
interface ListenerState {
|
interface ListenerState {
|
||||||
client: ImapFlow | null;
|
client: ImapFlow | null;
|
||||||
@@ -33,15 +35,114 @@ function createClient(settings: MailboxSettings): ImapFlow {
|
|||||||
// blindly — some senders (Outlook in particular) produce a text/plain
|
// blindly — some senders (Outlook in particular) produce a text/plain
|
||||||
// alternative that isn't actually clean, while html-to-text reliably drops
|
// alternative that isn't actually clean, while html-to-text reliably drops
|
||||||
// <head>/<style> content (e.g. Outlook's VML behavior: url(...) rules) and
|
// <head>/<style> content (e.g. Outlook's VML behavior: url(...) rules) and
|
||||||
// decodes entities, which a naive tag-stripping regex cannot do.
|
// decodes entities, which a naive tag-stripping regex cannot do. Images and
|
||||||
|
// any link whose href is itself a cid:/data: URI are skipped entirely — the
|
||||||
|
// alternative is a multi-KB base64 blob or a bare `cid:...` token leaking
|
||||||
|
// into the plain-text fallback as visible "text". The real HTML rendering
|
||||||
|
// (bodyHtml, built in handleRawMessage) is what actually shows the images.
|
||||||
function extractBody(parsed: ParsedMail): string {
|
function extractBody(parsed: ParsedMail): string {
|
||||||
if (typeof parsed.html === "string") {
|
if (typeof parsed.html === "string") {
|
||||||
return htmlToText(parsed.html, { baseElements: { selectors: ["body"] }, wordwrap: false }).trim();
|
return htmlToText(parsed.html, {
|
||||||
|
baseElements: { selectors: ["body"] },
|
||||||
|
wordwrap: false,
|
||||||
|
selectors: [
|
||||||
|
{ selector: "img", format: "skip" },
|
||||||
|
{ selector: 'a[href^="data:"]', format: "skip" },
|
||||||
|
{ selector: 'a[href^="cid:"]', format: "skip" },
|
||||||
|
],
|
||||||
|
}).trim();
|
||||||
}
|
}
|
||||||
if (parsed.text) return parsed.text;
|
if (parsed.text) return parsed.text;
|
||||||
return "(пустое письмо)";
|
return "(пустое письмо)";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ExtractedAttachment {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
storageKey: string;
|
||||||
|
contentId: string | null;
|
||||||
|
isInline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATA_URI_IMG_RE = /src=(["'])(data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+)\1/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls every `data:image/...;base64,...` embedded directly in an <img src>
|
||||||
|
* out into a real attachment file, replacing it in the HTML with that
|
||||||
|
* attachment's serving URL. Two things this fixes: (1) a data: URI can be
|
||||||
|
* hundreds of KB, sitting in the messages table's body_html column instead
|
||||||
|
* of the filesystem; (2) browsers (Chrome specifically) block navigating a
|
||||||
|
* tab to a data: URL, so a <a href="data:..." target="_blank"> around the
|
||||||
|
* image — the whole point of the click-to-zoom feature — silently does
|
||||||
|
* nothing. A real /api/attachments/{id} URL has neither problem. Identical
|
||||||
|
* data URIs (the same image embedded twice) are deduped to one attachment.
|
||||||
|
*/
|
||||||
|
export async function extractDataUriImages(html: string): Promise<{ html: string; attachments: ExtractedAttachment[] }> {
|
||||||
|
const attachmentIdByDataUri = new Map<string, string>();
|
||||||
|
const attachments: ExtractedAttachment[] = [];
|
||||||
|
|
||||||
|
for (const match of html.matchAll(DATA_URI_IMG_RE)) {
|
||||||
|
const dataUri = match[2];
|
||||||
|
if (attachmentIdByDataUri.has(dataUri)) continue;
|
||||||
|
|
||||||
|
const [meta, base64Payload] = dataUri.slice("data:".length).split(";base64,");
|
||||||
|
if (!base64Payload) continue;
|
||||||
|
const buffer = Buffer.from(base64Payload, "base64");
|
||||||
|
if (buffer.length === 0) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { storageKey, sizeBytes } = await saveAttachment(buffer);
|
||||||
|
const attachmentId = crypto.randomUUID();
|
||||||
|
const extension = meta.split("/")[1]?.split("+")[0] || "png";
|
||||||
|
attachments.push({
|
||||||
|
id: attachmentId,
|
||||||
|
filename: `image-${attachments.length + 1}.${extension}`,
|
||||||
|
mimeType: meta || "image/png",
|
||||||
|
sizeBytes,
|
||||||
|
storageKey,
|
||||||
|
contentId: null,
|
||||||
|
isInline: true,
|
||||||
|
});
|
||||||
|
attachmentIdByDataUri.set(dataUri, attachmentId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[mail] failed to save data-URI image:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rewritten = html;
|
||||||
|
for (const [dataUri, attachmentId] of attachmentIdByDataUri) {
|
||||||
|
rewritten = rewritten.split(dataUri).join(`/api/attachments/${attachmentId}`);
|
||||||
|
}
|
||||||
|
return { html: rewritten, attachments };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes the HTML part for display and rewrites `cid:`/data-URI inline
|
||||||
|
* images to the URL the matching attachment will be served from —
|
||||||
|
* `attachmentIdByCid` must already reflect the IDs the cid attachments are
|
||||||
|
* about to be inserted with (see handleRawMessage, which generates them
|
||||||
|
* upfront for exactly this reason). Data-URI images are extracted into
|
||||||
|
* their own attachments here instead (see extractDataUriImages) — the
|
||||||
|
* caller must fold the returned attachments into the same insert.
|
||||||
|
*/
|
||||||
|
async function extractHtmlBody(
|
||||||
|
parsed: ParsedMail,
|
||||||
|
attachmentIdByCid: Map<string, string>,
|
||||||
|
): Promise<{ html: string | null; attachments: ExtractedAttachment[] }> {
|
||||||
|
if (typeof parsed.html !== "string") return { html: null, attachments: [] };
|
||||||
|
|
||||||
|
const { html: withDataUrisExtracted, attachments } = await extractDataUriImages(parsed.html);
|
||||||
|
|
||||||
|
let html = withDataUrisExtracted;
|
||||||
|
for (const [cid, attachmentId] of attachmentIdByCid) {
|
||||||
|
html = html.split(`cid:${cid}`).join(`/api/attachments/${attachmentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { html: sanitizeEmailHtml(html), attachments };
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRawMessage(source: Buffer): Promise<void> {
|
async function handleRawMessage(source: Buffer): Promise<void> {
|
||||||
const parsed = await simpleParser(source);
|
const parsed = await simpleParser(source);
|
||||||
const from = parsed.from?.value[0];
|
const from = parsed.from?.value[0];
|
||||||
@@ -53,29 +154,41 @@ async function handleRawMessage(source: Buffer): Promise<void> {
|
|||||||
...(Array.isArray(parsed.references) ? parsed.references : parsed.references ? [parsed.references] : []),
|
...(Array.isArray(parsed.references) ? parsed.references : parsed.references ? [parsed.references] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// IDs are generated here rather than left to the DB default so bodyHtml's
|
||||||
|
// cid: rewrite (which happens before anything is inserted) can point at
|
||||||
|
// the exact attachment row this loop is about to create.
|
||||||
|
const attachmentIdByCid = new Map<string, string>();
|
||||||
const attachmentInputs = [];
|
const attachmentInputs = [];
|
||||||
for (const [i, part] of (parsed.attachments ?? []).entries()) {
|
for (const [i, part] of (parsed.attachments ?? []).entries()) {
|
||||||
try {
|
try {
|
||||||
const { storageKey, sizeBytes } = await saveAttachment(part.content);
|
const { storageKey, sizeBytes } = await saveAttachment(part.content);
|
||||||
|
const attachmentId = crypto.randomUUID();
|
||||||
|
if (part.cid) attachmentIdByCid.set(part.cid, attachmentId);
|
||||||
attachmentInputs.push({
|
attachmentInputs.push({
|
||||||
|
id: attachmentId,
|
||||||
filename: part.filename || `attachment-${i + 1}`,
|
filename: part.filename || `attachment-${i + 1}`,
|
||||||
mimeType: part.contentType || "application/octet-stream",
|
mimeType: part.contentType || "application/octet-stream",
|
||||||
sizeBytes,
|
sizeBytes,
|
||||||
storageKey,
|
storageKey,
|
||||||
|
contentId: part.cid ?? null,
|
||||||
|
isInline: Boolean(part.cid),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[mail] failed to save attachment:", err);
|
console.error("[mail] failed to save attachment:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { html: bodyHtml, attachments: dataUriAttachments } = await extractHtmlBody(parsed, attachmentIdByCid);
|
||||||
|
|
||||||
await recordEmailInboundMessage({
|
await recordEmailInboundMessage({
|
||||||
fromEmail: from.address,
|
fromEmail: from.address,
|
||||||
fromName: from.name || from.address,
|
fromName: from.name || from.address,
|
||||||
subject: parsed.subject ?? "",
|
subject: parsed.subject ?? "",
|
||||||
body: extractBody(parsed),
|
body: extractBody(parsed),
|
||||||
|
bodyHtml,
|
||||||
messageId: parsed.messageId ?? null,
|
messageId: parsed.messageId ?? null,
|
||||||
referencedMessageIds: referencedIds,
|
referencedMessageIds: referencedIds,
|
||||||
attachments: attachmentInputs,
|
attachments: [...attachmentInputs, ...dataUriAttachments],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,11 +224,54 @@ async function processUnseenMessages(client: ImapFlow): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shared by the idle loop's own catch and the watchdog below — only one of them should ever win the race to reconnect. */
|
||||||
|
function scheduleReconnect(client: ImapFlow, reason: string, err?: unknown): void {
|
||||||
|
if (state.client !== client) return; // already superseded (new client) or stopped — nothing to do
|
||||||
|
console.error(`[mail] ${reason}, will retry shortly:`, err ?? "");
|
||||||
|
state.client = null;
|
||||||
|
if (!state.stopping) {
|
||||||
|
setTimeout(() => {
|
||||||
|
ensureMailListenerStarted().catch((e) => console.error("Mail listener retry failed:", e));
|
||||||
|
}, 15_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WATCHDOG_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
const WATCHDOG_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live IDLE connection has twice gone quietly unresponsive in production —
|
||||||
|
* mail kept landing server-side but this process never noticed — without
|
||||||
|
* ImapFlow's own error handler ever firing. A plain IDLE loop has no way to
|
||||||
|
* detect that on its own: if the socket is dead but no TCP-level event
|
||||||
|
* arrives, `client.idle()` just hangs forever. This periodically forces a
|
||||||
|
* real round trip (NOOP, which ImapFlow transparently breaks IDLE for and
|
||||||
|
* resumes after) with its own hard deadline, independent of whatever
|
||||||
|
* ImapFlow's internal socket timeout is doing — if it doesn't come back in
|
||||||
|
* time, the connection is presumed dead and torn down for a fresh reconnect.
|
||||||
|
*/
|
||||||
|
function startWatchdog(client: ImapFlow): void {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (state.client !== client) {
|
||||||
|
clearInterval(interval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timeout = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("watchdog NOOP timed out")), WATCHDOG_TIMEOUT_MS),
|
||||||
|
);
|
||||||
|
Promise.race([client.noop(), timeout]).catch((err) => {
|
||||||
|
clearInterval(interval);
|
||||||
|
scheduleReconnect(client, "IMAP watchdog check failed", err);
|
||||||
|
});
|
||||||
|
}, WATCHDOG_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
async function runIdleLoop(client: ImapFlow): Promise<void> {
|
async function runIdleLoop(client: ImapFlow): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await client.mailboxOpen("INBOX");
|
await client.mailboxOpen("INBOX");
|
||||||
console.log("[mail] IDLE listener connected, watching INBOX");
|
console.log("[mail] IDLE listener connected, watching INBOX");
|
||||||
await processUnseenMessages(client);
|
await processUnseenMessages(client);
|
||||||
|
startWatchdog(client);
|
||||||
|
|
||||||
// New mail is signaled by the 'exists' event while IDLE is active — it's
|
// New mail is signaled by the 'exists' event while IDLE is active — it's
|
||||||
// independent of when idle() itself resolves (that happens on periodic
|
// independent of when idle() itself resolves (that happens on periodic
|
||||||
@@ -130,15 +286,7 @@ async function runIdleLoop(client: ImapFlow): Promise<void> {
|
|||||||
await client.idle();
|
await client.idle();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (state.client === client) {
|
scheduleReconnect(client, "IMAP idle loop error", err);
|
||||||
console.error("IMAP idle loop error, will retry shortly:", err);
|
|
||||||
state.client = null;
|
|
||||||
if (!state.stopping) {
|
|
||||||
setTimeout(() => {
|
|
||||||
ensureMailListenerStarted().catch((e) => console.error("Mail listener retry failed:", e));
|
|
||||||
}, 15_000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import * as cheerio from "cheerio";
|
||||||
|
import { sanitizeEmailHtml } from "./sanitize-html";
|
||||||
|
|
||||||
|
describe("sanitizeEmailHtml", () => {
|
||||||
|
it("keeps a data-URI screenshot as a real image, not text", () => {
|
||||||
|
const html = '<p>See screenshot:</p><img src="data:image/png;base64,iVBORw0KGgo=" alt="shot">';
|
||||||
|
const out = sanitizeEmailHtml(html);
|
||||||
|
expect(out).toContain('src="data:image/png;base64,iVBORw0KGgo="');
|
||||||
|
expect(out).not.toContain("[data:image");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves inline font/color formatting", () => {
|
||||||
|
const out = sanitizeEmailHtml('<p style="color:red;font-family:Arial;font-size:14px;">Hi</p>');
|
||||||
|
expect(out).toContain("color:red");
|
||||||
|
expect(out).toContain("font-family:Arial");
|
||||||
|
expect(out).toContain("font-size:14px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips <script> tags and inline event handlers", () => {
|
||||||
|
const out = sanitizeEmailHtml('<p onclick="alert(1)">click</p><script>alert(2)</script>');
|
||||||
|
expect(out).not.toContain("<script");
|
||||||
|
expect(out).not.toContain("onclick");
|
||||||
|
expect(out).not.toContain("alert");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips javascript: hrefs but keeps real links with noopener", () => {
|
||||||
|
const out = sanitizeEmailHtml('<a href="javascript:alert(1)">bad</a><a href="https://example.com">good</a>');
|
||||||
|
expect(out).not.toContain("javascript:");
|
||||||
|
expect(out).toContain('href="https://example.com"');
|
||||||
|
expect(out).toContain("noopener");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops unresolved cid: image references instead of leaking the token as text", () => {
|
||||||
|
const out = sanitizeEmailHtml('<img src="cid:image001.png@01D9" alt="x">');
|
||||||
|
expect(out).not.toContain("cid:image001.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops layout-breaking styles like position:fixed", () => {
|
||||||
|
const out = sanitizeEmailHtml('<div style="position:fixed;top:0;left:0;width:9999px;">x</div>');
|
||||||
|
expect(out).not.toContain("position");
|
||||||
|
expect(out).not.toContain("9999px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unwraps Outlook VML/mso markup without leaking its CSS as visible text", () => {
|
||||||
|
const html = `<html><head><style>v\\:* {behavior:url(#default#VML);}</style></head><body><p>Hi</p></body></html>`;
|
||||||
|
const out = sanitizeEmailHtml(html);
|
||||||
|
expect(out).not.toContain("behavior:url");
|
||||||
|
expect(out).toContain("Hi");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps a bare image in a link to its own full-size src, opened in a new tab", () => {
|
||||||
|
// A non-data: src, since Chrome refuses to navigate a tab to a data:
|
||||||
|
// URL — by the time sanitizeEmailHtml runs, imap.ts has already
|
||||||
|
// extracted data:image srcs into real /api/attachments/... URLs.
|
||||||
|
const out = sanitizeEmailHtml('<img src="/api/attachments/abc-123" alt="shot">');
|
||||||
|
expect(out).toMatch(/<a href="\/api\/attachments\/abc-123"[^>]*target="_blank"[^>]*><img[^>]*><\/a>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a residual data: URI image unwrapped rather than linking somewhere Chrome won't navigate", () => {
|
||||||
|
const out = sanitizeEmailHtml('<img src="data:image/png;base64,iVBORw0KGgo=" alt="shot">');
|
||||||
|
expect(out).not.toContain("<a ");
|
||||||
|
expect(out).toContain('src="data:image/png;base64,iVBORw0KGgo="');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not double-wrap an image the sender already linked themselves", () => {
|
||||||
|
const out = sanitizeEmailHtml('<a href="https://example.com/landing"><img src="https://example.com/banner.png"></a>');
|
||||||
|
const anchorCount = (out.match(/<a /g) ?? []).length;
|
||||||
|
expect(anchorCount).toBe(1);
|
||||||
|
expect(out).toContain('href="https://example.com/landing"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("safely wraps an image whose src contains characters that could break out of an HTML attribute", () => {
|
||||||
|
// sanitize-html itself only checks the URL scheme on a data: src, not
|
||||||
|
// that the payload is actually image data — the wrap step must not
|
||||||
|
// re-parse that value as HTML when building the wrapping <a>. A raw
|
||||||
|
// "<script>" substring inside a properly double-quoted attribute value
|
||||||
|
// is inert (browsers only end the attribute on the matching quote, not
|
||||||
|
// on ">"), so the real check is "no actual <script> element exists
|
||||||
|
// when the output is parsed as HTML", not a plain substring match.
|
||||||
|
const evilSrc = 'data:text/plain,"><script>alert(1)</script>';
|
||||||
|
const out = sanitizeEmailHtml(`<img src="${evilSrc.replace(/"/g, """)}">`);
|
||||||
|
const $ = cheerio.load(out, null, false);
|
||||||
|
expect($("script").length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import sanitizeHtml from "sanitize-html";
|
||||||
|
import * as cheerio from "cheerio";
|
||||||
|
|
||||||
|
// Formatting-only tags — no <script>/<style>/<iframe>/<object>/<form>/svg/etc.
|
||||||
|
// Unknown tags (Outlook's <o:p>, VML <v:*>) are unwrapped by sanitize-html
|
||||||
|
// (tag dropped, text content kept), which is what we want for stray markup.
|
||||||
|
const ALLOWED_TAGS = [
|
||||||
|
"a", "p", "br", "div", "span", "b", "strong", "i", "em", "u", "s", "strike", "small",
|
||||||
|
"ul", "ol", "li", "blockquote", "pre", "code", "hr",
|
||||||
|
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
|
"table", "thead", "tbody", "tr", "td", "th",
|
||||||
|
"img", "sub", "sup", "font",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only visual/typographic properties — nothing that can reposition or
|
||||||
|
// resize content out of the message bubble (position, width/height on
|
||||||
|
// non-img elements, etc. are simply not in this allowlist and get dropped).
|
||||||
|
const ALLOWED_STYLES = {
|
||||||
|
"*": {
|
||||||
|
color: [/^#[0-9a-fA-F]{3,8}$/, /^rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/, /^[a-zA-Z]+$/],
|
||||||
|
"background-color": [/^#[0-9a-fA-F]{3,8}$/, /^rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/, /^[a-zA-Z]+$/],
|
||||||
|
"font-family": [/^[\w\s,'"-]+$/],
|
||||||
|
"font-size": [/^\d+(\.\d+)?(px|pt|em|%)$/],
|
||||||
|
"font-weight": [/^(normal|bold|\d{3})$/],
|
||||||
|
"font-style": [/^(normal|italic)$/],
|
||||||
|
"text-align": [/^(left|right|center|justify)$/],
|
||||||
|
"text-decoration": [/^[\w\s-]+$/],
|
||||||
|
"line-height": [/^[\d.]+(px|em|%)?$/],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an inbound email's HTML part safely for display in the ticket
|
||||||
|
* thread — preserves fonts/colors/layout/inline images (the whole point:
|
||||||
|
* "как человек на почту отправил, ровно также оно и пришло"), while
|
||||||
|
* stripping anything that could execute script or break the page layout.
|
||||||
|
* `cid:` image references must already be rewritten to real attachment URLs
|
||||||
|
* before calling this (see imap.ts) — `cid`/`javascript` are not in the
|
||||||
|
* allowed URL schemes, so any that slip through as-is are dropped.
|
||||||
|
*/
|
||||||
|
export function sanitizeEmailHtml(html: string): string {
|
||||||
|
const clean = sanitizeHtml(html, {
|
||||||
|
allowedTags: ALLOWED_TAGS,
|
||||||
|
allowedAttributes: {
|
||||||
|
a: ["href", "title", "target", "rel"],
|
||||||
|
img: ["src", "alt", "width", "height", "style"],
|
||||||
|
td: ["colspan", "rowspan", "style"],
|
||||||
|
th: ["colspan", "rowspan", "style"],
|
||||||
|
table: ["style", "border", "cellpadding", "cellspacing"],
|
||||||
|
"*": ["style"],
|
||||||
|
},
|
||||||
|
allowedSchemes: ["http", "https", "mailto"],
|
||||||
|
allowedSchemesByTag: { img: ["http", "https", "data"] },
|
||||||
|
allowedStyles: ALLOWED_STYLES,
|
||||||
|
transformTags: {
|
||||||
|
a: sanitizeHtml.simpleTransform("a", { target: "_blank", rel: "noopener noreferrer" }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return wrapBareImagesInLinks(clean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plain <img> with no href around it isn't clickable — the message
|
||||||
|
* bubble shows it shrunk to fit, and the only way to see it at full
|
||||||
|
* resolution was the browser's right-click "open image in new tab". Wrap
|
||||||
|
* every image not already inside a real link (a sender-linked banner, say)
|
||||||
|
* in one pointing at its own src, so a normal click does the same thing.
|
||||||
|
* Runs after sanitizeHtml, on already-sanitized output — the href is just
|
||||||
|
* the img's own already-scheme-validated src, so this can't reintroduce
|
||||||
|
* anything sanitizeHtml would have stripped.
|
||||||
|
*
|
||||||
|
* data: URIs are skipped here — imap.ts extracts every data:image src into
|
||||||
|
* a real attachment (and a real /api/attachments/... URL) before this ever
|
||||||
|
* runs, specifically because Chrome silently refuses to navigate a tab to a
|
||||||
|
* data: URL, so a link built from one would look clickable but do nothing.
|
||||||
|
* Anything still starting with "data:" at this point is a residual case
|
||||||
|
* that extraction didn't catch — better left unwrapped (a plain image) than
|
||||||
|
* wrapped in a link that appears to work but doesn't.
|
||||||
|
*/
|
||||||
|
function wrapBareImagesInLinks(html: string): string {
|
||||||
|
const $ = cheerio.load(html, null, false);
|
||||||
|
$("img").each((_, el) => {
|
||||||
|
const $img = $(el);
|
||||||
|
if ($img.closest("a").length > 0) return;
|
||||||
|
const src = $img.attr("src");
|
||||||
|
if (!src || src.startsWith("data:")) return;
|
||||||
|
// Built via .attr(), not string-interpolated HTML — src is
|
||||||
|
// attacker-influenced (an already scheme-validated but otherwise
|
||||||
|
// arbitrary data:/http(s) URL), and interpolating it into an HTML
|
||||||
|
// string for cheerio to re-parse would let a crafted `src` (e.g. one
|
||||||
|
// containing an embedded `">`) break out of the attribute and inject
|
||||||
|
// markup. Setting it as a DOM attribute value keeps it inert.
|
||||||
|
const $a = $("<a>").attr({ href: src, target: "_blank", rel: "noopener noreferrer" });
|
||||||
|
$img.wrap($a);
|
||||||
|
});
|
||||||
|
return $.html();
|
||||||
|
}
|
||||||
@@ -32,11 +32,12 @@ export async function sendTicketReplyEmail(params: {
|
|||||||
const settings = await getMailboxSettings();
|
const settings = await getMailboxSettings();
|
||||||
if (!settings) throw new Error("Email channel is not configured");
|
if (!settings) throw new Error("Email channel is not configured");
|
||||||
|
|
||||||
const messageId = `<${crypto.randomUUID()}@${settings.user.split("@")[1] ?? "top-sysops.ru"}>`;
|
const fromAddress = settings.fromAddress || settings.user;
|
||||||
|
const messageId = `<${crypto.randomUUID()}@${fromAddress.split("@")[1] ?? "top-sysops.ru"}>`;
|
||||||
const subject = params.subject.toLowerCase().startsWith("re:") ? params.subject : `Re: ${params.subject}`;
|
const subject = params.subject.toLowerCase().startsWith("re:") ? params.subject : `Re: ${params.subject}`;
|
||||||
|
|
||||||
await createTransport(settings).sendMail({
|
await createTransport(settings).sendMail({
|
||||||
from: settings.user,
|
from: fromAddress,
|
||||||
to: params.to,
|
to: params.to,
|
||||||
subject,
|
subject,
|
||||||
text: params.body,
|
text: params.body,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export async function ensurePushSubscribed(): Promise<void> {
|
|||||||
const saveRes = await fetch("/api/push/subscribe", {
|
const saveRes = await fetch("/api/push/subscribe", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(subscription.toJSON()),
|
body: JSON.stringify({ ...subscription.toJSON(), userAgent: navigator.userAgent }),
|
||||||
});
|
});
|
||||||
if (saveRes.ok) {
|
if (saveRes.ok) {
|
||||||
console.log("[push] subscription saved server-side — done");
|
console.log("[push] subscription saved server-side — done");
|
||||||
|
|||||||
+24
-4
@@ -1,4 +1,4 @@
|
|||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray, and } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { pushSubscriptions, users } from "@/lib/db/schema";
|
import { pushSubscriptions, users } from "@/lib/db/schema";
|
||||||
import type { TicketDTO, MessageDTO } from "@/lib/tickets/types";
|
import type { TicketDTO, MessageDTO } from "@/lib/tickets/types";
|
||||||
@@ -10,13 +10,17 @@ export interface PushSubscriptionKeys {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Re-subscribing from the same browser (e.g. after clearing site data and re-granting) overwrites the old row via the endpoint's unique constraint, rather than accumulating stale duplicates. */
|
/** Re-subscribing from the same browser (e.g. after clearing site data and re-granting) overwrites the old row via the endpoint's unique constraint, rather than accumulating stale duplicates. */
|
||||||
export async function savePushSubscription(userId: string, sub: PushSubscriptionKeys): Promise<void> {
|
export async function savePushSubscription(
|
||||||
|
userId: string,
|
||||||
|
sub: PushSubscriptionKeys,
|
||||||
|
userAgent?: string,
|
||||||
|
): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.insert(pushSubscriptions)
|
.insert(pushSubscriptions)
|
||||||
.values({ userId, endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth })
|
.values({ userId, endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth, userAgent })
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: pushSubscriptions.endpoint,
|
target: pushSubscriptions.endpoint,
|
||||||
set: { userId, p256dh: sub.keys.p256dh, auth: sub.keys.auth },
|
set: { userId, p256dh: sub.keys.p256dh, auth: sub.keys.auth, userAgent },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +28,22 @@ export async function deletePushSubscriptionByEndpoint(endpoint: string): Promis
|
|||||||
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint));
|
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A user's own subscribed devices/browsers, for the "manage notifications" settings list — never exposes the endpoint/keys, only what's useful to tell rows apart. */
|
||||||
|
export async function listPushSubscriptionsForUser(userId: string) {
|
||||||
|
const rows = await db.query.pushSubscriptions.findMany({
|
||||||
|
where: eq(pushSubscriptions.userId, userId),
|
||||||
|
orderBy: (t, { desc }) => desc(t.createdAt),
|
||||||
|
});
|
||||||
|
return rows.map((r) => ({ id: r.id, userAgent: r.userAgent, createdAt: r.createdAt }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes one of the current user's own subscriptions by id — scoped so a user can only ever remove their own. */
|
||||||
|
export async function deleteOwnPushSubscription(userId: string, id: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.delete(pushSubscriptions)
|
||||||
|
.where(and(eq(pushSubscriptions.id, id), eq(pushSubscriptions.userId, userId)));
|
||||||
|
}
|
||||||
|
|
||||||
async function sendPushToUsers(userIds: string[], payload: { title: string; body: string; url: string }): Promise<void> {
|
async function sendPushToUsers(userIds: string[], payload: { title: string; body: string; url: string }): Promise<void> {
|
||||||
if (userIds.length === 0 || !ensureVapidConfigured()) return;
|
if (userIds.length === 0 || !ensureVapidConfigured()) return;
|
||||||
|
|
||||||
|
|||||||
+34
-12
@@ -49,10 +49,24 @@ export async function deliverAgentMessage(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Channel delivery (email/Telegram) is best-effort: the agent's reply is
|
||||||
|
// the thing that must never be lost. Previously an SMTP/Telegram failure
|
||||||
|
// here threw straight out of this function, so recordAgentReply below was
|
||||||
|
// never reached — the reply vanished entirely (not recorded, nothing
|
||||||
|
// shown in the thread) and the API route's catch-all turned it into a
|
||||||
|
// misleading "Ticket not found". A broken mail/bot account should degrade
|
||||||
|
// to "reply saved internally, not delivered" — never silent data loss.
|
||||||
|
// A ticket only ever has one channel, so at most one of the two branches
|
||||||
|
// below runs — this single flag is the outcome of whichever one does, and
|
||||||
|
// is what lets the agent tell "saved" from "the customer actually got
|
||||||
|
// this" in the UI (see messages.deliveryFailed).
|
||||||
let emailMessageId: string | undefined;
|
let emailMessageId: string | undefined;
|
||||||
|
let deliveryFailed = false;
|
||||||
|
|
||||||
if (ticket.channel === "email") {
|
if (ticket.channel === "email") {
|
||||||
const context = await getEmailThreadContext(params.ticketId);
|
const context = await getEmailThreadContext(params.ticketId);
|
||||||
if (context) {
|
if (context) {
|
||||||
|
try {
|
||||||
emailMessageId = await sendTicketReplyEmail({
|
emailMessageId = await sendTicketReplyEmail({
|
||||||
to: context.customerEmail,
|
to: context.customerEmail,
|
||||||
subject: context.subject,
|
subject: context.subject,
|
||||||
@@ -62,23 +76,17 @@ export async function deliverAgentMessage(params: {
|
|||||||
? { filename: params.attachment.filename, content: params.attachment.buffer }
|
? { filename: params.attachment.filename, content: params.attachment.buffer }
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to send reply email for ticket ${params.ticketId}:`, err);
|
||||||
|
deliveryFailed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (ticket.channel === "telegram") {
|
||||||
const result = await recordAgentReply({
|
|
||||||
ticketId: params.ticketId,
|
|
||||||
agentId: params.agentId,
|
|
||||||
agentName: params.agentName,
|
|
||||||
body: params.body,
|
|
||||||
emailMessageId,
|
|
||||||
attachments: attachmentInput ? [attachmentInput] : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.ticket.channel === "telegram") {
|
|
||||||
const customer = await db.query.customers.findFirst({
|
const customer = await db.query.customers.findFirst({
|
||||||
where: eq(customers.id, result.ticket.customerId),
|
where: eq(customers.id, ticket.customerId),
|
||||||
});
|
});
|
||||||
if (customer?.telegramChatId) {
|
if (customer?.telegramChatId) {
|
||||||
|
try {
|
||||||
if (params.attachment) {
|
if (params.attachment) {
|
||||||
await sendTelegramFile(
|
await sendTelegramFile(
|
||||||
customer.telegramChatId,
|
customer.telegramChatId,
|
||||||
@@ -88,8 +96,22 @@ export async function deliverAgentMessage(params: {
|
|||||||
} else {
|
} else {
|
||||||
await sendTelegramMessage(customer.telegramChatId, params.body);
|
await sendTelegramMessage(customer.telegramChatId, params.body);
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to deliver reply via Telegram for ticket ${params.ticketId}:`, err);
|
||||||
|
deliveryFailed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await recordAgentReply({
|
||||||
|
ticketId: params.ticketId,
|
||||||
|
agentId: params.agentId,
|
||||||
|
agentName: params.agentName,
|
||||||
|
body: params.body,
|
||||||
|
emailMessageId,
|
||||||
|
deliveryFailed,
|
||||||
|
attachments: attachmentInput ? [attachmentInput] : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
+107
-17
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "@/lib/db/schema";
|
} from "@/lib/db/schema";
|
||||||
import { publishTicketEvent } from "@/lib/events/bus";
|
import { publishTicketEvent } from "@/lib/events/bus";
|
||||||
import { notifyPushForCustomerMessage } from "@/lib/push/service";
|
import { notifyPushForCustomerMessage } from "@/lib/push/service";
|
||||||
|
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
|
||||||
import type {
|
import type {
|
||||||
TicketDTO,
|
TicketDTO,
|
||||||
MessageDTO,
|
MessageDTO,
|
||||||
@@ -22,9 +23,22 @@ import type {
|
|||||||
AttachmentDTO,
|
AttachmentDTO,
|
||||||
} from "@/lib/tickets/types";
|
} from "@/lib/tickets/types";
|
||||||
|
|
||||||
type AttachmentInput = { filename: string; mimeType: string; sizeBytes: number; storageKey: string };
|
type AttachmentInput = {
|
||||||
|
id?: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
storageKey: string;
|
||||||
|
contentId?: string | null;
|
||||||
|
isInline?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function toTicketDTO(ticket: typeof tickets.$inferSelect, customerName: string, tags: TagDTO[]): TicketDTO {
|
function toTicketDTO(
|
||||||
|
ticket: typeof tickets.$inferSelect,
|
||||||
|
customerName: string,
|
||||||
|
tags: TagDTO[],
|
||||||
|
assigneeName: string | null = null,
|
||||||
|
): TicketDTO {
|
||||||
return {
|
return {
|
||||||
id: ticket.id,
|
id: ticket.id,
|
||||||
subject: ticket.subject,
|
subject: ticket.subject,
|
||||||
@@ -34,6 +48,7 @@ function toTicketDTO(ticket: typeof tickets.$inferSelect, customerName: string,
|
|||||||
customerId: ticket.customerId,
|
customerId: ticket.customerId,
|
||||||
customerName,
|
customerName,
|
||||||
assigneeId: ticket.assigneeId,
|
assigneeId: ticket.assigneeId,
|
||||||
|
assigneeName,
|
||||||
tags,
|
tags,
|
||||||
createdAt: ticket.createdAt.getTime(),
|
createdAt: ticket.createdAt.getTime(),
|
||||||
updatedAt: ticket.updatedAt.getTime(),
|
updatedAt: ticket.updatedAt.getTime(),
|
||||||
@@ -41,6 +56,13 @@ function toTicketDTO(ticket: typeof tickets.$inferSelect, customerName: string,
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Single-ticket lookups only — listTickets/listTicketsForCustomer LEFT JOIN users directly instead, to avoid N+1 queries across a whole board. */
|
||||||
|
async function getAssigneeName(assigneeId: string | null): Promise<string | null> {
|
||||||
|
if (!assigneeId) return null;
|
||||||
|
const user = await db.query.users.findFirst({ where: eq(users.id, assigneeId) });
|
||||||
|
return user?.name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function toMessageDTO(message: typeof messages.$inferSelect, attachments: AttachmentDTO[] = []): MessageDTO {
|
function toMessageDTO(message: typeof messages.$inferSelect, attachments: AttachmentDTO[] = []): MessageDTO {
|
||||||
return {
|
return {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
@@ -48,10 +70,12 @@ function toMessageDTO(message: typeof messages.$inferSelect, attachments: Attach
|
|||||||
authorType: message.authorType,
|
authorType: message.authorType,
|
||||||
authorName: message.authorName,
|
authorName: message.authorName,
|
||||||
body: message.body,
|
body: message.body,
|
||||||
|
bodyHtml: message.bodyHtml,
|
||||||
direction: message.direction,
|
direction: message.direction,
|
||||||
visibility: message.visibility,
|
visibility: message.visibility,
|
||||||
attachments,
|
attachments,
|
||||||
createdAt: message.createdAt.getTime(),
|
createdAt: message.createdAt.getTime(),
|
||||||
|
deliveryFailed: message.deliveryFailed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +105,7 @@ async function getTagsForTickets(ticketIds: string[]): Promise<Map<string, TagDT
|
|||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const list = map.get(row.ticketId) ?? [];
|
const list = map.get(row.ticketId) ?? [];
|
||||||
list.push({ id: row.tag.id, name: row.tag.name, color: row.tag.color });
|
list.push({ id: row.tag.id, name: row.tag.name, color: row.tag.color, isDefault: row.tag.isDefault });
|
||||||
map.set(row.ticketId, list);
|
map.set(row.ticketId, list);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
@@ -101,7 +125,7 @@ async function getAttachmentsForMessages(messageIds: string[]): Promise<Map<stri
|
|||||||
});
|
});
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const list = map.get(row.messageId) ?? [];
|
const list = map.get(row.messageId) ?? [];
|
||||||
list.push({ id: row.id, filename: row.filename, mimeType: row.mimeType, sizeBytes: row.sizeBytes });
|
list.push({ id: row.id, filename: row.filename, mimeType: row.mimeType, sizeBytes: row.sizeBytes, isInline: row.isInline });
|
||||||
map.set(row.messageId, list);
|
map.set(row.messageId, list);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
@@ -115,9 +139,11 @@ async function appendMessage(params: {
|
|||||||
authorId: string | null;
|
authorId: string | null;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
bodyHtml?: string | null;
|
||||||
direction: "in" | "out";
|
direction: "in" | "out";
|
||||||
visibility?: "public" | "internal";
|
visibility?: "public" | "internal";
|
||||||
emailMessageId?: string;
|
emailMessageId?: string;
|
||||||
|
deliveryFailed?: boolean;
|
||||||
attachments?: AttachmentInput[];
|
attachments?: AttachmentInput[];
|
||||||
}) {
|
}) {
|
||||||
const [message] = await db
|
const [message] = await db
|
||||||
@@ -128,8 +154,10 @@ async function appendMessage(params: {
|
|||||||
authorId: params.authorId,
|
authorId: params.authorId,
|
||||||
authorName: params.authorName,
|
authorName: params.authorName,
|
||||||
body: params.body,
|
body: params.body,
|
||||||
|
bodyHtml: params.bodyHtml ?? null,
|
||||||
direction: params.direction,
|
direction: params.direction,
|
||||||
visibility: params.visibility ?? "public",
|
visibility: params.visibility ?? "public",
|
||||||
|
deliveryFailed: params.deliveryFailed ?? false,
|
||||||
emailMessageId: params.emailMessageId,
|
emailMessageId: params.emailMessageId,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -145,13 +173,21 @@ async function appendMessage(params: {
|
|||||||
filename: a.filename,
|
filename: a.filename,
|
||||||
mimeType: a.mimeType,
|
mimeType: a.mimeType,
|
||||||
sizeBytes: a.sizeBytes,
|
sizeBytes: a.sizeBytes,
|
||||||
|
isInline: a.isInline,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.ticketIsNew) {
|
||||||
|
const defaultTagId = await getDefaultTagId();
|
||||||
|
if (defaultTagId) {
|
||||||
|
await db.insert(ticketTags).values({ ticketId: params.ticket.id, tagId: defaultTagId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ticket = await touchTicket(params.ticket.id, { lastMessageAt: new Date() });
|
const ticket = await touchTicket(params.ticket.id, { lastMessageAt: new Date() });
|
||||||
const name = await customerName(ticket.customerId);
|
const name = await customerName(ticket.customerId);
|
||||||
const ticketTagsList = await getTagsForTicket(ticket.id);
|
const ticketTagsList = await getTagsForTicket(ticket.id);
|
||||||
const dto = toTicketDTO(ticket, name, ticketTagsList);
|
const dto = toTicketDTO(ticket, name, ticketTagsList, await getAssigneeName(ticket.assigneeId));
|
||||||
const messageDto = toMessageDTO(message, attachmentDTOs);
|
const messageDto = toMessageDTO(message, attachmentDTOs);
|
||||||
|
|
||||||
publishTicketEvent({
|
publishTicketEvent({
|
||||||
@@ -278,16 +314,19 @@ export async function recordChatInboundMessage(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Records an inbound email. Prefers exact thread matching via
|
* Records an inbound email. Threads it onto an existing ticket only via
|
||||||
* In-Reply-To/References (so a reply to a specific closed ticket lands on
|
* exact In-Reply-To/References matching. Unlike Telegram/the website widget
|
||||||
* that ticket, not a random other open one); falls back to the same
|
* (a continuous chat stream with no subject), email always carries a
|
||||||
* find-or-create-open-ticket heuristic used for Telegram.
|
* distinct subject per conversation — so a message with no header match is
|
||||||
|
* always a new, unrelated request and must become its own ticket, even if
|
||||||
|
* the customer already has another ticket open.
|
||||||
*/
|
*/
|
||||||
export async function recordEmailInboundMessage(params: {
|
export async function recordEmailInboundMessage(params: {
|
||||||
fromEmail: string;
|
fromEmail: string;
|
||||||
fromName: string;
|
fromName: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
bodyHtml?: string | null;
|
||||||
messageId: string | null;
|
messageId: string | null;
|
||||||
referencedMessageIds: string[];
|
referencedMessageIds: string[];
|
||||||
attachments?: AttachmentInput[];
|
attachments?: AttachmentInput[];
|
||||||
@@ -297,7 +336,7 @@ export async function recordEmailInboundMessage(params: {
|
|||||||
const matchedTicketId = await findTicketIdByEmailReferences(params.referencedMessageIds);
|
const matchedTicketId = await findTicketIdByEmailReferences(params.referencedMessageIds);
|
||||||
let ticket = matchedTicketId
|
let ticket = matchedTicketId
|
||||||
? await db.query.tickets.findFirst({ where: eq(tickets.id, matchedTicketId) })
|
? await db.query.tickets.findFirst({ where: eq(tickets.id, matchedTicketId) })
|
||||||
: await findOpenTicketForCustomer(customer.id);
|
: null;
|
||||||
|
|
||||||
let ticketIsNew = false;
|
let ticketIsNew = false;
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
@@ -321,6 +360,7 @@ export async function recordEmailInboundMessage(params: {
|
|||||||
authorId: customer.id,
|
authorId: customer.id,
|
||||||
authorName: params.fromName,
|
authorName: params.fromName,
|
||||||
body: params.body,
|
body: params.body,
|
||||||
|
bodyHtml: params.bodyHtml,
|
||||||
direction: "in",
|
direction: "in",
|
||||||
emailMessageId: params.messageId ?? undefined,
|
emailMessageId: params.messageId ?? undefined,
|
||||||
attachments: params.attachments,
|
attachments: params.attachments,
|
||||||
@@ -361,6 +401,7 @@ export async function recordAgentReply(params: {
|
|||||||
agentName: string;
|
agentName: string;
|
||||||
body: string;
|
body: string;
|
||||||
emailMessageId?: string;
|
emailMessageId?: string;
|
||||||
|
deliveryFailed?: boolean;
|
||||||
attachments?: AttachmentInput[];
|
attachments?: AttachmentInput[];
|
||||||
}) {
|
}) {
|
||||||
const existing = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
|
const existing = await db.query.tickets.findFirst({ where: eq(tickets.id, params.ticketId) });
|
||||||
@@ -378,6 +419,7 @@ export async function recordAgentReply(params: {
|
|||||||
body: params.body,
|
body: params.body,
|
||||||
direction: "out",
|
direction: "out",
|
||||||
emailMessageId: params.emailMessageId,
|
emailMessageId: params.emailMessageId,
|
||||||
|
deliveryFailed: params.deliveryFailed,
|
||||||
attachments: params.attachments,
|
attachments: params.attachments,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -416,14 +458,38 @@ export async function setTicketStatus(
|
|||||||
status: (typeof tickets.$inferSelect)["status"],
|
status: (typeof tickets.$inferSelect)["status"],
|
||||||
) {
|
) {
|
||||||
const ticket = await touchTicket(ticketId, { status });
|
const ticket = await touchTicket(ticketId, { status });
|
||||||
const dto = toTicketDTO(ticket, await customerName(ticket.customerId), await getTagsForTicket(ticket.id));
|
const dto = toTicketDTO(ticket, await customerName(ticket.customerId), await getTagsForTicket(ticket.id), await getAssigneeName(ticket.assigneeId));
|
||||||
publishTicketEvent({ type: "ticket.updated", ticket: dto });
|
publishTicketEvent({ type: "ticket.updated", ticket: dto });
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes status on many tickets at once, silently skipping any id that
|
||||||
|
* doesn't exist or isn't visible to the acting user (an agent selecting
|
||||||
|
* tickets only ever sees their own, so this is mostly a safety net against
|
||||||
|
* a stale client-side selection). Returns the ids actually updated.
|
||||||
|
*/
|
||||||
|
export async function bulkSetTicketStatus(
|
||||||
|
ticketIds: string[],
|
||||||
|
status: (typeof tickets.$inferSelect)["status"],
|
||||||
|
currentUser: { id: string; role: "admin" | "agent" },
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (ticketIds.length === 0) return [];
|
||||||
|
|
||||||
|
const rows = await db.query.tickets.findMany({ where: inArray(tickets.id, ticketIds) });
|
||||||
|
const visible = rows.filter((row) => isTicketVisibleTo(row, currentUser));
|
||||||
|
|
||||||
|
const updated: string[] = [];
|
||||||
|
for (const row of visible) {
|
||||||
|
await setTicketStatus(row.id, status);
|
||||||
|
updated.push(row.id);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
export async function assignTicket(ticketId: string, assigneeId: string | null) {
|
export async function assignTicket(ticketId: string, assigneeId: string | null) {
|
||||||
const ticket = await touchTicket(ticketId, { assigneeId });
|
const ticket = await touchTicket(ticketId, { assigneeId });
|
||||||
const dto = toTicketDTO(ticket, await customerName(ticket.customerId), await getTagsForTicket(ticket.id));
|
const dto = toTicketDTO(ticket, await customerName(ticket.customerId), await getTagsForTicket(ticket.id), await getAssigneeName(ticket.assigneeId));
|
||||||
publishTicketEvent({ type: "ticket.updated", ticket: dto });
|
publishTicketEvent({ type: "ticket.updated", ticket: dto });
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
@@ -464,9 +530,10 @@ export async function listTickets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ ticket: tickets, customerName: customers.displayName })
|
.select({ ticket: tickets, customerName: customers.displayName, assigneeName: users.name })
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.innerJoin(customers, eq(tickets.customerId, customers.id))
|
.innerJoin(customers, eq(tickets.customerId, customers.id))
|
||||||
|
.leftJoin(users, eq(tickets.assigneeId, users.id))
|
||||||
.orderBy(desc(tickets.lastMessageAt));
|
.orderBy(desc(tickets.lastMessageAt));
|
||||||
|
|
||||||
const idsToKeep = matchingTicketIds;
|
const idsToKeep = matchingTicketIds;
|
||||||
@@ -477,7 +544,9 @@ export async function listTickets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tagsMap = await getTagsForTickets(filteredRows.map((r) => r.ticket.id));
|
const tagsMap = await getTagsForTickets(filteredRows.map((r) => r.ticket.id));
|
||||||
return filteredRows.map((row) => toTicketDTO(row.ticket, row.customerName, tagsMap.get(row.ticket.id) ?? []));
|
return filteredRows.map((row) =>
|
||||||
|
toTicketDTO(row.ticket, row.customerName, tagsMap.get(row.ticket.id) ?? [], row.assigneeName ?? null),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTicketWithMessages(ticketId: string) {
|
export async function getTicketWithMessages(ticketId: string) {
|
||||||
@@ -498,7 +567,7 @@ export async function getTicketWithMessages(ticketId: string) {
|
|||||||
const ticketTagsList = await getTagsForTicket(ticketId);
|
const ticketTagsList = await getTagsForTicket(ticketId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ticket: toTicketDTO(row[0].ticket, row[0].customerName, ticketTagsList),
|
ticket: toTicketDTO(row[0].ticket, row[0].customerName, ticketTagsList, await getAssigneeName(row[0].ticket.assigneeId)),
|
||||||
messages: ticketMessages.map((m) => toMessageDTO(m, attachmentsMap.get(m.id) ?? [])),
|
messages: ticketMessages.map((m) => toMessageDTO(m, attachmentsMap.get(m.id) ?? [])),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -607,6 +676,7 @@ export async function createManualTicket(params: {
|
|||||||
body: string;
|
body: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerEmail?: string;
|
customerEmail?: string;
|
||||||
|
attachment?: AttachmentInput;
|
||||||
}) {
|
}) {
|
||||||
const [customer] = await db
|
const [customer] = await db
|
||||||
.insert(customers)
|
.insert(customers)
|
||||||
@@ -630,6 +700,7 @@ export async function createManualTicket(params: {
|
|||||||
authorName: params.customerName,
|
authorName: params.customerName,
|
||||||
body: params.body,
|
body: params.body,
|
||||||
direction: "in",
|
direction: "in",
|
||||||
|
attachments: params.attachment ? [params.attachment] : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,7 +742,7 @@ export async function deleteCannedResponse(id: string) {
|
|||||||
|
|
||||||
export async function listTags(): Promise<TagDTO[]> {
|
export async function listTags(): Promise<TagDTO[]> {
|
||||||
const rows = await db.query.tags.findMany({ orderBy: tagsTable.name });
|
const rows = await db.query.tags.findMany({ orderBy: tagsTable.name });
|
||||||
return rows.map((r) => ({ id: r.id, name: r.name, color: r.color }));
|
return rows.map((r) => ({ id: r.id, name: r.name, color: r.color, isDefault: r.isDefault }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTag(name: string, color: TagDTO["color"]) {
|
export async function createTag(name: string, color: TagDTO["color"]) {
|
||||||
@@ -683,6 +754,20 @@ export async function deleteTag(id: string) {
|
|||||||
await db.delete(tagsTable).where(eq(tagsTable.id, id));
|
await db.delete(tagsTable).where(eq(tagsTable.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** At most one default at a time — pass null to clear it entirely. */
|
||||||
|
export async function setDefaultTag(id: string | null): Promise<TagDTO[]> {
|
||||||
|
await db.update(tagsTable).set({ isDefault: false }).where(eq(tagsTable.isDefault, true));
|
||||||
|
if (id) {
|
||||||
|
await db.update(tagsTable).set({ isDefault: true }).where(eq(tagsTable.id, id));
|
||||||
|
}
|
||||||
|
return listTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDefaultTagId(): Promise<string | null> {
|
||||||
|
const row = await db.query.tags.findFirst({ where: eq(tagsTable.isDefault, true) });
|
||||||
|
return row?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Full replace — the ticket ends up tagged with exactly this set. */
|
/** Full replace — the ticket ends up tagged with exactly this set. */
|
||||||
export async function setTicketTags(ticketId: string, tagIds: string[]): Promise<TagDTO[]> {
|
export async function setTicketTags(ticketId: string, tagIds: string[]): Promise<TagDTO[]> {
|
||||||
await db.delete(ticketTags).where(eq(ticketTags.ticketId, ticketId));
|
await db.delete(ticketTags).where(eq(ticketTags.ticketId, ticketId));
|
||||||
@@ -692,7 +777,12 @@ export async function setTicketTags(ticketId: string, tagIds: string[]): Promise
|
|||||||
|
|
||||||
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, ticketId) });
|
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, ticketId) });
|
||||||
if (ticket) {
|
if (ticket) {
|
||||||
const dto = toTicketDTO(ticket, await customerName(ticket.customerId), await getTagsForTicket(ticketId));
|
const dto = toTicketDTO(
|
||||||
|
ticket,
|
||||||
|
await customerName(ticket.customerId),
|
||||||
|
await getTagsForTicket(ticketId),
|
||||||
|
await getAssigneeName(ticket.assigneeId),
|
||||||
|
);
|
||||||
publishTicketEvent({ type: "ticket.updated", ticket: dto });
|
publishTicketEvent({ type: "ticket.updated", ticket: dto });
|
||||||
}
|
}
|
||||||
return getTagsForTicket(ticketId);
|
return getTagsForTicket(ticketId);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface TagDTO {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
color: TagColor;
|
color: TagColor;
|
||||||
|
isDefault: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketDTO {
|
export interface TicketDTO {
|
||||||
@@ -21,6 +22,7 @@ export interface TicketDTO {
|
|||||||
customerId: string;
|
customerId: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
assigneeId: string | null;
|
assigneeId: string | null;
|
||||||
|
assigneeName: string | null;
|
||||||
tags: TagDTO[];
|
tags: TagDTO[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -32,6 +34,10 @@ export interface AttachmentDTO {
|
|||||||
filename: string;
|
filename: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
|
// True for an inline image referenced from a message's bodyHtml (a cid:
|
||||||
|
// rewritten to this attachment's URL) — the UI uses this to skip showing
|
||||||
|
// it a second time as a separate chip below an already-rendered body.
|
||||||
|
isInline: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageDTO {
|
export interface MessageDTO {
|
||||||
@@ -40,8 +46,10 @@ export interface MessageDTO {
|
|||||||
authorType: MessageAuthorType;
|
authorType: MessageAuthorType;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
bodyHtml: string | null;
|
||||||
direction: MessageDirection;
|
direction: MessageDirection;
|
||||||
visibility: MessageVisibility;
|
visibility: MessageVisibility;
|
||||||
attachments: AttachmentDTO[];
|
attachments: AttachmentDTO[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
deliveryFailed: boolean;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user