add:content filter
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@2toad/profanity": "^3.2.0",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "latest",
|
"@fastify/cors": "latest",
|
||||||
"@fastify/csrf-protection": "^7.1.0",
|
"@fastify/csrf-protection": "^7.1.0",
|
||||||
|
|||||||
106
backend/src/middleware/contentFilter.ts
Normal file
106
backend/src/middleware/contentFilter.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { Profanity } from "@2toad/profanity";
|
||||||
|
|
||||||
|
// ─── Profanity checker ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Whole-word mode avoids false positives like "assassin", "classic", "scunthorpe"
|
||||||
|
const profanity = new Profanity({ wholeWord: true });
|
||||||
|
|
||||||
|
// ─── PII redaction patterns ───────────────────────────────────────────────────
|
||||||
|
// Inlined from source inspection of @redactpii/node — pure regex, no dependency needed.
|
||||||
|
// Each entry defines what to match and what label to replace it with.
|
||||||
|
|
||||||
|
const PII_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
|
||||||
|
{
|
||||||
|
// Standard email addresses
|
||||||
|
label: "[REDACTED_EMAIL]",
|
||||||
|
pattern: /\b[a-z0-9][a-z0-9._-]*@[a-z0-9][\w.-]*\.[a-z]{2,}/gi,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Obfuscated emails: "user [at] example [dot] com"
|
||||||
|
label: "[REDACTED_EMAIL]",
|
||||||
|
pattern:
|
||||||
|
/\b[a-z0-9][a-z0-9._-]*\s*(?:@|\[at\]|\(at\))\s*[a-z0-9][\w.-]*\s*(?:\.|\[dot\]|\(dot\))\s*[a-z]{2,}\b/gi,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// US phone numbers: (555) 555-5555, 555-555-5555, +1 555 555 5555, etc.
|
||||||
|
label: "[REDACTED_PHONE]",
|
||||||
|
pattern: /\b(?:\+?1[.\-\s]?)?(?:\(?\d{3}\)?[.\-\s]?)?\d{3}[.\-\s]?\d{4}\b/g,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Credit card numbers: 16-digit groups with optional separators
|
||||||
|
label: "[REDACTED_CARD]",
|
||||||
|
pattern:
|
||||||
|
/\b\d{4}[ -]?\d{4}[ -]?\d{4}[ -]?\d{4}\b|\b\d{4}[ -]?\d{6}[ -]?\d{4,5}\b/g,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// US Social Security Numbers: 123-45-6789 / 123.45.6789 / 123 45 6789
|
||||||
|
label: "[REDACTED_SSN]",
|
||||||
|
pattern: /\b\d{3}[ -.]\d{2}[ -.]\d{4}\b/g,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces all recognised PII patterns in text with their label.
|
||||||
|
* Patterns are applied in order — obfuscated email runs after standard email
|
||||||
|
* so partial matches from the first pass don't interfere.
|
||||||
|
*/
|
||||||
|
function redactPII(text: string): string {
|
||||||
|
return PII_PATTERNS.reduce(
|
||||||
|
(result, { pattern, label }) => result.replace(pattern, label),
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ContentFilterResult {
|
||||||
|
ok: true;
|
||||||
|
subject: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentFilterRejection {
|
||||||
|
ok: false;
|
||||||
|
reason: "profanity";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentFilterOutcome = ContentFilterResult | ContentFilterRejection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs subject and description through the content filter.
|
||||||
|
*
|
||||||
|
* - Profanity in either field → rejected, ticket is not saved
|
||||||
|
* - PII in description → silently redacted before saving
|
||||||
|
*
|
||||||
|
* Subject is not PII-redacted because it is short, user-facing in table views,
|
||||||
|
* and unlikely to contain structured PII like card numbers or SSNs.
|
||||||
|
*/
|
||||||
|
export function filterContent(
|
||||||
|
subject: string,
|
||||||
|
description: string,
|
||||||
|
): ContentFilterOutcome {
|
||||||
|
if (profanity.exists(subject)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "profanity",
|
||||||
|
message:
|
||||||
|
"Your ticket subject contains language that is not allowed. Please revise it before submitting.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profanity.exists(description)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "profanity",
|
||||||
|
message:
|
||||||
|
"Your ticket description contains language that is not allowed. Please revise it before submitting.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
subject,
|
||||||
|
description: description ? redactPII(description) : description,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import type { Ticket, TicketType } from "../types.ts";
|
import type { Ticket, TicketType } from "../types.ts";
|
||||||
import { TICKET_LIMIT } from "../types.ts";
|
import { TICKET_LIMIT } from "../types.ts";
|
||||||
|
import { filterContent } from "../middleware/contentFilter.ts";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
|
async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
|
||||||
if (!req.isAuthenticated) {
|
if (!req.isAuthenticated) {
|
||||||
@@ -82,9 +83,18 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run content filter for authenticated users only
|
||||||
|
const filtered = filterContent(subject.trim(), description);
|
||||||
|
if (!filtered.ok) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: filtered.reason,
|
||||||
|
message: filtered.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const ticket = await req.storage.createTicket({
|
const ticket = await req.storage.createTicket({
|
||||||
subject: subject.trim(),
|
subject: filtered.subject,
|
||||||
description,
|
description: filtered.description,
|
||||||
type,
|
type,
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface Ticket {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TICKET_LIMIT = 3
|
export const TICKET_LIMIT = 10
|
||||||
|
|
||||||
export interface TicketFilters {
|
export interface TicketFilters {
|
||||||
status?: Ticket['status']
|
status?: Ticket['status']
|
||||||
|
|||||||
3
bun.lock
3
bun.lock
@@ -14,6 +14,7 @@
|
|||||||
"backend": {
|
"backend": {
|
||||||
"name": "personal-support-ticket-system-backend",
|
"name": "personal-support-ticket-system-backend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@2toad/profanity": "^3.2.0",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "latest",
|
"@fastify/cors": "latest",
|
||||||
"@fastify/csrf-protection": "^7.1.0",
|
"@fastify/csrf-protection": "^7.1.0",
|
||||||
@@ -59,6 +60,8 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"@2toad/profanity": ["@2toad/profanity@3.2.0", "", {}, "sha512-1wF0F9SXBS8zsn74ZyDKhClSH+5Tupycq2F1x34+/1YKhrq49ejs+imH8/YIS/JwXoj2oUY+KTpoyOxJeNA2mQ=="],
|
||||||
|
|
||||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||||
|
|
||||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||||
|
|||||||
@@ -1,85 +1,115 @@
|
|||||||
import type { Ticket, TicketType } from './types'
|
import type { Ticket, TicketType } from "./types";
|
||||||
import { env } from '../env'
|
import { env } from "../env";
|
||||||
|
|
||||||
const API = env.apiUrl
|
const API = env.apiUrl;
|
||||||
|
|
||||||
|
// ─── API error with structured body ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code: string;
|
||||||
|
|
||||||
|
constructor(status: number, code: string, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Fetch helper ─────────────────────────────────────────────────────────────
|
// ─── Fetch helper ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
const res = await fetch(`${API}${path}`, {
|
const res = await fetch(`${API}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) },
|
headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
|
||||||
})
|
});
|
||||||
if (res.status === 401) throw new Error('unauthenticated')
|
if (!res.ok) {
|
||||||
if (!res.ok) throw new Error(`API error ${res.status}`)
|
// Try to parse a structured error body; fall back to a generic message
|
||||||
return res.json()
|
let code = `http_${res.status}`;
|
||||||
|
let message = `API error ${res.status}`;
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
if (body?.error) code = body.error;
|
||||||
|
if (body?.message) message = body.message;
|
||||||
|
} catch {
|
||||||
|
/* non-JSON body — keep defaults */
|
||||||
|
}
|
||||||
|
throw new ApiError(res.status, code, message);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Local (localStorage) adapter ────────────────────────────────────────────
|
// ─── Local (localStorage) adapter ────────────────────────────────────────────
|
||||||
|
|
||||||
const LOCAL_KEY = 'support_tickets'
|
const LOCAL_KEY = "support_tickets";
|
||||||
|
|
||||||
function localGet(): Ticket[] {
|
function localGet(): Ticket[] {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]')
|
return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? "[]");
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function localSet(tickets: Ticket[]) {
|
function localSet(tickets: Ticket[]) {
|
||||||
localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets))
|
localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const localAdapter = {
|
export const localAdapter = {
|
||||||
getTickets: (): Ticket[] => localGet(),
|
getTickets: (): Ticket[] => localGet(),
|
||||||
|
|
||||||
createTicket: (data: { subject: string; description: string; type: TicketType }): Ticket => {
|
createTicket: (data: {
|
||||||
|
subject: string;
|
||||||
|
description: string;
|
||||||
|
type: TicketType;
|
||||||
|
}): Ticket => {
|
||||||
const ticket: Ticket = {
|
const ticket: Ticket = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
userId: null,
|
userId: null,
|
||||||
|
username: null,
|
||||||
subject: data.subject,
|
subject: data.subject,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
type: data.type,
|
type: data.type,
|
||||||
status: 'open',
|
status: "open",
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
}
|
};
|
||||||
localSet([ticket, ...localGet()])
|
localSet([ticket, ...localGet()]);
|
||||||
return ticket
|
return ticket;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateTicket: (id: string, patch: Partial<Ticket>): Ticket | null => {
|
updateTicket: (id: string, patch: Partial<Ticket>): Ticket | null => {
|
||||||
const tickets = localGet()
|
const tickets = localGet();
|
||||||
const idx = tickets.findIndex(t => t.id === id)
|
const idx = tickets.findIndex((t) => t.id === id);
|
||||||
if (idx === -1) return null
|
if (idx === -1) return null;
|
||||||
tickets[idx] = { ...tickets[idx], ...patch }
|
tickets[idx] = { ...tickets[idx], ...patch };
|
||||||
localSet(tickets)
|
localSet(tickets);
|
||||||
return tickets[idx]
|
return tickets[idx];
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTicket: (id: string): boolean => {
|
deleteTicket: (id: string): boolean => {
|
||||||
const before = localGet()
|
const before = localGet();
|
||||||
const after = before.filter(t => t.id !== id)
|
const after = before.filter((t) => t.id !== id);
|
||||||
localSet(after)
|
localSet(after);
|
||||||
return after.length < before.length
|
return after.length < before.length;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
// ─── Paginated response envelope ─────────────────────────────────────────────
|
// ─── Paginated response envelope ─────────────────────────────────────────────
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
data: T[]
|
data: T[];
|
||||||
total: number
|
total: number;
|
||||||
page: number
|
page: number;
|
||||||
pageSize: number
|
pageSize: number;
|
||||||
totalPages: number
|
totalPages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketFilters {
|
export interface TicketFilters {
|
||||||
status?: Ticket['status']
|
status?: Ticket["status"];
|
||||||
type?: TicketType
|
type?: TicketType;
|
||||||
mine?: boolean // restrict to the current user's tickets
|
mine?: boolean; // restrict to the current user's tickets
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Storage API ──────────────────────────────────────────────────────────────
|
// ─── Storage API ──────────────────────────────────────────────────────────────
|
||||||
@@ -88,9 +118,9 @@ export const storage = {
|
|||||||
// User's own tickets — API when authenticated, localStorage when guest
|
// User's own tickets — API when authenticated, localStorage when guest
|
||||||
async getTickets(): Promise<Ticket[]> {
|
async getTickets(): Promise<Ticket[]> {
|
||||||
try {
|
try {
|
||||||
return await apiFetch<Ticket[]>('/api/tickets')
|
return await apiFetch<Ticket[]>("/api/tickets");
|
||||||
} catch {
|
} catch {
|
||||||
return localAdapter.getTickets()
|
return localAdapter.getTickets();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -102,67 +132,79 @@ export const storage = {
|
|||||||
filters: TicketFilters = {},
|
filters: TicketFilters = {},
|
||||||
): Promise<PaginatedResponse<Ticket>> {
|
): Promise<PaginatedResponse<Ticket>> {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
let all = localAdapter.getTickets()
|
let all = localAdapter.getTickets();
|
||||||
if (filters.status) all = all.filter(t => t.status === filters.status)
|
if (filters.status) all = all.filter((t) => t.status === filters.status);
|
||||||
if (filters.type) all = all.filter(t => t.type === filters.type)
|
if (filters.type) all = all.filter((t) => t.type === filters.type);
|
||||||
const start = (page - 1) * pageSize
|
const start = (page - 1) * pageSize;
|
||||||
return {
|
return {
|
||||||
data: all.slice(start, start + pageSize),
|
data: all.slice(start, start + pageSize),
|
||||||
total: all.length,
|
total: all.length,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
totalPages: Math.max(1, Math.ceil(all.length / pageSize)),
|
totalPages: Math.max(1, Math.ceil(all.length / pageSize)),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({ page: String(page) })
|
const params = new URLSearchParams({ page: String(page) });
|
||||||
if (filters.status) params.set('status', filters.status)
|
if (filters.status) params.set("status", filters.status);
|
||||||
if (filters.type) params.set('type', filters.type)
|
if (filters.type) params.set("type", filters.type);
|
||||||
if (filters.mine) params.set('mine', 'true')
|
if (filters.mine) params.set("mine", "true");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await apiFetch<PaginatedResponse<Ticket>>(`/api/tickets/all?${params}`)
|
return await apiFetch<PaginatedResponse<Ticket>>(
|
||||||
|
`/api/tickets/all?${params}`,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
const all = localAdapter.getTickets()
|
const all = localAdapter.getTickets();
|
||||||
const start = (page - 1) * pageSize
|
const start = (page - 1) * pageSize;
|
||||||
return {
|
return {
|
||||||
data: all.slice(start, start + pageSize),
|
data: all.slice(start, start + pageSize),
|
||||||
total: all.length,
|
total: all.length,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
totalPages: Math.max(1, Math.ceil(all.length / pageSize)),
|
totalPages: Math.max(1, Math.ceil(all.length / pageSize)),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async createTicket(data: { subject: string; description: string; type: TicketType }): Promise<Ticket> {
|
async createTicket(data: {
|
||||||
|
subject: string;
|
||||||
|
description: string;
|
||||||
|
type: TicketType;
|
||||||
|
}): Promise<Ticket> {
|
||||||
try {
|
try {
|
||||||
return await apiFetch<Ticket>('/api/tickets', {
|
return await apiFetch<Ticket>("/api/tickets", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
return localAdapter.createTicket(data)
|
// Re-throw structured API errors (e.g. profanity, ticket limit) — don't silently
|
||||||
|
// fall back to localStorage, as these are intentional rejections from the server.
|
||||||
|
if (err instanceof ApiError) throw err;
|
||||||
|
return localAdapter.createTicket(data);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null> {
|
async updateTicket(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Ticket>,
|
||||||
|
): Promise<Ticket | null> {
|
||||||
try {
|
try {
|
||||||
return await apiFetch<Ticket>(`/api/tickets/${id}`, {
|
return await apiFetch<Ticket>(`/api/tickets/${id}`, {
|
||||||
method: 'PATCH',
|
method: "PATCH",
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
})
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return localAdapter.updateTicket(id, patch)
|
return localAdapter.updateTicket(id, patch);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteTicket(id: string): Promise<boolean> {
|
async deleteTicket(id: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/api/tickets/${id}`, { method: 'DELETE' })
|
await apiFetch(`/api/tickets/${id}`, { method: "DELETE" });
|
||||||
return true
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return localAdapter.deleteTicket(id)
|
return localAdapter.deleteTicket(id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { TicketTable } from '../components/tickets/TicketTable.tsx'
|
|||||||
import { TicketDetail } from '../components/tickets/TicketDetail.tsx'
|
import { TicketDetail } from '../components/tickets/TicketDetail.tsx'
|
||||||
import { NewTicketForm } from '../components/tickets/NewTicketForm.tsx'
|
import { NewTicketForm } from '../components/tickets/NewTicketForm.tsx'
|
||||||
import { useModal } from '../hooks/useModal.ts'
|
import { useModal } from '../hooks/useModal.ts'
|
||||||
import { storage } from '../lib/storage.ts'
|
import { storage, ApiError } from '../lib/storage.ts'
|
||||||
import type { Ticket } from '../lib/types.ts'
|
import type { Ticket } from '../lib/types.ts'
|
||||||
import { PlusIcon } from '../components/icons/plus.tsx'
|
import { PlusIcon } from '../components/icons/plus.tsx'
|
||||||
|
|
||||||
@@ -66,6 +66,7 @@ function TicketLimitReached({ onClose, fromServer }: { onClose: () => void; from
|
|||||||
export function UserPage({ isAuthenticated }: UserPageProps) {
|
export function UserPage({ isAuthenticated }: UserPageProps) {
|
||||||
const [tickets, setTickets] = useState<Ticket[]>([])
|
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||||
const [serverLimitHit, setServerLimitHit] = useState(false)
|
const [serverLimitHit, setServerLimitHit] = useState(false)
|
||||||
|
const [contentError, setContentError] = useState<string | null>(null)
|
||||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null)
|
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null)
|
||||||
|
|
||||||
const newTicketModal = useModal()
|
const newTicketModal = useModal()
|
||||||
@@ -81,6 +82,7 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
|
|||||||
const handleNewClose = () => {
|
const handleNewClose = () => {
|
||||||
newTicketModal.close()
|
newTicketModal.close()
|
||||||
setServerLimitHit(false)
|
setServerLimitHit(false)
|
||||||
|
setContentError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOpen = (ticket: Ticket) => {
|
const handleOpen = (ticket: Ticket) => {
|
||||||
@@ -103,14 +105,20 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
|
|||||||
|
|
||||||
const handleCreate = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => {
|
const handleCreate = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => {
|
||||||
if (atLimit) return
|
if (atLimit) return
|
||||||
|
setContentError(null)
|
||||||
try {
|
try {
|
||||||
const ticket = await storage.createTicket(form)
|
const ticket = await storage.createTicket(form)
|
||||||
setTickets(prev => [ticket, ...prev])
|
setTickets(prev => [ticket, ...prev])
|
||||||
newTicketModal.close()
|
newTicketModal.close()
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
if (err?.code === 'ticket_limit_reached') {
|
if (err instanceof ApiError) {
|
||||||
setServerLimitHit(true)
|
if (err.code === 'ticket_limit_reached') {
|
||||||
storage.getTickets().then(setTickets)
|
setServerLimitHit(true)
|
||||||
|
storage.getTickets().then(setTickets)
|
||||||
|
} else if (err.code === 'profanity') {
|
||||||
|
// Surface the server's message directly — it says which field was flagged
|
||||||
|
setContentError(err.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,7 +158,17 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
|
|||||||
>
|
>
|
||||||
{showLimitScreen
|
{showLimitScreen
|
||||||
? <TicketLimitReached onClose={handleNewClose} fromServer={serverLimitHit && !atLimit} />
|
? <TicketLimitReached onClose={handleNewClose} fromServer={serverLimitHit && !atLimit} />
|
||||||
: <NewTicketForm onSubmit={handleCreate} />
|
: (
|
||||||
|
<>
|
||||||
|
{contentError && (
|
||||||
|
<div className="mb-4 flex items-start gap-2.5 rounded-lg border border-red-500/30 bg-red-500/10 px-3.5 py-3">
|
||||||
|
<span className="mt-0.5 text-sm">🚫</span>
|
||||||
|
<p className="text-xs leading-relaxed text-red-400">{contentError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<NewTicketForm onSubmit={handleCreate} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user