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:
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
data
|
||||
*.env
|
||||
!.env.example
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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`) — он засветился в истории сессии.
|
||||
@@ -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:
|
||||
@@ -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"),
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Generated
+10177
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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}` });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 ?? [],
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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.48–0.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);
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import { WidgetChat } from "./widget-chat";
|
||||
|
||||
export default function WidgetChatPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<WidgetChat />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function WidgetLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div style={{ height: "100vh", width: "100%" }}>{children}</div>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user