Compare commits

..
20 Commits
Author SHA1 Message Date
ogrechkoandClaude Sonnet 5 c4e48328ee Add a separate "From" address for mailboxes that can't send as their own login
Real error from the previously-silent delivery failure: "550 5.7.60 SMTP;
Client does not have permissions to send as this sender" — Exchange
Online's exact rejection when the authenticated identity doesn't match
the asserted From address. Common in hybrid M365/on-prem AD setups: the
SMTP login is the AD UPN (e.g. elsup@zag.lan), but the mailbox's actual
permitted send-as address is a different, customer-facing domain.

Added an optional mailbox_config.fromAddress, surfaced in the email
settings form, defaulting to the login when unset so every existing setup
is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-10-07 10:02:24 +00:00
ogrechkoandClaude Sonnet 5 b92d9801ff Surface channel delivery failures on agent replies instead of swallowing them
The previous fix made a broken SMTP/Telegram account non-fatal — the
reply gets recorded either way now — but that also made delivery
failures completely invisible: an agent sees their reply "sent"
successfully with no indication the customer never actually received it.
Added messages.deliveryFailed, set whenever the outbound send throws, and
a warning badge on the message bubble so this is now visible instead of
silent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-10-07 08:26:54 +00:00
ogrechkoandClaude Sonnet 5 8d1f91187a Fix dark-mode email readability, silent reply failures, and add an IMAP connection watchdog
Dark theme: inbound email HTML intentionally keeps its own inline colors
for fidelity (sanitize-html.ts), but a sender's hardcoded dark text (the
common case) was unreadable once the surrounding chrome went dark. Now
forced to the theme's own text color in dark mode only; light mode keeps
full fidelity as before.

Send button: deliverAgentMessage awaited the outbound email/Telegram send
*before* recording the reply, so a broken mail/bot account (e.g. bad SMTP
creds) made the reply vanish entirely — not sent, not saved, no error
shown — with the API's catch-all masking the real cause as a generic
"Ticket not found". Channel delivery is now best-effort: the reply is
always recorded even if outbound delivery fails, and real errors surface
to the compose box instead of failing silently.

IMAP watchdog: a live IDLE connection has gone quietly unresponsive in
production more than once, with mail landing server-side but never
noticed here and no error ever firing. Added a periodic forced NOOP
round-trip with its own deadline, independent of ImapFlow's internal
socket timeout, that tears down and reconnects a connection that stops
answering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-10-07 07:24:14 +00:00
ogrechkoandClaude Sonnet 5 93e17c37c7 Fix LDAP directory import: paginate past server size limits, stop leaking bind password into error messages
The "import accounts" browse was shelling out a single unbounded
ldapsearch call, which failed outright with "Size limit exceeded (4)"
once the directory grew past whatever sizeLimit the LDAP server enforces
for this bind account. Added RFC 2696 paged results (-E pr=500/noprompt)
so ldapsearch transparently walks the whole base DN in pages instead of
one request that trips the limit.

Separately: execFile's rejection .message is "Command failed: <full
argv>\n<stderr>", and the full argv includes the bind password passed via
-w — that was reaching the admin UI verbatim in the error banner shown
after a failed search. Only stderr (ldapsearch's own diagnostic text,
which never echoes its invocation) is now surfaced.

Also fixes an unrelated image-build flake: drizzle-kit's migrate step
leaves db.sqlite in SQLite's default (non-WAL) journal mode, and next
build's parallel page-data-collection workers then race to perform the
first-ever journal_mode=WAL switch — a mode change that, unlike ordinary
statements, doesn't reliably honor busy_timeout, so one worker's pragma
call throws SQLITE_BUSY regardless of the configured timeout. Switching
to WAL once, single-process, right after migration avoids the race
entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-10-07 07:14:07 +00:00
ogrechkoandClaude Sonnet 5 d54107d722 Pin an explicit Docker network subnet
Docker's auto-assigned default bridge range can land on the same
block as a real LAN/VLAN on the host it's deployed to, breaking
routing to that network. Same fix already applied to zportal
(10.199.0.0/24) and kkpab (10.200.0.0/24); this one takes
10.201.0.0/24.

Not applied to the currently running deployment on this host (no
`docker compose up -d` run here) — takes effect on the next redeploy
or on a fresh clone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-09-09 06:42:45 +00:00
ogrechkoandClaude Sonnet 5 ec3ab6a25f Pin explicit Compose project name
Without this, Compose derives the project name from the directory
basename — fine today, but a silent trap if this repo is ever checked
out under a differently-named sibling path/host and someone reuses
"top-tickets" elsewhere. Value matches the current derived name
exactly, so this is a no-op for the running deployment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-09-09 06:20:52 +00:00
ogrechkoandClaude Sonnet 5 e42fd4697f Add mobile bottom nav + PWA installability
Same shell pattern as top-buh/top-finance/thbd-portal: desktop top nav
stays >=lg, a fixed bottom tab bar takes over below it. Only 2 nav
links here so both fit directly, no overflow menu needed.

Also adds manifest.webmanifest + 192/512 icons (rendered from the
existing icon.svg) and appleWebApp/viewport metadata so the app can be
installed to a phone's home screen.

Nudged ticket-toasts.tsx's fixed bottom offset so it clears the new
bottom nav bar on mobile instead of overlapping it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-09-07 07:58:52 +00:00
ogrechkoandClaude Sonnet 5 f5e49b5c53 Let users see and unsubscribe their own push devices
Fixes duplicate/triple OS notifications on machines where more than
one browser (e.g. both Chrome and Edge on Windows) independently got
notification permission for the site — each held its own valid
subscription, so every event pushed to both.

Adds a userAgent column (captured client-side at subscribe time,
display-only) and a Settings > Account section listing a user's own
subscribed browsers with a friendly label plus an unsubscribe button,
backed by new GET/DELETE /api/push/subscriptions endpoints scoped to
the caller's own rows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-09-07 06:49:34 +00:00
ogrechkoandClaude Sonnet 5 4d393aec91 Add bulk status change for tickets on the board
Select multiple tickets (any column) and open/pend/close them in one
action via a floating selection toolbar, backed by a new
PATCH /api/tickets/bulk-status endpoint. Scoped by the same
per-ticket visibility rule agents already get everywhere else.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-09-07 05:42:10 +00:00
ogrechkoandClaude Sonnet 5 99927aea0b Stop merging unrelated emails into a customer's other open ticket
Inbound email with no In-Reply-To/References match against a tracked
message now always opens a new ticket, instead of falling back to the
customer's most recent open ticket. That fallback made sense for
Telegram/website chat (no subject, single running stream) but was
wrong for email, where an unrelated new request from a repeat
customer got silently appended to whatever ticket happened to still
be open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
2026-09-07 05:22:29 +00:00
ogrechko d96e354e63 Prevent horizontal overflow on mobile (overflow-x: hidden on html/body) 2026-08-27 18:59:52 +00:00
ogrechkoandClaude Sonnet 5 d9eaed41e0 Dashboard: show ticket assignee on cards, widen layout to use the screen better
TicketDTO now carries assigneeName alongside the existing assigneeId — the
board's TicketCard shows it as a small avatar+name line ("Не назначено"
when nobody's assigned), so it's actually visible which admin/agent owns a
ticket without opening it. listTickets/listTicketsForCustomer resolve it
via a LEFT JOIN on users (avoids N+1 across a whole board); the handful of
single-ticket call sites (setTicketStatus, assignTicket, setTicketTags,
appendMessage, getTicketWithMessages) use a small getAssigneeName() lookup.

Also widened the admin layout's max-width from 7xl (1280px) to 1800px —
the 4-column kanban board was leaving a lot of unused space on wide
screens, cards were narrower than they needed to be. Verified visually via
Playwright screenshots at both 1920px and 1440px.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-21 06:01:52 +00:00
ogrechkoandClaude Sonnet 5 1a31f72dd8 Extend TOTP 2FA to LDAP accounts; disable now requires a code, not a password
2FA was previously local-accounts-only, gated on the assumption that LDAP
already has its own MFA story — that's not actually guaranteed (depends on
what's behind the directory), so app-level TOTP is now available for LDAP
accounts too, as a second factor independent of whatever the directory does
or doesn't enforce. The login route's LDAP branch now checks
user.totpEnabled the same way the local branch already did, routing through
the same login-challenge flow before minting a session.

This forced a change to disabling 2FA: it used to require the current
password, but an LDAP-provisioned user has passwordHash: null — there's no
password to check. Disable now requires a live TOTP code or a recovery
code instead (same "prove you still hold the factor" idea, just checking
the right thing), which works identically for local and LDAP accounts.

Verified: re-ran the full local-account Playwright flow with the new
code-based disable (still passes end-to-end). For the LDAP path — no real
LDAP server available to log in through — flipped a throwaway test account
to authSource="ldap"/passwordHash=null directly in the DB and exercised the
setup/confirm/disable logic functions directly (same technique used
earlier in this session for testing mail ingestion without a live IMAP
server): setup no longer rejects it, confirm and disable both work correctly
with no password present. Test account fully deleted after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-20 08:53:51 +00:00
ogrechkoandClaude Sonnet 5 4ecb0e7698 Add opt-in TOTP 2FA for local accounts
New card in Настройки → Аккаунт: scan a QR code (otplib + qrcode), confirm
with a live code, get 8 one-time recovery codes shown once. Only offered
for authSource="local" — LDAP accounts already have their own MFA story at
the directory level and are turned away with a clear message if they somehow
hit the setup endpoint directly.

Login flow: a 2FA account's password check now creates a short-lived
"login challenge" (separate table from `sessions`, 5-minute TTL, capped at
6 verify attempts) instead of a real session, and the login page swaps to a
second screen asking for a code — TOTP or a recovery code, either works.
The LDAP branch of the login route is untouched; it returns before ever
reaching the 2FA check.

totpSecret is encrypted at rest via the existing lib/crypto/credentials.ts
helper (same one already used for mailbox/LDAP bind passwords) rather than
adding a second encryption scheme. Recovery codes are hashed, not stored
plaintext, and each is single-use (marked usedAt, not deleted).

Verified against the real deployment end-to-end with Playwright against a
throwaway test account (created via the real admin-users API, fully
deleted after): setup → confirm → recovery-code issuance → second login
correctly prompted for a code → wrong code rejected → correct code and a
recovery code both worked → a reused recovery code was correctly rejected
→ disable (password-gated) → subsequent login went straight through again.
Also added unit tests for the TOTP/recovery-code helpers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-20 08:25:15 +00:00
ogrechkoandClaude Sonnet 5 95522fdedd Fix data-URI screenshots not opening on click (Chrome blocks data: URL navigation)
The click-to-zoom feature from the previous commit worked for cid:-referenced
images but silently failed for images embedded directly as a data: URI —
confirmed with a real Playwright click: Chrome refuses to navigate a tab
(even a new one, even from a direct user click) to a data: URL, so
<a href="data:...' target="_blank"> just does nothing. The cursor still
showed zoom-in on hover since that's plain CSS, which is exactly the "лупа
появляется, но не нажимается" symptom reported.

Fix: imap.ts now extracts every data:image src out of an inbound email's
HTML into a real attachment file (deduping identical images embedded more
than once), the same way cid: images already were, and rewrites the HTML to
point at that attachment's normal /api/attachments/... URL instead. That
also shrinks messages.body_html (data URIs can be hundreds of KB sitting in
a DB column) and gets data-URI images the same "no duplicate chip below"
treatment cid: images already had.

attachments.isInline is now its own real column (backfilled from the
existing content_id-based cases) instead of being derived from content_id,
since a data-URI-derived attachment is inline but was never cid-referenced.
sanitizeEmailHtml's link-wrapping step now skips any residual data: src
defensively (unwrapped-but-visible beats a link that looks clickable but
isn't).

Verified against the real deployment with actual browser clicks (Playwright):
both a data-URI image and a cid: image now open their full-resolution
attachment in a new tab; before this fix the data-URI one silently did
nothing. Added a vitest.config.ts (needed for the new test file's @/ import
aliases) and unit tests for the extraction/dedup logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-19 09:07:40 +00:00
ogrechkoandClaude Sonnet 5 1a09903326 Make inline email images clickable — open full resolution in a new tab
A bare <img> in a rendered email body wasn't wrapped in any link, so the
only way to see it full-size was the browser's right-click "open image in
new tab". sanitizeEmailHtml now wraps every image that isn't already inside
a real link (a sender-linked banner is left alone, no double-wrapping) in
<a href={its own src} target="_blank" rel="noopener noreferrer"> — a normal
left click, middle click, or ctrl-click all now do the expected thing.

The wrapping step (lib/mail/sanitize-html.ts, using the new cheerio dep)
builds the <a> via .attr() rather than string-interpolating the src into an
HTML template — sanitize-html only validates an img src's URL *scheme*, not
that a data: URI's declared content is actually image data, so a crafted
src could otherwise contain characters that break out of an attribute if
naively concatenated into HTML text for re-parsing. Covered by 3 new tests
in sanitize-html.test.ts, including that exact injection attempt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-19 08:43:19 +00:00
ogrechkoandClaude Sonnet 5 cabcc0b1d4 Fix inline email images showing twice (once in the body, once as a chip)
An inline image referenced via cid: is already rendered inside message.bodyHtml
(added in the previous commit) but was also still stored as a regular
attachment row, so it showed up a second time as an AttachmentChip thumbnail
underneath the message — the images at top were correct, the small chips
below were a duplicate of the same image.

AttachmentDTO now carries isInline (true when the attachment's contentId is
set), and the message thread — both the admin ticket view and the customer
portal — filters those out of the separate attachment-chip list. Verified
against a live inbound message with a cid-referenced image: previously
rendered as both an <img> and an <a>-wrapped chip, now only the <img>.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-19 08:28:06 +00:00
ogrechkoandClaude Sonnet 5 b11ec711c5 Dashboard tag filter now covers every tag, not just ones already in use; add a default tag
The dashboard's tag filter chips were derived from tags already attached to
a currently-loaded ticket — a brand-new tag (e.g. VIP was the only one ever
used) had nothing to filter by until someone happened to apply it. The
filter bar now lists every tag defined in Settings, fetched server-side
alongside the ticket list.

Also added a "default tag" concept: mark one tag as default in Settings
(star icon), and it's auto-attached to every new ticket the moment it's
created, across every channel (email/telegram/widget/manual) — matches the
request to have "В порядке очереди" applied by default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-19 08:09:14 +00:00
ogrechkoandClaude Sonnet 5 5af360fd15 Support attaching a file / pasting a screenshot when creating a new ticket
The "New ticket" modal only took a plain-text message. It now matches the
existing reply box on an open ticket: a "Прикрепить файл" button, and Ctrl+V
paste of a clipboard screenshot attaches it directly. POST /api/tickets
switched from JSON to multipart/form-data to carry the optional file,
reusing the same saveAttachment/attachment-delivery path replies already use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-19 07:54:23 +00:00
ogrechkoandClaude Sonnet 5 955fdeee41 Preserve email formatting in ticket display; paste-to-attach screenshots in replies
Inbound HTML emails were being flattened through html-to-text for display,
which lost all formatting and — for images embedded as data: URIs — leaked
the raw base64 as visible "[data:image/png;base64,...]" link text (an
Outlook/webmail screenshot-paste artifact). Customer messages now also
store a sanitized HTML rendering (messages.body_html) that preserves the
sender's fonts/colors/layout and shows inline images (data: URIs render
natively; cid: references are rewritten to the matching attachment's
serving URL via a new attachments.content_id column). Sanitization is a
tag/style allowlist (lib/mail/sanitize-html.ts, covered by
sanitize-html.test.ts) — no script/event handlers/javascript: hrefs, no
layout-breaking CSS. The plain-text fallback (used for channels other than
email) also drops image/data-URI link text via html-to-text selectors, for
the same underlying bug on that path.

Also: pasting a screenshot (Ctrl+V) into the agent reply box now attaches
it directly, reusing the existing attachment-upload/email-delivery path —
no more "save to disk, then click attach" round trip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-19 07:40:19 +00:00
73 changed files with 10655 additions and 117 deletions

No files matched your search

+10
View File
@@ -22,6 +22,16 @@ COPY . .
# Migrating first — as its own isolated, single-process step — means the
# workers only ever see an already-existing, already-stable file.
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
ENV NODE_ENV=production
+20
View File
@@ -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:
app:
build: .
@@ -11,6 +16,21 @@ services:
volumes:
- data:/data
restart: unless-stopped
networks:
top_tickets_net:
ipv4_address: 10.201.0.10
volumes:
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
+586 -7
View File
@@ -10,6 +10,7 @@
"dependencies": {
"argon2": "^0.45.1",
"better-sqlite3": "^12.11.1",
"cheerio": "^1.2.0",
"drizzle-orm": "^0.45.2",
"framer-motion": "^12.4.7",
"html-to-text": "^10.0.0",
@@ -19,8 +20,11 @@
"mailparser": "^3.9.14",
"next": "16.2.12",
"nodemailer": "^9.0.3",
"otplib": "^13.4.1",
"qrcode": "^1.5.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"sanitize-html": "^2.13.1",
"telegraf": "^4.16.3",
"web-push": "^3.6.7",
"zod": "^4.4.3"
@@ -32,8 +36,10 @@
"@types/mailparser": "^3.4.6",
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/sanitize-html": "^2.16.1",
"@types/web-push": "^3.6.4",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
@@ -2087,6 +2093,17 @@
"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": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2131,6 +2148,56 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
@@ -2475,6 +2542,14 @@
"integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
"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": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
@@ -2861,6 +2936,15 @@
"@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": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -2879,6 +2963,15 @@
"@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": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
@@ -3647,11 +3740,18 @@
"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": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"dependencies": {
"color-convert": "^2.0.1"
},
@@ -4011,6 +4111,11 @@
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
"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": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
@@ -4184,6 +4289,14 @@
"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": {
"version": "1.0.30001806",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
@@ -4244,6 +4357,46 @@
"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": {
"version": "1.1.4",
"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",
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"dependencies": {
"color-name": "~1.1.4"
},
@@ -4269,8 +4431,7 @@
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/concat-map": {
"version": "0.0.1",
@@ -4318,6 +4479,32 @@
"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": {
"version": "3.2.3",
"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": {
"version": "6.0.0",
"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==",
"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": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
@@ -4484,6 +4687,11 @@
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -4728,6 +4936,29 @@
"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": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -5010,7 +5241,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"engines": {
"node": ">=10"
},
@@ -5684,6 +5914,14 @@
"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": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -6293,6 +6531,14 @@
"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": {
"version": "1.1.2",
"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"
}
},
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -7404,6 +7658,17 @@
"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": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -7552,6 +7817,19 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
@@ -7608,6 +7886,14 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -7620,6 +7906,56 @@
"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": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz",
@@ -7636,7 +7972,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -7729,6 +8064,14 @@
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"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": {
"version": "1.1.0",
"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",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -7861,6 +8203,22 @@
"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": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -7996,6 +8354,19 @@
"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": {
"version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
@@ -8214,6 +8585,37 @@
"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": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -8239,6 +8641,11 @@
"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": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -8585,6 +8992,24 @@
"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": {
"version": "2.0.1",
"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"
}
},
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -9641,6 +10077,14 @@
"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": {
"version": "6.21.0",
"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",
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
@@ -10084,6 +10559,11 @@
"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": {
"version": "1.1.22",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
@@ -10130,17 +10610,116 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"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": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"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": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+6
View File
@@ -16,6 +16,7 @@
"dependencies": {
"argon2": "^0.45.1",
"better-sqlite3": "^12.11.1",
"cheerio": "^1.2.0",
"drizzle-orm": "^0.45.2",
"framer-motion": "^12.4.7",
"html-to-text": "^10.0.0",
@@ -25,8 +26,11 @@
"mailparser": "^3.9.14",
"next": "16.2.12",
"nodemailer": "^9.0.3",
"otplib": "^13.4.1",
"qrcode": "^1.5.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"sanitize-html": "^2.13.1",
"telegraf": "^4.16.3",
"web-push": "^3.6.7",
"zod": "^4.4.3"
@@ -38,8 +42,10 @@
"@types/mailparser": "^3.4.6",
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/sanitize-html": "^2.16.1",
"@types/web-push": "^3.6.4",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+14
View File
@@ -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" }
]
}
+54 -13
View File
@@ -1,23 +1,27 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
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 }) {
const [subject, setSubject] = useState("");
const [customerName, setCustomerName] = useState("");
const [customerEmail, setCustomerEmail] = useState("");
const [body, setBody] = useState("");
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
function reset() {
setSubject("");
setCustomerName("");
setCustomerEmail("");
setBody("");
setPendingFile(null);
setError(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}
async function handleSubmit(e: React.FormEvent) {
@@ -25,16 +29,14 @@ export function NewTicketModal({ open, onClose }: { open: boolean; onClose: () =
setLoading(true);
setError(null);
const res = await fetch("/api/tickets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
subject,
customerName,
customerEmail: customerEmail || undefined,
body,
}),
});
const formData = new FormData();
formData.set("subject", subject);
formData.set("customerName", customerName);
if (customerEmail) formData.set("customerEmail", customerEmail);
formData.set("body", body);
if (pendingFile) formData.set("file", pendingFile);
const res = await fetch("/api/tickets", { method: "POST", body: formData });
setLoading(false);
if (!res.ok) {
@@ -104,17 +106,56 @@ export function NewTicketModal({ open, onClose }: { open: boolean; onClose: () =
</label>
</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>
<textarea
required
rows={4}
value={body}
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"
/>
</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>}
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
+3 -3
View File
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { listTickets } from "@/lib/tickets/service";
import { listTickets, listTags } from "@/lib/tickets/service";
import { TicketBoard } from "./ticket-board";
export default async function DashboardPage() {
@@ -8,6 +8,6 @@ export default async function DashboardPage() {
if (!session) redirect("/login");
const currentUser = { id: session.user.id, role: session.user.role };
const tickets = await listTickets(undefined, currentUser);
return <TicketBoard initialTickets={tickets} currentUser={currentUser} />;
const [tickets, tags] = await Promise.all([listTickets(undefined, currentUser), listTags()]);
return <TicketBoard initialTickets={tickets} allTags={tags} currentUser={currentUser} />;
}
+134 -12
View File
@@ -3,10 +3,10 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
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 { 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 { NewTicketModal } from "./new-ticket-modal";
import { Avatar } from "@/components/avatar";
@@ -45,9 +45,11 @@ const PRIORITY_COLOR: Record<TicketDTO["priority"], string> = {
export function TicketBoard({
initialTickets,
allTags,
currentUser,
}: {
initialTickets: TicketDTO[];
allTags: TagDTO[];
currentUser: { id: string; role: "admin" | "agent" };
}) {
const [tickets, setTickets] = useState<TicketDTO[]>(initialTickets);
@@ -56,6 +58,41 @@ export function TicketBoard({
const [query, setQuery] = useState("");
const [searchResults, setSearchResults] = useState<TicketDTO[] | null>(null);
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) => {
if (event.type === "ticket.created" || event.type === "ticket.updated") {
@@ -100,13 +137,11 @@ export function TicketBoard({
const baseTickets = query.trim() ? (searchResults ?? tickets) : tickets;
const availableTags = useMemo(() => {
const map = new Map<string, TicketDTO["tags"][number]>();
for (const ticket of baseTickets) {
for (const tag of ticket.tags) map.set(tag.id, tag);
}
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
}, [baseTickets]);
// Every tag that exists, not just ones already applied to a currently
// loaded ticket — otherwise a freshly created tag has nothing to filter
// by until someone happens to tag a ticket with it. allTags is already
// name-sorted server-side (listTags orders by tagsTable.name).
const availableTags = allTags;
const filteredTickets = useMemo(() => {
if (!activeTagId) return baseTickets;
@@ -186,7 +221,12 @@ export function TicketBoard({
<div className="flex flex-col gap-2">
<AnimatePresence initial={false}>
{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>
{byStatus[col.status].length === 0 && (
@@ -201,11 +241,64 @@ export function TicketBoard({
</div>
<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>
);
}
function TicketCard({ ticket }: { ticket: TicketDTO }) {
function TicketCard({
ticket,
selected,
onToggleSelect,
}: {
ticket: TicketDTO;
selected: boolean;
onToggleSelect: (id: string) => void;
}) {
const Icon = CHANNEL_ICON[ticket.channel];
return (
@@ -218,9 +311,27 @@ function TicketCard({ ticket }: { ticket: TicketDTO }) {
>
<Link
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]}` }}
>
<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>
{ticket.tags.length > 0 && (
<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>
</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>
</motion.div>
);
+12 -9
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
import { Ticket } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session";
import { LogoutButton } from "./logout-button";
import { NavLinks } from "./nav-links";
import { NavShell } from "./nav-shell";
import { ThemeToggle } from "./theme-toggle";
import { SettingsMenu } from "./settings-menu";
import { NotificationToggle } from "./notification-toggle";
@@ -21,18 +21,18 @@ export default async function AdminLayout({ children }: { children: React.ReactN
return (
<div className="flex min-h-screen flex-col bg-bg">
<header className="border-b border-border bg-surface">
<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">
<Link href="/dashboard" className="flex items-center gap-2 font-display text-[15px] font-bold tracking-tight">
<header className="border-b border-border bg-surface pt-[env(safe-area-inset-top)]">
<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 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">
<Ticket size={15} strokeWidth={2.5} />
</div>
top-tickets
<span className="hidden sm:inline">top-tickets</span>
</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">
<NavLinks isAdmin={isAdmin} />
<div className="flex flex-1 justify-center overflow-x-auto">
<NavShell isAdmin={isAdmin} />
</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 />
<ThemeToggle />
{isAdmin && <SettingsMenu isAdmin={isAdmin} />}
@@ -41,7 +41,10 @@ export default async function AdminLayout({ children }: { children: React.ReactN
</div>
</div>
</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} />
<TicketToasts currentUser={currentUser} />
</div>
+34
View File
@@ -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>
);
}
+13
View File
@@ -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 },
];
+1 -6
View File
@@ -2,12 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LayoutDashboard, BarChart3 } from "lucide-react";
const links = [
{ href: "/dashboard", label: "Заявки", icon: LayoutDashboard, adminOnly: false },
{ href: "/stats", label: "Статистика", icon: BarChart3, adminOnly: true },
];
import { links } from "./nav-data";
export function NavLinks({ isAdmin }: { isAdmin: boolean }) {
const pathname = usePathname();
+16
View File
@@ -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} />
</>
);
}
+10
View File
@@ -1,5 +1,7 @@
import { getCurrentSession } from "@/lib/auth/session";
import { ChangePasswordForm } from "./change-password-form";
import { TwoFactorSettings } from "./two-factor-settings";
import { PushSubscriptionsSettings } from "./push-subscriptions-settings";
export default async function AccountSettingsPage() {
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>
<p className="mb-6 text-sm text-text-muted">{session?.user.email}</p>
<ChangePasswordForm />
{session && (
<div className="mt-4">
<TwoFactorSettings initialEnabled={session.user.totpEnabled} />
</div>
)}
<div className="mt-4">
<PushSubscriptionsSettings />
</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;
imapHost: string | null;
allowInsecureTls: boolean;
fromAddress: string | null;
verifiedAt: number | null;
}
@@ -19,6 +20,7 @@ export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }
const [smtpPort, setSmtpPort] = useState("587");
const [user, setUser] = useState(initialStatus.user ?? "");
const [password, setPassword] = useState("");
const [fromAddress, setFromAddress] = useState(initialStatus.fromAddress ?? "");
const [allowInsecureTls, setAllowInsecureTls] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -38,6 +40,7 @@ export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }
user,
password,
allowInsecureTls,
fromAddress,
}),
});
@@ -48,7 +51,15 @@ export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }
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("");
}
@@ -131,6 +142,21 @@ export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }
/>
</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">
<input
type="checkbox"
+24 -1
View File
@@ -1,7 +1,7 @@
"use client";
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 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));
}
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 (
<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">
@@ -77,12 +89,23 @@ export function TagsManager({ initialTags }: { initialTags: TagDTO[] }) {
{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">
{tags.map((tag) => (
<span
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]}`}
>
<button
onClick={() => handleToggleDefault(tag)}
className="opacity-60 hover:opacity-100"
title={tag.isDefault ? "Убрать как тег по умолчанию" : "Сделать тегом по умолчанию"}
>
<Star size={12} fill={tag.isDefault ? "currentColor" : "none"} />
</button>
{tag.name}
<button onClick={() => handleDelete(tag.id)} className="opacity-60 hover:opacity-100">
<Trash2 size={12} />
+1 -1
View File
@@ -103,7 +103,7 @@ export function TicketToasts({ currentUser }: { currentUser: { id: string; role:
}
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}>
{toasts.map((toast) => (
<motion.div
+44 -3
View File
@@ -3,11 +3,12 @@
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
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 { formatRelativeTime } from "@/lib/format";
import { Avatar } from "@/components/avatar";
import { AttachmentChip } from "@/components/attachment-chip";
import { MessageBodyHtml } from "@/components/message-body-html";
import { TagPicker } from "./tag-picker";
import type { TicketDTO, MessageDTO, TicketStatus } from "@/lib/tickets/types";
@@ -36,6 +37,7 @@ export function TicketThread({
const [visibility, setVisibility] = useState<"public" | "internal">("public");
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [cannedResponses, setCannedResponses] = useState<{ id: string; title: string; body: string }[]>([]);
const [cannedOpen, setCannedOpen] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
@@ -81,6 +83,7 @@ export function TicketThread({
async function sendReply() {
if (!draft.trim() && !pendingFile) return;
setSending(true);
setSendError(null);
let res: Response;
if (pendingFile) {
@@ -102,6 +105,9 @@ export function TicketThread({
setDraft("");
setPendingFile(null);
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>
</div>
)}
{sendError && <p className="mb-1.5 text-xs text-danger-soft-text">{sendError}</p>}
{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">
<Paperclip size={12} />
@@ -234,6 +241,19 @@ export function TicketThread({
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}
placeholder={visibility === "internal" ? "Заметка — клиент её не увидит…" : "Ответить клиенту…"}
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} />
Внутренняя заметка
</p>
{message.bodyHtml ? (
<MessageBodyHtml html={message.bodyHtml} className="mb-1" />
) : (
<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} />
))}
<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"}`}
>
<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>
{message.attachments.map((a) => (
)}
{message.attachments
.filter((a) => !a.isInline)
.map((a) => (
<AttachmentChip key={a.id} attachment={a} />
))}
<p className={`text-[11px] ${isAgent ? "text-white/70" : "text-text-faint"}`}>
{message.authorName} · {formatRelativeTime(message.createdAt)}
</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>
</motion.div>
);
+79 -1
View File
@@ -3,12 +3,14 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { Ticket, LogIn } from "lucide-react";
import { Ticket, LogIn, ShieldCheck } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [totpRequired, setTotpRequired] = useState(false);
const [code, setCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -30,6 +32,36 @@ export default function LoginPage() {
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.refresh();
}
@@ -41,6 +73,51 @@ export default function LoginPage() {
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
onSubmit={handleSubmit}
initial={{ opacity: 0, y: 12 }}
@@ -96,6 +173,7 @@ export default function LoginPage() {
{loading ? "Входим…" : "Войти"}
</button>
</motion.form>
)}
</main>
);
}
@@ -7,6 +7,7 @@ import { ArrowLeft, Send, Paperclip, X } from "lucide-react";
import { useTicketEvents } from "@/lib/events/use-ticket-events";
import { formatRelativeTime } from "@/lib/format";
import { AttachmentChip } from "@/components/attachment-chip";
import { MessageBodyHtml } from "@/components/message-body-html";
import type { TicketDTO, MessageDTO } from "@/lib/tickets/types";
export function PortalThread({
@@ -153,8 +154,14 @@ function MessageBubble({
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"}`}>
{message.bodyHtml ? (
<MessageBodyHtml html={message.bodyHtml} className="mb-1" />
) : (
<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} />
))}
<p className={`text-[11px] ${isMine ? "text-white/70" : "text-text-faint"}`}>
+16
View File
@@ -5,6 +5,7 @@ import { z } from "zod";
import { db } from "@/lib/db/client";
import { verifyPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { createLoginChallenge, setLoginChallengeCookie } from "@/lib/auth/login-challenge";
import { authenticateLdapUser } from "@/lib/ldap/client";
import { findOrCreateUserFromLdap } from "@/lib/auth/users";
import { getLdapSettings } from "@/lib/auth/ldap-config";
@@ -56,6 +57,15 @@ export async function POST(request: Request) {
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);
await setSessionCookie(token);
@@ -78,6 +88,12 @@ export async function POST(request: Request) {
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);
await setSessionCookie(token);
+41
View File
@@ -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 });
}
+54
View File
@@ -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 });
}
+29
View File
@@ -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 });
}
+61
View File
@@ -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 } });
}
+2
View File
@@ -21,6 +21,7 @@ const configureSchema = z.object({
user: z.string().email(),
password: z.string().min(1),
allowInsecureTls: z.boolean(),
fromAddress: z.string().email().optional().or(z.literal("")),
});
export async function POST(request: Request) {
@@ -41,6 +42,7 @@ export async function POST(request: Request) {
user: parsed.data.user,
password: parsed.data.password,
allowInsecureTls: parsed.data.allowInsecureTls,
fromAddress: parsed.data.fromAddress || undefined,
};
try {
+2 -1
View File
@@ -11,6 +11,7 @@ const subscribeSchema = z.object({
p256dh: z.string().min(1),
auth: z.string().min(1),
}),
userAgent: z.string().max(500).optional(),
});
export async function POST(request: Request) {
@@ -23,7 +24,7 @@ export async function POST(request: Request) {
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 });
}
@@ -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 });
}
+14
View File
@@ -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 });
}
+18 -1
View File
@@ -1,8 +1,25 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
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 }> }) {
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 });
} catch {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
} catch (err) {
const message = err instanceof Error ? err.message : "Не удалось отправить вложение";
return NextResponse.json({ error: message }, { status: 400 });
}
}
+3 -2
View File
@@ -44,7 +44,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
visibility,
});
return NextResponse.json(result, { status: 201 });
} catch {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
} catch (err) {
const message = err instanceof Error ? err.message : "Не удалось отправить сообщение";
return NextResponse.json({ error: message }, { status: 400 });
}
}
+29
View File
@@ -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 });
}
+42 -4
View File
@@ -4,6 +4,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { listTickets, createManualTicket } from "@/lib/tickets/service";
import { saveAttachment, AttachmentTooLargeError, MAX_ATTACHMENT_BYTES } from "@/lib/attachments/storage";
export async function GET(request: Request) {
const { session, response } = await requireSession();
@@ -18,19 +19,56 @@ const createSchema = z.object({
subject: z.string().min(1).max(200),
body: z.string().min(1).max(10_000),
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) {
const { session, response } = await requireSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = createSchema.safeParse(body);
const formData = await request.formData().catch(() => null);
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) {
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 });
}
+21
View File
@@ -203,6 +203,26 @@
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
data-theme cascade the palette above uses, so it never needs JS state. */
.theme-toggle-btn .theme-icon-sun {
@@ -233,6 +253,7 @@
html,
body {
height: 100%;
overflow-x: hidden;
}
body {
+18 -1
View File
@@ -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 "./globals.css";
@@ -21,6 +21,23 @@ const mono = JetBrains_Mono({
export const metadata: Metadata = {
title: "top-tickets",
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
+12
View File
@@ -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 }}
/>
);
}
+70
View File
@@ -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));
}
+41
View File
@@ -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]));
});
});
+43
View File
@@ -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
+49
View File
@@ -50,6 +50,55 @@
"when": 1786130418783,
"tag": "0006_big_daredevil",
"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
}
]
}
+78
View File
@@ -30,6 +30,13 @@ export const users = sqliteTable("users", {
authSource: text("auth_source", { enum: ["local", "ldap"] })
.notNull()
.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,
});
@@ -44,6 +51,41 @@ export const sessions = sqliteTable("sessions", {
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. */
export const customers = sqliteTable("customers", {
id: id(),
@@ -89,6 +131,11 @@ export const messages = sqliteTable("messages", {
authorId: text("author_id"),
authorName: text("author_name").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(),
// "internal" notes are agent-only — never sent to the customer over any
// 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
// exact ticket via In-Reply-To/References, even if they have other tickets.
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,
});
@@ -122,6 +174,13 @@ export const mailboxConfig = sqliteTable("mailbox_config", {
smtpPort: integer("smtp_port").notNull().default(587),
user: text("user").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
// setup time. This is a LAN-only mailbox (never exposed to the internet),
// 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(),
// Relative path under DATA_DIR/attachments/ — see src/lib/attachments/storage.ts.
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,
});
@@ -195,6 +264,10 @@ export const tags = sqliteTable("tags", {
color: text("color", { enum: ["accent", "success", "warning", "info", "rose", "danger"] })
.notNull()
.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,
});
@@ -226,5 +299,10 @@ export const pushSubscriptions = sqliteTable("push_subscriptions", {
endpoint: text("endpoint").notNull().unique(),
p256dh: text("p256dh").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,
});
+19
View File
@@ -119,11 +119,20 @@ async function ldapSearchCli(
filter: string,
): Promise<{ dn: string; attributes: Record<string, string> }[]> {
const protocol = settings.useTls ? "ldaps" : "ldap";
try {
const { stdout } = await execFileAsync(
"ldapsearch",
[
"-x",
"-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",
`${protocol}://${settings.host}:${settings.port}`,
"-D",
@@ -143,6 +152,16 @@ async function ldapSearchCli(
{ maxBuffer: 20 * 1024 * 1024, timeout: 15_000 },
);
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). */
+6
View File
@@ -11,6 +11,9 @@ export interface MailboxSettings {
user: string;
password: string;
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. */
@@ -26,6 +29,7 @@ export async function getMailboxSettings(): Promise<MailboxSettings | null> {
user: config.user,
password: decryptCredential(config.passwordEnc),
allowInsecureTls: config.allowInsecureTls,
fromAddress: config.fromAddress ?? undefined,
};
}
@@ -38,6 +42,7 @@ export async function saveMailboxSettings(settings: MailboxSettings): Promise<vo
user: settings.user,
passwordEnc: encryptCredential(settings.password),
allowInsecureTls: settings.allowInsecureTls,
fromAddress: settings.fromAddress || null,
enabled: true,
verifiedAt: new Date(),
};
@@ -65,6 +70,7 @@ export async function getMailboxStatus() {
user: config?.user ?? null,
imapHost: config?.imapHost ?? null,
allowInsecureTls: config?.allowInsecureTls ?? false,
fromAddress: config?.fromAddress ?? null,
verifiedAt: config?.verifiedAt?.getTime() ?? null,
};
}
+35
View File
@@ -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
View File
@@ -1,9 +1,11 @@
import crypto from "node:crypto";
import { ImapFlow } from "imapflow";
import { simpleParser, type ParsedMail } from "mailparser";
import { htmlToText } from "html-to-text";
import { getMailboxSettings, saveMailboxSettings, disableMailboxConfig, type MailboxSettings } from "@/lib/mail/config";
import { recordEmailInboundMessage } from "@/lib/tickets/service";
import { saveAttachment } from "@/lib/attachments/storage";
import { sanitizeEmailHtml } from "@/lib/mail/sanitize-html";
interface ListenerState {
client: ImapFlow | null;
@@ -33,15 +35,114 @@ function createClient(settings: MailboxSettings): ImapFlow {
// blindly — some senders (Outlook in particular) produce a text/plain
// alternative that isn't actually clean, while html-to-text reliably drops
// <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 {
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;
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> {
const parsed = await simpleParser(source);
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] : []),
];
// 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 = [];
for (const [i, part] of (parsed.attachments ?? []).entries()) {
try {
const { storageKey, sizeBytes } = await saveAttachment(part.content);
const attachmentId = crypto.randomUUID();
if (part.cid) attachmentIdByCid.set(part.cid, attachmentId);
attachmentInputs.push({
id: attachmentId,
filename: part.filename || `attachment-${i + 1}`,
mimeType: part.contentType || "application/octet-stream",
sizeBytes,
storageKey,
contentId: part.cid ?? null,
isInline: Boolean(part.cid),
});
} catch (err) {
console.error("[mail] failed to save attachment:", err);
}
}
const { html: bodyHtml, attachments: dataUriAttachments } = await extractHtmlBody(parsed, attachmentIdByCid);
await recordEmailInboundMessage({
fromEmail: from.address,
fromName: from.name || from.address,
subject: parsed.subject ?? "",
body: extractBody(parsed),
bodyHtml,
messageId: parsed.messageId ?? null,
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> {
try {
await client.mailboxOpen("INBOX");
console.log("[mail] IDLE listener connected, watching INBOX");
await processUnseenMessages(client);
startWatchdog(client);
// New mail is signaled by the 'exists' event while IDLE is active — it's
// independent of when idle() itself resolves (that happens on periodic
@@ -130,15 +286,7 @@ async function runIdleLoop(client: ImapFlow): Promise<void> {
await client.idle();
}
} catch (err) {
if (state.client === client) {
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);
}
}
scheduleReconnect(client, "IMAP idle loop error", err);
}
}
+86
View File
@@ -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, "&quot;")}">`);
const $ = cheerio.load(out, null, false);
expect($("script").length).toBe(0);
});
});
+97
View File
@@ -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();
}
+3 -2
View File
@@ -32,11 +32,12 @@ export async function sendTicketReplyEmail(params: {
const settings = await getMailboxSettings();
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}`;
await createTransport(settings).sendMail({
from: settings.user,
from: fromAddress,
to: params.to,
subject,
text: params.body,
+1 -1
View File
@@ -57,7 +57,7 @@ export async function ensurePushSubscribed(): Promise<void> {
const saveRes = await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription.toJSON()),
body: JSON.stringify({ ...subscription.toJSON(), userAgent: navigator.userAgent }),
});
if (saveRes.ok) {
console.log("[push] subscription saved server-side — done");
+24 -4
View File
@@ -1,4 +1,4 @@
import { eq, inArray } from "drizzle-orm";
import { eq, inArray, and } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { pushSubscriptions, users } from "@/lib/db/schema";
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. */
export async function savePushSubscription(userId: string, sub: PushSubscriptionKeys): Promise<void> {
export async function savePushSubscription(
userId: string,
sub: PushSubscriptionKeys,
userAgent?: string,
): Promise<void> {
await db
.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({
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));
}
/** 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> {
if (userIds.length === 0 || !ensureVapidConfigured()) return;
+34 -12
View File
@@ -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 deliveryFailed = false;
if (ticket.channel === "email") {
const context = await getEmailThreadContext(params.ticketId);
if (context) {
try {
emailMessageId = await sendTicketReplyEmail({
to: context.customerEmail,
subject: context.subject,
@@ -62,23 +76,17 @@ export async function deliverAgentMessage(params: {
? { filename: params.attachment.filename, content: params.attachment.buffer }
: undefined,
});
} catch (err) {
console.error(`Failed to send reply email for ticket ${params.ticketId}:`, err);
deliveryFailed = true;
}
}
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") {
} else if (ticket.channel === "telegram") {
const customer = await db.query.customers.findFirst({
where: eq(customers.id, result.ticket.customerId),
where: eq(customers.id, ticket.customerId),
});
if (customer?.telegramChatId) {
try {
if (params.attachment) {
await sendTelegramFile(
customer.telegramChatId,
@@ -88,8 +96,22 @@ export async function deliverAgentMessage(params: {
} else {
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;
}
+107 -17
View File
@@ -13,6 +13,7 @@ import {
} from "@/lib/db/schema";
import { publishTicketEvent } from "@/lib/events/bus";
import { notifyPushForCustomerMessage } from "@/lib/push/service";
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
import type {
TicketDTO,
MessageDTO,
@@ -22,9 +23,22 @@ import type {
AttachmentDTO,
} 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 {
id: ticket.id,
subject: ticket.subject,
@@ -34,6 +48,7 @@ function toTicketDTO(ticket: typeof tickets.$inferSelect, customerName: string,
customerId: ticket.customerId,
customerName,
assigneeId: ticket.assigneeId,
assigneeName,
tags,
createdAt: ticket.createdAt.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 {
return {
id: message.id,
@@ -48,10 +70,12 @@ function toMessageDTO(message: typeof messages.$inferSelect, attachments: Attach
authorType: message.authorType,
authorName: message.authorName,
body: message.body,
bodyHtml: message.bodyHtml,
direction: message.direction,
visibility: message.visibility,
attachments,
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) {
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);
}
return map;
@@ -101,7 +125,7 @@ async function getAttachmentsForMessages(messageIds: string[]): Promise<Map<stri
});
for (const row of rows) {
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);
}
return map;
@@ -115,9 +139,11 @@ async function appendMessage(params: {
authorId: string | null;
authorName: string;
body: string;
bodyHtml?: string | null;
direction: "in" | "out";
visibility?: "public" | "internal";
emailMessageId?: string;
deliveryFailed?: boolean;
attachments?: AttachmentInput[];
}) {
const [message] = await db
@@ -128,8 +154,10 @@ async function appendMessage(params: {
authorId: params.authorId,
authorName: params.authorName,
body: params.body,
bodyHtml: params.bodyHtml ?? null,
direction: params.direction,
visibility: params.visibility ?? "public",
deliveryFailed: params.deliveryFailed ?? false,
emailMessageId: params.emailMessageId,
})
.returning();
@@ -145,13 +173,21 @@ async function appendMessage(params: {
filename: a.filename,
mimeType: a.mimeType,
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 name = await customerName(ticket.customerId);
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);
publishTicketEvent({
@@ -278,16 +314,19 @@ export async function recordChatInboundMessage(params: {
}
/**
* Records an inbound email. Prefers exact thread matching via
* In-Reply-To/References (so a reply to a specific closed ticket lands on
* that ticket, not a random other open one); falls back to the same
* find-or-create-open-ticket heuristic used for Telegram.
* Records an inbound email. Threads it onto an existing ticket only via
* exact In-Reply-To/References matching. Unlike Telegram/the website widget
* (a continuous chat stream with no subject), email always carries a
* 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: {
fromEmail: string;
fromName: string;
subject: string;
body: string;
bodyHtml?: string | null;
messageId: string | null;
referencedMessageIds: string[];
attachments?: AttachmentInput[];
@@ -297,7 +336,7 @@ export async function recordEmailInboundMessage(params: {
const matchedTicketId = await findTicketIdByEmailReferences(params.referencedMessageIds);
let ticket = matchedTicketId
? await db.query.tickets.findFirst({ where: eq(tickets.id, matchedTicketId) })
: await findOpenTicketForCustomer(customer.id);
: null;
let ticketIsNew = false;
if (!ticket) {
@@ -321,6 +360,7 @@ export async function recordEmailInboundMessage(params: {
authorId: customer.id,
authorName: params.fromName,
body: params.body,
bodyHtml: params.bodyHtml,
direction: "in",
emailMessageId: params.messageId ?? undefined,
attachments: params.attachments,
@@ -361,6 +401,7 @@ export async function recordAgentReply(params: {
agentName: string;
body: string;
emailMessageId?: string;
deliveryFailed?: boolean;
attachments?: AttachmentInput[];
}) {
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,
direction: "out",
emailMessageId: params.emailMessageId,
deliveryFailed: params.deliveryFailed,
attachments: params.attachments,
});
}
@@ -416,14 +458,38 @@ export async function setTicketStatus(
status: (typeof tickets.$inferSelect)["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 });
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) {
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 });
return dto;
}
@@ -464,9 +530,10 @@ export async function listTickets(
}
const rows = await db
.select({ ticket: tickets, customerName: customers.displayName })
.select({ ticket: tickets, customerName: customers.displayName, assigneeName: users.name })
.from(tickets)
.innerJoin(customers, eq(tickets.customerId, customers.id))
.leftJoin(users, eq(tickets.assigneeId, users.id))
.orderBy(desc(tickets.lastMessageAt));
const idsToKeep = matchingTicketIds;
@@ -477,7 +544,9 @@ export async function listTickets(
}
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) {
@@ -498,7 +567,7 @@ export async function getTicketWithMessages(ticketId: string) {
const ticketTagsList = await getTagsForTicket(ticketId);
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) ?? [])),
};
}
@@ -607,6 +676,7 @@ export async function createManualTicket(params: {
body: string;
customerName: string;
customerEmail?: string;
attachment?: AttachmentInput;
}) {
const [customer] = await db
.insert(customers)
@@ -630,6 +700,7 @@ export async function createManualTicket(params: {
authorName: params.customerName,
body: params.body,
direction: "in",
attachments: params.attachment ? [params.attachment] : undefined,
});
}
@@ -671,7 +742,7 @@ export async function deleteCannedResponse(id: string) {
export async function listTags(): Promise<TagDTO[]> {
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"]) {
@@ -683,6 +754,20 @@ export async function deleteTag(id: string) {
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. */
export async function setTicketTags(ticketId: string, tagIds: string[]): Promise<TagDTO[]> {
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) });
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 });
}
return getTagsForTicket(ticketId);
+8
View File
@@ -10,6 +10,7 @@ export interface TagDTO {
id: string;
name: string;
color: TagColor;
isDefault: boolean;
}
export interface TicketDTO {
@@ -21,6 +22,7 @@ export interface TicketDTO {
customerId: string;
customerName: string;
assigneeId: string | null;
assigneeName: string | null;
tags: TagDTO[];
createdAt: number;
updatedAt: number;
@@ -32,6 +34,10 @@ export interface AttachmentDTO {
filename: string;
mimeType: string;
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 {
@@ -40,8 +46,10 @@ export interface MessageDTO {
authorType: MessageAuthorType;
authorName: string;
body: string;
bodyHtml: string | null;
direction: MessageDirection;
visibility: MessageVisibility;
attachments: AttachmentDTO[];
createdAt: number;
deliveryFailed: boolean;
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
import path from "node:path";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});