Compare commits
8 Commits
v1.0.0
...
a9bf332369
| Author | SHA1 | Date | |
|---|---|---|---|
| a9bf332369 | |||
| f98930b54f | |||
| f7028c563a | |||
| d8785a964d | |||
| a9ba74f1af | |||
| b341c64b02 | |||
| 2c11d19f76 | |||
| 186dcc4686 |
@@ -22,6 +22,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Install root dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
working-directory: ./client
|
working-directory: ./client
|
||||||
@@ -46,6 +49,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Install root dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
working-directory: ./server
|
working-directory: ./server
|
||||||
|
|||||||
@@ -19,21 +19,20 @@ Internal ticketing system with CTI-based routing, severity levels, role-based ac
|
|||||||
- **Command palette + keyboard shortcuts** — ⌘K palette, `j/k` navigation, `g d/t/m/n/s` leaders, `?` help
|
- **Command palette + keyboard shortcuts** — ⌘K palette, `j/k` navigation, `g d/t/m/n/s` leaders, `?` help
|
||||||
- **PWA** — installable on mobile, offline app shell
|
- **PWA** — installable on mobile, offline app shell
|
||||||
- **Comments** — GitHub/Gitea-style threads with markdown, draft autosave, Ctrl+Enter submit
|
- **Comments** — GitHub/Gitea-style threads with markdown, draft autosave, Ctrl+Enter submit
|
||||||
- **Roles** — Admin, Agent, User, Service (API key auth for automation)
|
- **Roles** — Admin, Agent, User
|
||||||
- **Audit log** — every action tracked with actor, timestamp, and expandable detail
|
- **Audit log** — every action tracked with actor, timestamp, and expandable detail
|
||||||
- **Admin panel** — manage users, CTI hierarchy, and webhooks via UI
|
- **Admin panel** — manage users, CTI hierarchy, and webhooks via UI
|
||||||
- **n8n ready** — service accounts authenticate via `X-Api-Key` header
|
- **n8n ready** — every Agent gets an auto-generated API key for `X-Api-Key` header auth
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Roles
|
## Roles
|
||||||
|
|
||||||
| Role | Access |
|
| Role | Access |
|
||||||
| ----------- | ---------------------------------------------------------------------------- |
|
| --------- | ----------------------------------------------------------------------------------------- |
|
||||||
| **Admin** | Full access — manage users, CTI config, webhooks, close and delete tickets |
|
| **Admin** | Full access — manage users, CTI config, webhooks, close and delete tickets |
|
||||||
| **Agent** | Manage tickets — create, update, assign, comment, change status (not Closed) |
|
| **Agent** | Manage tickets — create, update, assign, comment, change status (not Closed). Logs in with password and can authenticate via `X-Api-Key` header (key shown once at creation) |
|
||||||
| **User** | Basic access — view tickets and add comments only |
|
| **User** | Basic access — view tickets and add comments only |
|
||||||
| **Service** | Automation account — authenticates via API key, no password login |
|
|
||||||
|
|
||||||
> Only **Admins** can manually set a ticket status to **Closed**.
|
> Only **Admins** can manually set a ticket status to **Closed**.
|
||||||
|
|
||||||
@@ -99,7 +98,9 @@ docker compose exec server npm run db:seed
|
|||||||
This creates:
|
This creates:
|
||||||
|
|
||||||
- `admin` user (password: `admin123`) — **change this immediately**
|
- `admin` user (password: `admin123`) — **change this immediately**
|
||||||
- `goddard` service account — API key is printed to the console; copy it now
|
- Sample CTI hierarchy (categories, types, items)
|
||||||
|
|
||||||
|
Automation accounts are no longer seeded. Create an **Agent** via Admin → Users to get an API key for n8n / scripts — the key is shown once in a modal at creation time.
|
||||||
|
|
||||||
### Upgrading from v0.9
|
### Upgrading from v0.9
|
||||||
|
|
||||||
@@ -140,7 +141,7 @@ cd server
|
|||||||
cp .env.example .env # set DATABASE_URL and JWT_SECRET
|
cp .env.example .env # set DATABASE_URL and JWT_SECRET
|
||||||
npm install
|
npm install
|
||||||
npm run db:push # creates tables + search indexes
|
npm run db:push # creates tables + search indexes
|
||||||
npm run db:seed # seeds admin + Goddard + sample CTI
|
npm run db:seed # seeds admin user + sample CTI
|
||||||
npm run dev # http://localhost:3000
|
npm run dev # http://localhost:3000
|
||||||
npm test # vitest (service layer)
|
npm test # vitest (service layer)
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
@@ -165,12 +166,10 @@ CI runs typecheck + tests on both packages before building Docker images.
|
|||||||
All endpoints (except `/api/auth/*` and `/healthz`) require authentication via one of:
|
All endpoints (except `/api/auth/*` and `/healthz`) require authentication via one of:
|
||||||
|
|
||||||
- **JWT**: `Authorization: Bearer <token>` (obtained from `POST /api/auth/login`)
|
- **JWT**: `Authorization: Bearer <token>` (obtained from `POST /api/auth/login`)
|
||||||
- **API Key**: `X-Api-Key: sk_<key>` (Service accounts only)
|
- **API Key**: `X-Api-Key: sk_<key>` (on any Agent account)
|
||||||
|
|
||||||
Base URL: `https://tickets.thewrightserver.net/api`
|
Base URL: `https://tickets.thewrightserver.net/api`
|
||||||
|
|
||||||
`POST /api/auth/login` is rate-limited to 10 attempts per 15 minutes per IP.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
@@ -236,7 +235,7 @@ List tickets, sorted by severity (ASC) then created date (DESC).
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response (unpaginated — `page` omitted):** Array of ticket objects with nested `category`, `type`, `item`, `assignee`, `createdBy`, and `_count.comments`. This preserves compatibility with v0.9 clients and the Goddard integration.
|
**Response (unpaginated — `page` omitted):** Array of ticket objects with nested `category`, `type`, `item`, `assignee`, `createdBy`, and `_count.comments`. This preserves compatibility with v0.9 clients and API-key integrations.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -248,7 +247,7 @@ Fetch a single ticket by internal ID or display ID (e.g. `V325813929`). Includes
|
|||||||
|
|
||||||
#### `POST /api/tickets`
|
#### `POST /api/tickets`
|
||||||
|
|
||||||
Create a new ticket. Requires **Agent**, **Admin**, or **Service** role.
|
Create a new ticket. Requires **Agent** or **Admin** role.
|
||||||
|
|
||||||
**Body:**
|
**Body:**
|
||||||
|
|
||||||
@@ -270,7 +269,7 @@ Create a new ticket. Requires **Agent**, **Admin**, or **Service** role.
|
|||||||
|
|
||||||
#### `PATCH /api/tickets/:id`
|
#### `PATCH /api/tickets/:id`
|
||||||
|
|
||||||
Update a ticket. Accepts any combination of fields. Requires **Agent**, **Admin**, or **Service** role.
|
Update a ticket. Accepts any combination of fields. Requires **Agent** or **Admin** role.
|
||||||
|
|
||||||
> Setting `status` to `CLOSED` requires **Admin** role.
|
> Setting `status` to `CLOSED` requires **Admin** role.
|
||||||
|
|
||||||
@@ -470,12 +469,12 @@ Create a user.
|
|||||||
"username": "string",
|
"username": "string",
|
||||||
"email": "string",
|
"email": "string",
|
||||||
"displayName": "string",
|
"displayName": "string",
|
||||||
"password": "string (not required for SERVICE role)",
|
"password": "string (min 8 chars)",
|
||||||
"role": "ADMIN | AGENT | USER | SERVICE"
|
"role": "ADMIN | AGENT | USER"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Service accounts receive an auto-generated API key returned in the response. Copy it immediately — it is not shown again.
|
Agent accounts receive an auto-generated API key returned in the response. Copy it immediately — it is not shown again. Use `PATCH /api/users/:id` with `{ "regenerateApiKey": true }` to rotate.
|
||||||
|
|
||||||
#### `PATCH /api/users/:id`
|
#### `PATCH /api/users/:id`
|
||||||
|
|
||||||
@@ -487,15 +486,15 @@ Delete a user. Cannot delete your own account.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## n8n Integration (Goddard)
|
## n8n Integration
|
||||||
|
|
||||||
The `goddard` service account authenticates via API key — no login flow needed.
|
Create an **Agent** account via Admin → Users. The API key is shown once in a modal at creation — copy it into n8n's credentials as the `X-Api-Key` header value. Every Agent can authenticate via both password (for the UI) and API key (for automation).
|
||||||
|
|
||||||
**Create a ticket from n8n:**
|
**Create a ticket from n8n:**
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/tickets
|
POST /api/tickets
|
||||||
X-Api-Key: sk_<goddard api key>
|
X-Api-Key: sk_<agent api key>
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -505,7 +504,7 @@ Content-Type: application/json
|
|||||||
"categoryId": "<TheWrightServer category ID>",
|
"categoryId": "<TheWrightServer category ID>",
|
||||||
"typeId": "<Automation type ID>",
|
"typeId": "<Automation type ID>",
|
||||||
"itemId": "<Backup item ID>",
|
"itemId": "<Backup item ID>",
|
||||||
"assigneeId": "<Goddard user ID>"
|
"assigneeId": "<agent user ID>"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -515,7 +514,7 @@ CTI IDs can be fetched from:
|
|||||||
- `GET /api/cti/types?categoryId=<id>`
|
- `GET /api/cti/types?categoryId=<id>`
|
||||||
- `GET /api/cti/items?typeId=<id>`
|
- `GET /api/cti/items?typeId=<id>`
|
||||||
|
|
||||||
To regenerate the Goddard API key: Admin → Users → refresh icon next to Goddard.
|
To rotate an Agent's API key: Admin → Users → refresh icon next to the Agent. The old key stops working immediately.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
COPY client/package*.json ./client/
|
COPY client/package*.json ./client/
|
||||||
RUN cd client && npm ci
|
RUN npm ci --omit=dev && cd client && npm ci
|
||||||
COPY client ./client
|
COPY client ./client
|
||||||
COPY shared ./shared
|
COPY shared ./shared
|
||||||
RUN cd client && npm run build
|
RUN cd client && npm run build
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#2563eb" />
|
<meta name="theme-color" content="#4f46e5" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectClass =
|
const selectClass =
|
||||||
'block w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed';
|
'block w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export default function Layout({ children, title, action, subheader, wide }: Lay
|
|||||||
<div className="min-h-screen bg-background text-foreground flex flex-col">
|
<div className="min-h-screen bg-background text-foreground flex flex-col">
|
||||||
{/* Top nav */}
|
{/* Top nav */}
|
||||||
<header className="sticky top-0 z-30 flex-shrink-0 border-b border-border bg-card/95 backdrop-blur">
|
<header className="sticky top-0 z-30 flex-shrink-0 border-b border-border bg-card/95 backdrop-blur">
|
||||||
<div className="mx-auto max-w-[1400px] px-4 h-12 flex items-center gap-4">
|
<div className="mx-auto max-w-[1400px] 2xl:max-w-[1800px] px-4 h-12 flex items-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
to="/dashboard"
|
to="/dashboard"
|
||||||
className="flex items-center gap-2 font-semibold text-sm whitespace-nowrap"
|
className="flex items-center gap-2 font-semibold text-sm whitespace-nowrap"
|
||||||
@@ -233,7 +233,7 @@ export default function Layout({ children, title, action, subheader, wide }: Lay
|
|||||||
{(title || action || subheader) && (
|
{(title || action || subheader) && (
|
||||||
<div className="border-b border-border bg-card/50">
|
<div className="border-b border-border bg-card/50">
|
||||||
<div
|
<div
|
||||||
className={`mx-auto px-4 py-3 ${wide ? 'max-w-[1400px]' : 'max-w-6xl'} flex items-center justify-between gap-3`}
|
className={`mx-auto px-4 py-3 ${wide ? 'max-w-[1400px] 2xl:max-w-[1800px]' : 'max-w-6xl'} flex items-center justify-between gap-3`}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{title && (
|
{title && (
|
||||||
@@ -246,7 +246,7 @@ export default function Layout({ children, title, action, subheader, wide }: Lay
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<main className={`flex-1 mx-auto w-full px-4 py-6 ${wide ? 'max-w-[1400px]' : 'max-w-6xl'}`}>
|
<main className={`flex-1 mx-auto w-full px-4 py-6 ${wide ? 'max-w-[1400px] 2xl:max-w-[1800px]' : 'max-w-6xl'}`}>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
+35
-35
@@ -5,70 +5,70 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 240 10% 3.9%;
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 240 10% 3.9%;
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 240 10% 3.9%;
|
||||||
|
|
||||||
--primary: 221.2 83.2% 53.3%;
|
--primary: 263 70% 50.4%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 240 4.8% 95.9%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
|
|
||||||
--muted: 210 40% 96.1%;
|
--muted: 240 4.8% 95.9%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted-foreground: 240 3.8% 46.1%;
|
||||||
|
|
||||||
--accent: 210 40% 96.1%;
|
--accent: 240 4.8% 95.9%;
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
--accent-foreground: 240 5.9% 10%;
|
||||||
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 240 5.9% 90%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 240 5.9% 90%;
|
||||||
--ring: 221.2 83.2% 53.3%;
|
--ring: 263 70% 50.4%;
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 222.2 84% 4.9%;
|
--background: 240 10% 3.9%;
|
||||||
--foreground: 210 40% 98%;
|
--foreground: 0 0% 98%;
|
||||||
|
|
||||||
--card: 222.2 84% 4.9%;
|
--card: 240 10% 3.9%;
|
||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--popover: 222.2 84% 4.9%;
|
--popover: 240 10% 3.9%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--primary: 217.2 91.2% 59.8%;
|
--primary: 263 70% 50.4%;
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: 240 3.7% 15.9%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--muted: 217.2 32.6% 17.5%;
|
--muted: 240 3.7% 15.9%;
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--muted-foreground: 240 5% 64.9%;
|
||||||
|
|
||||||
--accent: 217.2 32.6% 17.5%;
|
--accent: 240 3.7% 15.9%;
|
||||||
--accent-foreground: 210 40% 98%;
|
--accent-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 240 3.7% 15.9%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 240 3.7% 15.9%;
|
||||||
--ring: 224.3 76.3% 48%;
|
--ring: 263 70% 50.4%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Native select dark option styling */
|
/* Native select dark option styling */
|
||||||
select option {
|
select option {
|
||||||
background-color: #1f2937;
|
background-color: #27272a;
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ select option {
|
|||||||
@apply mt-1 mb-0;
|
@apply mt-1 mb-0;
|
||||||
}
|
}
|
||||||
.prose a {
|
.prose a {
|
||||||
@apply text-blue-400 underline hover:text-blue-300;
|
@apply text-indigo-400 underline hover:text-indigo-300;
|
||||||
}
|
}
|
||||||
.prose strong {
|
.prose strong {
|
||||||
@apply font-semibold;
|
@apply font-semibold;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function Dashboard() {
|
|||||||
const maxSeverity = Math.max(...(a?.openBySeverity.map((r) => r.count) ?? [1]));
|
const maxSeverity = Math.max(...(a?.openBySeverity.map((r) => r.count) ?? [1]));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title="Dashboard" subheader={<p className="text-xs text-muted-foreground">Last 30 days</p>}>
|
<Layout wide title="Dashboard" subheader={<p className="text-xs text-muted-foreground">Last 30 days</p>}>
|
||||||
{!a ? (
|
{!a ? (
|
||||||
<p className="py-16 text-center text-sm text-muted-foreground">Loading analytics…</p>
|
<p className="py-16 text-center text-sm text-muted-foreground">Loading analytics…</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -143,7 +143,7 @@ export default function Dashboard() {
|
|||||||
{a.queueByAssignee.length === 0 ? (
|
{a.queueByAssignee.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">No open tickets right now.</p>
|
<p className="text-xs text-muted-foreground">No open tickets right now.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-6">
|
||||||
{a.queueByAssignee
|
{a.queueByAssignee
|
||||||
.slice()
|
.slice()
|
||||||
.sort((x, y) => y.count - x.count)
|
.sort((x, y) => y.count - x.count)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function Login() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent';
|
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4">
|
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4">
|
||||||
@@ -70,7 +70,7 @@ export default function Login() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="w-full bg-blue-600 text-white py-2 rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
className="w-full bg-indigo-600 text-white py-2 rounded-lg text-sm font-medium hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Signing in...' : 'Sign in'}
|
{isSubmitting ? 'Signing in...' : 'Sign in'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function MyTickets() {
|
|||||||
<Link
|
<Link
|
||||||
key={ticket.id}
|
key={ticket.id}
|
||||||
to={`/${ticket.displayId}`}
|
to={`/${ticket.displayId}`}
|
||||||
className="flex items-center gap-4 bg-gray-900 border border-gray-800 rounded-lg px-4 py-3 hover:border-blue-500/50 transition-all group"
|
className="flex items-center gap-4 bg-gray-900 border border-gray-800 rounded-lg px-4 py-3 hover:border-indigo-500/50 transition-all group"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`w-1 self-stretch rounded-full flex-shrink-0 ${
|
className={`w-1 self-stretch rounded-full flex-shrink-0 ${
|
||||||
@@ -66,7 +66,7 @@ export default function MyTickets() {
|
|||||||
{ticket.category.name} › {ticket.type.name} › {ticket.item.name}
|
{ticket.category.name} › {ticket.type.name} › {ticket.item.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-gray-200 truncate group-hover:text-blue-400">
|
<p className="text-sm font-medium text-gray-200 truncate group-hover:text-indigo-400">
|
||||||
{ticket.title}
|
{ticket.title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function NewTicketModal({ onClose }: NewTicketModalProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent';
|
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent';
|
||||||
const labelClass = 'block text-sm font-medium text-gray-300 mb-1';
|
const labelClass = 'block text-sm font-medium text-gray-300 mb-1';
|
||||||
const errorClass = 'mt-1 text-xs text-red-400';
|
const errorClass = 'mt-1 text-xs text-red-400';
|
||||||
|
|
||||||
@@ -105,9 +105,7 @@ export default function NewTicketModal({ onClose }: NewTicketModalProps) {
|
|||||||
<label className={labelClass}>Assignee</label>
|
<label className={labelClass}>Assignee</label>
|
||||||
<select className={inputClass} {...register('assigneeId')}>
|
<select className={inputClass} {...register('assigneeId')}>
|
||||||
<option value="">Unassigned</option>
|
<option value="">Unassigned</option>
|
||||||
{users
|
{users.map((u) => (
|
||||||
.filter((u) => u.role !== 'SERVICE')
|
|
||||||
.map((u) => (
|
|
||||||
<option key={u.id} value={u.id}>
|
<option key={u.id} value={u.id}>
|
||||||
{u.displayName}
|
{u.displayName}
|
||||||
</option>
|
</option>
|
||||||
@@ -164,7 +162,7 @@ export default function NewTicketModal({ onClose }: NewTicketModalProps) {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Creating...' : 'Create Ticket'}
|
{isSubmitting ? 'Creating...' : 'Create Ticket'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Copy } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
@@ -47,12 +46,6 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyKey = async () => {
|
|
||||||
if (!user?.apiKey) return;
|
|
||||||
await navigator.clipboard.writeText(user.apiKey);
|
|
||||||
toast.success('API key copied');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title="Settings">
|
<Layout title="Settings">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -118,24 +111,6 @@ export default function Settings() {
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* API key (service accounts only) */}
|
|
||||||
{user?.role === 'SERVICE' && user?.apiKey && (
|
|
||||||
<section className="rounded-md border border-border p-4">
|
|
||||||
<h2 className="text-sm font-semibold mb-3">API key</h2>
|
|
||||||
<div className="flex items-center gap-2 bg-muted rounded-md px-3 py-2 font-mono text-xs break-all">
|
|
||||||
<span className="flex-1">{user.apiKey}</span>
|
|
||||||
<button
|
|
||||||
onClick={copyKey}
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Copy size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
|
||||||
Pass as <code>x-api-key</code> header on any server-to-server request.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ export default function TicketDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const commentCount = ticket.comments?.length ?? 0;
|
const commentCount = ticket.comments?.length ?? 0;
|
||||||
const agentUsers = users.filter((u) => u.role !== 'SERVICE');
|
const agentUsers = users;
|
||||||
|
|
||||||
const statusOptions: { value: TicketStatus; label: string }[] = [
|
const statusOptions: { value: TicketStatus; label: string }[] = [
|
||||||
{ value: 'OPEN', label: 'Open' },
|
{ value: 'OPEN', label: 'Open' },
|
||||||
@@ -291,7 +291,7 @@ export default function TicketDetail() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={editForm.title}
|
value={editForm.title}
|
||||||
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
|
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
|
||||||
className="w-full text-2xl font-bold text-gray-100 bg-transparent border-0 border-b-2 border-blue-500 focus:outline-none pb-1"
|
className="w-full text-2xl font-bold text-gray-100 bg-transparent border-0 border-b-2 border-indigo-500 focus:outline-none pb-1"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -319,7 +319,7 @@ export default function TicketDetail() {
|
|||||||
onClick={() => setTab(key)}
|
onClick={() => setTab(key)}
|
||||||
className={`flex items-center gap-2 px-4 py-3.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
className={`flex items-center gap-2 px-4 py-3.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||||
tab === key
|
tab === key
|
||||||
? 'border-blue-500 text-blue-400'
|
? 'border-indigo-500 text-indigo-400'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-300'
|
: 'border-transparent text-gray-500 hover:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -338,7 +338,7 @@ export default function TicketDetail() {
|
|||||||
value={editForm.overview}
|
value={editForm.overview}
|
||||||
onChange={(e) => setEditForm((f) => ({ ...f, overview: e.target.value }))}
|
onChange={(e) => setEditForm((f) => ({ ...f, overview: e.target.value }))}
|
||||||
rows={12}
|
rows={12}
|
||||||
className="w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y font-mono"
|
className="w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-y font-mono"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -349,7 +349,7 @@ export default function TicketDetail() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={saveEdit}
|
onClick={saveEdit}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||||
>
|
>
|
||||||
<Check size={13} /> Save changes
|
<Check size={13} /> Save changes
|
||||||
</button>
|
</button>
|
||||||
@@ -426,7 +426,7 @@ export default function TicketDetail() {
|
|||||||
onClick={() => setPreview(label === 'Preview')}
|
onClick={() => setPreview(label === 'Preview')}
|
||||||
className={`text-xs py-2 border-b-2 -mb-px transition-colors ${
|
className={`text-xs py-2 border-b-2 -mb-px transition-colors ${
|
||||||
(label === 'Preview') === preview
|
(label === 'Preview') === preview
|
||||||
? 'border-blue-500 text-blue-400'
|
? 'border-indigo-500 text-indigo-400'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-300'
|
: 'border-transparent text-gray-500 hover:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -468,7 +468,7 @@ export default function TicketDetail() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={addComment.isPending || !commentBody.trim()}
|
disabled={addComment.isPending || !commentBody.trim()}
|
||||||
className="flex items-center gap-2 px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
className="flex items-center gap-2 px-4 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
<Send size={13} />
|
<Send size={13} />
|
||||||
Comment
|
Comment
|
||||||
@@ -783,7 +783,7 @@ export default function TicketDetail() {
|
|||||||
<button
|
<button
|
||||||
onClick={saveReroute}
|
onClick={saveReroute}
|
||||||
disabled={!pendingCTI.itemId}
|
disabled={!pendingCTI.itemId}
|
||||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
Save routing
|
Save routing
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ export default function Tickets() {
|
|||||||
setSearchInput(String(filters.search ?? ''));
|
setSearchInput(String(filters.search ?? ''));
|
||||||
};
|
};
|
||||||
|
|
||||||
const agentUsers = users.filter((u) => u.role !== 'SERVICE');
|
const agentUsers = users;
|
||||||
|
|
||||||
// Keyboard navigation
|
// Keyboard navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export default function AdminCTI() {
|
|||||||
const itemClass = (active: boolean) =>
|
const itemClass = (active: boolean) =>
|
||||||
`flex items-center justify-between px-4 py-2.5 cursor-pointer group transition-colors ${
|
`flex items-center justify-between px-4 py-2.5 cursor-pointer group transition-colors ${
|
||||||
active
|
active
|
||||||
? 'bg-blue-600/20 border-l-2 border-blue-500'
|
? 'bg-indigo-600/20 border-l-2 border-indigo-500'
|
||||||
: 'hover:bg-gray-800 border-l-2 border-transparent'
|
: 'hover:bg-gray-800 border-l-2 border-transparent'
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ export default function AdminCTI() {
|
|||||||
<h3 className="text-sm font-semibold text-gray-300">Categories</h3>
|
<h3 className="text-sm font-semibold text-gray-300">Categories</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => openAdd('category')}
|
onClick={() => openAdd('category')}
|
||||||
className="text-blue-400 hover:text-blue-300 transition-colors"
|
className="text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -199,7 +199,7 @@ export default function AdminCTI() {
|
|||||||
{selectedCategory && (
|
{selectedCategory && (
|
||||||
<button
|
<button
|
||||||
onClick={() => openAdd('type')}
|
onClick={() => openAdd('type')}
|
||||||
className="text-blue-400 hover:text-blue-300 transition-colors"
|
className="text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -257,7 +257,7 @@ export default function AdminCTI() {
|
|||||||
{selectedType && (
|
{selectedType && (
|
||||||
<button
|
<button
|
||||||
onClick={() => openAdd('item')}
|
onClick={() => openAdd('item')}
|
||||||
className="text-blue-400 hover:text-blue-300 transition-colors"
|
className="text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -314,7 +314,7 @@ export default function AdminCTI() {
|
|||||||
onChange={(e) => setNameValue(e.target.value)}
|
onChange={(e) => setNameValue(e.target.value)}
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
@@ -328,7 +328,7 @@ export default function AdminCTI() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{submitting ? 'Saving...' : 'Save'}
|
{submitting ? 'Saving...' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -42,21 +42,18 @@ const ROLE_LABELS: Record<Role, string> = {
|
|||||||
ADMIN: 'Admin',
|
ADMIN: 'Admin',
|
||||||
AGENT: 'Agent',
|
AGENT: 'Agent',
|
||||||
USER: 'User',
|
USER: 'User',
|
||||||
SERVICE: 'Service',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ROLE_BADGE: Record<Role, string> = {
|
const ROLE_BADGE: Record<Role, string> = {
|
||||||
ADMIN: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
ADMIN: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||||
AGENT: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
AGENT: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||||
USER: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
USER: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||||
SERVICE: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
||||||
ADMIN: 'Full access — manage users, CTI config, close and delete tickets',
|
ADMIN: 'Full access — manage users, CTI config, close and delete tickets',
|
||||||
AGENT: 'Manage tickets — create, update, assign, comment, change status',
|
AGENT: 'Manage tickets and automation — logs in with password and can authenticate via API key',
|
||||||
USER: 'Basic access — view tickets and add comments only',
|
USER: 'Basic access — view tickets and add comments only',
|
||||||
SERVICE: 'Automation account — authenticates via API key, no password login',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AdminUsers() {
|
export default function AdminUsers() {
|
||||||
@@ -177,7 +174,7 @@ export default function AdminUsers() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent';
|
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent';
|
||||||
const labelClass = 'block text-sm font-medium text-gray-300 mb-1';
|
const labelClass = 'block text-sm font-medium text-gray-300 mb-1';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -186,7 +183,7 @@ export default function AdminUsers() {
|
|||||||
action={
|
action={
|
||||||
<button
|
<button
|
||||||
onClick={openAdd}
|
onClick={openAdd}
|
||||||
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700 transition-colors"
|
className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-indigo-700 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
Add User
|
Add User
|
||||||
@@ -227,7 +224,7 @@ export default function AdminUsers() {
|
|||||||
<td className="px-5 py-3 text-gray-400">{u.email}</td>
|
<td className="px-5 py-3 text-gray-400">{u.email}</td>
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
{u.role === 'SERVICE' && (
|
{u.role === 'AGENT' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setRotating(u)}
|
onClick={() => setRotating(u)}
|
||||||
className="text-gray-600 hover:text-gray-300 transition-colors"
|
className="text-gray-600 hover:text-gray-300 transition-colors"
|
||||||
@@ -284,7 +281,7 @@ export default function AdminUsers() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={closeModal}
|
onClick={closeModal}
|
||||||
className="w-full bg-blue-600 text-white py-2 rounded-lg text-sm hover:bg-blue-700 transition-colors"
|
className="w-full bg-indigo-600 text-white py-2 rounded-lg text-sm hover:bg-indigo-700 transition-colors"
|
||||||
>
|
>
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
@@ -343,7 +340,7 @@ export default function AdminUsers() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={form.password}
|
value={form.password}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||||
required={modal === 'add' && form.role !== 'SERVICE'}
|
required={modal === 'add'}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
placeholder={modal === 'edit' ? '••••••••' : ''}
|
placeholder={modal === 'edit' ? '••••••••' : ''}
|
||||||
/>
|
/>
|
||||||
@@ -359,7 +356,6 @@ export default function AdminUsers() {
|
|||||||
<option value="AGENT">Agent</option>
|
<option value="AGENT">Agent</option>
|
||||||
<option value="USER">User</option>
|
<option value="USER">User</option>
|
||||||
<option value="ADMIN">Admin</option>
|
<option value="ADMIN">Admin</option>
|
||||||
<option value="SERVICE">Service</option>
|
|
||||||
</select>
|
</select>
|
||||||
<p className="mt-1.5 text-xs text-gray-500">{ROLE_DESCRIPTIONS[form.role]}</p>
|
<p className="mt-1.5 text-xs text-gray-500">{ROLE_DESCRIPTIONS[form.role]}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -375,7 +371,7 @@ export default function AdminUsers() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{submitting ? 'Saving...' : modal === 'add' ? 'Create User' : 'Save Changes'}
|
{submitting ? 'Saving...' : modal === 'add' ? 'Create User' : 'Save Changes'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type Role = 'ADMIN' | 'AGENT' | 'USER' | 'SERVICE';
|
export type Role = 'ADMIN' | 'AGENT' | 'USER';
|
||||||
export type TicketStatus = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED';
|
export type TicketStatus = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED';
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
COPY server/package*.json ./server/
|
COPY server/package*.json ./server/
|
||||||
RUN cd server && npm ci
|
RUN npm ci --omit=dev && cd server && npm ci
|
||||||
COPY server ./server
|
COPY server ./server
|
||||||
COPY shared ./shared
|
COPY shared ./shared
|
||||||
RUN cd server && npx prisma generate && npm run build
|
RUN cd server && npx prisma generate && npm run build
|
||||||
|
|||||||
Generated
+12
-29
@@ -14,7 +14,6 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-async-errors": "^3.1.0",
|
"express-async-errors": "^3.1.0",
|
||||||
"express-rate-limit": "^7.5.0",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
@@ -39,7 +38,7 @@
|
|||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vitest": "^2.1.8",
|
"vitest": "^2.1.8",
|
||||||
"vitest-mock-extended": "^4.0.0"
|
"vitest-mock-extended": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
@@ -542,14 +541,14 @@
|
|||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
|
||||||
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
|
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines": {
|
"node_modules/@prisma/engines": {
|
||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
|
||||||
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
|
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -563,14 +562,14 @@
|
|||||||
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
|
||||||
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
|
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/fetch-engine": {
|
"node_modules/@prisma/fetch-engine": {
|
||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
|
||||||
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
|
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "5.22.0",
|
"@prisma/debug": "5.22.0",
|
||||||
@@ -582,7 +581,7 @@
|
|||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
|
||||||
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
|
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "5.22.0"
|
"@prisma/debug": "5.22.0"
|
||||||
@@ -1917,21 +1916,6 @@
|
|||||||
"express": "^4.16.2"
|
"express": "^4.16.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express-rate-limit": {
|
|
||||||
"version": "7.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
|
||||||
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 16"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/express-rate-limit"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"express": ">= 4.11"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fast-copy": {
|
"node_modules/fast-copy": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz",
|
||||||
@@ -2019,7 +2003,6 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -2689,7 +2672,7 @@
|
|||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
||||||
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3996,17 +3979,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest-mock-extended": {
|
"node_modules/vitest-mock-extended": {
|
||||||
"version": "4.0.0",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/vitest-mock-extended/-/vitest-mock-extended-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/vitest-mock-extended/-/vitest-mock-extended-2.0.2.tgz",
|
||||||
"integrity": "sha512-m2FmH8JYfxzZoLsHuhXRY+Pv++a3zd91HYpSz81tpRLEHbtFkEL2QcWvJowucWuNTirzQURKfWbJJSXbYqkTsA==",
|
"integrity": "sha512-n3MBqVITKyclZ0n0y66hkT4UiiEYFQn9tteAnIxT0MPz1Z8nFcPUG3Cf0cZOyoPOj/cq6Ab1XFw2lM/qM5EDWQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ts-essentials": ">=10.0.0"
|
"ts-essentials": ">=10.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "3.x || 4.x || 5.x || 6.x",
|
"typescript": "3.x || 4.x || 5.x",
|
||||||
"vitest": ">=4.0.0"
|
"vitest": ">=2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest/node_modules/debug": {
|
"node_modules/vitest/node_modules/debug": {
|
||||||
|
|||||||
+3
-4
@@ -5,8 +5,8 @@
|
|||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/server/src/index.js",
|
"start": "node dist/server/src/index.js",
|
||||||
"start:prod": "prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma && node dist/server/src/index.js",
|
"start:prod": "prisma db execute --file prisma/pre-push.sql --schema prisma/schema.prisma && prisma db push --accept-data-loss && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma && node dist/server/src/index.js",
|
||||||
"db:push": "prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma",
|
"db:push": "prisma db execute --file prisma/pre-push.sql --schema prisma/schema.prisma && prisma db push --accept-data-loss && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-async-errors": "^3.1.0",
|
"express-async-errors": "^3.1.0",
|
||||||
"express-rate-limit": "^7.5.0",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
@@ -45,6 +44,6 @@
|
|||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vitest": "^2.1.8",
|
"vitest": "^2.1.8",
|
||||||
"vitest-mock-extended": "^4.0.0"
|
"vitest-mock-extended": "^2.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Idempotent SQL applied BEFORE `prisma db push`.
|
||||||
|
-- Flips any residual SERVICE-role users to AGENT before Prisma rewrites the Role enum.
|
||||||
|
-- Safe no-op on fresh databases or databases already migrated past the SERVICE role.
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_type t
|
||||||
|
JOIN pg_enum e ON e.enumtypid = t.oid
|
||||||
|
WHERE t.typname = 'Role' AND e.enumlabel = 'SERVICE'
|
||||||
|
) THEN
|
||||||
|
EXECUTE 'UPDATE "User" SET "role" = ''AGENT'' WHERE "role"::text = ''SERVICE''';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -11,7 +11,6 @@ enum Role {
|
|||||||
ADMIN
|
ADMIN
|
||||||
AGENT
|
AGENT
|
||||||
USER
|
USER
|
||||||
SERVICE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TicketStatus {
|
enum TicketStatus {
|
||||||
@@ -87,6 +86,9 @@ model Ticket {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Managed by post-push.sql — trigger keeps it in sync; queried only via raw SQL.
|
||||||
|
searchVector Unsupported("tsvector")?
|
||||||
|
|
||||||
category Category @relation(fields: [categoryId], references: [id])
|
category Category @relation(fields: [categoryId], references: [id])
|
||||||
type Type @relation(fields: [typeId], references: [id])
|
type Type @relation(fields: [typeId], references: [id])
|
||||||
item Item @relation(fields: [itemId], references: [id])
|
item Item @relation(fields: [itemId], references: [id])
|
||||||
@@ -111,6 +113,9 @@ model Comment {
|
|||||||
authorId String
|
authorId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Managed by post-push.sql — trigger keeps it in sync; queried only via raw SQL.
|
||||||
|
searchVector Unsupported("tsvector")?
|
||||||
|
|
||||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||||
author User @relation(fields: [authorId], references: [id])
|
author User @relation(fields: [authorId], references: [id])
|
||||||
attachments Attachment[]
|
attachments Attachment[]
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import crypto from 'crypto';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -20,25 +19,6 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Goddard — n8n service account
|
|
||||||
const apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
|
|
||||||
await prisma.user.upsert({
|
|
||||||
where: { username: 'goddard' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
username: 'goddard',
|
|
||||||
email: 'goddard@internal',
|
|
||||||
displayName: 'Goddard',
|
|
||||||
passwordHash: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12),
|
|
||||||
role: 'SERVICE',
|
|
||||||
apiKey,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const existingGoddard = await prisma.user.findUnique({ where: { username: 'goddard' } });
|
|
||||||
console.log(`\nGoddard API key: ${existingGoddard?.apiKey ?? apiKey}`);
|
|
||||||
console.log('(This key is only displayed once on first seed — copy it now)\n');
|
|
||||||
|
|
||||||
// Sample CTI structure
|
// Sample CTI structure
|
||||||
const theWrightServer = await prisma.category.upsert({
|
const theWrightServer = await prisma.category.upsert({
|
||||||
where: { name: 'TheWrightServer' },
|
where: { name: 'TheWrightServer' },
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import express from 'express';
|
|||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import pinoHttp from 'pino-http';
|
import pinoHttp from 'pino-http';
|
||||||
import rateLimit from 'express-rate-limit';
|
|
||||||
|
|
||||||
import authRoutes from './routes/auth';
|
import authRoutes from './routes/auth';
|
||||||
import ticketRoutes from './routes/tickets';
|
import ticketRoutes from './routes/tickets';
|
||||||
@@ -38,16 +37,7 @@ app.get('/healthz', (_req, res) => {
|
|||||||
res.json({ status: 'ok' });
|
res.json({ status: 'ok' });
|
||||||
});
|
});
|
||||||
|
|
||||||
const loginLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000,
|
|
||||||
max: 10,
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
app.use('/api/auth/login', loginLimiter);
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
|
|
||||||
// Protected
|
// Protected
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu
|
|||||||
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
const user = await prisma.user.findUnique({ where: { apiKey } });
|
const user = await prisma.user.findUnique({ where: { apiKey } });
|
||||||
if (!user || user.role !== 'SERVICE') {
|
if (!user) {
|
||||||
return res.status(401).json({ error: 'Invalid API key' });
|
return res.status(401).json({ error: 'Invalid API key' });
|
||||||
}
|
}
|
||||||
req.user = { id: user.id, role: user.role, username: user.username };
|
req.user = { id: user.id, role: user.role, username: user.username };
|
||||||
@@ -48,7 +48,7 @@ export const requireAdmin = (req: AuthRequest, res: Response, next: NextFunction
|
|||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Blocks USER role — allows ADMIN, AGENT, SERVICE
|
// Blocks USER role — allows ADMIN and AGENT
|
||||||
export const requireAgent = (req: AuthRequest, res: Response, next: NextFunction) => {
|
export const requireAgent = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
if (req.user?.role === 'USER') {
|
if (req.user?.role === 'USER') {
|
||||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||||
|
|||||||
@@ -51,24 +51,4 @@ describe('authService.login', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects SERVICE role from password login', async () => {
|
|
||||||
const password = 'svc-pw';
|
|
||||||
prismaMock.user.findUnique.mockResolvedValue({
|
|
||||||
id: 'svc',
|
|
||||||
username: 'goddard',
|
|
||||||
email: 'g@x.io',
|
|
||||||
displayName: 'Goddard',
|
|
||||||
passwordHash: await bcrypt.hash(password, 4),
|
|
||||||
role: 'SERVICE',
|
|
||||||
apiKey: 'sk_xyz',
|
|
||||||
notificationPrefs: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(login({ username: 'goddard', password })).rejects.toMatchObject({
|
|
||||||
status: 401,
|
|
||||||
message: expect.stringMatching(/API key/i),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ export async function login({ username, password }: LoginInput) {
|
|||||||
throw new HttpError(401, 'Invalid credentials');
|
throw new HttpError(401, 'Invalid credentials');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.role === 'SERVICE') {
|
|
||||||
throw new HttpError(401, 'Service accounts must authenticate via API key');
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ id: user.id, role: user.role, username: user.username },
|
{ id: user.id, role: user.role, username: user.username },
|
||||||
process.env.JWT_SECRET!,
|
process.env.JWT_SECRET!,
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ const stubUser = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('userService.createUser', () => {
|
describe('userService.createUser', () => {
|
||||||
it('hashes the password and omits apiKey for non-SERVICE roles', async () => {
|
it('hashes the password and omits apiKey for ADMIN and USER roles', async () => {
|
||||||
prismaMock.user.create.mockResolvedValue(stubUser);
|
prismaMock.user.create.mockResolvedValue({ ...stubUser, role: 'USER' });
|
||||||
|
|
||||||
await createUser({
|
await createUser({
|
||||||
username: 'bob',
|
username: 'bob',
|
||||||
email: 'b@x.io',
|
email: 'b@x.io',
|
||||||
displayName: 'Bob',
|
displayName: 'Bob',
|
||||||
password: 'hunter2!',
|
password: 'hunter2!',
|
||||||
role: 'AGENT',
|
role: 'USER',
|
||||||
});
|
});
|
||||||
|
|
||||||
const call = prismaMock.user.create.mock.calls[0][0] as { data: Record<string, unknown> };
|
const call = prismaMock.user.create.mock.calls[0][0] as { data: Record<string, unknown> };
|
||||||
@@ -34,14 +34,15 @@ describe('userService.createUser', () => {
|
|||||||
expect(call.data.apiKey).toBeUndefined();
|
expect(call.data.apiKey).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('assigns an apiKey for SERVICE role', async () => {
|
it('assigns an apiKey for AGENT role', async () => {
|
||||||
prismaMock.user.create.mockResolvedValue({ ...stubUser, role: 'SERVICE' });
|
prismaMock.user.create.mockResolvedValue(stubUser);
|
||||||
|
|
||||||
await createUser({
|
await createUser({
|
||||||
username: 'svc',
|
username: 'agent',
|
||||||
email: 's@x.io',
|
email: 'a@x.io',
|
||||||
displayName: 'Svc',
|
displayName: 'Agent',
|
||||||
role: 'SERVICE',
|
password: 'hunter2!',
|
||||||
|
role: 'AGENT',
|
||||||
});
|
});
|
||||||
|
|
||||||
const call = prismaMock.user.create.mock.calls[0][0] as { data: Record<string, unknown> };
|
const call = prismaMock.user.create.mock.calls[0][0] as { data: Record<string, unknown> };
|
||||||
|
|||||||
@@ -41,12 +41,10 @@ export async function getCurrentUser(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createUser(data: CreateUserInput) {
|
export async function createUser(data: CreateUserInput) {
|
||||||
const passwordHash = data.password
|
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||||
? await bcrypt.hash(data.password, 12)
|
|
||||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12);
|
|
||||||
|
|
||||||
const apiKey =
|
const apiKey =
|
||||||
data.role === 'SERVICE' ? `sk_${crypto.randomBytes(32).toString('hex')}` : undefined;
|
data.role === 'AGENT' ? `sk_${crypto.randomBytes(32).toString('hex')}` : undefined;
|
||||||
|
|
||||||
return prisma.user.create({
|
return prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -68,9 +66,14 @@ export async function updateUser(id: string, data: UpdateUserInput) {
|
|||||||
if (data.password) update.passwordHash = await bcrypt.hash(data.password, 12);
|
if (data.password) update.passwordHash = await bcrypt.hash(data.password, 12);
|
||||||
if (data.role) {
|
if (data.role) {
|
||||||
update.role = data.role;
|
update.role = data.role;
|
||||||
if (data.role === 'SERVICE' && !update.apiKey) {
|
if (data.role === 'AGENT') {
|
||||||
|
const existing = await prisma.user.findUnique({ where: { id }, select: { apiKey: true } });
|
||||||
|
if (!existing?.apiKey) {
|
||||||
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
|
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
update.apiKey = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (data.regenerateApiKey) {
|
if (data.regenerateApiKey) {
|
||||||
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
|
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const ROLES = ['ADMIN', 'AGENT', 'USER', 'SERVICE'] as const;
|
export const ROLES = ['ADMIN', 'AGENT', 'USER'] as const;
|
||||||
export const TICKET_STATUSES = ['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'] as const;
|
export const TICKET_STATUSES = ['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'] as const;
|
||||||
|
|
||||||
export const roleSchema = z.enum(ROLES);
|
export const roleSchema = z.enum(ROLES);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const createUserSchema = z.object({
|
|||||||
username: z.string().min(1).max(50),
|
username: z.string().min(1).max(50),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
displayName: z.string().min(1).max(100),
|
displayName: z.string().min(1).max(100),
|
||||||
password: z.string().min(8).optional(),
|
password: z.string().min(8),
|
||||||
role: roleSchema.default('AGENT'),
|
role: roleSchema.default('AGENT'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user