From d8785a964d600574b6712bdebbea4735e4b6c77e Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 18 Apr 2026 22:44:32 -0400 Subject: [PATCH] Merge SERVICE role into AGENT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every AGENT now gets an auto-generated API key on creation, shown once in a modal. AGENTs log in with password and authenticate to the API with X-Api-Key. pre-push.sql defensively migrates any residual SERVICE rows to AGENT before Prisma rewrites the enum. Goddard is no longer baked into the seed — create agents via Admin → Users. Co-Authored-By: Claude Opus 4.7 --- README.md | 45 +++++++++++++------------ client/src/pages/NewTicket.tsx | 12 +++---- client/src/pages/Settings.tsx | 25 -------------- client/src/pages/TicketDetail.tsx | 2 +- client/src/pages/Tickets.tsx | 2 +- client/src/pages/admin/Users.tsx | 10 ++---- client/src/types/index.ts | 2 +- server/package.json | 4 +-- server/prisma/pre-push.sql | 14 ++++++++ server/prisma/schema.prisma | 1 - server/prisma/seed.ts | 20 ----------- server/src/middleware/auth.ts | 4 +-- server/src/services/authService.test.ts | 20 ----------- server/src/services/authService.ts | 4 --- server/src/services/userService.test.ts | 19 ++++++----- server/src/services/userService.ts | 15 +++++---- shared/schemas/enums.ts | 2 +- shared/schemas/user.ts | 2 +- 18 files changed, 73 insertions(+), 130 deletions(-) create mode 100644 server/prisma/pre-push.sql diff --git a/README.md b/README.md index a004d22..f01f36e 100644 --- a/README.md +++ b/README.md @@ -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 - **PWA** — installable on mobile, offline app shell - **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 - **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 -| Role | Access | -| ----------- | ---------------------------------------------------------------------------- | -| **Admin** | Full access — manage users, CTI config, webhooks, close and delete tickets | -| **Agent** | Manage tickets — create, update, assign, comment, change status (not Closed) | -| **User** | Basic access — view tickets and add comments only | -| **Service** | Automation account — authenticates via API key, no password login | +| Role | Access | +| --------- | ----------------------------------------------------------------------------------------- | +| **Admin** | Full access — manage users, CTI config, webhooks, close and delete tickets | +| **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 | > Only **Admins** can manually set a ticket status to **Closed**. @@ -99,7 +98,9 @@ docker compose exec server npm run db:seed This creates: - `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 @@ -140,7 +141,7 @@ cd server cp .env.example .env # set DATABASE_URL and JWT_SECRET npm install 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 test # vitest (service layer) npm run typecheck @@ -165,7 +166,7 @@ CI runs typecheck + tests on both packages before building Docker images. All endpoints (except `/api/auth/*` and `/healthz`) require authentication via one of: - **JWT**: `Authorization: Bearer ` (obtained from `POST /api/auth/login`) -- **API Key**: `X-Api-Key: sk_` (Service accounts only) +- **API Key**: `X-Api-Key: sk_` (on any Agent account) Base URL: `https://tickets.thewrightserver.net/api` @@ -234,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. --- @@ -246,7 +247,7 @@ Fetch a single ticket by internal ID or display ID (e.g. `V325813929`). Includes #### `POST /api/tickets` -Create a new ticket. Requires **Agent**, **Admin**, or **Service** role. +Create a new ticket. Requires **Agent** or **Admin** role. **Body:** @@ -268,7 +269,7 @@ Create a new ticket. Requires **Agent**, **Admin**, or **Service** role. #### `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. @@ -468,12 +469,12 @@ Create a user. "username": "string", "email": "string", "displayName": "string", - "password": "string (not required for SERVICE role)", - "role": "ADMIN | AGENT | USER | SERVICE" + "password": "string (min 8 chars)", + "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` @@ -485,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:** ``` POST /api/tickets -X-Api-Key: sk_ +X-Api-Key: sk_ Content-Type: application/json { @@ -503,7 +504,7 @@ Content-Type: application/json "categoryId": "", "typeId": "", "itemId": "", - "assigneeId": "" + "assigneeId": "" } ``` @@ -513,7 +514,7 @@ CTI IDs can be fetched from: - `GET /api/cti/types?categoryId=` - `GET /api/cti/items?typeId=` -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. --- diff --git a/client/src/pages/NewTicket.tsx b/client/src/pages/NewTicket.tsx index 5b0a000..e655a3f 100644 --- a/client/src/pages/NewTicket.tsx +++ b/client/src/pages/NewTicket.tsx @@ -105,13 +105,11 @@ export default function NewTicketModal({ onClose }: NewTicketModalProps) { diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index e18be72..01efc4c 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from 'react'; -import { Copy } from 'lucide-react'; import { toast } from 'sonner'; import Layout from '../components/Layout'; 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 (
@@ -118,24 +111,6 @@ export default function Settings() {

- {/* API key (service accounts only) */} - {user?.role === 'SERVICE' && user?.apiKey && ( -
-

API key

-
- {user.apiKey} - -
-

- Pass as x-api-key header on any server-to-server request. -

-
- )}
); diff --git a/client/src/pages/TicketDetail.tsx b/client/src/pages/TicketDetail.tsx index c659d52..bef55fc 100644 --- a/client/src/pages/TicketDetail.tsx +++ b/client/src/pages/TicketDetail.tsx @@ -243,7 +243,7 @@ export default function TicketDetail() { } const commentCount = ticket.comments?.length ?? 0; - const agentUsers = users.filter((u) => u.role !== 'SERVICE'); + const agentUsers = users; const statusOptions: { value: TicketStatus; label: string }[] = [ { value: 'OPEN', label: 'Open' }, diff --git a/client/src/pages/Tickets.tsx b/client/src/pages/Tickets.tsx index 9ef2cc6..e0013d9 100644 --- a/client/src/pages/Tickets.tsx +++ b/client/src/pages/Tickets.tsx @@ -214,7 +214,7 @@ export default function Tickets() { setSearchInput(String(filters.search ?? '')); }; - const agentUsers = users.filter((u) => u.role !== 'SERVICE'); + const agentUsers = users; // Keyboard navigation useEffect(() => { diff --git a/client/src/pages/admin/Users.tsx b/client/src/pages/admin/Users.tsx index e4ce438..4351ef9 100644 --- a/client/src/pages/admin/Users.tsx +++ b/client/src/pages/admin/Users.tsx @@ -42,21 +42,18 @@ const ROLE_LABELS: Record = { ADMIN: 'Admin', AGENT: 'Agent', USER: 'User', - SERVICE: 'Service', }; const ROLE_BADGE: Record = { ADMIN: 'bg-purple-500/20 text-purple-400 border-purple-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', - SERVICE: 'bg-orange-500/20 text-orange-400 border-orange-500/30', }; const ROLE_DESCRIPTIONS: Record = { 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', - SERVICE: 'Automation account — authenticates via API key, no password login', }; export default function AdminUsers() { @@ -227,7 +224,7 @@ export default function AdminUsers() { {u.email}
- {u.role === 'SERVICE' && ( + {u.role === 'AGENT' && (
diff --git a/client/src/types/index.ts b/client/src/types/index.ts index 616e515..9d2d9ef 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts @@ -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 interface User { diff --git a/server/package.json b/server/package.json index 7a13082..746fe62 100644 --- a/server/package.json +++ b/server/package.json @@ -5,8 +5,8 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "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", - "db:push": "prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma", + "start:prod": "prisma db execute --file prisma/pre-push.sql --schema prisma/schema.prisma && prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma && node dist/server/src/index.js", + "db:push": "prisma db execute --file prisma/pre-push.sql --schema prisma/schema.prisma && prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma", "db:generate": "prisma generate", "db:seed": "tsx prisma/seed.ts", "typecheck": "tsc --noEmit", diff --git a/server/prisma/pre-push.sql b/server/prisma/pre-push.sql new file mode 100644 index 0000000..998b754 --- /dev/null +++ b/server/prisma/pre-push.sql @@ -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 $$; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 656d922..ff96df8 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -11,7 +11,6 @@ enum Role { ADMIN AGENT USER - SERVICE } enum TicketStatus { diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts index c0e33d4..3c88399 100644 --- a/server/prisma/seed.ts +++ b/server/prisma/seed.ts @@ -1,6 +1,5 @@ import { PrismaClient } from '@prisma/client'; import bcrypt from 'bcryptjs'; -import crypto from 'crypto'; 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 const theWrightServer = await prisma.category.upsert({ where: { name: 'TheWrightServer' }, diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 3098f8c..a0d2e19 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -15,7 +15,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu if (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' }); } 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(); }; -// Blocks USER role — allows ADMIN, AGENT, SERVICE +// Blocks USER role — allows ADMIN and AGENT export const requireAgent = (req: AuthRequest, res: Response, next: NextFunction) => { if (req.user?.role === 'USER') { return res.status(403).json({ error: 'Insufficient permissions' }); diff --git a/server/src/services/authService.test.ts b/server/src/services/authService.test.ts index 339c6d3..09f6aaf 100644 --- a/server/src/services/authService.test.ts +++ b/server/src/services/authService.test.ts @@ -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), - }); - }); }); diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index c12513a..42d372d 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -10,10 +10,6 @@ export async function login({ username, password }: LoginInput) { 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( { id: user.id, role: user.role, username: user.username }, process.env.JWT_SECRET!, diff --git a/server/src/services/userService.test.ts b/server/src/services/userService.test.ts index 35a687a..a03878a 100644 --- a/server/src/services/userService.test.ts +++ b/server/src/services/userService.test.ts @@ -17,15 +17,15 @@ const stubUser = { }; describe('userService.createUser', () => { - it('hashes the password and omits apiKey for non-SERVICE roles', async () => { - prismaMock.user.create.mockResolvedValue(stubUser); + it('hashes the password and omits apiKey for ADMIN and USER roles', async () => { + prismaMock.user.create.mockResolvedValue({ ...stubUser, role: 'USER' }); await createUser({ username: 'bob', email: 'b@x.io', displayName: 'Bob', password: 'hunter2!', - role: 'AGENT', + role: 'USER', }); const call = prismaMock.user.create.mock.calls[0][0] as { data: Record }; @@ -34,14 +34,15 @@ describe('userService.createUser', () => { expect(call.data.apiKey).toBeUndefined(); }); - it('assigns an apiKey for SERVICE role', async () => { - prismaMock.user.create.mockResolvedValue({ ...stubUser, role: 'SERVICE' }); + it('assigns an apiKey for AGENT role', async () => { + prismaMock.user.create.mockResolvedValue(stubUser); await createUser({ - username: 'svc', - email: 's@x.io', - displayName: 'Svc', - role: 'SERVICE', + username: 'agent', + email: 'a@x.io', + displayName: 'Agent', + password: 'hunter2!', + role: 'AGENT', }); const call = prismaMock.user.create.mock.calls[0][0] as { data: Record }; diff --git a/server/src/services/userService.ts b/server/src/services/userService.ts index c1a20da..d60e7f3 100644 --- a/server/src/services/userService.ts +++ b/server/src/services/userService.ts @@ -41,12 +41,10 @@ export async function getCurrentUser(id: string) { } export async function createUser(data: CreateUserInput) { - const passwordHash = data.password - ? await bcrypt.hash(data.password, 12) - : await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12); + const passwordHash = await bcrypt.hash(data.password, 12); 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({ data: { @@ -68,8 +66,13 @@ export async function updateUser(id: string, data: UpdateUserInput) { if (data.password) update.passwordHash = await bcrypt.hash(data.password, 12); if (data.role) { update.role = data.role; - if (data.role === 'SERVICE' && !update.apiKey) { - update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`; + 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')}`; + } + } else { + update.apiKey = null; } } if (data.regenerateApiKey) { diff --git a/shared/schemas/enums.ts b/shared/schemas/enums.ts index 7a164d7..9cbafd6 100644 --- a/shared/schemas/enums.ts +++ b/shared/schemas/enums.ts @@ -1,6 +1,6 @@ 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 roleSchema = z.enum(ROLES); diff --git a/shared/schemas/user.ts b/shared/schemas/user.ts index a267f3d..86b4e3b 100644 --- a/shared/schemas/user.ts +++ b/shared/schemas/user.ts @@ -5,7 +5,7 @@ export const createUserSchema = z.object({ username: z.string().min(1).max(50), email: z.string().email(), displayName: z.string().min(1).max(100), - password: z.string().min(8).optional(), + password: z.string().min(8), role: roleSchema.default('AGENT'), });