Restore from Gitea ZIP snapshot (12.08.2026) after full instance reinstall

Git history was lost when the previous Gitea instance was wiped and
reinstalled due to an unresolved corruption bug — this commit is the
last known-good file content, exported before the reinstall. Prior
commit history is not recoverable through this path.
This commit is contained in:
Claude Sonnet 5
2026-08-12 19:24:03 +00:00
commit 52ca317e9c
138 changed files with 24016 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
.next
.git
data
*.env
!.env.example
coverage
*.tsbuildinfo
+16
View File
@@ -0,0 +1,16 @@
# Copy to .env and fill in — never commit the real .env.
# AES-256-GCM key that encrypts channel credentials (Telegram token, and
# later IMAP/SMTP) at rest in the database. Generate with:
# npm run generate-key
CREDENTIALS_ENCRYPTION_KEY=
# Initial admin account, created on first start if it doesn't exist yet.
ADMIN_BOOTSTRAP_EMAIL=admin@top-sysops.ru
ADMIN_BOOTSTRAP_PASSWORD=
ADMIN_BOOTSTRAP_NAME=Admin
# Optional: seeds the Telegram channel on first start (only if no bot has
# been configured yet via the admin UI — Settings → Telegram always wins
# afterwards). Get this from @BotFather.
TELEGRAM_BOT_TOKEN=
+40
View File
@@ -0,0 +1,40 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# app data (SQLite db, WAL files)
/data/
# typescript
*.tsbuildinfo
next-env.d.ts
+32
View File
@@ -0,0 +1,32 @@
FROM node:20-bookworm-slim
# python3/make/g++ let npm fall back to compiling better-sqlite3/argon2 from
# source if no prebuilt binary matches this platform. ldap-utils provides
# the `ldapsearch` binary used for the LDAP directory browse — ldapjs's own
# BER decoder proved unreliable against real AD responses for that query.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ ca-certificates ldap-utils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# `next build`'s page-data collection spins up several parallel workers that
# each import the db client at module-eval time. If data/db.sqlite doesn't
# exist yet, every worker races to create it from scratch — that race is at
# the file-creation level, before our code ever gets to run a busy_timeout
# pragma, so it can throw SQLITE_BUSY (or worse) independent of pragma order.
# 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
RUN npm run build
ENV NODE_ENV=production
EXPOSE 8081
# Migrate the (volume-mounted) SQLite DB and bootstrap the admin account on
# every start — both are no-ops once already applied.
CMD ["sh", "-c", "npm run db:migrate && npm run bootstrap-admin && npm start"]
+31
View File
@@ -0,0 +1,31 @@
# top-tickets
Хелпдеск-система: тикеты, дашборд по статусам, канал Telegram, realtime-обновления без перезагрузки страницы, отдельный клиентский портал. MVP-1 — см. `/root/.claude/plans/refactored-discovering-corbato.md` за архитектурой и тем, что отложено на MVP-2 (email, встраиваемый виджет).
## Запуск в Docker
```bash
cp .env.example .env
npm run generate-key # вставить результат в CREDENTIALS_ENCRYPTION_KEY
# заполнить ADMIN_BOOTSTRAP_EMAIL / ADMIN_BOOTSTRAP_PASSWORD / TELEGRAM_BOT_TOKEN в .env
docker compose up --build -d
```
Приложение слушает `:8081` (уже проброшено внешним nginx на `help.top-sysops.ru`). При первом старте контейнер сам применяет миграции и создаёт админ-аккаунт из `.env`.
## Локальная разработка
```bash
npm install
cp .env.example .env # + generate-key, как выше
npm run db:migrate
npm run bootstrap-admin
npm run dev
```
## Telegram
Подключается через **Настройки → Telegram** в интерфейсе (или через `TELEGRAM_BOT_TOKEN` в `.env` — сработает только при первом старте, если бот ещё не настроен). Чтобы бот видел все сообщения в группе, а не только с упоминанием — `@BotFather``/setprivacy``Disable`.
Токен, который прислали в чат Claude Code, стоит перевыпустить (`@BotFather``/revoke`) — он засветился в истории сессии.
+16
View File
@@ -0,0 +1,16 @@
services:
app:
build: .
ports:
- "8081:8081"
env_file:
- .env
environment:
- PORT=8081
- DATA_DIR=/data
volumes:
- data:/data
restart: unless-stopped
volumes:
data:
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "drizzle-kit";
import path from "node:path";
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
export default defineConfig({
out: "./src/lib/db/migrations",
schema: "./src/lib/db/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: path.join(dataDir, "db.sqlite"),
},
});
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
globalIgnores([
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+11
View File
@@ -0,0 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Not using output: "standalone" — file-tracing only copies argon2's
// native .node binaries, not its JS loader, causing a segfault in
// production (same issue hit in the sibling project-claude repo).
// Deploying with the full node_modules via `next start` is simple
// enough for a single-VM deployment.
};
export default nextConfig;
+10177
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
{
"name": "top-tickets",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start -p ${PORT:-8081}",
"lint": "eslint",
"test": "vitest run",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"generate-key": "tsx scripts/generate-key.ts",
"bootstrap-admin": "tsx scripts/bootstrap-admin.ts"
},
"dependencies": {
"argon2": "^0.45.1",
"better-sqlite3": "^12.11.1",
"drizzle-orm": "^0.45.2",
"framer-motion": "^12.4.7",
"html-to-text": "^10.0.0",
"imapflow": "^1.5.0",
"ldapjs": "^3.0.7",
"lucide-react": "^0.545.0",
"mailparser": "^3.9.14",
"next": "16.2.12",
"nodemailer": "^9.0.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"telegraf": "^4.16.3",
"web-push": "^3.6.7",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.16",
"@types/better-sqlite3": "^7.6.13",
"@types/ldapjs": "^3.0.6",
"@types/mailparser": "^3.4.6",
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/web-push": "^3.6.4",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.12",
"tailwindcss": "^4.1.16",
"tsx": "^4.23.1",
"typescript": "^5",
"vitest": "^3.2.7"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+39
View File
@@ -0,0 +1,39 @@
// Web Push service worker. Deliberately minimal — no offline caching, no
// asset interception. Its only job is to keep receiving push events even
// when the page's own tab is frozen or closed, and turn them into an OS
// notification.
self.addEventListener("push", (event) => {
let payload = { title: "top-tickets", body: "", url: "/dashboard" };
try {
if (event.data) payload = { ...payload, ...event.data.json() };
} catch {
// malformed payload — fall back to the generic notification above
}
event.waitUntil(
self.registration.showNotification(payload.title, {
body: payload.body,
icon: "/icon.svg",
data: { url: payload.url },
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data && event.notification.data.url ? event.notification.data.url : "/dashboard";
event.waitUntil(
(async () => {
const clientsList = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
const existing = clientsList.find((c) => "focus" in c);
if (existing) {
await existing.navigate(url);
await existing.focus();
return;
}
await self.clients.openWindow(url);
})(),
);
});
+78
View File
@@ -0,0 +1,78 @@
(function () {
var scriptEl = document.currentScript;
if (!scriptEl) return;
var siteKey = scriptEl.getAttribute("data-key");
if (!siteKey) {
console.error("[top-tickets widget] missing data-key attribute");
return;
}
var origin = new URL(scriptEl.src).origin;
var open = false;
var button = document.createElement("button");
button.setAttribute("aria-label", "Открыть чат поддержки");
button.style.cssText = [
"position:fixed", "right:20px", "bottom:20px", "z-index:2147483000",
"width:56px", "height:56px", "border-radius:999px", "border:none",
"background:#7c3aed", "color:#fff", "cursor:pointer",
"box-shadow:0 6px 20px rgba(0,0,0,.25)",
"display:flex", "align-items:center", "justify-content:center",
"font-family:system-ui,sans-serif",
].join(";");
button.innerHTML =
'<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>';
var badge = document.createElement("span");
badge.style.cssText = [
"position:absolute", "top:-2px", "right:-2px", "width:12px", "height:12px",
"border-radius:999px", "background:#dc2626", "border:2px solid #fff",
"display:none",
].join(";");
button.style.position = "fixed";
button.appendChild(badge);
var panel = document.createElement("div");
var isMobile = window.innerWidth < 480;
panel.style.cssText = [
"position:fixed",
isMobile ? "right:0" : "right:20px",
isMobile ? "bottom:0" : "bottom:88px",
isMobile ? "width:100vw" : "width:360px",
isMobile ? "height:100vh" : "height:520px",
"max-height:80vh",
"border-radius:" + (isMobile ? "0" : "16px"),
"overflow:hidden",
"box-shadow:0 12px 40px rgba(0,0,0,.3)",
"z-index:2147483000",
"display:none",
].join(";");
var iframe = document.createElement("iframe");
iframe.src = origin + "/widget/chat?key=" + encodeURIComponent(siteKey);
iframe.style.cssText = "width:100%;height:100%;border:none;";
panel.appendChild(iframe);
function setOpen(next) {
open = next;
panel.style.display = open ? "block" : "none";
if (open) {
badge.style.display = "none";
}
}
button.addEventListener("click", function () {
setOpen(!open);
});
window.addEventListener("message", function (event) {
if (event.origin !== origin) return;
if (event.data && event.data.type === "top-tickets:new-message" && !open) {
badge.style.display = "block";
}
});
document.body.appendChild(panel);
document.body.appendChild(button);
})();
+46
View File
@@ -0,0 +1,46 @@
/**
* Creates the initial admin user from ADMIN_BOOTSTRAP_EMAIL /
* ADMIN_BOOTSTRAP_PASSWORD if no user with that email exists yet.
* Safe to run on every container start — idempotent.
*/
import { db } from "../src/lib/db/client";
import { users } from "../src/lib/db/schema";
import { hashPassword } from "../src/lib/auth/password";
async function main() {
const email = process.env.ADMIN_BOOTSTRAP_EMAIL;
const password = process.env.ADMIN_BOOTSTRAP_PASSWORD;
const name = process.env.ADMIN_BOOTSTRAP_NAME ?? "Admin";
if (!email || !password) {
console.log("ADMIN_BOOTSTRAP_EMAIL/PASSWORD not set — skipping admin bootstrap.");
return;
}
const normalizedEmail = email.toLowerCase().trim();
const existing = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.email, normalizedEmail),
});
if (existing) {
console.log(`Admin user ${normalizedEmail} already exists — skipping.`);
return;
}
const passwordHash = await hashPassword(password);
await db.insert(users).values({
email: normalizedEmail,
passwordHash,
name,
role: "admin",
});
console.log(`Bootstrapped admin user ${normalizedEmail}.`);
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error("Admin bootstrap failed:", err);
process.exit(1);
});
+7
View File
@@ -0,0 +1,7 @@
/**
* Generates a fresh AES-256-GCM key for CREDENTIALS_ENCRYPTION_KEY.
* Usage: npm run generate-key
*/
import crypto from "node:crypto";
console.log(crypto.randomBytes(32).toString("base64"));
@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { 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 [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
function reset() {
setSubject("");
setCustomerName("");
setCustomerEmail("");
setBody("");
setError(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
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,
}),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось создать заявку");
return;
}
reset();
onClose();
}
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.form
onClick={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
initial={{ opacity: 0, y: 12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.98 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="card w-full max-w-md p-5"
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold">Новая заявка</h2>
<button type="button" onClick={onClose} className="text-text-muted hover:text-text">
<X size={18} />
</button>
</div>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Тема</span>
<input
required
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
<div className="mb-3 grid grid-cols-2 gap-3">
<label className="block text-sm">
<span className="mb-1 block font-medium text-text-muted">Клиент</span>
<input
required
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
<label className="block text-sm">
<span className="mb-1 block font-medium text-text-muted">Email</span>
<input
type="email"
value={customerEmail}
onChange={(e) => setCustomerEmail(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
</div>
<label className="mb-4 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)}
className="w-full resize-none rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
{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">
{loading ? "Создаём…" : "Создать заявку"}
</button>
</motion.form>
</motion.div>
)}
</AnimatePresence>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { listTickets } from "@/lib/tickets/service";
import { TicketBoard } from "./ticket-board";
export default async function DashboardPage() {
const session = await getCurrentSession();
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} />;
}
+249
View File
@@ -0,0 +1,249 @@
"use client";
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 { useTicketEvents } from "@/lib/events/use-ticket-events";
import { formatRelativeTime } from "@/lib/format";
import type { TicketDTO, TicketStatus } from "@/lib/tickets/types";
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
import { NewTicketModal } from "./new-ticket-modal";
import { Avatar } from "@/components/avatar";
import { TAG_COLOR_CLASSES } from "@/lib/tags/colors";
const COLUMNS: { status: TicketStatus; label: string; empty: string }[] = [
{ status: "new", label: "Новые", empty: "Новых заявок нет" },
{ status: "open", label: "Открытые", empty: "Ничего в работе" },
{ status: "pending", label: "В ожидании", empty: "Никто не ждёт ответа" },
{ status: "closed", label: "Закрытые", empty: "Пока нечего закрывать" },
];
const CHANNEL_ICON = {
telegram: Send,
portal: Globe,
manual: UserRound,
email: Mail,
widget: MessageSquare,
} as const;
// Full literal strings — see components/avatar.tsx for why.
const CHANNEL_CHIP_CLASSES = {
telegram: "bg-info-soft text-info-soft-text",
email: "bg-accent-soft text-accent-soft-text",
widget: "bg-success-soft text-success-soft-text",
portal: "bg-warning-soft text-warning-soft-text",
manual: "bg-surface-hover text-text-muted",
} as const;
const PRIORITY_COLOR: Record<TicketDTO["priority"], string> = {
low: "var(--text-faint)",
normal: "var(--border-strong)",
high: "var(--warning)",
urgent: "var(--danger)",
};
export function TicketBoard({
initialTickets,
currentUser,
}: {
initialTickets: TicketDTO[];
currentUser: { id: string; role: "admin" | "agent" };
}) {
const [tickets, setTickets] = useState<TicketDTO[]>(initialTickets);
const [modalOpen, setModalOpen] = useState(false);
const [activeTagId, setActiveTagId] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [searchResults, setSearchResults] = useState<TicketDTO[] | null>(null);
const [searching, setSearching] = useState(false);
useTicketEvents("/api/events", (event) => {
if (event.type === "ticket.created" || event.type === "ticket.updated") {
const visible = isTicketVisibleTo(event.ticket, currentUser);
setTickets((prev) => {
const withoutIt = prev.filter((t) => t.id !== event.ticket.id);
// Drop it if a live update (e.g. reassignment) just made it invisible
// to this agent — otherwise it'd linger on their board until reload.
return visible ? [event.ticket, ...withoutIt] : withoutIt;
});
}
});
// Server push (SSE) already keeps this current, but that depends on the
// event stream actually reaching the browser through whatever reverse
// proxy sits in front — a periodic refetch is a plain safety net so the
// board is never more than 30s stale even if that path is broken.
useEffect(() => {
const interval = setInterval(() => {
fetch("/api/tickets")
.then((res) => res.json())
.then((data) => setTickets(data.tickets ?? []))
.catch(() => {});
}, 30_000);
return () => clearInterval(interval);
}, []);
// Search hits the server (message bodies aren't loaded client-side),
// debounced. An empty query needs no fetch — baseTickets below just falls
// back to the live SSE-backed list, so there's nothing to reset here.
useEffect(() => {
if (!query.trim()) return;
const handle = setTimeout(() => {
setSearching(true);
fetch(`/api/tickets?q=${encodeURIComponent(query)}`)
.then((res) => res.json())
.then((data) => setSearchResults(data.tickets ?? []))
.finally(() => setSearching(false));
}, 300);
return () => clearTimeout(handle);
}, [query]);
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]);
const filteredTickets = useMemo(() => {
if (!activeTagId) return baseTickets;
return baseTickets.filter((t) => t.tags.some((tag) => tag.id === activeTagId));
}, [baseTickets, activeTagId]);
const byStatus = useMemo(() => {
const grouped: Record<TicketStatus, TicketDTO[]> = { new: [], open: [], pending: [], closed: [] };
for (const ticket of filteredTickets) {
grouped[ticket.status].push(ticket);
}
for (const list of Object.values(grouped)) {
list.sort((a, b) => b.lastMessageAt - a.lastMessageAt);
}
return grouped;
}, [filteredTickets]);
return (
<div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h1 className="font-display text-xl font-bold tracking-tight">Заявки</h1>
<button className="btn btn-primary" onClick={() => setModalOpen(true)}>
<Plus size={16} />
Новая заявка
</button>
</div>
<div className="relative mb-4 max-w-sm">
<Search size={14} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-text-faint" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск по заявкам…"
className="w-full rounded-md border border-border bg-surface py-2 pl-9 pr-8 text-sm outline-none focus:border-accent"
/>
{query && (
<button
onClick={() => setQuery("")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-text-faint hover:text-text"
>
<X size={14} />
</button>
)}
</div>
{query.trim() && searchResults && (
<p className="mb-4 text-xs text-text-faint">
{searching ? "Ищем…" : `Найдено: ${searchResults.length}`}
</p>
)}
{availableTags.length > 0 && (
<div className="mb-4 flex flex-wrap gap-1.5">
{availableTags.map((tag) => (
<button
key={tag.id}
onClick={() => setActiveTagId((prev) => (prev === tag.id ? null : tag.id))}
className={`rounded-full px-2.5 py-1 text-xs font-medium transition-opacity ${TAG_COLOR_CLASSES[tag.color]} ${
activeTagId && activeTagId !== tag.id ? "opacity-40" : ""
}`}
>
{tag.name}
</button>
))}
</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{COLUMNS.map((col) => (
<div key={col.status} className="min-w-0">
<div className="mb-2 flex items-center gap-2 px-1">
<h2 className="text-sm font-semibold text-text-muted">{col.label}</h2>
<span className="rounded-full bg-surface-hover px-1.5 text-xs text-text-faint">
{byStatus[col.status].length}
</span>
</div>
<div className="flex flex-col gap-2">
<AnimatePresence initial={false}>
{byStatus[col.status].map((ticket) => (
<TicketCard key={ticket.id} ticket={ticket} />
))}
</AnimatePresence>
{byStatus[col.status].length === 0 && (
<div className="flex flex-col items-center gap-2 rounded-md border border-dashed border-border px-3 py-8 text-center">
<Inbox size={18} className="text-text-faint" />
<p className="text-xs text-text-faint">{col.empty}</p>
</div>
)}
</div>
</div>
))}
</div>
<NewTicketModal open={modalOpen} onClose={() => setModalOpen(false)} />
</div>
);
}
function TicketCard({ ticket }: { ticket: TicketDTO }) {
const Icon = CHANNEL_ICON[ticket.channel];
return (
<motion.div
layout
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.97 }}
transition={{ duration: 0.18, ease: "easeOut" }}
>
<Link
href={`/tickets/${ticket.id}`}
className="card block p-3 shadow-sm transition-shadow hover:shadow-md"
style={{ borderLeft: `3px solid ${PRIORITY_COLOR[ticket.priority]}` }}
>
<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">
{ticket.tags.map((tag) => (
<span key={tag.id} className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${TAG_COLOR_CLASSES[tag.color]}`}>
{tag.name}
</span>
))}
</div>
)}
<div className="flex items-center justify-between">
<span className="flex min-w-0 items-center gap-1.5">
<Avatar name={ticket.customerName} size={20} />
<span className="truncate text-xs text-text-muted">{ticket.customerName}</span>
</span>
<span className="flex shrink-0 items-center gap-1.5">
<span className={`flex h-5 w-5 items-center justify-center rounded-full ${CHANNEL_CHIP_CLASSES[ticket.channel]}`}>
<Icon size={11} />
</span>
<span className="text-xs text-text-faint">{formatRelativeTime(ticket.lastMessageAt)}</span>
</span>
</div>
</Link>
</motion.div>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useRouter } from "next/navigation";
import { useTicketEvents } from "@/lib/events/use-ticket-events";
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
/**
* No visible UI — just a permanently-mounted subscriber (in the admin
* layout, so it's alive regardless of which admin page is open) that fires
* a desktop notification for events that mean "a customer needs a
* response," while the tab is in the background. Agent-authored messages
* are deliberately skipped — that's the agent's own action, not a signal.
*/
export function DesktopNotifications({ currentUser }: { currentUser: { id: string; role: "admin" | "agent" } }) {
const router = useRouter();
useTicketEvents("/api/events", (event) => {
if (typeof window === "undefined" || !("Notification" in window)) return;
if (Notification.permission !== "granted" || !document.hidden) return;
if (event.type === "ticket.created") {
if (!isTicketVisibleTo(event.ticket, currentUser)) return;
const notification = new Notification("Новая заявка", { body: event.ticket.subject });
notification.onclick = () => {
window.focus();
router.push(`/tickets/${event.ticket.id}`);
};
} else if (event.type === "message.created" && event.message.authorType === "customer") {
if (!isTicketVisibleTo({ assigneeId: event.assigneeId }, currentUser)) return;
const notification = new Notification(`Сообщение от ${event.message.authorName}`, {
body: event.message.body.slice(0, 120),
});
notification.onclick = () => {
window.focus();
router.push(`/tickets/${event.ticketId}`);
};
}
});
return null;
}
+49
View File
@@ -0,0 +1,49 @@
import { redirect } from "next/navigation";
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 { ThemeToggle } from "./theme-toggle";
import { SettingsMenu } from "./settings-menu";
import { NotificationToggle } from "./notification-toggle";
import { DesktopNotifications } from "./desktop-notifications";
import { TicketToasts } from "./ticket-toasts";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await getCurrentSession();
if (!session) {
redirect("/login");
}
const isAdmin = session.user.role === "admin";
const currentUser = { id: session.user.id, role: session.user.role };
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">
<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
</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>
<div className="ml-auto flex items-center gap-2 sm:ml-0 sm:gap-3">
<NotificationToggle />
<ThemeToggle />
{isAdmin && <SettingsMenu isAdmin={isAdmin} />}
<span className="hidden text-sm text-text-muted sm:inline">{session.user.name}</span>
<LogoutButton />
</div>
</div>
</header>
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-6 sm:px-6">{children}</main>
<DesktopNotifications currentUser={currentUser} />
<TicketToasts currentUser={currentUser} />
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { LogOut } from "lucide-react";
export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/login");
router.refresh();
}
return (
<button onClick={handleLogout} className="btn btn-ghost" title="Выйти">
<LogOut size={15} />
</button>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
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 },
];
export function NavLinks({ isAdmin }: { isAdmin: boolean }) {
const pathname = usePathname();
const visibleLinks = links.filter((link) => !link.adminOnly || isAdmin);
return (
<nav className="flex items-center gap-1">
{visibleLinks.map(({ href, label, icon: Icon }) => {
const active = pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={`flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
active ? "bg-accent-soft text-accent-soft-text" : "text-text-muted hover:bg-surface-hover hover:text-text"
}`}
>
<Icon size={15} />
{label}
</Link>
);
})}
</nav>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { useEffect, useState } from "react";
import { Bell, BellOff } from "lucide-react";
import { ensurePushSubscribed } from "@/lib/push/client";
function initialPermission(): NotificationPermission | "unsupported" {
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
return Notification.permission;
}
export function NotificationToggle() {
const [permission, setPermission] = useState(initialPermission);
// Returning user who already granted permission in an earlier session —
// make sure the push subscription is (still) registered, not just the
// notification permission.
useEffect(() => {
if (permission === "granted") void ensurePushSubscribed();
}, [permission]);
if (permission === "unsupported") return null;
async function requestPermission() {
if (permission === "granted") return; // no API to revoke it programmatically — only the browser's own site settings can
const next = await Notification.requestPermission();
setPermission(next);
if (next === "granted") void ensurePushSubscribed();
}
return (
<button
onClick={requestPermission}
className="btn btn-ghost"
title={permission === "granted" ? "Уведомления включены" : "Включить уведомления о новых заявках"}
>
{permission === "granted" ? <Bell size={15} /> : <BellOff size={15} />}
</button>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Settings, Send, Mail, Code2, MessageSquareText, Tags, UserCog, Users, KeyRound } from "lucide-react";
const items = [
{ href: "/settings/telegram", label: "Telegram", icon: Send, adminOnly: false },
{ href: "/settings/email", label: "Почта", icon: Mail, adminOnly: false },
{ href: "/settings/widget", label: "Виджет", icon: Code2, adminOnly: false },
{ href: "/settings/canned", label: "Шаблоны ответов", icon: MessageSquareText, adminOnly: false },
{ href: "/settings/tags", label: "Теги", icon: Tags, adminOnly: false },
{ href: "/settings/accounts", label: "Аккаунты", icon: Users, adminOnly: true },
{ href: "/settings/ldap", label: "LDAP", icon: KeyRound, adminOnly: true },
{ href: "/settings/account", label: "Аккаунт", icon: UserCog, adminOnly: false },
];
export function SettingsMenu({ isAdmin }: { isAdmin: boolean }) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const rootRef = useRef<HTMLDivElement>(null);
const visibleItems = items.filter((item) => !item.adminOnly || isAdmin);
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, []);
const isActive = visibleItems.some((item) => pathname.startsWith(item.href));
return (
<div ref={rootRef} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className={`btn btn-ghost ${isActive ? "bg-accent-soft text-accent-soft-text" : ""}`}
title="Настройки"
aria-label="Настройки"
>
<Settings size={15} />
</button>
{open && (
<div className="card absolute right-0 top-full z-20 mt-2 w-52 overflow-hidden p-1">
{visibleItems.map(({ href, label, icon: Icon }) => {
const active = pathname.startsWith(href);
return (
<Link
key={href}
href={href}
onClick={() => setOpen(false)}
className={`flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
active ? "bg-accent-soft text-accent-soft-text" : "text-text-muted hover:bg-surface-hover hover:text-text"
}`}
>
<Icon size={15} />
{label}
</Link>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,90 @@
"use client";
import { useState } from "react";
import { KeyRound } from "lucide-react";
export function ChangePasswordForm() {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSuccess(false);
if (newPassword !== confirmPassword) {
setError("Пароли не совпадают");
return;
}
setLoading(true);
const res = await fetch("/api/auth/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось сменить пароль");
return;
}
setSuccess(true);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
}
return (
<form onSubmit={handleSubmit} className="card p-4">
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Текущий пароль</span>
<input
required
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Новый пароль</span>
<input
required
type="password"
minLength={8}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="mb-4 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Повторите новый пароль</span>
<input
required
type="password"
minLength={8}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
{success && (
<p className="mb-3 rounded-md bg-success-soft px-3 py-2 text-sm text-success-soft-text">Пароль изменён</p>
)}
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
<KeyRound size={15} />
Сменить пароль
</button>
</form>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { getCurrentSession } from "@/lib/auth/session";
import { ChangePasswordForm } from "./change-password-form";
export default async function AccountSettingsPage() {
const session = await getCurrentSession();
return (
<div className="max-w-sm">
<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 />
</div>
);
}
@@ -0,0 +1,285 @@
"use client";
import { useState } from "react";
import { Plus, Trash2, Download } from "lucide-react";
import type { UserAccountDTO } from "@/lib/auth/users";
interface LdapEntry {
dn: string;
email: string;
name: string;
}
const ROLE_LABELS: Record<UserAccountDTO["role"], string> = { admin: "Администратор", agent: "Агент" };
export function AccountsManager({
initialUsers,
currentUserId,
ldapEnabled,
}: {
initialUsers: UserAccountDTO[];
currentUserId: string;
ldapEnabled: boolean;
}) {
const [users, setUsers] = useState(initialUsers);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<UserAccountDTO["role"]>("agent");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [directory, setDirectory] = useState<LdapEntry[] | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [directoryLoading, setDirectoryLoading] = useState(false);
const [directoryError, setDirectoryError] = useState<string | null>(null);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password, role }),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось создать аккаунт");
return;
}
const { user } = await res.json();
setUsers((prev) => [...prev, user].sort((a, b) => a.name.localeCompare(b.name)));
setName("");
setEmail("");
setPassword("");
setRole("agent");
}
async function handleRoleChange(id: string, newRole: UserAccountDTO["role"]) {
setError(null);
const res = await fetch(`/api/users/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: newRole }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось изменить роль");
return;
}
const { user } = await res.json();
setUsers((prev) => prev.map((u) => (u.id === id ? user : u)));
}
async function handleDelete(id: string) {
setError(null);
const res = await fetch(`/api/users/${id}`, { method: "DELETE" });
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось удалить аккаунт");
return;
}
setUsers((prev) => prev.filter((u) => u.id !== id));
}
async function loadDirectory() {
setDirectoryLoading(true);
setDirectoryError(null);
const res = await fetch("/api/ldap/directory");
setDirectoryLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setDirectoryError(data?.error ?? "Не удалось получить список из LDAP");
return;
}
const data = await res.json();
setDirectory(data.entries);
setSelected(new Set());
}
function toggleSelected(email: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(email)) next.delete(email);
else next.add(email);
return next;
});
}
async function handleImport() {
if (selected.size === 0) return;
setDirectoryLoading(true);
setDirectoryError(null);
const res = await fetch("/api/ldap/directory/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ emails: Array.from(selected) }),
});
setDirectoryLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setDirectoryError(data?.error ?? "Не удалось импортировать");
return;
}
const usersRes = await fetch("/api/users");
if (usersRes.ok) {
const { users: fresh } = await usersRes.json();
setUsers(fresh);
}
setDirectory((prev) => (prev ? prev.filter((e) => !selected.has(e.email)) : prev));
setSelected(new Set());
}
return (
<div className="flex flex-col gap-6">
<form onSubmit={handleCreate} className="card flex flex-col gap-3 p-4">
<h2 className="text-sm font-semibold">Создать аккаунт</h2>
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Имя</span>
<input
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Email</span>
<input
required
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
<input
required
type="password"
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Роль</span>
<select
value={role}
onChange={(e) => setRole(e.target.value as UserAccountDTO["role"])}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
>
<option value="agent">Агент</option>
<option value="admin">Администратор</option>
</select>
</label>
</div>
{error && <p className="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 self-start">
<Plus size={15} />
Создать
</button>
</form>
<div className="card p-4">
<h2 className="mb-3 text-sm font-semibold">Аккаунты ({users.length})</h2>
<div className="flex flex-col divide-y divide-border">
{users.map((u) => (
<div key={u.id} className="flex flex-wrap items-center gap-3 py-2.5">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{u.name}</p>
<p className="truncate text-xs text-text-muted">{u.email}</p>
</div>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
u.authSource === "ldap" ? "bg-info-soft text-info-soft-text" : "bg-surface-hover text-text-muted"
}`}
>
{u.authSource === "ldap" ? "LDAP" : "Локальный"}
</span>
<select
value={u.role}
onChange={(e) => handleRoleChange(u.id, e.target.value as UserAccountDTO["role"])}
className="rounded-md border border-border bg-surface px-2 py-1 text-sm outline-none focus:border-accent"
>
<option value="agent">{ROLE_LABELS.agent}</option>
<option value="admin">{ROLE_LABELS.admin}</option>
</select>
<button
onClick={() => handleDelete(u.id)}
disabled={u.id === currentUserId}
title={u.id === currentUserId ? "Нельзя удалить собственный аккаунт" : "Удалить"}
className="btn btn-ghost text-danger disabled:opacity-30"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
<div className="card p-4">
<h2 className="mb-1 text-sm font-semibold">Импорт из LDAP</h2>
{!ldapEnabled ? (
<p className="text-sm text-text-muted">
LDAP не подключён настройте его на странице «LDAP», чтобы импортировать аккаунты из каталога.
</p>
) : (
<>
<p className="mb-3 text-sm text-text-muted">Найдите аккаунты из каталога, которых ещё нет в системе.</p>
<button onClick={loadDirectory} disabled={directoryLoading} className="btn btn-ghost mb-3">
<Download size={14} />
Проверить каталог
</button>
{directoryError && (
<p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{directoryError}</p>
)}
{directory && (
<>
{directory.length === 0 ? (
<p className="text-sm text-text-muted">Новых аккаунтов не найдено всё уже импортировано.</p>
) : (
<div className="mb-3 flex flex-col divide-y divide-border">
{directory.map((entry) => (
<label key={entry.email} className="flex items-center gap-2 py-2 text-sm">
<input
type="checkbox"
checked={selected.has(entry.email)}
onChange={() => toggleSelected(entry.email)}
/>
<span className="font-medium">{entry.name}</span>
<span className="text-text-muted">{entry.email}</span>
</label>
))}
</div>
)}
{directory.length > 0 && (
<button
onClick={handleImport}
disabled={directoryLoading || selected.size === 0}
className="btn btn-primary"
>
Импортировать выбранные ({selected.size})
</button>
)}
</>
)}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,28 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { listUsers } from "@/lib/auth/users";
import { getLdapStatus } from "@/lib/auth/ldap-config";
import { AccountsManager } from "./accounts-manager";
export default async function AccountsSettingsPage() {
const session = await getCurrentSession();
if (!session || session.user.role !== "admin") {
redirect("/dashboard");
}
const [users, ldapStatus] = await Promise.all([listUsers(), getLdapStatus()]);
return (
<div className="max-w-2xl">
<h1 className="mb-1 text-xl font-bold tracking-tight">Аккаунты</h1>
<p className="mb-6 text-sm text-text-muted">
Управление аккаунтами агентов и администраторов создание вручную и импорт из LDAP.
</p>
<AccountsManager
initialUsers={users}
currentUserId={session.user.id}
ldapEnabled={ldapStatus.enabled}
/>
</div>
);
}
@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import { Plus, Pencil, Trash2, X, Check } from "lucide-react";
interface CannedResponse {
id: string;
title: string;
body: string;
}
export function CannedResponsesManager({ initialResponses }: { initialResponses: CannedResponse[] }) {
const [responses, setResponses] = useState(initialResponses);
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/canned-responses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, body }),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось создать шаблон");
return;
}
const { response } = await res.json();
setResponses((prev) => [...prev, response].sort((a, b) => a.title.localeCompare(b.title)));
setTitle("");
setBody("");
}
async function handleDelete(id: string) {
await fetch(`/api/canned-responses/${id}`, { method: "DELETE" });
setResponses((prev) => prev.filter((r) => r.id !== id));
}
async function handleUpdate(id: string, newTitle: string, newBody: string) {
const res = await fetch(`/api/canned-responses/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: newTitle, body: newBody }),
});
if (res.ok) {
const { response } = await res.json();
setResponses((prev) => prev.map((r) => (r.id === id ? response : r)));
setEditingId(null);
}
}
return (
<div className="flex flex-col gap-4">
<form onSubmit={handleCreate} className="card flex flex-col gap-3 p-4">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Название</span>
<input
required
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Например: Просим больше информации"
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Текст</span>
<textarea
required
rows={3}
value={body}
onChange={(e) => setBody(e.target.value)}
className="w-full resize-none rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<button type="submit" disabled={loading} className="btn btn-primary self-start">
<Plus size={15} />
Добавить
</button>
</form>
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
{responses.length === 0 && <p className="text-sm text-text-muted">Шаблонов пока нет.</p>}
<div className="flex flex-col gap-3">
{responses.map((response) =>
editingId === response.id ? (
<EditForm
key={response.id}
response={response}
onCancel={() => setEditingId(null)}
onSave={(t, b) => handleUpdate(response.id, t, b)}
/>
) : (
<div key={response.id} className="card p-4">
<div className="mb-1 flex items-center justify-between gap-2">
<p className="text-sm font-medium">{response.title}</p>
<div className="flex shrink-0 gap-1">
<button onClick={() => setEditingId(response.id)} className="btn btn-ghost px-2 py-1">
<Pencil size={13} />
</button>
<button onClick={() => handleDelete(response.id)} className="btn btn-ghost px-2 py-1">
<Trash2 size={13} />
</button>
</div>
</div>
<p className="whitespace-pre-wrap text-sm text-text-muted">{response.body}</p>
</div>
),
)}
</div>
</div>
);
}
function EditForm({
response,
onCancel,
onSave,
}: {
response: CannedResponse;
onCancel: () => void;
onSave: (title: string, body: string) => void;
}) {
const [title, setTitle] = useState(response.title);
const [body, setBody] = useState(response.body);
return (
<div className="card p-4">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mb-2 w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
<textarea
rows={3}
value={body}
onChange={(e) => setBody(e.target.value)}
className="mb-2 w-full resize-none rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
<div className="flex gap-2">
<button onClick={() => onSave(title, body)} className="btn btn-primary px-2 py-1">
<Check size={13} />
</button>
<button onClick={onCancel} className="btn btn-ghost px-2 py-1">
<X size={13} />
</button>
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { listCannedResponses } from "@/lib/tickets/service";
import { CannedResponsesManager } from "./canned-responses-manager";
export default async function CannedResponsesPage() {
const responses = await listCannedResponses();
return (
<div className="max-w-2xl">
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Шаблоны ответов</h1>
<p className="mb-6 text-sm text-text-muted">
Готовые тексты, которые можно вставить в ответ клиенту одним кликом.
</p>
<CannedResponsesManager initialResponses={responses} />
</div>
);
}
@@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import { CheckCircle2, XCircle } from "lucide-react";
interface Status {
configured: boolean;
enabled: boolean;
user: string | null;
imapHost: string | null;
allowInsecureTls: boolean;
verifiedAt: number | null;
}
export function MailboxSettingsForm({ initialStatus }: { initialStatus: Status }) {
const [status, setStatus] = useState(initialStatus);
const [host, setHost] = useState(initialStatus.imapHost ?? "");
const [imapPort, setImapPort] = useState("993");
const [smtpPort, setSmtpPort] = useState("587");
const [user, setUser] = useState(initialStatus.user ?? "");
const [password, setPassword] = useState("");
const [allowInsecureTls, setAllowInsecureTls] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleConnect(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/mailbox/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
host,
imapPort: Number(imapPort),
smtpPort: Number(smtpPort),
user,
password,
allowInsecureTls,
}),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось подключить почту");
return;
}
setStatus({ configured: true, enabled: true, user, imapHost: host, allowInsecureTls, verifiedAt: Date.now() });
setPassword("");
}
async function handleDisable() {
setLoading(true);
await fetch("/api/mailbox/config", { method: "DELETE" });
setLoading(false);
setStatus((s) => ({ ...s, enabled: false }));
}
return (
<div className="card p-5">
<div className="mb-4 flex items-center gap-2 text-sm">
{status.enabled ? (
<>
<CheckCircle2 size={16} className="text-success" />
<span>Подключено: {status.user}</span>
</>
) : (
<>
<XCircle size={16} className="text-text-faint" />
<span className="text-text-muted">Почта не подключена</span>
</>
)}
</div>
<form onSubmit={handleConnect}>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Хост (IMAP и SMTP)</span>
<input
required
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="mail.top-sysops.ru или 10.33.33.42"
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
</label>
<div className="mb-3 grid grid-cols-2 gap-3">
<label className="block text-sm">
<span className="mb-1 block font-medium text-text-muted">IMAP порт</span>
<input
required
value={imapPort}
onChange={(e) => setImapPort(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
</label>
<label className="block text-sm">
<span className="mb-1 block font-medium text-text-muted">SMTP порт</span>
<input
required
value={smtpPort}
onChange={(e) => setSmtpPort(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
</label>
</div>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Логин</span>
<input
required
type="email"
value={user}
onChange={(e) => setUser(e.target.value)}
placeholder="support@top-sysops.ru"
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
<input
required
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="mb-4 flex items-center gap-2 text-sm text-text-muted">
<input
type="checkbox"
checked={allowInsecureTls}
onChange={(e) => setAllowInsecureTls(e.target.checked)}
/>
Не проверять TLS-сертификат сервера (нужно, пока сертификат просрочен)
</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="submit" disabled={loading} className="btn btn-primary">
{status.configured ? "Обновить" : "Подключить"}
</button>
{status.enabled && (
<button type="button" onClick={handleDisable} disabled={loading} className="btn btn-ghost">
Отключить
</button>
)}
</div>
</form>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { getMailboxStatus } from "@/lib/mail/config";
import { MailboxSettingsForm } from "./mailbox-settings-form";
export default async function EmailSettingsPage() {
const status = await getMailboxStatus();
return (
<div className="max-w-lg">
<h1 className="mb-1 text-xl font-bold tracking-tight">Почта</h1>
<p className="mb-6 text-sm text-text-muted">
Письма на этот ящик будут превращаться в заявки, ответы агентов уходят обратно по SMTP.
</p>
<MailboxSettingsForm initialStatus={status} />
</div>
);
}
@@ -0,0 +1,210 @@
"use client";
import { useState } from "react";
import { CheckCircle2, XCircle } from "lucide-react";
interface Status {
configured: boolean;
enabled: boolean;
host: string | null;
port: number | null;
useTls: boolean;
bindDn: string | null;
baseDn: string | null;
userFilter: string | null;
listFilter: string | null;
defaultRole: "admin" | "agent";
verifiedAt: number | null;
}
export function LdapSettingsForm({ initialStatus }: { initialStatus: Status }) {
const [status, setStatus] = useState(initialStatus);
const [host, setHost] = useState(initialStatus.host ?? "");
const [port, setPort] = useState(String(initialStatus.port ?? 389));
const [useTls, setUseTls] = useState(initialStatus.useTls);
const [bindDn, setBindDn] = useState(initialStatus.bindDn ?? "");
const [bindPassword, setBindPassword] = useState("");
const [baseDn, setBaseDn] = useState(initialStatus.baseDn ?? "");
const [userFilter, setUserFilter] = useState(initialStatus.userFilter ?? "(mail={{email}})");
const [listFilter, setListFilter] = useState(initialStatus.listFilter ?? "(objectClass=person)");
const [defaultRole, setDefaultRole] = useState<"admin" | "agent">(initialStatus.defaultRole);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleConnect(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/ldap/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
host,
port: Number(port),
useTls,
bindDn,
bindPassword,
baseDn,
userFilter,
listFilter,
defaultRole,
}),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось подключить LDAP");
return;
}
setStatus({
configured: true,
enabled: true,
host,
port: Number(port),
useTls,
bindDn,
baseDn,
userFilter,
listFilter,
defaultRole,
verifiedAt: Date.now(),
});
setBindPassword("");
}
async function handleDisable() {
setLoading(true);
await fetch("/api/ldap/config", { method: "DELETE" });
setLoading(false);
setStatus((s) => ({ ...s, enabled: false }));
}
return (
<div className="card p-5">
<div className="mb-4 flex items-center gap-2 text-sm">
{status.enabled ? (
<>
<CheckCircle2 size={16} className="text-success" />
<span>Подключено: {status.host}</span>
</>
) : (
<>
<XCircle size={16} className="text-text-faint" />
<span className="text-text-muted">LDAP не подключён</span>
</>
)}
</div>
<form onSubmit={handleConnect}>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Хост</span>
<input
required
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="ldap.example.local"
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
</label>
<div className="mb-3 grid grid-cols-2 gap-3">
<label className="block text-sm">
<span className="mb-1 block font-medium text-text-muted">Порт</span>
<input
required
value={port}
onChange={(e) => setPort(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
</label>
<label className="flex items-end gap-2 pb-2 text-sm text-text-muted">
<input type="checkbox" checked={useTls} onChange={(e) => setUseTls(e.target.checked)} />
Использовать TLS (ldaps://)
</label>
</div>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Bind DN (служебная учётка)</span>
<input
required
value={bindDn}
onChange={(e) => setBindDn(e.target.value)}
placeholder="cn=svc-helpdesk,dc=example,dc=local"
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
</label>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Bind пароль</span>
<input
required
type="password"
value={bindPassword}
onChange={(e) => setBindPassword(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Base DN</span>
<input
required
value={baseDn}
onChange={(e) => setBaseDn(e.target.value)}
placeholder="dc=example,dc=local"
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
</label>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Фильтр поиска пользователя при входе</span>
<input
required
value={userFilter}
onChange={(e) => setUserFilter(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
<span className="mt-1 block text-xs text-text-faint">{"{{email}}"} заменяется на введённый при входе email</span>
</label>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Фильтр для импорта каталога</span>
<input
required
value={listFilter}
onChange={(e) => setListFilter(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
</label>
<label className="mb-4 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Роль по умолчанию для новых LDAP-аккаунтов</span>
<select
value={defaultRole}
onChange={(e) => setDefaultRole(e.target.value as "admin" | "agent")}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
>
<option value="agent">Агент</option>
<option value="admin">Администратор</option>
</select>
</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="submit" disabled={loading} className="btn btn-primary">
{status.configured ? "Обновить" : "Подключить"}
</button>
{status.enabled && (
<button type="button" onClick={handleDisable} disabled={loading} className="btn btn-ghost">
Отключить
</button>
)}
</div>
</form>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { getLdapStatus } from "@/lib/auth/ldap-config";
import { LdapSettingsForm } from "./ldap-settings-form";
export default async function LdapSettingsPage() {
const session = await getCurrentSession();
if (!session || session.user.role !== "admin") {
redirect("/dashboard");
}
const status = await getLdapStatus();
return (
<div className="max-w-lg">
<h1 className="mb-1 text-xl font-bold tracking-tight">LDAP</h1>
<p className="mb-6 text-sm text-text-muted">
Подключите каталог LDAP/AD вход будет сначала пробовать LDAP, а при неудаче откатываться на локальный
пароль. Пока LDAP не подключён, локальный вход работает как обычно.
</p>
<LdapSettingsForm initialStatus={status} />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { listTags } from "@/lib/tickets/service";
import { TagsManager } from "./tags-manager";
export default async function TagsSettingsPage() {
const tags = await listTags();
return (
<div className="max-w-lg">
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Теги</h1>
<p className="mb-6 text-sm text-text-muted">Метки для категоризации заявок.</p>
<TagsManager initialTags={tags} />
</div>
);
}
@@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import { Plus, 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";
export function TagsManager({ initialTags }: { initialTags: TagDTO[] }) {
const [tags, setTags] = useState(initialTags);
const [name, setName] = useState("");
const [color, setColor] = useState<TagColor>("accent");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, color }),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось создать тег");
return;
}
const { tag } = await res.json();
setTags((prev) => [...prev, tag]);
setName("");
}
async function handleDelete(id: string) {
await fetch(`/api/tags/${id}`, { method: "DELETE" });
setTags((prev) => prev.filter((t) => t.id !== id));
}
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">
<label className="flex-1 text-sm">
<span className="mb-1 block font-medium text-text-muted">Название</span>
<input
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Срочно"
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Цвет</span>
<select
value={color}
onChange={(e) => setColor(e.target.value as TagColor)}
className="rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
>
{TAG_COLORS.map((c) => (
<option key={c} value={c}>
{TAG_COLOR_LABELS[c]}
</option>
))}
</select>
</label>
<button type="submit" disabled={loading} className="btn btn-primary shrink-0">
<Plus size={15} />
Добавить
</button>
</form>
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
{tags.length === 0 && <p className="text-sm text-text-muted">Тегов пока нет.</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]}`}
>
{tag.name}
<button onClick={() => handleDelete(tag.id)} className="opacity-60 hover:opacity-100">
<Trash2 size={12} />
</button>
</span>
))}
</div>
</div>
);
}
@@ -0,0 +1,15 @@
import { getTelegramStatus } from "@/lib/telegram/bot";
import { TelegramSettingsForm } from "./telegram-settings-form";
export default async function TelegramSettingsPage() {
const status = await getTelegramStatus();
return (
<div className="max-w-lg">
<h1 className="mb-1 text-xl font-bold tracking-tight">Telegram</h1>
<p className="mb-6 text-sm text-text-muted">
Подключите бота личные сообщения и сообщения из групп, куда он добавлен, будут превращаться в заявки.
</p>
<TelegramSettingsForm initialStatus={status} />
</div>
);
}
@@ -0,0 +1,99 @@
"use client";
import { useState } from "react";
import { CheckCircle2, XCircle } from "lucide-react";
interface Status {
configured: boolean;
enabled: boolean;
botUsername: string | null;
verifiedAt: number | null;
}
export function TelegramSettingsForm({ initialStatus }: { initialStatus: Status }) {
const [status, setStatus] = useState(initialStatus);
const [token, setToken] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleConnect(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/telegram/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ botToken: token }),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось подключить бота");
return;
}
const data = await res.json();
setStatus({ configured: true, enabled: true, botUsername: data.botUsername, verifiedAt: Date.now() });
setToken("");
}
async function handleDisable() {
setLoading(true);
await fetch("/api/telegram/config", { method: "DELETE" });
setLoading(false);
setStatus((s) => ({ ...s, enabled: false }));
}
return (
<div className="card p-5">
<div className="mb-4 flex items-center gap-2 text-sm">
{status.enabled ? (
<>
<CheckCircle2 size={16} className="text-success" />
<span>
Подключено{status.botUsername ? `: @${status.botUsername}` : ""}
</span>
</>
) : (
<>
<XCircle size={16} className="text-text-faint" />
<span className="text-text-muted">Бот не подключён</span>
</>
)}
</div>
<form onSubmit={handleConnect}>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Bot API token</span>
<input
required
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="123456:ABC-DEF..."
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
</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="submit" disabled={loading || !token} className="btn btn-primary">
{status.configured ? "Обновить токен" : "Подключить"}
</button>
{status.enabled && (
<button type="button" onClick={handleDisable} disabled={loading} className="btn btn-ghost">
Отключить
</button>
)}
</div>
</form>
<p className="mt-4 text-xs text-text-faint">
Чтобы бот видел все сообщения в группе (а не только те, где его упомянули), отключите Group Privacy Mode
через @BotFather /setprivacy Disable.
</p>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { listWidgetSites } from "@/lib/tickets/service";
import { WidgetSitesManager } from "./widget-sites-manager";
export default async function WidgetSettingsPage() {
const sites = await listWidgetSites();
return (
<div className="max-w-2xl">
<h1 className="mb-1 text-xl font-bold tracking-tight">Виджет для сайтов</h1>
<p className="mb-6 text-sm text-text-muted">
Встройте чат на любой сайт сообщения посетителей превращаются в заявки.
</p>
<WidgetSitesManager
initialSites={sites.map((s) => ({
id: s.id,
name: s.name,
siteKey: s.siteKey,
allowedOrigin: s.allowedOrigin,
enabled: s.enabled,
}))}
/>
</div>
);
}
@@ -0,0 +1,129 @@
"use client";
import { useState } from "react";
import { Plus, Copy, Check } from "lucide-react";
interface Site {
id: string;
name: string;
siteKey: string;
allowedOrigin: string | null;
enabled: boolean;
}
export function WidgetSitesManager({ initialSites }: { initialSites: Site[] }) {
const [sites, setSites] = useState(initialSites);
const [name, setName] = useState("");
const [allowedOrigin, setAllowedOrigin] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/widget/sites", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, allowedOrigin: allowedOrigin || undefined }),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось создать сайт");
return;
}
const { site } = await res.json();
setSites((prev) => [site, ...prev]);
setName("");
setAllowedOrigin("");
}
async function toggleEnabled(site: Site) {
const res = await fetch("/api/widget/sites", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: site.id, enabled: !site.enabled }),
});
if (res.ok) {
const { site: updated } = await res.json();
setSites((prev) => prev.map((s) => (s.id === site.id ? updated : s)));
}
}
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">
<label className="flex-1 text-sm">
<span className="mb-1 block font-medium text-text-muted">Название сайта</span>
<input
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="top-sysops.ru"
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="flex-1 text-sm">
<span className="mb-1 block font-medium text-text-muted">Домен (необязательно)</span>
<input
value={allowedOrigin}
onChange={(e) => setAllowedOrigin(e.target.value)}
placeholder="https://top-sysops.ru"
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<button type="submit" disabled={loading} className="btn btn-primary shrink-0">
<Plus size={15} />
Добавить
</button>
</form>
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
{sites.length === 0 && <p className="text-sm text-text-muted">Сайтов пока нет.</p>}
<div className="flex flex-col gap-3">
{sites.map((site) => (
<SiteCard key={site.id} site={site} onToggle={() => toggleEnabled(site)} />
))}
</div>
</div>
);
}
function SiteCard({ site, onToggle }: { site: Site; onToggle: () => void }) {
const [copied, setCopied] = useState(false);
const origin = typeof window !== "undefined" ? window.location.origin : "";
const snippet = `<script src="${origin}/widget.js" data-key="${site.siteKey}" async></script>`;
async function copySnippet() {
await navigator.clipboard.writeText(snippet);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
return (
<div className="card p-4">
<div className="mb-2 flex items-center justify-between">
<div>
<p className="text-sm font-medium">{site.name}</p>
{site.allowedOrigin && <p className="text-xs text-text-faint">{site.allowedOrigin}</p>}
</div>
<label className="flex items-center gap-2 text-xs text-text-muted">
<input type="checkbox" checked={site.enabled} onChange={onToggle} />
{site.enabled ? "Включён" : "Выключен"}
</label>
</div>
<div className="flex items-center gap-2 rounded-md bg-surface-hover px-3 py-2">
<code className="flex-1 overflow-x-auto whitespace-nowrap text-xs">{snippet}</code>
<button onClick={copySnippet} className="btn btn-ghost shrink-0 px-2 py-1">
{copied ? <Check size={13} /> : <Copy size={13} />}
</button>
</div>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
// Small inline HTML/CSS charts — see the dataviz skill guidance this file
// follows: thin marks (≤24px), 4px rounded data-end / square baseline,
// direct labels (never color alone), text in text tokens (never data color).
export function StatTile({ label, value }: { label: string; value: string }) {
return (
<div className="card p-4">
<p className="mb-1 text-xs text-text-muted">{label}</p>
<p className="text-2xl font-semibold">{value}</p>
</div>
);
}
const BAR_HEIGHT = 16;
export function HorizontalBar({
label,
value,
max,
colorClass,
}: {
label: string;
value: number;
max: number;
colorClass: string;
}) {
const pct = max > 0 ? (value / max) * 100 : 0;
return (
<div className="flex items-center gap-3">
<span className="w-24 shrink-0 truncate text-xs text-text-muted">{label}</span>
<div
className="flex-1 overflow-hidden bg-surface-hover"
style={{ height: BAR_HEIGHT, borderRadius: 4 }}
title={`${label}: ${value}`}
>
<div
className={colorClass}
style={{
width: `${pct}%`,
height: "100%",
borderRadius: "0 4px 4px 0",
transition: "width 300ms ease",
}}
/>
</div>
<span className="w-6 shrink-0 text-right text-xs font-medium text-text">{value}</span>
</div>
);
}
export function DailyBarChart({ data }: { data: { label: string; count: number }[] }) {
const max = Math.max(...data.map((d) => d.count), 1);
return (
<div>
<div className="flex h-32 gap-1 border-b border-border pb-0.5">
{data.map((d, i) => (
<div key={i} className="group relative flex flex-1 flex-col items-center justify-end" title={`${d.label}: ${d.count}`}>
{d.count > 0 && (
<span className="mb-1 text-[10px] text-text-faint opacity-0 transition-opacity group-hover:opacity-100">
{d.count}
</span>
)}
<div
className="w-full bg-chart-1"
style={{
height: max > 0 ? `${Math.max((d.count / max) * 100, d.count > 0 ? 4 : 1)}%` : "1%",
borderRadius: "4px 4px 0 0",
opacity: d.count > 0 ? 1 : 0.15,
}}
/>
</div>
))}
</div>
<div className="mt-1 flex gap-1">
{data.map((d, i) => (
<span
key={i}
className="flex-1 truncate text-center text-[10px] text-text-faint"
style={{ visibility: i % 3 === 0 ? "visible" : "hidden" }}
>
{d.label}
</span>
))}
</div>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { redirect } from "next/navigation";
import { Inbox, CircleDot, Clock, CheckCircle2 } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session";
import { getStatsOverview } from "@/lib/tickets/service";
import { formatDurationMinutes } from "@/lib/format";
import { StatTile, HorizontalBar, DailyBarChart } from "./charts";
const STATUS_META = {
new: { label: "Новые", icon: Inbox, soft: "--accent-soft", softText: "--accent-soft-text" },
open: { label: "Открытые", icon: CircleDot, soft: "--success-soft", softText: "--success-soft-text" },
pending: { label: "В ожидании", icon: Clock, soft: "--warning-soft", softText: "--warning-soft-text" },
closed: { label: "Закрытые", icon: CheckCircle2, soft: "--border", softText: "--text-muted" },
} as const;
const CHANNEL_META: Record<string, { label: string; colorClass: string }> = {
email: { label: "Почта", colorClass: "bg-chart-1" },
widget: { label: "Виджет", colorClass: "bg-chart-2" },
telegram: { label: "Telegram", colorClass: "bg-chart-3" },
portal: { label: "Портал", colorClass: "bg-chart-4" },
manual: { label: "Вручную", colorClass: "bg-chart-muted" },
};
export default async function StatsPage() {
const session = await getCurrentSession();
if (!session || session.user.role !== "admin") {
redirect("/dashboard");
}
const stats = await getStatsOverview();
const maxChannelCount = Math.max(...Object.values(stats.channelCounts), 1);
const statusesWithWorkload = (Object.keys(STATUS_META) as (keyof typeof STATUS_META)[]).filter(
(status) => stats.agentWorkloadByStatus[status].length > 0,
);
return (
<div>
<h1 className="mb-5 font-display text-xl font-bold tracking-tight">Статистика</h1>
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatTile label="Всего заявок" value={String(stats.totalTickets)} />
<StatTile label="В работе сейчас" value={String(stats.openTickets)} />
<StatTile label="Новых сегодня" value={String(stats.newToday)} />
<StatTile
label="Среднее время ответа"
value={stats.avgFirstResponseMinutes != null ? formatDurationMinutes(stats.avgFirstResponseMinutes) : "—"}
/>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="card p-4">
<h2 className="mb-3 text-sm font-semibold text-text-muted">По статусам</h2>
<div className="grid grid-cols-2 gap-2">
{(Object.keys(STATUS_META) as (keyof typeof STATUS_META)[]).map((status) => {
const meta = STATUS_META[status];
const Icon = meta.icon;
return (
<div
key={status}
className="flex items-center justify-between rounded-md px-3 py-2 text-sm"
style={{ background: `var(${meta.soft})`, color: `var(${meta.softText})` }}
>
<span className="flex items-center gap-1.5">
<Icon size={13} />
{meta.label}
</span>
<span className="font-semibold">{stats.statusCounts[status]}</span>
</div>
);
})}
</div>
</div>
<div className="card p-4">
<h2 className="mb-3 text-sm font-semibold text-text-muted">По каналам</h2>
<div className="flex flex-col gap-2">
{Object.entries(stats.channelCounts).map(([channel, count]) => (
<HorizontalBar
key={channel}
label={CHANNEL_META[channel]?.label ?? channel}
value={count}
max={maxChannelCount}
colorClass={CHANNEL_META[channel]?.colorClass ?? "bg-chart-muted"}
/>
))}
</div>
</div>
<div className="card p-4 lg:col-span-2">
<h2 className="mb-3 text-sm font-semibold text-text-muted">Заявки за 14 дней</h2>
<DailyBarChart data={stats.ticketsPerDay} />
</div>
{statusesWithWorkload.length === 0 ? (
<div className="card p-4 lg:col-span-2">
<h2 className="mb-3 text-sm font-semibold text-text-muted">Заявки по агентам</h2>
<p className="text-sm text-text-faint">Пока нет данных.</p>
</div>
) : (
statusesWithWorkload.map((status) => {
const workload = stats.agentWorkloadByStatus[status];
const max = Math.max(...workload.map((a) => a.count), 1);
return (
<div key={status} className="card p-4">
<h2 className="mb-3 text-sm font-semibold text-text-muted">
Заявки по агентам {STATUS_META[status].label}
</h2>
<div className="flex flex-col gap-2">
{workload.map((agent) => (
<HorizontalBar
key={agent.name}
label={agent.name}
value={agent.count}
max={max}
colorClass="bg-chart-1"
/>
))}
</div>
</div>
);
})
)}
</div>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useLayoutEffect } from "react";
import { Sun, Moon } from "lucide-react";
function getEffectiveTheme(): "light" | "dark" {
const stored = document.documentElement.dataset.theme;
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
// Both icons always render; CSS (mirroring the same data-theme cascade the
// palette itself uses) decides which one shows — avoids a useState/useEffect
// pair just to reflect the DOM attribute the no-flash script already set.
export function ThemeToggle() {
// The <head> no-flash script sets data-theme before hydration, but
// something in this app's streaming SSR/hydration commits again shortly
// after first paint and strips it back off (confirmed: present at ~0ms,
// gone by ~50ms, and it doesn't come back on its own) — suppressHydration
// Warning on <html> only silences the mismatch warning for the *first*
// hydration diff, it doesn't stop a later commit from resetting an
// attribute React doesn't know about. Re-applying here, once this client
// component has mounted (i.e. after that reset has already happened),
// wins the race and makes it stick.
useLayoutEffect(() => {
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") {
document.documentElement.dataset.theme = stored;
}
}, []);
function toggle() {
const next = getEffectiveTheme() === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = next;
localStorage.setItem("theme", next);
}
return (
<button onClick={toggle} className="btn btn-ghost theme-toggle-btn" title="Переключить тему" aria-label="Переключить тему">
<Sun size={15} className="theme-icon-sun" />
<Moon size={15} className="theme-icon-moon" />
</button>
);
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion";
import { Bell, X } from "lucide-react";
import { useTicketEvents } from "@/lib/events/use-ticket-events";
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
interface Toast {
id: string;
title: string;
body: string;
href: string;
}
// A single, reused AudioContext. Two separate browser behaviors fight it:
// (1) autoplay policy — a context created/resumed outside a real user
// gesture starts "suspended" and stays silent; (2) power-saving
// auto-suspend — Chrome can suspend an already-unlocked context again
// after a stretch of no audio activity, independent of (1). Neither fails
// loudly: start()/stop() never throw, the chime just doesn't sound.
// Fix: keep the unlock listeners attached for the whole session (not just
// the first click) so any later click also re-resumes a context the
// browser suspended for power-saving, and actually await resume() before
// scheduling a chime instead of assuming it completes synchronously.
let sharedAudioContext: AudioContext | null = null;
function getAudioContext(): AudioContext | null {
if (typeof window === "undefined") return null;
const AudioCtor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioCtor) return null;
if (!sharedAudioContext) sharedAudioContext = new AudioCtor();
return sharedAudioContext;
}
if (typeof window !== "undefined") {
const unlockAudio = () => {
const ctx = getAudioContext();
if (ctx?.state === "suspended") ctx.resume();
};
window.addEventListener("pointerdown", unlockAudio);
window.addEventListener("keydown", unlockAudio);
}
/** A short two-tone chime via the Web Audio API — no audio asset needed. */
async function playChime() {
const ctx = getAudioContext();
if (!ctx) return;
try {
if (ctx.state === "suspended") await ctx.resume();
if (ctx.state !== "running") return; // still not unlocked by a user gesture yet — nothing to do
const now = ctx.currentTime;
for (const [freq, start] of [[880, 0], [1175, 0.12]] as const) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.0001, now + start);
gain.gain.exponentialRampToValueAtTime(0.25, now + start + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, now + start + 0.25);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now + start);
osc.stop(now + start + 0.25);
}
} catch {
// audio isn't critical to the notification — a silent toast still works
}
}
/**
* In-page toast + sound for new tickets and customer messages, shown
* regardless of tab focus. Complements DesktopNotifications, which only
* fires the native OS notification while the tab is in the background —
* this is the "still on the page" case.
*/
export function TicketToasts({ currentUser }: { currentUser: { id: string; role: "admin" | "agent" } }) {
const router = useRouter();
const [toasts, setToasts] = useState<Toast[]>([]);
const idCounter = useRef(0);
const pushToast = useCallback((title: string, body: string, href: string) => {
const id = `${Date.now()}-${idCounter.current++}`;
setToasts((prev) => [...prev, { id, title, body, href }]);
void playChime();
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 6_000);
}, []);
useTicketEvents("/api/events", (event) => {
if (event.type === "ticket.created") {
if (!isTicketVisibleTo(event.ticket, currentUser)) return;
pushToast("Новая заявка", event.ticket.subject, `/tickets/${event.ticket.id}`);
} else if (event.type === "message.created" && event.message.authorType === "customer") {
if (!isTicketVisibleTo({ assigneeId: event.assigneeId }, currentUser)) return;
pushToast(`Сообщение от ${event.message.authorName}`, event.message.body.slice(0, 120), `/tickets/${event.ticketId}`);
}
});
function dismiss(id: string) {
setToasts((prev) => prev.filter((t) => t.id !== id));
}
return (
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2">
<AnimatePresence initial={false}>
{toasts.map((toast) => (
<motion.div
key={toast.id}
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.15 }}
className="card pointer-events-auto flex items-start gap-2 p-3 shadow-md"
>
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-soft text-accent-soft-text">
<Bell size={13} />
</span>
<button
onClick={() => {
dismiss(toast.id);
router.push(toast.href);
}}
className="min-w-0 flex-1 text-left"
>
<p className="truncate text-sm font-medium">{toast.title}</p>
<p className="line-clamp-2 text-xs text-text-muted">{toast.body}</p>
</button>
<button onClick={() => dismiss(toast.id)} className="text-text-faint hover:text-text" aria-label="Закрыть">
<X size={13} />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { notFound, redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { getTicketWithMessages, listAgents } from "@/lib/tickets/service";
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
import { TicketThread } from "./ticket-thread";
export default async function TicketPage({ params }: { params: Promise<{ id: string }> }) {
const session = await getCurrentSession();
if (!session) redirect("/login");
const currentUser = { id: session.user.id, role: session.user.role };
const { id } = await params;
const result = await getTicketWithMessages(id);
if (!result) notFound();
if (!isTicketVisibleTo(result.ticket, currentUser)) {
redirect("/dashboard");
}
const agents = await listAgents();
const visibleMessages = currentUser.role === "admin"
? result.messages
: result.messages.filter((m) => m.visibility !== "internal");
return (
<TicketThread
ticket={result.ticket}
initialMessages={visibleMessages}
agents={agents.map((a) => ({ id: a.id, name: a.name }))}
currentUser={currentUser}
/>
);
}
@@ -0,0 +1,87 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Tags as TagsIcon, Check } from "lucide-react";
import { TAG_COLOR_CLASSES } from "@/lib/tags/colors";
import type { TagDTO } from "@/lib/tickets/types";
export function TagPicker({
ticketId,
selectedTags,
onChange,
}: {
ticketId: string;
selectedTags: TagDTO[];
onChange: (tags: TagDTO[]) => void;
}) {
const [allTags, setAllTags] = useState<TagDTO[]>([]);
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetch("/api/tags")
.then((res) => res.json())
.then((data) => setAllTags(data.tags ?? []));
}, []);
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, []);
async function toggle(tagId: string) {
const selectedIds = selectedTags.map((t) => t.id);
const nextIds = selectedIds.includes(tagId)
? selectedIds.filter((id) => id !== tagId)
: [...selectedIds, tagId];
const res = await fetch(`/api/tickets/${ticketId}/tags`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tagIds: nextIds }),
});
if (res.ok) {
const { tags } = await res.json();
onChange(tags);
}
}
return (
<div ref={rootRef} className="relative flex flex-wrap items-center gap-1.5">
{selectedTags.map((tag) => (
<span key={tag.id} className={`rounded-full px-2 py-0.5 text-xs font-medium ${TAG_COLOR_CLASSES[tag.color]}`}>
{tag.name}
</span>
))}
<button type="button" onClick={() => setOpen((v) => !v)} className="btn btn-ghost px-1.5 py-1" title="Теги">
<TagsIcon size={13} />
</button>
{open && (
<div className="card absolute left-0 top-full z-20 mt-2 w-56 p-1">
{allTags.length === 0 && (
<p className="p-2 text-xs text-text-faint">Тегов пока нет добавьте в настройках</p>
)}
{allTags.map((tag) => {
const active = selectedTags.some((t) => t.id === tag.id);
return (
<button
key={tag.id}
type="button"
onClick={() => toggle(tag.id)}
className="flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left hover:bg-surface-hover"
>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${TAG_COLOR_CLASSES[tag.color]}`}>
{tag.name}
</span>
{active && <Check size={13} className="text-accent" />}
</button>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,347 @@
"use client";
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 { useTicketEvents } from "@/lib/events/use-ticket-events";
import { formatRelativeTime } from "@/lib/format";
import { Avatar } from "@/components/avatar";
import { AttachmentChip } from "@/components/attachment-chip";
import { TagPicker } from "./tag-picker";
import type { TicketDTO, MessageDTO, TicketStatus } from "@/lib/tickets/types";
const STATUS_LABEL: Record<TicketStatus, string> = {
new: "Новая",
open: "Открыта",
pending: "В ожидании",
closed: "Закрыта",
};
export function TicketThread({
ticket: initialTicket,
initialMessages,
agents,
currentUser,
}: {
ticket: TicketDTO;
initialMessages: MessageDTO[];
agents: { id: string; name: string }[];
currentUser: { id: string; role: "admin" | "agent" };
}) {
const isAdmin = currentUser.role === "admin";
const [ticket, setTicket] = useState(initialTicket);
const [messages, setMessages] = useState(initialMessages);
const [draft, setDraft] = useState("");
const [visibility, setVisibility] = useState<"public" | "internal">("public");
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [sending, setSending] = useState(false);
const [cannedResponses, setCannedResponses] = useState<{ id: string; title: string; body: string }[]>([]);
const [cannedOpen, setCannedOpen] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const cannedRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetch("/api/canned-responses")
.then((res) => res.json())
.then((data) => setCannedResponses(data.responses ?? []));
}, []);
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (cannedRef.current && !cannedRef.current.contains(e.target as Node)) setCannedOpen(false);
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, []);
function insertCanned(body: string) {
setDraft((prev) => (prev.trim() ? `${prev}\n${body}` : body));
setCannedOpen(false);
}
useTicketEvents("/api/events", (event) => {
if (event.type === "ticket.updated" && event.ticket.id === ticket.id) {
setTicket(event.ticket);
}
if (event.type === "message.created" && event.ticketId === ticket.id) {
// Internal notes are admin-only — an agent's SSE connection still
// receives every event on the bus, so this has to be filtered here
// too, not just at the initial server-rendered load.
if (event.message.visibility === "internal" && !isAdmin) return;
setMessages((prev) => (prev.some((m) => m.id === event.message.id) ? prev : [...prev, event.message]));
}
});
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages.length]);
async function sendReply() {
if (!draft.trim() && !pendingFile) return;
setSending(true);
let res: Response;
if (pendingFile) {
const formData = new FormData();
formData.append("file", pendingFile);
formData.append("caption", draft);
formData.append("visibility", visibility);
res = await fetch(`/api/tickets/${ticket.id}/attachments`, { method: "POST", body: formData });
} else {
res = await fetch(`/api/tickets/${ticket.id}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: draft, visibility }),
});
}
setSending(false);
if (res.ok) {
setDraft("");
setPendingFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
async function updateStatus(status: TicketStatus) {
const res = await fetch(`/api/tickets/${ticket.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
if (res.ok) {
const { ticket: updated } = await res.json();
setTicket(updated);
}
}
async function updateAssignee(assigneeId: string) {
const res = await fetch(`/api/tickets/${ticket.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assigneeId: assigneeId || null }),
});
if (res.ok) {
const { ticket: updated } = await res.json();
setTicket(updated);
}
}
return (
<div className="flex h-[calc(100vh-11rem)] flex-col sm:h-[calc(100vh-6.5rem)]">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<Link href="/dashboard" className="mb-1 flex items-center gap-1 text-xs text-text-muted hover:text-text">
<ArrowLeft size={13} />
Дашборд
</Link>
<h1 className="truncate font-display text-lg font-bold">{ticket.subject}</h1>
<span className="mb-2 flex items-center gap-1.5">
<Avatar name={ticket.customerName} size={18} />
<span className="text-sm text-text-muted">{ticket.customerName}</span>
</span>
<TagPicker
ticketId={ticket.id}
selectedTags={ticket.tags}
onChange={(tags) => setTicket((t) => ({ ...t, tags }))}
/>
</div>
<div className="flex w-full shrink-0 items-center gap-2 sm:w-auto">
<select
value={ticket.status}
onChange={(e) => updateStatus(e.target.value as TicketStatus)}
className="min-w-0 flex-1 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent sm:flex-none"
>
{Object.entries(STATUS_LABEL).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<select
value={ticket.assigneeId ?? ""}
onChange={(e) => updateAssignee(e.target.value)}
className="min-w-0 flex-1 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent sm:flex-none"
>
<option value="">Не назначено</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
</div>
</div>
<div className="card flex-1 overflow-y-auto p-4">
<AnimatePresence initial={false}>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
</AnimatePresence>
<div ref={bottomRef} />
</div>
<div className="mt-3">
{/* Internal notes are admin-only — agents only ever reply to the client, so there's nothing to toggle. */}
{isAdmin && (
<div className="mb-1.5 flex gap-1">
<button
type="button"
onClick={() => setVisibility("public")}
className={`rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
visibility === "public" ? "bg-accent-soft text-accent-soft-text" : "text-text-faint hover:text-text-muted"
}`}
>
Ответ клиенту
</button>
<button
type="button"
onClick={() => setVisibility("internal")}
className={`rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
visibility === "internal" ? "bg-warning-soft text-warning-soft-text" : "text-text-faint hover:text-text-muted"
}`}
>
Заметка для команды
</button>
</div>
)}
{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} />
<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>
)}
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => setPendingFile(e.target.files?.[0] ?? null)}
/>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendReply();
}
}}
rows={2}
placeholder={visibility === "internal" ? "Заметка — клиент её не увидит…" : "Ответить клиенту…"}
className={`flex-1 resize-none rounded-md border px-3 py-2 text-sm outline-none ${
visibility === "internal"
? "border-warning-soft-text/30 bg-warning-soft focus:border-warning"
: "border-border bg-surface focus:border-accent"
}`}
/>
<div ref={cannedRef} className="relative self-end">
<button
type="button"
onClick={() => setCannedOpen((v) => !v)}
className="btn btn-ghost"
title="Шаблоны ответов"
>
<MessageSquareText size={15} />
</button>
{cannedOpen && (
<div className="card absolute bottom-full right-0 z-20 mb-2 max-h-64 w-72 overflow-y-auto p-1">
{cannedResponses.length === 0 && (
<p className="p-2 text-xs text-text-faint">Шаблонов пока нет</p>
)}
{cannedResponses.map((c) => (
<button
key={c.id}
type="button"
onClick={() => insertCanned(c.body)}
className="block w-full rounded-md px-3 py-2 text-left text-sm hover:bg-surface-hover"
>
<p className="font-medium">{c.title}</p>
<p className="truncate text-xs text-text-faint">{c.body}</p>
</button>
))}
</div>
)}
</div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="btn btn-ghost self-end"
title="Прикрепить файл"
>
<Paperclip size={15} />
</button>
<button
onClick={sendReply}
disabled={sending || (!draft.trim() && !pendingFile)}
className={visibility === "internal" ? "btn self-end bg-warning text-white hover:brightness-95" : "btn btn-primary self-end"}
>
{visibility === "internal" ? <StickyNote size={15} /> : <Send size={15} />}
</button>
</div>
</div>
</div>
);
}
function MessageBubble({ message }: { message: MessageDTO }) {
const isAgent = message.authorType === "agent";
const isSystem = message.authorType === "system";
if (isSystem) {
return (
<p className="my-2 text-center text-xs text-text-faint">{message.body}</p>
);
}
if (message.visibility === "internal") {
return (
<motion.div
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18 }}
className="mb-3 rounded-lg border border-dashed border-warning-soft-text/40 bg-warning-soft px-3 py-2"
>
<p className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold text-warning-soft-text">
<StickyNote size={11} />
Внутренняя заметка
</p>
<p className="mb-1 whitespace-pre-wrap text-sm">{message.body}</p>
{message.attachments.map((a) => (
<AttachmentChip key={a.id} attachment={a} />
))}
<p className="text-[11px] text-warning-soft-text/80">
{message.authorName} · {formatRelativeTime(message.createdAt)}
</p>
</motion.div>
);
}
return (
<motion.div
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18 }}
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"}`}>
<p className="mb-1 whitespace-pre-wrap">{message.body}</p>
{message.attachments.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>
</div>
</motion.div>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { Ticket, LogIn } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось войти");
setLoading(false);
return;
}
router.push("/dashboard");
router.refresh();
}
return (
<main
className="flex min-h-screen items-center justify-center px-4"
style={{
background: "radial-gradient(ellipse 60% 50% at 50% -10%, var(--accent-soft), var(--bg) 70%)",
}}
>
<motion.form
onSubmit={handleSubmit}
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">
<Ticket 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-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Email</span>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
autoFocus
/>
</label>
<label className="mb-5 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</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>
</main>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { Ticket } from "lucide-react";
export default function PortalLayout({ children }: { children: React.ReactNode }) {
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-2xl items-center gap-2 px-6 py-3 font-display font-bold tracking-tight">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-accent text-white">
<Ticket size={15} strokeWidth={2.5} />
</div>
Поддержка
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-6 py-6">{children}</main>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { LogIn } from "lucide-react";
export default function PortalLoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/portal/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json().catch(() => null);
if (!res.ok) {
setError(data?.error ?? "Не удалось войти");
setLoading(false);
return;
}
router.push(data.portalUrl);
}
return (
<motion.form
onSubmit={handleSubmit}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="card mx-auto max-w-sm p-8"
>
<h1 className="mb-1 font-display text-xl font-semibold">Вход по учётной записи</h1>
<p className="mb-6 text-sm text-text-muted">
Если вы потеряли персональную ссылку из письма или Telegram, войдите с рабочим логином и паролем заявки
привяжутся к вашей учётной записи.
</p>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Email</span>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
autoFocus
/>
</label>
<label className="mb-5 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</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>
);
}
@@ -0,0 +1,25 @@
import { notFound } from "next/navigation";
import { getCustomerByPortalToken, getTicketForCustomer } from "@/lib/tickets/service";
import { PortalThread } from "./portal-thread";
export default async function PortalTicketPage({
params,
}: {
params: Promise<{ token: string; ticketId: string }>;
}) {
const { token, ticketId } = await params;
const customer = await getCustomerByPortalToken(token);
if (!customer) notFound();
const result = await getTicketForCustomer(customer.id, ticketId);
if (!result) notFound();
return (
<PortalThread
token={token}
ticket={result.ticket}
initialMessages={result.messages}
customerName={customer.displayName}
/>
);
}
@@ -0,0 +1,166 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion";
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 type { TicketDTO, MessageDTO } from "@/lib/tickets/types";
export function PortalThread({
token,
ticket,
initialMessages,
customerName,
}: {
token: string;
ticket: TicketDTO;
initialMessages: MessageDTO[];
customerName: string;
}) {
const [messages, setMessages] = useState(initialMessages);
const [draft, setDraft] = useState("");
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [sending, setSending] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useTicketEvents(`/api/portal/events?token=${encodeURIComponent(token)}`, (event) => {
if (event.type === "message.created" && event.ticketId === ticket.id) {
setMessages((prev) => (prev.some((m) => m.id === event.message.id) ? prev : [...prev, event.message]));
}
});
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages.length]);
async function sendReply() {
if (!draft.trim() && !pendingFile) return;
setSending(true);
let res: Response;
if (pendingFile) {
const formData = new FormData();
formData.append("token", token);
formData.append("file", pendingFile);
formData.append("caption", draft);
res = await fetch(`/api/portal/tickets/${ticket.id}/attachments`, { method: "POST", body: formData });
} else {
res = await fetch(`/api/portal/tickets/${ticket.id}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, body: draft }),
});
}
setSending(false);
if (res.ok) {
setDraft("");
setPendingFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
return (
<div className="flex h-[calc(100vh-8rem)] flex-col">
<Link href={`/t/${token}`} className="mb-3 flex items-center gap-1 text-xs text-text-muted hover:text-text">
<ArrowLeft size={13} />
Все обращения
</Link>
<h1 className="mb-4 text-lg font-bold">{ticket.subject}</h1>
<div className="card flex-1 overflow-y-auto p-4">
<AnimatePresence initial={false}>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} isMine={message.authorType === "customer"} token={token} />
))}
</AnimatePresence>
<div ref={bottomRef} />
</div>
{pendingFile && (
<div className="mt-3 flex 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>
)}
<div className="mt-3 flex gap-2">
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => setPendingFile(e.target.files?.[0] ?? null)}
/>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendReply();
}
}}
rows={2}
placeholder={`Ответить как ${customerName}`}
className="flex-1 resize-none rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="btn btn-ghost self-end"
title="Прикрепить файл"
>
<Paperclip size={15} />
</button>
<button
onClick={sendReply}
disabled={sending || (!draft.trim() && !pendingFile)}
className="btn btn-primary self-end"
>
<Send size={15} />
</button>
</div>
</div>
);
}
function MessageBubble({
message,
isMine,
token,
}: {
message: MessageDTO;
isMine: boolean;
token: string;
}) {
if (message.authorType === "system") {
return <p className="my-2 text-center text-xs text-text-faint">{message.body}</p>;
}
return (
<motion.div
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18 }}
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"}`}>
<p className="mb-1 whitespace-pre-wrap">{message.body}</p>
{message.attachments.map((a) => (
<AttachmentChip key={a.id} attachment={a} token={token} />
))}
<p className={`text-[11px] ${isMine ? "text-white/70" : "text-text-faint"}`}>
{message.authorName} · {formatRelativeTime(message.createdAt)}
</p>
</div>
</motion.div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { getCustomerByPortalToken, listTicketsForCustomer } from "@/lib/tickets/service";
import { formatRelativeTime } from "@/lib/format";
const STATUS_LABEL: Record<string, string> = {
new: "Новая",
open: "Открыта",
pending: "В ожидании",
closed: "Закрыта",
};
export default async function PortalTicketListPage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const customer = await getCustomerByPortalToken(token);
if (!customer) notFound();
const tickets = await listTicketsForCustomer(customer.id);
return (
<div>
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Здравствуйте, {customer.displayName}</h1>
<p className="mb-6 text-sm text-text-muted">Ваши обращения в поддержку.</p>
{tickets.length === 0 && <p className="text-sm text-text-muted">Заявок пока нет.</p>}
<div className="flex flex-col gap-2">
{tickets.map((ticket) => (
<Link key={ticket.id} href={`/t/${token}/${ticket.id}`} className="card block p-4 hover:shadow-md">
<div className="mb-1 flex items-center justify-between gap-3">
<p className="truncate text-sm font-medium">{ticket.subject}</p>
<span className={`badge badge-${ticket.status}`}>{STATUS_LABEL[ticket.status]}</span>
</div>
<p className="text-xs text-text-faint">{formatRelativeTime(ticket.lastMessageAt)}</p>
</Link>
))}
</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { getCurrentSession } from "@/lib/auth/session";
import { getAttachmentContext, getCustomerByPortalToken } from "@/lib/tickets/service";
import { readAttachmentBuffer } from "@/lib/attachments/storage";
function contentDisposition(type: "inline" | "attachment", filename: string): string {
const asciiFallback = filename.replace(/[^\x20-\x7E]/g, "_");
return `${type}; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
}
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const context = await getAttachmentContext(id);
if (!context) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const session = await getCurrentSession();
let authorized = Boolean(session);
// Internal-note attachments are never servable via a customer token, only
// an agent session — same rule getTicketForCustomer applies to messages.
if (!authorized && context.message.visibility === "public") {
const token = new URL(request.url).searchParams.get("token");
if (token) {
const customer = await getCustomerByPortalToken(token);
authorized = Boolean(customer && customer.id === context.ticket.customerId);
}
}
if (!authorized) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const buffer = await readAttachmentBuffer(context.attachment.storageKey);
const isImage = context.attachment.mimeType.startsWith("image/");
return new Response(new Uint8Array(buffer), {
headers: {
"Content-Type": context.attachment.mimeType,
"Content-Disposition": contentDisposition(isImage ? "inline" : "attachment", context.attachment.filename),
"Content-Length": String(context.attachment.sizeBytes),
"Cache-Control": "private, max-age=31536000, immutable",
},
});
}
+46
View File
@@ -0,0 +1,46 @@
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 { verifyPassword, hashPassword } from "@/lib/auth/password";
import { db } from "@/lib/db/client";
import { users } from "@/lib/db/schema";
const schema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8, "Пароль должен быть не короче 8 символов"),
});
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid input" }, { status: 400 });
}
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 });
}
if (!user.passwordHash) {
return NextResponse.json(
{ error: "Этот аккаунт входит через LDAP — локальный пароль не используется" },
{ status: 400 },
);
}
const ok = await verifyPassword(user.passwordHash, parsed.data.currentPassword);
if (!ok) {
return NextResponse.json({ error: "Текущий пароль неверен" }, { status: 401 });
}
const newHash = await hashPassword(parsed.data.newPassword);
await db.update(users).set({ passwordHash: newHash }).where(eq(users.id, user.id));
return NextResponse.json({ ok: true });
}
+85
View File
@@ -0,0 +1,85 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db/client";
import { verifyPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { authenticateLdapUser } from "@/lib/ldap/client";
import { findOrCreateUserFromLdap } from "@/lib/auth/users";
import { getLdapSettings } from "@/lib/auth/ldap-config";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
// Simple in-memory rate limit — good enough at MVP scale, resets on restart.
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 10;
const WINDOW_MS = 15 * 60 * 1000;
function isRateLimited(key: string): boolean {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || entry.resetAt < now) {
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
return false;
}
entry.count += 1;
return entry.count > MAX_ATTEMPTS;
}
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const email = parsed.data.email.toLowerCase().trim();
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
if (isRateLimited(`${ip}:${email}`)) {
return NextResponse.json({ error: "Too many attempts — try again later" }, { status: 429 });
}
// LDAP first — if configured and this login succeeds against the
// directory, JIT-provision/reuse the local account and skip local auth
// entirely. A directory outage or non-match returns null, never throws,
// so it always falls through cleanly to the local password check below.
const ldapUser = await authenticateLdapUser(email, parsed.data.password);
if (ldapUser) {
const ldapSettings = await getLdapSettings();
const user = await findOrCreateUserFromLdap({
email: ldapUser.email,
name: ldapUser.name,
defaultRole: ldapSettings?.defaultRole ?? "agent",
});
const token = await createSession(user.id);
await setSessionCookie(token);
return NextResponse.json({ ok: true, user: { id: user.id, name: user.name, role: user.role } });
}
const user = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.email, email),
});
// Always run verifyPassword (even against a placeholder hash) so the
// response timing doesn't reveal whether the email exists.
const ok = await verifyPassword(
user?.passwordHash ??
"$argon2id$v=19$m=65536,t=3,p=4$00000000000000000000000000$0000000000000000000000000000000000000000000000000000000000000000",
parsed.data.password,
);
if (!user || !user.passwordHash || !ok) {
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
}
const token = await createSession(user.id);
await setSessionCookie(token);
return NextResponse.json({ ok: true, user: { id: user.id, name: user.name, role: user.role } });
}
+13
View File
@@ -0,0 +1,13 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { destroySessionToken, getSessionToken, clearSessionCookie } from "@/lib/auth/session";
export async function POST() {
const token = await getSessionToken();
if (token) {
await destroySessionToken(token);
}
await clearSessionCookie();
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,35 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { updateCannedResponse, deleteCannedResponse } from "@/lib/tickets/service";
const updateSchema = z.object({
title: z.string().min(1).max(100),
body: z.string().min(1).max(5_000),
});
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 body = await request.json().catch(() => null);
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const canned = await updateCannedResponse(id, parsed.data.title, parsed.data.body);
return NextResponse.json({ response: canned });
}
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 deleteCannedResponse(id);
return NextResponse.json({ ok: true });
}
+32
View File
@@ -0,0 +1,32 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { listCannedResponses, createCannedResponse } from "@/lib/tickets/service";
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
return NextResponse.json({ responses: await listCannedResponses() });
}
const createSchema = z.object({
title: z.string().min(1).max(100),
body: z.string().min(1).max(5_000),
});
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);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const canned = await createCannedResponse(parsed.data.title, parsed.data.body);
return NextResponse.json({ response: canned }, { status: 201 });
}
+12
View File
@@ -0,0 +1,12 @@
export const runtime = "nodejs";
import { requireSession } from "@/lib/auth/require";
import { createEventStream } from "@/lib/events/sse";
/** Realtime feed for the admin/agent dashboard — sees every ticket event. */
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
return createEventStream();
}
+54
View File
@@ -0,0 +1,54 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/auth/require";
import { getLdapStatus, saveLdapSettings, disableLdap } from "@/lib/auth/ldap-config";
import { testLdapBind } from "@/lib/ldap/client";
export async function GET() {
const { session, response } = await requireAdminSession();
if (!session) return response;
return NextResponse.json(await getLdapStatus());
}
const configureSchema = z.object({
host: z.string().min(1),
port: z.coerce.number().int().positive(),
useTls: z.boolean(),
bindDn: z.string().min(1),
bindPassword: z.string().min(1),
baseDn: z.string().min(1),
userFilter: z.string().min(1),
listFilter: z.string().min(1),
defaultRole: z.enum(["admin", "agent"]),
});
export async function POST(request: Request) {
const { session, response } = await requireAdminSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = configureSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
try {
await testLdapBind(parsed.data);
await saveLdapSettings(parsed.data);
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: `Не удалось подключиться: ${message}` }, { status: 400 });
}
}
export async function DELETE() {
const { session, response } = await requireAdminSession();
if (!session) return response;
await disableLdap();
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,47 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/auth/require";
import { getLdapSettings } from "@/lib/auth/ldap-config";
import { searchLdapDirectory } from "@/lib/ldap/client";
import { findOrCreateUserFromLdap } from "@/lib/auth/users";
const importSchema = z.object({ emails: z.array(z.string().email()).min(1) });
export async function POST(request: Request) {
const { session, response } = await requireAdminSession();
if (!session) return response;
const settings = await getLdapSettings();
if (!settings) {
return NextResponse.json({ error: "LDAP не настроен" }, { status: 400 });
}
const body = await request.json().catch(() => null);
const parsed = importSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
try {
const entries = await searchLdapDirectory();
const wanted = new Set(parsed.data.emails.map((e) => e.toLowerCase()));
const toImport = entries.filter((e) => wanted.has(e.email.toLowerCase()));
const imported = [];
for (const entry of toImport) {
const user = await findOrCreateUserFromLdap({
email: entry.email,
name: entry.name,
defaultRole: settings.defaultRole,
});
imported.push(user.email);
}
return NextResponse.json({ ok: true, imported });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: `Не удалось импортировать: ${message}` }, { status: 502 });
}
}
+27
View File
@@ -0,0 +1,27 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { requireAdminSession } from "@/lib/auth/require";
import { getLdapSettings } from "@/lib/auth/ldap-config";
import { searchLdapDirectory } from "@/lib/ldap/client";
import { listUsers } from "@/lib/auth/users";
export async function GET() {
const { session, response } = await requireAdminSession();
if (!session) return response;
const settings = await getLdapSettings();
if (!settings) {
return NextResponse.json({ configured: false, entries: [] });
}
try {
const [entries, existing] = await Promise.all([searchLdapDirectory(), listUsers()]);
const existingEmails = new Set(existing.map((u) => u.email.toLowerCase()));
const fresh = entries.filter((e) => !existingEmails.has(e.email.toLowerCase()));
return NextResponse.json({ configured: true, entries: fresh });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: `Не удалось получить список из LDAP: ${message}` }, { status: 502 });
}
}
+62
View File
@@ -0,0 +1,62 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { getMailboxStatus } from "@/lib/mail/config";
import { configureMailbox, disableMailListener } from "@/lib/mail/imap";
import { verifySmtp } from "@/lib/mail/smtp";
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
return NextResponse.json(await getMailboxStatus());
}
const configureSchema = z.object({
host: z.string().min(1),
imapPort: z.coerce.number().int().positive(),
smtpPort: z.coerce.number().int().positive(),
user: z.string().email(),
password: z.string().min(1),
allowInsecureTls: z.boolean(),
});
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = configureSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const settings = {
imapHost: parsed.data.host,
imapPort: parsed.data.imapPort,
smtpHost: parsed.data.host,
smtpPort: parsed.data.smtpPort,
user: parsed.data.user,
password: parsed.data.password,
allowInsecureTls: parsed.data.allowInsecureTls,
};
try {
await verifySmtp(settings);
await configureMailbox(settings);
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: `Не удалось подключиться: ${message}` }, { status: 400 });
}
}
export async function DELETE() {
const { session, response } = await requireSession();
if (!session) return response;
await disableMailListener();
return NextResponse.json({ ok: true });
}
+63
View File
@@ -0,0 +1,63 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { authenticateLdapUser } from "@/lib/ldap/client";
import { getLdapSettings } from "@/lib/auth/ldap-config";
import { findOrCreateCustomerByEmail } from "@/lib/tickets/service";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
// Simple in-memory rate limit — mirrors /api/auth/login.
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 10;
const WINDOW_MS = 15 * 60 * 1000;
function isRateLimited(key: string): boolean {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || entry.resetAt < now) {
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
return false;
}
entry.count += 1;
return entry.count > MAX_ATTEMPTS;
}
// Customer portal access has no local password — LDAP is the only login
// method here (the personal link stays the primary path). Unlike the admin
// login, we surface "LDAP isn't set up" as its own message: with no local
// fallback to quietly degrade to, a generic "invalid credentials" would be
// actively misleading while LDAP is disabled.
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const email = parsed.data.email.toLowerCase().trim();
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
if (isRateLimited(`${ip}:${email}`)) {
return NextResponse.json({ error: "Слишком много попыток — попробуйте позже" }, { status: 429 });
}
const ldapSettings = await getLdapSettings();
if (!ldapSettings) {
return NextResponse.json(
{ error: "Вход по LDAP пока недоступен. Используйте ссылку из письма или Telegram." },
{ status: 400 },
);
}
const ldapUser = await authenticateLdapUser(email, parsed.data.password);
if (!ldapUser) {
return NextResponse.json({ error: "Неверный email или пароль" }, { status: 401 });
}
const customer = await findOrCreateCustomerByEmail(ldapUser.email, ldapUser.name);
return NextResponse.json({ ok: true, portalUrl: `/t/${customer.portalToken}` });
}
+31
View File
@@ -0,0 +1,31 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { getCustomerByPortalToken } from "@/lib/tickets/service";
import { createEventStream } from "@/lib/events/sse";
import { ticketEventCustomerId } from "@/lib/events/bus";
/**
* Realtime feed for a customer's portal — scoped to their own tickets only.
* EventSource can't send custom headers, so the token travels as a query param.
*/
export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get("token");
if (!token) {
return NextResponse.json({ error: "Missing token" }, { status: 400 });
}
const customer = await getCustomerByPortalToken(token);
if (!customer) {
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
}
return createEventStream((event) => {
if (ticketEventCustomerId(event) !== customer.id) return false;
// Internal notes must never reach a customer-facing stream, even though
// the initial page-load fetch (getTicketForCustomer) already filters
// them out — SSE is a second, independent delivery path for the same data.
if (event.type === "message.created" && event.message.visibility === "internal") return false;
return true;
});
}
@@ -0,0 +1,65 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { getCustomerByPortalToken, appendCustomerReply } from "@/lib/tickets/service";
import { saveAttachment, AttachmentTooLargeError, MAX_ATTACHMENT_BYTES } from "@/lib/attachments/storage";
export async function POST(
request: Request,
{ params }: { params: Promise<{ ticketId: string }> },
) {
const { ticketId } = await params;
const formData = await request.formData().catch(() => null);
const token = formData?.get("token");
if (typeof token !== "string" || !token) {
return NextResponse.json({ error: "Missing token" }, { status: 400 });
}
const file = formData?.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "Missing file" }, { status: 400 });
}
if (file.size > MAX_ATTACHMENT_BYTES) {
return NextResponse.json({ error: "File too large" }, { status: 413 });
}
const customer = await getCustomerByPortalToken(token);
if (!customer) {
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
}
const captionRaw = formData?.get("caption");
const caption = typeof captionRaw === "string" ? captionRaw.trim() : "";
const buffer = Buffer.from(await file.arrayBuffer());
let saved;
try {
saved = await saveAttachment(buffer);
} catch (err) {
if (err instanceof AttachmentTooLargeError) {
return NextResponse.json({ error: "File too large" }, { status: 413 });
}
throw err;
}
try {
const result = await appendCustomerReply({
ticketId,
customerId: customer.id,
authorName: customer.displayName,
body: caption || file.name || "Вложение",
attachments: [
{
filename: file.name || "file",
mimeType: file.type || "application/octet-stream",
sizeBytes: saved.sizeBytes,
storageKey: saved.storageKey,
},
],
});
return NextResponse.json(result, { status: 201 });
} catch {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
}
}
@@ -0,0 +1,39 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getCustomerByPortalToken, appendCustomerReply } from "@/lib/tickets/service";
const schema = z.object({
token: z.string().min(1),
body: z.string().min(1).max(10_000),
});
export async function POST(
request: Request,
{ params }: { params: Promise<{ ticketId: string }> },
) {
const { ticketId } = await params;
const body = await request.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const customer = await getCustomerByPortalToken(parsed.data.token);
if (!customer) {
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
}
try {
const result = await appendCustomerReply({
ticketId,
customerId: customer.id,
authorName: customer.displayName,
body: parsed.data.body,
});
return NextResponse.json(result, { status: 201 });
} catch {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
}
}
+44
View File
@@ -0,0 +1,44 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { savePushSubscription, deletePushSubscriptionByEndpoint } from "@/lib/push/service";
const subscribeSchema = z.object({
endpoint: z.string().url(),
keys: z.object({
p256dh: z.string().min(1),
auth: z.string().min(1),
}),
});
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = subscribeSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
await savePushSubscription(session.user.id, parsed.data);
return NextResponse.json({ ok: true }, { status: 201 });
}
const unsubscribeSchema = z.object({ endpoint: z.string().url() });
export async function DELETE(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = unsubscribeSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
await deletePushSubscriptionByEndpoint(parsed.data.endpoint);
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,16 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/auth/require";
import { getVapidPublicKey } from "@/lib/push/vapid";
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
const publicKey = getVapidPublicKey();
if (!publicKey) {
return NextResponse.json({ error: "Push not configured" }, { status: 503 });
}
return NextResponse.json({ publicKey });
}
+14
View File
@@ -0,0 +1,14 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/auth/require";
import { deleteTag } from "@/lib/tickets/service";
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 deleteTag(id);
return NextResponse.json({ ok: true });
}
+32
View File
@@ -0,0 +1,32 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { listTags, createTag } from "@/lib/tickets/service";
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
return NextResponse.json({ tags: await listTags() });
}
const createSchema = z.object({
name: z.string().min(1).max(50),
color: z.enum(["accent", "success", "warning", "info", "rose", "danger"]),
});
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);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const tag = await createTag(parsed.data.name, parsed.data.color);
return NextResponse.json({ tag }, { status: 201 });
}
+44
View File
@@ -0,0 +1,44 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { configureTelegramBot, disableTelegramBot, getTelegramStatus } from "@/lib/telegram/bot";
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
return NextResponse.json(await getTelegramStatus());
}
const configureSchema = z.object({ botToken: z.string().min(1) });
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = configureSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
try {
const { username } = await configureTelegramBot(parsed.data.botToken);
return NextResponse.json({ ok: true, botUsername: username });
} catch {
return NextResponse.json(
{ error: "Telegram rejected this token — check it and try again" },
{ status: 400 },
);
}
}
export async function DELETE() {
const { session, response } = await requireSession();
if (!session) return response;
await disableTelegramBot();
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,66 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/auth/require";
import { deliverAgentMessage } from "@/lib/tickets/delivery";
import { getTicketWithMessages } from "@/lib/tickets/service";
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
import { saveAttachment, AttachmentTooLargeError, MAX_ATTACHMENT_BYTES } from "@/lib/attachments/storage";
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const currentUser = { id: session.user.id, role: session.user.role };
const { id } = await params;
const existing = await getTicketWithMessages(id);
if (!existing || !isTicketVisibleTo(existing.ticket, currentUser)) {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
}
const formData = await request.formData().catch(() => null);
const file = formData?.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "Missing file" }, { status: 400 });
}
if (file.size > MAX_ATTACHMENT_BYTES) {
return NextResponse.json({ error: "File too large" }, { status: 413 });
}
const captionRaw = formData?.get("caption");
const caption = typeof captionRaw === "string" ? captionRaw.trim() : "";
// Internal notes are admin-only — see messages/route.ts for the same rule.
const visibility = currentUser.role === "admin" && formData?.get("visibility") === "internal" ? "internal" : "public";
const buffer = Buffer.from(await file.arrayBuffer());
let saved;
try {
saved = await saveAttachment(buffer);
} catch (err) {
if (err instanceof AttachmentTooLargeError) {
return NextResponse.json({ error: "File too large" }, { status: 413 });
}
throw err;
}
try {
const result = await deliverAgentMessage({
ticketId: id,
agentId: session.user.id,
agentName: session.user.name,
body: caption || file.name || "Вложение",
visibility,
attachment: {
filename: file.name || "file",
mimeType: file.type || "application/octet-stream",
sizeBytes: saved.sizeBytes,
storageKey: saved.storageKey,
buffer,
},
});
return NextResponse.json(result, { status: 201 });
} catch {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
}
}
@@ -0,0 +1,50 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { deliverAgentMessage } from "@/lib/tickets/delivery";
import { getTicketWithMessages } from "@/lib/tickets/service";
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
const messageSchema = z.object({
body: z.string().min(1).max(10_000),
visibility: z.enum(["public", "internal"]).default("public"),
});
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const currentUser = { id: session.user.id, role: session.user.role };
const { id } = await params;
const existing = await getTicketWithMessages(id);
if (!existing || !isTicketVisibleTo(existing.ticket, currentUser)) {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
}
const body = await request.json().catch(() => null);
const parsed = messageSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
// Internal notes are admin-only — the client already hides the toggle for
// agents, but a raw API call could still set it, so it's forced back to
// public here too.
const visibility = currentUser.role === "admin" ? parsed.data.visibility : "public";
try {
const result = await deliverAgentMessage({
ticketId: id,
agentId: session.user.id,
agentName: session.user.name,
body: parsed.data.body,
visibility,
});
return NextResponse.json(result, { status: 201 });
} catch {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
}
}
+62
View File
@@ -0,0 +1,62 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { getTicketWithMessages, setTicketStatus, assignTicket } from "@/lib/tickets/service";
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const currentUser = { id: session.user.id, role: session.user.role };
const { id } = await params;
const result = await getTicketWithMessages(id);
if (!result || !isTicketVisibleTo(result.ticket, currentUser)) {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
}
const messages = currentUser.role === "admin"
? result.messages
: result.messages.filter((m) => m.visibility !== "internal");
return NextResponse.json({ ticket: result.ticket, messages });
}
const patchSchema = z.object({
status: z.enum(["new", "open", "pending", "closed"]).optional(),
assigneeId: z.string().nullable().optional(),
});
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const currentUser = { id: session.user.id, role: session.user.role };
const { id } = await params;
const existing = await getTicketWithMessages(id);
if (!existing || !isTicketVisibleTo(existing.ticket, currentUser)) {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
}
const body = await request.json().catch(() => null);
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
let ticket = null;
if (parsed.data.status) {
ticket = await setTicketStatus(id, parsed.data.status);
}
if (parsed.data.assigneeId !== undefined) {
ticket = await assignTicket(id, parsed.data.assigneeId);
}
if (!ticket) {
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
}
return NextResponse.json({ ticket });
}
+31
View File
@@ -0,0 +1,31 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { getTicketWithMessages, setTicketTags } from "@/lib/tickets/service";
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
const schema = z.object({ tagIds: z.array(z.string()) });
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const currentUser = { id: session.user.id, role: session.user.role };
const { id } = await params;
const existing = await getTicketWithMessages(id);
if (!existing || !isTicketVisibleTo(existing.ticket, currentUser)) {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
}
const body = await request.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const tags = await setTicketTags(id, parsed.data.tagIds);
return NextResponse.json({ tags });
}
+36
View File
@@ -0,0 +1,36 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { listTickets, createManualTicket } from "@/lib/tickets/service";
export async function GET(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const query = new URL(request.url).searchParams.get("q") ?? undefined;
const tickets = await listTickets(query, { id: session.user.id, role: session.user.role });
return NextResponse.json({ tickets });
}
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(),
});
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);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const result = await createManualTicket(parsed.data);
return NextResponse.json(result, { status: 201 });
}
+46
View File
@@ -0,0 +1,46 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/auth/require";
import { updateUserRole, deleteUser } from "@/lib/auth/users";
const patchSchema = z.object({ role: z.enum(["admin", "agent"]) });
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireAdminSession();
if (!session) return response;
const { id } = await params;
const body = await request.json().catch(() => null);
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
try {
const user = await updateUserRole(id, parsed.data.role);
return NextResponse.json({ user });
} catch (err) {
const message = err instanceof Error ? err.message : "Не удалось изменить роль";
return NextResponse.json({ error: message }, { status: 400 });
}
}
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireAdminSession();
if (!session) return response;
const { id } = await params;
if (id === session.user.id) {
return NextResponse.json({ error: "Нельзя удалить собственный аккаунт" }, { status: 400 });
}
try {
await deleteUser(id);
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Не удалось удалить аккаунт";
return NextResponse.json({ error: message }, { status: 400 });
}
}
+38
View File
@@ -0,0 +1,38 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/auth/require";
import { listUsers, createLocalUser } from "@/lib/auth/users";
export async function GET() {
const { session, response } = await requireAdminSession();
if (!session) return response;
return NextResponse.json({ users: await listUsers() });
}
const createSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
password: z.string().min(8, "Пароль должен быть не короче 8 символов"),
role: z.enum(["admin", "agent"]),
});
export async function POST(request: Request) {
const { session, response } = await requireAdminSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = createSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid input" }, { status: 400 });
}
try {
const user = await createLocalUser(parsed.data);
return NextResponse.json({ user }, { status: 201 });
} catch {
return NextResponse.json({ error: "Такой email уже используется" }, { status: 409 });
}
}
+98
View File
@@ -0,0 +1,98 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import {
getWidgetSiteByKey,
getCustomerByPortalToken,
renameCustomerIfGuest,
recordChatInboundMessage,
} from "@/lib/tickets/service";
import { saveAttachment, AttachmentTooLargeError, MAX_ATTACHMENT_BYTES } from "@/lib/attachments/storage";
// Same shape as /api/widget/messages' limiter — kept separate rather than
// shared since each route's rate budget is independent.
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 10;
const WINDOW_MS = 5 * 60 * 1000;
function isRateLimited(key: string): boolean {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || entry.resetAt < now) {
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
return false;
}
entry.count += 1;
return entry.count > MAX_ATTEMPTS;
}
export async function POST(request: Request) {
const formData = await request.formData().catch(() => null);
const siteKey = formData?.get("siteKey");
const token = formData?.get("token");
if (typeof siteKey !== "string" || !siteKey || typeof token !== "string" || !token) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
if (isRateLimited(token)) {
return NextResponse.json({ error: "Слишком много сообщений — подождите немного" }, { status: 429 });
}
const file = formData?.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "Missing file" }, { status: 400 });
}
if (file.size > MAX_ATTACHMENT_BYTES) {
return NextResponse.json({ error: "File too large" }, { status: 413 });
}
const site = await getWidgetSiteByKey(siteKey);
if (!site || !site.enabled) {
return NextResponse.json({ error: "Unknown or disabled widget" }, { status: 404 });
}
const customer = await getCustomerByPortalToken(token);
if (!customer) {
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
}
const nameRaw = formData?.get("name");
const name = typeof nameRaw === "string" ? nameRaw.trim() : "";
if (name) {
await renameCustomerIfGuest(customer.id, name);
}
const captionRaw = formData?.get("caption");
const caption = typeof captionRaw === "string" ? captionRaw.trim() : "";
const buffer = Buffer.from(await file.arrayBuffer());
let saved;
try {
saved = await saveAttachment(buffer);
} catch (err) {
if (err instanceof AttachmentTooLargeError) {
return NextResponse.json({ error: "File too large" }, { status: 413 });
}
throw err;
}
const body = caption || file.name || "Вложение";
const result = await recordChatInboundMessage({
customerId: customer.id,
channel: "widget",
body,
authorName: name || customer.displayName,
subjectForNewTicket: `Чат (${site.name}): ${body.slice(0, 60)}`,
attachments: [
{
filename: file.name || "file",
mimeType: file.type || "application/octet-stream",
sizeBytes: saved.sizeBytes,
storageKey: saved.storageKey,
},
],
});
return NextResponse.json(result, { status: 201 });
}
+71
View File
@@ -0,0 +1,71 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import {
getWidgetSiteByKey,
getCustomerByPortalToken,
renameCustomerIfGuest,
recordChatInboundMessage,
} from "@/lib/tickets/service";
// Simple in-memory per-visitor rate limit — this is a public, unauthenticated
// endpoint. Good enough at MVP scale; resets on restart.
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 20;
const WINDOW_MS = 5 * 60 * 1000;
function isRateLimited(key: string): boolean {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || entry.resetAt < now) {
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
return false;
}
entry.count += 1;
return entry.count > MAX_ATTEMPTS;
}
const schema = z.object({
siteKey: z.string().min(1),
token: z.string().min(1),
body: z.string().min(1).max(4_000),
name: z.string().max(100).optional(),
});
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
if (isRateLimited(parsed.data.token)) {
return NextResponse.json({ error: "Слишком много сообщений — подождите немного" }, { status: 429 });
}
const site = await getWidgetSiteByKey(parsed.data.siteKey);
if (!site || !site.enabled) {
return NextResponse.json({ error: "Unknown or disabled widget" }, { status: 404 });
}
const customer = await getCustomerByPortalToken(parsed.data.token);
if (!customer) {
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
}
const name = parsed.data.name?.trim();
if (name) {
await renameCustomerIfGuest(customer.id, name);
}
const result = await recordChatInboundMessage({
customerId: customer.id,
channel: "widget",
body: parsed.data.body,
authorName: name || customer.displayName,
subjectForNewTicket: `Чат (${site.name}): ${parsed.data.body.slice(0, 60)}`,
});
return NextResponse.json(result, { status: 201 });
}
+52
View File
@@ -0,0 +1,52 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import {
getWidgetSiteByKey,
getCustomerByPortalToken,
createGuestCustomer,
listTicketsForCustomer,
getTicketForCustomer,
} from "@/lib/tickets/service";
const schema = z.object({
siteKey: z.string().min(1),
token: z.string().optional(),
});
function originAllowed(request: Request, allowedOrigin: string | null): boolean {
if (!allowedOrigin) return true;
return request.headers.get("origin") === allowedOrigin;
}
/** Bootstraps (or resumes) a widget visitor's session and their current conversation, in one call. */
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const site = await getWidgetSiteByKey(parsed.data.siteKey);
if (!site || !site.enabled) {
return NextResponse.json({ error: "Unknown or disabled widget" }, { status: 404 });
}
if (!originAllowed(request, site.allowedOrigin)) {
return NextResponse.json({ error: "Origin not allowed" }, { status: 403 });
}
const customer =
(parsed.data.token ? await getCustomerByPortalToken(parsed.data.token) : null) ??
(await createGuestCustomer());
const tickets = await listTicketsForCustomer(customer.id);
const openTicket = tickets.find((t) => t.status !== "closed") ?? null;
const result = openTicket ? await getTicketForCustomer(customer.id, openTicket.id) : null;
return NextResponse.json({
token: customer.portalToken,
ticket: result?.ticket ?? null,
messages: result?.messages ?? [],
});
}
+51
View File
@@ -0,0 +1,51 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { listWidgetSites, createWidgetSite, setWidgetSiteEnabled } from "@/lib/tickets/service";
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
return NextResponse.json({ sites: await listWidgetSites() });
}
const createSchema = z.object({
name: z.string().min(1).max(100),
allowedOrigin: z.string().url().optional(),
});
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);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const site = await createWidgetSite(parsed.data.name, parsed.data.allowedOrigin);
return NextResponse.json({ site }, { status: 201 });
}
const patchSchema = z.object({
id: z.string().min(1),
enabled: z.boolean(),
});
export async function PATCH(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const site = await setWidgetSiteEnabled(parsed.data.id, parsed.data.enabled);
return NextResponse.json({ site });
}
+332
View File
@@ -0,0 +1,332 @@
@import "tailwindcss";
@theme {
--font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
--font-display: var(--font-display), var(--font-sans), ui-sans-serif, sans-serif;
--font-mono: var(--font-mono), ui-monospace, monospace;
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-surface-hover: var(--surface-hover);
--color-border: var(--border);
--color-border-strong: var(--border-strong);
--color-text: var(--text);
--color-text-muted: var(--text-muted);
--color-text-faint: var(--text-faint);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-soft: var(--accent-soft);
--color-accent-soft-text: var(--accent-soft-text);
--color-danger: var(--danger);
--color-danger-soft: var(--danger-soft);
--color-danger-soft-text: var(--danger-soft-text);
--color-warning: var(--warning);
--color-warning-soft: var(--warning-soft);
--color-warning-soft-text: var(--warning-soft-text);
--color-success: var(--success);
--color-success-soft: var(--success-soft);
--color-success-soft-text: var(--success-soft-text);
--color-info: var(--info);
--color-info-soft: var(--info-soft);
--color-info-soft-text: var(--info-soft-text);
--color-rose: var(--rose);
--color-rose-soft: var(--rose-soft);
--color-rose-soft-text: var(--rose-soft-text);
/* Chart marks — validated categorical set (dataviz skill), distinct from
the softer *-soft badge tokens which are too light for dark-mode marks. */
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-muted: var(--chart-muted);
--radius-sm: var(--radius-sm-val);
--radius-md: var(--radius-md-val);
--radius-lg: var(--radius-lg-val);
}
:root {
--bg: #f8f7fb;
--surface: #ffffff;
--surface-hover: #f2effa;
--border: #e5e0f2;
--border-strong: #cec4e8;
--text: #171325;
--text-muted: #635d7a;
--text-faint: #9891ad;
--accent: #6d28d9;
--accent-hover: #5b21b6;
--accent-soft: #f0e8fe;
--accent-soft-text: #5b21b6;
--danger: #dc2626;
--danger-soft: #fef2f2;
--danger-soft-text: #b91c1c;
--warning: #d97706;
--warning-soft: #fffbeb;
--warning-soft-text: #b45309;
--success: #0d9488;
--success-soft: #ecfdf9;
--success-soft-text: #0f766e;
--info: #2563eb;
--info-soft: #eff6ff;
--info-soft-text: #1d4ed8;
--rose: #e11d48;
--rose-soft: #fff1f2;
--rose-soft-text: #be123c;
/* Chart marks — fixed categorical order, CVD-validated (accent/success/
info/warning); reorder only after re-running the validator. */
--chart-1: #6d28d9;
--chart-2: #0d9488;
--chart-3: #2563eb;
--chart-4: #d97706;
--chart-muted: var(--border-strong);
--radius-sm-val: 8px;
--radius-md-val: 12px;
--radius-lg-val: 18px;
--bg-glow: none;
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0b0a12;
--surface: #161320;
--surface-hover: #1d1929;
--border: #2d2640;
--border-strong: #453c60;
--text: #f1eefb;
--text-muted: #a79fc4;
--text-faint: #6f6790;
--accent: #8b5cf6;
--accent-hover: #7c3aed;
--accent-soft: #251c3f;
--accent-soft-text: #c4b5fd;
--danger: #f87171;
--danger-soft: #2a1517;
--danger-soft-text: #fca5a5;
--warning: #fbbf24;
--warning-soft: #2a2110;
--warning-soft-text: #fcd34d;
--success: #2dd4bf;
--success-soft: #0f2320;
--success-soft-text: #5eead4;
--info: #60a5fa;
--info-soft: #12203a;
--info-soft-text: #93c5fd;
--rose: #fb7185;
--rose-soft: #2a1420;
--rose-soft-text: #fda4af;
/* Darker/more saturated than the badge tokens above — validated
separately for the dark-mode lightness band (L 0.480.67). */
--chart-1: #8b5cf6;
--chart-2: #0d9488;
--chart-3: #3b82f6;
--chart-4: #d97706;
--chart-muted: var(--border-strong);
--bg-glow: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(139, 92, 246, 0.12), transparent 60%);
--card-shadow: 0 1px 0 rgba(255, 255, 255, 0.05) inset, 0 8px 24px rgba(0, 0, 0, 0.35);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0b0a12;
--surface: #161320;
--surface-hover: #1d1929;
--border: #2d2640;
--border-strong: #453c60;
--text: #f1eefb;
--text-muted: #a79fc4;
--text-faint: #6f6790;
--accent: #8b5cf6;
--accent-hover: #7c3aed;
--accent-soft: #251c3f;
--accent-soft-text: #c4b5fd;
--danger: #f87171;
--danger-soft: #2a1517;
--danger-soft-text: #fca5a5;
--warning: #fbbf24;
--warning-soft: #2a2110;
--warning-soft-text: #fcd34d;
--success: #2dd4bf;
--success-soft: #0f2320;
--success-soft-text: #5eead4;
--info: #60a5fa;
--info-soft: #12203a;
--info-soft-text: #93c5fd;
--rose: #fb7185;
--rose-soft: #2a1420;
--rose-soft-text: #fda4af;
--chart-1: #8b5cf6;
--chart-2: #0d9488;
--chart-3: #3b82f6;
--chart-4: #d97706;
--chart-muted: var(--border-strong);
--bg-glow: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(139, 92, 246, 0.12), transparent 60%);
--card-shadow: 0 1px 0 rgba(255, 255, 255, 0.05) inset, 0 8px 24px rgba(0, 0, 0, 0.35);
color-scheme: dark;
}
/* 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 {
display: none;
}
.theme-toggle-btn .theme-icon-moon {
display: inline;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .theme-toggle-btn .theme-icon-sun {
display: inline;
}
:root:not([data-theme="light"]) .theme-toggle-btn .theme-icon-moon {
display: none;
}
}
:root[data-theme="dark"] .theme-toggle-btn .theme-icon-sun {
display: inline;
}
:root[data-theme="dark"] .theme-toggle-btn .theme-icon-moon {
display: none;
}
* {
border-color: var(--border);
}
html,
body {
height: 100%;
}
body {
background: var(--bg);
background-image: var(--bg-glow, none);
background-attachment: fixed;
color: var(--text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
::selection {
background: var(--accent-soft);
color: var(--accent-soft-text);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 999px;
}
::-webkit-scrollbar-track {
background: transparent;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--card-shadow);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 0.875rem;
padding: 0.5rem 0.9rem;
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease, transform 100ms ease;
}
.btn:active {
transform: translateY(1px);
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
}
.btn-ghost:hover {
background: var(--surface-hover);
color: var(--text);
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.15rem 0.55rem;
border-radius: 999px;
}
.badge-new {
background: var(--accent-soft);
color: var(--accent-soft-text);
}
.badge-open {
background: var(--success-soft);
color: var(--success-soft-text);
}
.badge-pending {
background: var(--warning-soft);
color: var(--warning-soft-text);
}
.badge-closed {
background: var(--border);
color: var(--text-muted);
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#6d28d9"/>
<path d="M9 10.5C9 9.11929 10.1193 8 11.5 8H20.5C21.8807 8 23 9.11929 23 10.5V17.5C23 18.8807 21.8807 20 20.5 20H14L10 23.5V20H11.5C10.1193 20 9 18.8807 9 17.5V10.5Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

+54
View File
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import { Onest, Unbounded, JetBrains_Mono } from "next/font/google";
import "./globals.css";
const sans = Onest({
variable: "--font-sans",
subsets: ["latin", "cyrillic"],
});
const display = Unbounded({
variable: "--font-display",
subsets: ["latin", "cyrillic"],
weight: ["600", "700", "800"],
});
const mono = JetBrains_Mono({
variable: "--font-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "top-tickets",
description: "Helpdesk for support@top-sysops.ru, Telegram, and the web.",
};
// Reads the persisted theme before hydration so there's no flash of the
// wrong theme — data-theme is absent by default (system preference via the
// CSS prefers-color-scheme block), and only set once the visitor has
// explicitly chosen a theme via the toggle.
const noFlashThemeScript = `
(function () {
try {
var stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") {
document.documentElement.dataset.theme = stored;
}
} catch {}
})();
`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html
lang="ru"
className={`${sans.variable} ${display.variable} ${mono.variable}`}
suppressHydrationWarning
>
<head>
<script dangerouslySetInnerHTML={{ __html: noFlashThemeScript }} />
</head>
<body>{children}</body>
</html>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
export default async function RootPage() {
const session = await getCurrentSession();
redirect(session ? "/dashboard" : "/login");
}
+10
View File
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import { WidgetChat } from "./widget-chat";
export default function WidgetChatPage() {
return (
<Suspense fallback={null}>
<WidgetChat />
</Suspense>
);
}
+188
View File
@@ -0,0 +1,188 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion";
import { 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 type { MessageDTO } from "@/lib/tickets/types";
const STORAGE_KEY = "top-tickets-widget-token";
export function WidgetChat() {
const siteKey = useSearchParams().get("key") ?? "";
const [token, setToken] = useState<string | null>(null);
const [messages, setMessages] = useState<MessageDTO[]>([]);
const [name, setName] = useState("");
const [draft, setDraft] = useState("");
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [ready, setReady] = useState(false);
const [sending, setSending] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY) ?? undefined;
fetch("/api/widget/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ siteKey, token: stored }),
})
.then((res) => res.json())
.then((data) => {
localStorage.setItem(STORAGE_KEY, data.token);
setToken(data.token);
setMessages(data.messages ?? []);
setReady(true);
});
}, [siteKey]);
useTicketEvents(token ? `/api/portal/events?token=${encodeURIComponent(token)}` : "", (event) => {
if (event.type !== "message.created") return;
setMessages((prev) => (prev.some((m) => m.id === event.message.id) ? prev : [...prev, event.message]));
if (event.message.authorType === "agent") {
window.parent.postMessage({ type: "top-tickets:new-message" }, "*");
}
});
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages.length]);
async function sendMessage() {
if ((!draft.trim() && !pendingFile) || !token) return;
setSending(true);
let res: Response;
if (pendingFile) {
const formData = new FormData();
formData.append("siteKey", siteKey);
formData.append("token", token);
formData.append("file", pendingFile);
formData.append("caption", draft);
if (name.trim()) formData.append("name", name.trim());
res = await fetch("/api/widget/attachments", { method: "POST", body: formData });
} else {
res = await fetch("/api/widget/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ siteKey, token, body: draft, name: name.trim() || undefined }),
});
}
setSending(false);
if (res.ok) {
setDraft("");
setPendingFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
if (!ready) {
return <div style={{ height: "100%" }} />;
}
const isFirstMessage = messages.length === 0;
return (
<div className="flex h-full flex-col p-3">
{isFirstMessage && (
<label className="mb-2 block text-xs">
<span className="mb-1 block text-text-muted">Ваше имя (необязательно)</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Как к вам обращаться?"
className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
/>
</label>
)}
<div className="flex-1 overflow-y-auto">
<AnimatePresence initial={false}>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} token={token ?? ""} />
))}
</AnimatePresence>
{isFirstMessage && (
<p className="mt-6 text-center text-sm text-text-muted">Напишите нам обычно отвечаем быстро.</p>
)}
<div ref={bottomRef} />
</div>
{pendingFile && (
<div className="mt-2 flex 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>
)}
<div className="mt-2 flex gap-2">
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => setPendingFile(e.target.files?.[0] ?? null)}
/>
<textarea
rows={1}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}}
placeholder="Напишите сообщение…"
className="flex-1 resize-none rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="btn btn-ghost"
title="Прикрепить файл"
>
<Paperclip size={15} />
</button>
<button onClick={sendMessage} disabled={sending || (!draft.trim() && !pendingFile)} className="btn btn-primary">
<Send size={15} />
</button>
</div>
</div>
);
}
function MessageBubble({ message, token }: { message: MessageDTO; token: string }) {
const isMine = message.authorType === "customer";
if (message.authorType === "system") {
return <p className="my-2 text-center text-xs text-text-faint">{message.body}</p>;
}
return (
<motion.div
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18 }}
className={`mb-3 flex ${isMine ? "justify-end" : "justify-start"}`}
>
<div className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${isMine ? "bg-accent text-white" : "bg-surface-hover"}`}>
<p className="mb-1 whitespace-pre-wrap">{message.body}</p>
{message.attachments.map((a) => (
<AttachmentChip key={a.id} attachment={a} token={token} />
))}
<p className={`text-[11px] ${isMine ? "text-white/70" : "text-text-faint"}`}>
{formatRelativeTime(message.createdAt)}
</p>
</div>
</motion.div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function WidgetLayout({ children }: { children: React.ReactNode }) {
return <div style={{ height: "100vh", width: "100%" }}>{children}</div>;
}
+40
View File
@@ -0,0 +1,40 @@
import { Paperclip } from "lucide-react";
import type { AttachmentDTO } from "@/lib/tickets/types";
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export function AttachmentChip({ attachment, token }: { attachment: AttachmentDTO; token?: string }) {
const url = `/api/attachments/${attachment.id}${token ? `?token=${encodeURIComponent(token)}` : ""}`;
const isImage = attachment.mimeType.startsWith("image/");
if (isImage) {
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="mt-1.5 block max-w-[220px] overflow-hidden rounded-md border border-border"
>
{/* eslint-disable-next-line @next/next/no-img-element -- serves from our own auth-gated API route, not next/image-optimizable */}
<img src={url} alt={attachment.filename} className="block max-h-48 w-full object-cover" />
</a>
);
}
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="mt-1.5 flex max-w-[220px] items-center gap-2 rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs hover:bg-surface-hover"
>
<Paperclip size={13} className="shrink-0" />
<span className="truncate">{attachment.filename}</span>
<span className="shrink-0 text-text-faint">{formatSize(attachment.sizeBytes)}</span>
</a>
);
}
+35
View File
@@ -0,0 +1,35 @@
// Full literal class strings so Tailwind's scanner picks them up — a
// runtime-interpolated class name (e.g. `bg-${hue}-soft`) wouldn't be
// detected since Tailwind scans source text, not computed output.
const PALETTE_CLASSES = [
"bg-accent-soft text-accent-soft-text",
"bg-success-soft text-success-soft-text",
"bg-warning-soft text-warning-soft-text",
"bg-info-soft text-info-soft-text",
"bg-rose-soft text-rose-soft-text",
"bg-danger-soft text-danger-soft-text",
] as const;
function classesFor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) | 0;
return PALETTE_CLASSES[Math.abs(hash) % PALETTE_CLASSES.length];
}
function initialsFor(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[1][0]).toUpperCase();
}
export function Avatar({ name, size = 28 }: { name: string; size?: number }) {
return (
<div
className={`flex shrink-0 items-center justify-center rounded-full font-display font-semibold ${classesFor(name)}`}
style={{ width: size, height: size, fontSize: size * 0.4 }}
>
{initialsFor(name)}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
// Node terminates the whole process on an unhandled promise rejection
// by default — one bug in any best-effort/fire-and-forget path (push
// notifications, a background job, ...) would otherwise take down every
// in-flight request, not just the one that triggered it. Log-and-continue
// is the right call here: anything that reaches this handler was already
// meant to be non-fatal (a real, must-not-fail error should be caught and
// handled at its own call site instead).
process.on("uncaughtException", (err) => {
console.error("[process] uncaughtException (ignored, server continues)", err);
});
process.on("unhandledRejection", (reason) => {
console.error("[process] unhandledRejection (ignored, server continues)", reason);
});
const { ensureTelegramBotStarted } = await import("@/lib/telegram/bot");
await ensureTelegramBotStarted();
const { ensureMailListenerStarted } = await import("@/lib/mail/imap");
await ensureMailListenerStarted();
}
}
+28
View File
@@ -0,0 +1,28 @@
import fs from "node:fs/promises";
import path from "node:path";
import crypto from "node:crypto";
export const MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024;
export class AttachmentTooLargeError extends Error {}
function attachmentsDir(): string {
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
return path.join(dataDir, "attachments");
}
/** storageKey is always a freshly generated UUID here — never derived from user input, so no path-traversal risk. */
export async function saveAttachment(buffer: Buffer): Promise<{ storageKey: string; sizeBytes: number }> {
if (buffer.byteLength > MAX_ATTACHMENT_BYTES) {
throw new AttachmentTooLargeError(`File exceeds ${MAX_ATTACHMENT_BYTES} bytes`);
}
const dir = attachmentsDir();
await fs.mkdir(dir, { recursive: true });
const storageKey = crypto.randomUUID();
await fs.writeFile(path.join(dir, storageKey), buffer);
return { storageKey, sizeBytes: buffer.byteLength };
}
export async function readAttachmentBuffer(storageKey: string): Promise<Buffer> {
return fs.readFile(path.join(attachmentsDir(), storageKey));
}
+81
View File
@@ -0,0 +1,81 @@
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { ldapConfig } from "@/lib/db/schema";
import { encryptCredential, decryptCredential } from "@/lib/crypto/credentials";
export interface LdapSettings {
host: string;
port: number;
useTls: boolean;
bindDn: string;
bindPassword: string;
baseDn: string;
userFilter: string;
listFilter: string;
defaultRole: "admin" | "agent";
}
/** Settings for actually connecting — null if never configured or disabled. */
export async function getLdapSettings(): Promise<LdapSettings | null> {
const config = await db.query.ldapConfig.findFirst();
if (!config || !config.enabled) return null;
return {
host: config.host,
port: config.port,
useTls: config.useTls,
bindDn: config.bindDn,
bindPassword: decryptCredential(config.bindPasswordEnc),
baseDn: config.baseDn,
userFilter: config.userFilter,
listFilter: config.listFilter,
defaultRole: config.defaultRole,
};
}
export async function saveLdapSettings(settings: LdapSettings): Promise<void> {
const row = {
host: settings.host,
port: settings.port,
useTls: settings.useTls,
bindDn: settings.bindDn,
bindPasswordEnc: encryptCredential(settings.bindPassword),
baseDn: settings.baseDn,
userFilter: settings.userFilter,
listFilter: settings.listFilter,
defaultRole: settings.defaultRole,
enabled: true,
verifiedAt: new Date(),
};
const existing = await db.query.ldapConfig.findFirst();
if (existing) {
await db.update(ldapConfig).set(row).where(eq(ldapConfig.id, existing.id));
} else {
await db.insert(ldapConfig).values(row);
}
}
export async function disableLdap(): Promise<void> {
const existing = await db.query.ldapConfig.findFirst();
if (existing) {
await db.update(ldapConfig).set({ enabled: false }).where(eq(ldapConfig.id, existing.id));
}
}
export async function getLdapStatus() {
const config = await db.query.ldapConfig.findFirst();
return {
configured: Boolean(config),
enabled: Boolean(config?.enabled),
host: config?.host ?? null,
port: config?.port ?? null,
useTls: config?.useTls ?? false,
bindDn: config?.bindDn ?? null,
baseDn: config?.baseDn ?? null,
userFilter: config?.userFilter ?? null,
listFilter: config?.listFilter ?? null,
defaultRole: config?.defaultRole ?? "agent",
verifiedAt: config?.verifiedAt?.getTime() ?? null,
};
}
+13
View File
@@ -0,0 +1,13 @@
import argon2 from "argon2";
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, { type: argon2.argon2id });
}
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { getCurrentSession } from "./session";
/** Guard for API route handlers — returns the session, or a 401 response to return as-is. */
export async function requireSession() {
const session = await getCurrentSession();
if (!session) {
return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
}
return { session, response: null };
}
/** Same as requireSession, but also requires role === "admin" — for account/LDAP management. */
export async function requireAdminSession() {
const session = await getCurrentSession();
if (!session) {
return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
}
if (session.user.role !== "admin") {
return { session: null, response: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
}
return { session, response: null };
}
+76
View File
@@ -0,0 +1,76 @@
import crypto from "node:crypto";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { sessions, users } from "@/lib/db/schema";
const SESSION_COOKIE = "session";
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export async function createSession(userId: string): Promise<string> {
const token = crypto.randomBytes(32).toString("base64url");
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await db.insert(sessions).values({ tokenHash, userId, expiresAt });
return token;
}
export async function validateSessionToken(token: string) {
const tokenHash = hashToken(token);
const rows = await db
.select({ session: sessions, user: users })
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.tokenHash, tokenHash))
.limit(1);
const row = rows[0];
if (!row) return null;
if (row.session.expiresAt.getTime() < Date.now()) {
await db.delete(sessions).where(eq(sessions.tokenHash, tokenHash));
return null;
}
return row;
}
export async function destroySessionToken(token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));
}
export async function setSessionCookie(token: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
// Secure by default in production. Set COOKIE_ALLOW_INSECURE=true only
// for temporary testing over plain HTTP (e.g. mid-migration before the
// HTTPS reverse proxy is wired up) — a Secure cookie is silently
// dropped by the browser over HTTP, which looks exactly like "login
// accepts the password but you're bounced straight back to /login".
secure: process.env.NODE_ENV === "production" && process.env.COOKIE_ALLOW_INSECURE !== "true",
sameSite: "lax",
path: "/",
maxAge: SESSION_TTL_MS / 1000,
});
}
export async function clearSessionCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
}
export async function getSessionToken(): Promise<string | undefined> {
const cookieStore = await cookies();
return cookieStore.get(SESSION_COOKIE)?.value;
}
/** Reads the session cookie and validates it against the DB. Returns null if absent/invalid/expired. */
export async function getCurrentSession() {
const token = await getSessionToken();
if (!token) return null;
return validateSessionToken(token);
}

Some files were not shown because too many files have changed in this diff Show More