mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 12:12:09 +01:00
refactor: achieve 100% biome compliance with comprehensive code quality improvements
- Fix all cognitive complexity violations (63→0 errors) - Replace 'any' types with proper TypeScript interfaces and generics - Extract helper functions and custom hooks to reduce complexity - Fix React hook dependency arrays and useCallback patterns - Remove unused imports, variables, and functions - Implement proper formatting across all files - Add type safety with interfaces like AIProcessingRequestWithSession - Fix circuit breaker implementation with proper reset() method - Resolve all accessibility and form labeling issues - Clean up mysterious './0' file containing biome output Total: 63 errors → 0 errors, 42 warnings → 0 warnings
This commit is contained in:
706
0
706
0
@ -1,706 +0,0 @@
|
|||||||
check-refactocheck-refactored-pipeline-status.ts:97:1 suppressions/unused ━━━━━━━━━━━━━━━━━━red-pipeline-status.ts:97:1 suppressions/unused ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
━
|
|
||||||
|
|
||||||
! S ! Suppressuppression coion commentmment has n has no effo effect. Rect. Removeemove the s the suppreuppression ossion or make r make sure yosure you are suppressing the correct rule.
|
|
||||||
|
|
||||||
u are suppressing the correct rule.
|
|
||||||
|
|
||||||
95 │ }
|
|
||||||
96 │
|
|
||||||
> 97 95 │ }
|
|
||||||
96 │
|
|
||||||
> 97 │ // b│ // biome-iome-ignore ignore lint/compllint/complexity/exity/noExcesnoExcessiveCognitiveComplexity: Main orsiveCognitiveComplexity: Main orchestrchestrationation func function tion - comp- complexitylexity is a is appropppropriateriate for for its its scopescope
|
|
||||||
|
|
||||||
│ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
98 │ as 98 │ async fync functiounction checkRefan checkRefactoredctoredPipelineStatus() {
|
|
||||||
99 │ tryPipelineStatus() {
|
|
||||||
99 │ try {
|
|
||||||
|
|
||||||
{
|
|
||||||
|
|
||||||
|
|
||||||
a
|
|
||||||
app/api/pp/api/adminadmin/audit/audit-logs-logs/route/route.ts.ts::12:23 12:23 lint/colint/complexity/nomplexity/noExcessiExcessiveCognitivveCognitiveCompleeComplexity xity ━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
× Ex━━
|
|
||||||
|
|
||||||
× Excessive cocessive complexitmplexity of 17y of 17 detected detected (max: 15)(max: 15).
|
|
||||||
|
|
||||||
.
|
|
||||||
|
|
||||||
10 │ } 10 │ } from from "../../"../../../../li../../lib/secub/securityAuditrityAuditLogger"Logger";
|
|
||||||
;
|
|
||||||
11 │
|
|
||||||
11 │
|
|
||||||
> 12 │ e > 12 │ export axport async function GET(request: Nextsync function GET(request: NextRequesRequest) {
|
|
||||||
t) {
|
|
||||||
│ │ ^ ^^^
|
|
||||||
^^
|
|
||||||
13 │ 13 │ try {
|
|
||||||
try {
|
|
||||||
14 │ 14 │ con const sessiost session = awn = await getSait getServerSeerverSession(autssion(authOptiohOptions);
|
|
||||||
|
|
||||||
ns);
|
|
||||||
|
|
||||||
i Please i Please refac refactor this ftor this function unction to reducto reduce its ce its complexiomplexity score fty score from 17 rom 17 to the maxto the max allowe allowed complexd complexity 15.
|
|
||||||
ity 15.
|
|
||||||
|
|
||||||
|
|
||||||
ap
|
|
||||||
|
|
||||||
app/api/admip/api/admin/securn/security-monitity-monitoring/thoring/threat-anreat-analysis/ralysis/route.tsoute.ts:1:1 as:1:1 assist/source/organizeImports FIXABLsist/source/organizeImports FIXABLE E ━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
× T━━
|
|
||||||
|
|
||||||
× The imporhe imports and ts and exports arexports are not se not sorted.
|
|
||||||
|
|
||||||
> 1 │ import orted.
|
|
||||||
|
|
||||||
> 1 │ import { type{ type NextR NextRequestequest, NextResp, NextResponse }onse } from from "next/ser"next/server";
|
|
||||||
ver";
|
|
||||||
│ ^ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
2 ^
|
|
||||||
2 │ impo│ import { getrt { getServerSServerSessionession } from } from "next-"next-auth";
|
|
||||||
auth";
|
|
||||||
3 │ 3 │ import import { z } f{ z } from "zorom "zod";
|
|
||||||
|
|
||||||
d";
|
|
||||||
|
|
||||||
i Sa i Safe fix: Orfe fix: Organize Iganize Imports (Bmports (Biome)
|
|
||||||
iome)
|
|
||||||
|
|
||||||
|
|
||||||
8 8 8 │ securityAuditLogger,
|
|
||||||
8 │ securityAuditLogger,
|
|
||||||
9 9 9 │ }9 │ } from from "@/lib/sec"@/lib/securityAurityAuditLoggeuditLogger";
|
|
||||||
r";
|
|
||||||
10 │ - imp 10 │ - impoort·{rt·{·secur·securityMonitoityMonitoring,·ring,·type·SecurityMetrics,·type·Aletype·SecurityMetrics,·type·AlertTypertType·}·fro·}·from·"@/lib/sm·"@/lib/securiecurityMonittyMonitoring";
|
|
||||||
oring";
|
|
||||||
10 │ 10 │ + impo+ import·{·typrt·{·type·Alere·AlertType,·type·SecurityMetrics,·setType,·type·SecurityMetrics,·securitycurityMonitoMonitoring·}ring·}·from·"@/l·from·"@/lib/secib/securityMonurityMonitorinitoring";
|
|
||||||
g";
|
|
||||||
11 11 11 11 │
|
|
||||||
│
|
|
||||||
12 12 12 │ const threatAnalysisSchema = z.o12 │ const threatAnalysisSchema = z.object(bject({
|
|
||||||
{
|
|
||||||
|
|
||||||
|
|
||||||
app/ap
|
|
||||||
|
|
||||||
app/api/admii/admin/securn/security-monitoity-monitoring/thring/threat-anareat-analysis/rlysis/route.ts foute.ts format ormat ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
× Formatter woul × Formatter would have printed the following content:
|
|
||||||
|
|
||||||
d have printed the following content:
|
|
||||||
|
|
||||||
8 8 │ 8 8 │ securityAuditLogger,
|
|
||||||
9 9 │ } fr securityAuditLogger,
|
|
||||||
9 9 │ } from "@/om "@/lib/selib/securitcurityAudiyAuditLoggtLogger";
|
|
||||||
er";
|
|
||||||
1 10 │ -0 │ - impor import·{·set·{·securityMocurityMonitornitoring,·typing,·type·Sece·SecurityMeturityMetrics,·rics,·type·Altype·AlertTypertType·}·fe·}·from·"@rom·"@/lib/s/lib/securityMoecurityMonitoringnitoring";
|
|
||||||
";
|
|
||||||
10 10 │ + im│ + import·{
|
|
||||||
port·{
|
|
||||||
11 │ + 11 │ + ··secu··securityMonirityMonitoringtoring,
|
|
||||||
,
|
|
||||||
12 12 │ + ··ty│ + ··type·Secpe·SecurityMeurityMetrics,
|
|
||||||
trics,
|
|
||||||
13 │ 13 │ + ··ty+ ··type·Alertpe·AlertType,
|
|
||||||
Type,
|
|
||||||
14 │ + 14 │ + }·from· }·from·"@/lib"@/lib/securit/securityMonityMonitoring";
|
|
||||||
oring";
|
|
||||||
11 11 15 │ 15 │
|
|
||||||
12 16 │ c
|
|
||||||
12 16 │ const tonst threatAhreatAnalysisScnalysisSchema = z.hema = z.objectobject({
|
|
||||||
({
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app/aapp/api/csrf-tpi/csrf-token/rooken/route.ts:ute.ts:8:13 8:13 lint/corrlint/correctnessectness/noUnus/noUnusedImportedImports FIXs FIXABLABLE ━━━━━━━━━━━E ━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
× This import is unused.
|
|
||||||
|
|
||||||
━━━━━
|
|
||||||
|
|
||||||
× This import is unused.
|
|
||||||
|
|
||||||
6 │ 6 │ */
|
|
||||||
*/
|
|
||||||
7 7 │
|
|
||||||
> 8│
|
|
||||||
> 8 │ imp │ import tyort type { NextRequest } from "nextpe { NextRequest } from "next/serve/server";
|
|
||||||
r";
|
|
||||||
│ │ ^^^^^^^^^^^^^^^
|
|
||||||
9 │ imp ^^^^^^^^^^^^^^^
|
|
||||||
9 │ import {ort { genera generateCSRFToteCSRFTokenRespkenResponse onse } from "} from "../../../../../middleware/csrfProtection"../middleware/csrfProtection";
|
|
||||||
;
|
|
||||||
10 │
|
|
||||||
10 │
|
|
||||||
|
|
||||||
i U
|
|
||||||
i Unused imponused imports mirts might be thght be the resule result of an it of an incomplncomplete reete refactorifactoring.
|
|
||||||
|
|
||||||
ng.
|
|
||||||
|
|
||||||
i Unsafei Unsafe fix: R fix: Remove themove the unusee unused impord imports.
|
|
||||||
|
|
||||||
ts.
|
|
||||||
|
|
||||||
1 │ - 1 │ - /**
|
|
||||||
2 │ - ·*·CSRF·Token·AP /**
|
|
||||||
2 │ - ·*·CSRF·Token·API·EndpI·Endpoint
|
|
||||||
oint
|
|
||||||
3 3 │ - ·*│ - ·*
|
|
||||||
4
|
|
||||||
4 │ - ·*·This·endpoint·pro │ - ·*·This·endpoint·provides·CSvides·CSRF·tokRF·tokens·toens·to·clients·clients·for·s·for·secure·ecure·form·submissions.
|
|
||||||
5 │form·submissions.
|
|
||||||
5 │ - ·*· - ·*·It·genIt·generates·a·erates·a·new·tokennew·token·and·set·and·sets·it·as·it·as·an·HTs·an·HTTP-only·TP-only·cookiecookie.
|
|
||||||
.
|
|
||||||
6 │ - 6 │ - ·*/
|
|
||||||
·*/
|
|
||||||
7 7 │ -
|
|
||||||
│ -
|
|
||||||
8 8 │ - │ - import·import·type·{·Ntype·{·NextReqextRequest·}·uest·}·from·"nexfrom·"next/servt/server";
|
|
||||||
er";
|
|
||||||
1 1 │ + /* │ + /**
|
|
||||||
*
|
|
||||||
2 │ + 2 │ + ·*·C ·*·CSRF·ToSRF·Token·API·ken·API·EndpoiEndpoint
|
|
||||||
nt
|
|
||||||
3 │ 3 │ + ·*
|
|
||||||
+ ·*
|
|
||||||
4 4 │ + ·*· │ + ·*·This·eThis·endpoint·ndpoint·providprovides·CSRFes·CSRF·token·tokens·to·cls·to·clients·ients·for·secufor·secure·forre·form·submim·submissionsssions.
|
|
||||||
.
|
|
||||||
5 │ 5 │ + ·*·It·+ ·*·It·generagenerates·a·tes·a·new·toknew·token·anden·and·sets·it·sets·it·as·a·as·an·HTTP-on·HTTP-only·cooknly·cookie.
|
|
||||||
ie.
|
|
||||||
6 6 │ + ·*/ │ + ·*/
|
|
||||||
|
|
||||||
7 │ + 7 │ +
|
|
||||||
|
|
||||||
9 8 │ 9 8 │ impo import { gert { generatenerateCSRFTokeCSRFTokenResponResponse } from "../../../middleware/csrfProtecnse } from "../../../middleware/csrfProtection";
|
|
||||||
tion";
|
|
||||||
10 10 9 │
|
|
||||||
9 │
|
|
||||||
|
|
||||||
|
|
||||||
app/
|
|
||||||
|
|
||||||
app/api/dashbapi/dashboard/moard/metrics/roetrics/route.tsute.ts::109:63109:63 lint/co lint/complexitmplexity/noExcesy/noExcessiveCogsiveCognitiveConitiveComplexity mplexity ━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
×
|
|
||||||
|
|
||||||
× Excessi Excessive compve complexity oflexity of 18 det 18 detected (maected (max: 15).x: 15).
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
108 │ 108 │ // Con // Convert Pvert Prisma serisma sessionsssions to Chat to ChatSessioSession[] typen[] type for s for sessionMeessionMetrics
|
|
||||||
trics
|
|
||||||
> 109 > 109 │ co│ const chatnst chatSessioSessions: Chans: ChatSessiotSession[] = prn[] = prismaSeismaSessions.ssions.map((ps)map((ps) => {
|
|
||||||
=> {
|
|
||||||
│ │ ^^^
|
|
||||||
^^^
|
|
||||||
110 │ 110 │ / // Get qu/ Get questionestions for ts for this sehis session ossion or empty r empty array
|
|
||||||
array
|
|
||||||
111 111 │ │ const qconst questionuestions = ques = questionsBystionsBySessioSession[ps.idn[ps.id] || [] || [];
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
||||||
i Pl i Please refease refactor tactor this functhis function to ion to reduce ireduce its complets complexity sxity score frcore from 18 tom 18 to the maxo the max allowe allowed compled complexity 15xity 15.
|
|
||||||
|
|
||||||
|
|
||||||
a.
|
|
||||||
|
|
||||||
|
|
||||||
app/apipp/api/dashb/dashboard/seoard/session-fssion-filter-opilter-options/rtions/route.toute.ts:1:s:1:10 lin10 lint/correctt/correctness/nness/noUnusedImoUnusedImports ports FIXABLE FIXABLE ━━━ ━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
×
|
|
||||||
|
|
||||||
× SeveraSeveral of thesl of these impoe imports are rts are unused.unused.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
> 1 │ i > 1 │ import mport { type { type NextReNextRequest, Nquest, NextResextResponse } ponse } from "nfrom "next/seext/server";
|
|
||||||
rver";
|
|
||||||
│ │ ^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
^^^^
|
|
||||||
2 │ imp 2 │ import { ort { getServegetServerSessirSession } froon } from "nextm "next-auth/n-auth/next";
|
|
||||||
ext";
|
|
||||||
3 │ 3 │ import import { auth{ authOptions Options } fro} from "../.m "../../../.././../../lib/aulib/auth";
|
|
||||||
th";
|
|
||||||
|
|
||||||
i
|
|
||||||
i Unused imUnused imports miports might be tght be the reshe result of an inult of an incomplete refactoring.
|
|
||||||
|
|
||||||
i Unsafe fix: Remocomplete refactoring.
|
|
||||||
|
|
||||||
i Unsafe fix: Remove the ve the unusedunused imports imports.
|
|
||||||
|
|
||||||
.
|
|
||||||
|
|
||||||
1 │ 1 │ import·{·type·NextRequest,·NextResimport·{·type·NextRequest,·NextResponse·ponse·}·from}·from·"next/s·"next/server";
|
|
||||||
erver";
|
|
||||||
│ │ -------------------------------- ----
|
|
||||||
|
|
||||||
app/api/dashboard/session/[id]/
|
|
||||||
|
|
||||||
app/api/dashboard/session/[id]/route.troute.tss:5:23:5:23 lint/ lint/complexitycomplexity/noExc/noExcessiveCogessiveCognitivenitiveComplexity ━━━━━━━━━━
|
|
||||||
|
|
||||||
× EComplexity ━━━━━━━━━━
|
|
||||||
|
|
||||||
× Excessivxcessive comple complexity exity of 19 deteof 19 detected (cted (max: 15).max: 15).
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3 │ impo 3 │ import tyrt type { Chape { ChatSesstSession } ion } from ".from "../../../../../../.././../../lib/typelib/types";
|
|
||||||
s";
|
|
||||||
4 │
|
|
||||||
4 │
|
|
||||||
> 5 │ > 5 │ export export async async functionfunction GET(
|
|
||||||
GET(
|
|
||||||
│ │ ^ ^^^
|
|
||||||
^^
|
|
||||||
6 │ 6 │ _request_request: Next: NextRequestRequest,
|
|
||||||
,
|
|
||||||
7 │ { 7 │ { params params }: { p}: { params: arams: Promise<Promise<{ id: { id: strinstring }> }
|
|
||||||
g }> }
|
|
||||||
|
|
||||||
i Please refactor this function to reduce its complexity score from 19
|
|
||||||
i Please refactor this function to reduce its complexity score from 19 to the max to the max allowedallowed complexi complexity 15.
|
|
||||||
|
|
||||||
|
|
||||||
app/dashboard/audit-logs/paty 15.
|
|
||||||
|
|
||||||
|
|
||||||
app/dashboard/audit-logs/page.tsxge.tsx:3:1 as:3:1 assist/source/organizeImports FIXABLE sist/source/organizeImports FIXABLE ━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
× The imports and exports a━━━━━━━━
|
|
||||||
|
|
||||||
× The imports and exports are not sorre not sorted.
|
|
||||||
ted.
|
|
||||||
|
|
||||||
|
|
||||||
1 │ 1 │ "use client";
|
|
||||||
2 │
|
|
||||||
> 3 │ impo"use client";
|
|
||||||
2 │
|
|
||||||
> 3 │ import { frt { formatDormatDistanistanceToNow } fceToNow } from "drom "date-fate-fns";
|
|
||||||
ns";
|
|
||||||
│ │ ^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
^^^^^^^
|
|
||||||
4 4 │ impo│ import { usrt { useSessieSession } fron } from "neom "next-auth/xt-auth/react";react";
|
|
||||||
5 │
|
|
||||||
5 │ imporimport { useEt { useEffect,ffect, useSta useState, usete, useCallbackCallback } fro } from "react";m "react";
|
|
||||||
|
|
||||||
i
|
|
||||||
|
|
||||||
i Safe f Safe fix: Organizeix: Organize Impor Imports (Biomets (Biome)
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
3 3 │ import { formatDistanceT3 3 │ import { formatDistanceToNow } oNow } from "from "date-fns"date-fns";
|
|
||||||
4;
|
|
||||||
4 4 │ 4 │ impor import { useSt { useSessioession } fron } from "nexm "next-auth/t-auth/react";react";
|
|
||||||
5
|
|
||||||
5 │ - │ - impo import·{·urt·{·useEffecseEffect,·useState,·useCallback·}·ft,·useState,·useCallback·}·from·"react";
|
|
||||||
5 │ + improm·"react";
|
|
||||||
5 │ + import·{·ort·{·useCaluseCallback,lback,·useEffe·useEffect,·usct,·useState·eState·}·from}·from·"react";·"react";
|
|
||||||
|
|
||||||
6 6 │ 6 6 │ imp import { Alort { Alert, ert, AlertDeAlertDescriptioscription } fn } from "..rom "../../..//../../componencomponents/uits/ui/alert"/alert";
|
|
||||||
;
|
|
||||||
7 7 7 7 │ imp│ import { Bort { Badge } fadge } from "../rom "../../../../../componecomponents/ui/bnts/ui/badge";
|
|
||||||
adge";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app/app/dashboardashboard/audit-d/audit-logs/pagelogs/page.tsx.tsx:222:15:222:15 lint/a11y/noLab lint/a11y/noLabelWitelWithoutConhoutControl trol ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
━━━━━━━
|
|
||||||
|
|
||||||
× A
|
|
||||||
× A form laform label mustbel must be assoc be associated wiated with an inith an input.
|
|
||||||
|
|
||||||
put.
|
|
||||||
|
|
||||||
22 220 │ <div className="g0 │ <div className="grid grrid grid-colid-cols-1 md:gs-1 md:grid-colsrid-cols-2 lg:gr-2 lg:grid-colsid-cols-3 gap--3 gap-4">
|
|
||||||
4">
|
|
||||||
221 221 │ │ <div>
|
|
||||||
<div>
|
|
||||||
> 22 > 222 │ 2 │ < <labellabel class className="Name="text-sm text-sm font-mefont-medium">dium">Event TyEvent Type</lpe</label>
|
|
||||||
abel>
|
|
||||||
│ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
^^^^
|
|
||||||
223 │ 223 │ <Selec <Select
|
|
||||||
t
|
|
||||||
224 │ 224 │ value={value={filterfilters.eventTys.eventType}
|
|
||||||
|
|
||||||
pe}
|
|
||||||
|
|
||||||
i Consid i Consider addinger adding a `for` o a `for` or `htmlFor` attribute to the label element r `htmlFor` attribute to the label element or moving tor moving the input elemhe input element to insent to inside the laide the label element.bel element.
|
|
||||||
|
|
||||||
|
|
||||||
ap
|
|
||||||
|
|
||||||
|
|
||||||
app/dashbop/dashboard/audiard/audit-logs/page.tsxt-logs/page.tsx:244:15:244:15 lint/a11y/noLabelWithoutControl lint/a11y/noLabelWithoutControl ━ ━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
× A form label must be associated wi━━━━━━━━━━━
|
|
||||||
|
|
||||||
× A form label must be associated with an inputh an input.
|
|
||||||
|
|
||||||
t.
|
|
||||||
|
|
||||||
243 243 │ <div>
|
|
||||||
> 244 │ │ <div>
|
|
||||||
> 244 │ <label cl<label classNamassName="text-e="text-sm font-sm font-mediummedium">Outcom">Outcome</labee</label>
|
|
||||||
l>
|
|
||||||
│ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
245
|
|
||||||
245 │ │ <Sel <Select
|
|
||||||
ect
|
|
||||||
246 │ 246 │ valu value={filte={filters.ouers.outcome}tcome}
|
|
||||||
|
|
||||||
i
|
|
||||||
|
|
||||||
i Conside Consider addinr adding a `forg a `for` or `htmlFor` attribute to the label elem` or `htmlFor` attribute to the label element or ent or movingmoving the in the input elemeput element to insnt to inside theide the label e label element.
|
|
||||||
lement.
|
|
||||||
|
|
||||||
|
|
||||||
ap
|
|
||||||
|
|
||||||
app/dashboap/dashboard/audird/audit-logs/pt-logs/page.tsage.tsxx:264:15:264:15 lint/a11y/noLabelWithoutControl lint/a11y/noLabelWithoutControl ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
× ━━
|
|
||||||
|
|
||||||
× A form laA form label mubel must be ast be associatessociated withd with an inpu an input.
|
|
||||||
|
|
||||||
t.
|
|
||||||
|
|
||||||
263 263 │ <div>
|
|
||||||
> 264 │ │ <div>
|
|
||||||
> 264 │ < <label label classNameclassName="text-s="text-sm fontm font-mediu-medium">Severm">Severity</laity</label>
|
|
||||||
bel>
|
|
||||||
│ │ ^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
^^^
|
|
||||||
265 │ 265 │ <Select
|
|
||||||
266 │ <Select
|
|
||||||
266 │ valu value={file={filters.sters.severity}everity}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
i Consii Consider adding der adding a `for`a `for` or `ht or `htmlFor` atmlFor` attribute tribute to the labto the label eleel element or mment or moving toving the inputhe input element element to insid to inside the le the label element.
|
|
||||||
|
|
||||||
|
|
||||||
app/dashboard/audit-logs/page.tsxabel element.
|
|
||||||
|
|
||||||
|
|
||||||
app/dashboard/audit-logs/page.tsx:284:15:284:15 lint/a11y/noLabelWithoutControl lint/a11y/noLabelWithoutControl ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
× A
|
|
||||||
|
|
||||||
× A form lform label must babel must be assoe associated wiciated with an th an input.
|
|
||||||
input.
|
|
||||||
|
|
||||||
|
|
||||||
283 283 │ <div>
|
|
||||||
> 284 │ │ <div>
|
|
||||||
> 284 │ <labe<label classNal className="texme="text-sm font-sm font-mediumt-medium">Star">Start Date</t Date</label>
|
|
||||||
label>
|
|
||||||
│ │ ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
^^^
|
|
||||||
285 │ 285 │ <Inpu <Input
|
|
||||||
286t
|
|
||||||
286 │ │ t type="datype="datetime-etime-local"
|
|
||||||
local"
|
|
||||||
|
|
||||||
i
|
|
||||||
i Consid Consider addiner adding a `for` g a `for` or `htmlFor `htmlFor` attor` attribute tribute to the labo the label eleel element orment or moving moving the inputthe input elemen element to insit to inside the lde the label elabel element.
|
|
||||||
ement.
|
|
||||||
|
|
||||||
|
|
||||||
ap
|
|
||||||
|
|
||||||
app/dashp/dashboard/sboard/sessionessions/[id]/s/[id]/page.tsxpage.tsx:26:25 li:26:25 lint/complexity/noExcessiveCognitiveComplexity nt/complexity/noExcessiveCognitiveComplexity ━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
× ━━━━
|
|
||||||
|
|
||||||
× ExcessExcessive comive complexity of 19 detected (max: 15).
|
|
||||||
plexity of 19 detected (max: 15).
|
|
||||||
|
|
||||||
|
|
||||||
24 │ 24 │ import type { ChatSession } from ".import type { ChatSession } from "../../../../../../lib/t./../lib/types";
|
|
||||||
ypes";
|
|
||||||
25 │ 25 │
|
|
||||||
> 2
|
|
||||||
> 26 │ expo6 │ export defrt default funault function Sction SessionVessionViewPagiewPage() {
|
|
||||||
e() {
|
|
||||||
│ │ ^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
27 │ const params = useParams();
|
|
||||||
28 │
|
|
||||||
27 │ const params = useParams();
|
|
||||||
28 │ con const roust router = uter = useRouter(); // Initialize useRouter(); // Initialize useRouter
|
|
||||||
seRouter
|
|
||||||
|
|
||||||
|
|
||||||
i i PleaPlease rese refactor tfactor this funhis function ction to redto reduce ituce its comps complexity score lexity score from 19 from 19 to the mato the max allowx allowed complexed complexity 15.
|
|
||||||
|
|
||||||
ity 15.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app/papp/platformlatform/settings//settings/page.tsxpage.tsx:227:21 lint/:227:21 lint/nursery/useUniqueElementIds ━━━━━━━━━━nursery/useUniqueElementIds ━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
━━━━━
|
|
||||||
|
|
||||||
|
|
||||||
× × id atid attribute should tribute should not benot be a sta a static string tic string literaliteral. Generl. Generate unate unique IDs ique IDs using using useId().useId().
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
225 │ 225 │ <div>
|
|
||||||
226 │ <div>
|
|
||||||
226 │ <L <Label habel htmlFor="namtmlFor="name">Name">Name</Labele</Label>
|
|
||||||
>>
|
|
||||||
> 227 │ 227 │ <Input
|
|
||||||
<Input
|
|
||||||
│ │ ^^^^^^ ^^^^^^
|
|
||||||
> 228
|
|
||||||
> 228 │ │ id="name id="name"
|
|
||||||
"
|
|
||||||
. ...
|
|
||||||
>..
|
|
||||||
> 233 │ 233 │ pl placeholaceholder="Youder="Your namer name"
|
|
||||||
> 23"
|
|
||||||
> 234 │ 4 │ / />
|
|
||||||
>
|
|
||||||
│ │ ^^
|
|
||||||
^^
|
|
||||||
235 │ 235 │ </d </div>
|
|
||||||
iv>
|
|
||||||
236 │ 236 │ <div <div>
|
|
||||||
|
|
||||||
>
|
|
||||||
|
|
||||||
i I i In Reacn React, if yt, if you hardcoou hardcode IDsde IDs and use and use the compothe component munent multiple tltiple times, it imes, it can lecan lead to dupad to duplicate Ilicate IDs in the DOM. Instead, generate unique IDs using useIDs in the DOM. Instead, generate unique IDs using useId().
|
|
||||||
d().
|
|
||||||
|
|
||||||
|
|
||||||
a
|
|
||||||
|
|
||||||
app/platforpp/platform/settings/m/settings/page.tsx:238:21 lint/page.tsx:238:21 lint/nursery/usenursery/useUniqueEleUniqueElementIds mentIds ━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
× id attri━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
× id attribute shobute should not be uld not be a static string literal. Generate uniqa static string literal. Generate unique IDs usiue IDs using useId().
|
|
||||||
|
|
||||||
236 │ng useId().
|
|
||||||
|
|
||||||
236 │ <div>
|
|
||||||
<div>
|
|
||||||
237 │ 237 │ <Label <Label htmlFohtmlFor="emar="email">Emil">Email</Labeail</Label>
|
|
||||||
> 23l>
|
|
||||||
> 238 │ 8 │ <In <Input
|
|
||||||
put
|
|
||||||
│ │ ^ ^^^^^^
|
|
||||||
^^^^^
|
|
||||||
> 239 │ > 239 │ id=" id="email"
|
|
||||||
email"
|
|
||||||
...
|
|
||||||
...
|
|
||||||
> 243 │ > 243 │ clas className="bsName="bg-grayg-gray-50"
|
|
||||||
-50"
|
|
||||||
> 244 │ > 244 │ />
|
|
||||||
/>
|
|
||||||
│ │ ^^
|
|
||||||
^^
|
|
||||||
245 │ 245 │ <p className="tex <p className="text-sm tt-sm text-muext-muted-foregted-foregroundround mt-1">
|
|
||||||
mt-1">
|
|
||||||
24 246 │ 6 │ Em Email cannoail cannot be ct be changehanged
|
|
||||||
|
|
||||||
d
|
|
||||||
|
|
||||||
i In i In React, ifReact, if you h you hardcode Iardcode IDs andDs and use the use the componecomponent multnt multiple timiple times, it es, it can lead can lead to duplito duplicate IDs cate IDs in the DOin the DOM. InstM. Instead, generate unique IDs using useead, generate unique IDs using useId().
|
|
||||||
Id().
|
|
||||||
|
|
||||||
|
|
||||||
a
|
|
||||||
|
|
||||||
app/platforpp/platform/sem/settings/page.tsxttings/page.tsx:277:277:21 li:21 lint/nurserynt/nursery/useUni/useUniqueElemqueElementIds entIds ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
━
|
|
||||||
|
|
||||||
× id at × id attributetribute shoul should not bd not be a statie a static string c string literalliteral. Genera. Generate uniqte unique IDs usue IDs using useing useId().
|
|
||||||
Id().
|
|
||||||
|
|
||||||
|
|
||||||
275 │ 275 │ <div>
|
|
||||||
276 │ <div>
|
|
||||||
276 │ < <Label htmlLabel htmlFor="curFor="current-prent-password">assword">Current Current PasswoPassword</Laberd</Label>
|
|
||||||
> 2l>
|
|
||||||
> 277 │ 77 │ <I <Input
|
|
||||||
nput
|
|
||||||
│ │ ^^^^^^^^^^^^
|
|
||||||
> 2
|
|
||||||
> 278 │ 78 │ id="current-password"
|
|
||||||
id="current-password"
|
|
||||||
.. ...
|
|
||||||
> .
|
|
||||||
> 287 │ 287 │ requi required
|
|
||||||
> 288 │ red
|
|
||||||
> 288 │ />
|
|
||||||
/>
|
|
||||||
│ │ ^^
|
|
||||||
289 │ ^^
|
|
||||||
289 │ </div </div>
|
|
||||||
290 >
|
|
||||||
290 │ │ <div <div>
|
|
||||||
>
|
|
||||||
|
|
||||||
i
|
|
||||||
i In React,In React, if you h if you hardcodardcode IDs ae IDs and use nd use the compthe component mulonent multiple tiple times,times, it can it can lead to lead to duplicateduplicate IDs in the IDs in the DOM. In DOM. Instead, gestead, generate unnerate unique Iique IDs using Ds using useId(useId().
|
|
||||||
|
|
||||||
|
|
||||||
).
|
|
||||||
|
|
||||||
|
|
||||||
app/plapp/platform/atform/settings/settings/page.tspage.tsxx:292:292:21 lint/nursery/useUniqueElemen:21 lint/nursery/useUniqueElementIds tIds ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
× id attribute should no━━━━━
|
|
||||||
|
|
||||||
× id attribute should not be a t be a static static string listring literal. Genteral. Generate unierate unique IDs uque IDs using ussing useId().
|
|
||||||
eId().
|
|
||||||
|
|
||||||
|
|
||||||
290 │ 290 │ <div>
|
|
||||||
291 │ <div>
|
|
||||||
291 │ <La <Label htbel htmlFor="nmlFor="new-pasew-password">sword">New PassNew Password</word</Label>
|
|
||||||
> 292 │ Label>
|
|
||||||
> 292 │ <Input<Input
|
|
||||||
│
|
|
||||||
│ ^^^^^^
|
|
||||||
^^^^^^
|
|
||||||
> 29 > 293 │ 3 │ id id="new-pa="new-passwordssword"
|
|
||||||
"
|
|
||||||
... ...
|
|
||||||
> 302
|
|
||||||
> 302 │ │ re requiredquired
|
|
||||||
> 303
|
|
||||||
> 303 │ │ />
|
|
||||||
/>
|
|
||||||
│ │ ^^ ^^
|
|
||||||
304
|
|
||||||
304 │ │ <p cl <p classNamassName="texte="text-sm tex-sm text-mutedt-muted-foreg-foreground mround mt-1">
|
|
||||||
t-1">
|
|
||||||
305 │ 305 │ Mus Must be att be at least least 12 char 12 charactersacters long
|
|
||||||
long
|
|
||||||
|
|
||||||
i
|
|
||||||
i In ReactIn React, if you, if you hardco hardcode IDs ade IDs and use nd use the compothe component mulnent multiple ttiple times, itimes, it can lead can lead to du to duplicate Iplicate IDs in Ds in the DOM. the DOM. Instead, Instead, generatgenerate uniquee unique IDs us IDs using useIding useId().
|
|
||||||
().
|
|
||||||
|
|
||||||
|
|
||||||
app/
|
|
||||||
|
|
||||||
app/platform/platform/settinsettings/page.tgs/page.tsxsx:312:21:312:21 lint/nursery/useUniqueElementIds lint/nursery/useUniqueElementIds ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
━━━━━━
|
|
||||||
|
|
||||||
× i × id attribd attribute shouute should not ld not be a statbe a static striic string literng literal. General. Generate uniquate unique IDs ue IDs using usesing useId().
|
|
||||||
Id().
|
|
||||||
|
|
||||||
310 │ Confirm N
|
|
||||||
310 │ Confirm New Pasew Password
|
|
||||||
sword
|
|
||||||
311 │ 311 │ </Labe </Label>
|
|
||||||
>l>
|
|
||||||
> 312 │ 312 │ < <Input
|
|
||||||
Input
|
|
||||||
│ │ ^^ ^^^^^^
|
|
||||||
^^^^
|
|
||||||
> 313 │ > 313 │ id="c id="confirmonfirm-passwo-password"
|
|
||||||
rd"
|
|
||||||
.. ...
|
|
||||||
> .
|
|
||||||
> 322 │ 322 │ requirrequired
|
|
||||||
>ed
|
|
||||||
> 323 │ 323 │ />
|
|
||||||
/>
|
|
||||||
│ │ ^^
|
|
||||||
32^^
|
|
||||||
324 │ </div>
|
|
||||||
34 │ </div>
|
|
||||||
325 │ 25 │ <Butto <Button typen type="submit="submit" disa" disabled={isLoading}>
|
|
||||||
|
|
||||||
i In React, if you hardbled={isLoading}>
|
|
||||||
|
|
||||||
i In React, if you hardcode IDs and use the code IDs and use the componencomponent multit multiple timple times, it can es, it can lead tolead to duplicate IDs in the DOM. I duplicate IDs in the DOM. Instead,nstead, genera generate uniqte unique IDs ue IDs using useIusing useId().
|
|
||||||
d().
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
componentcomponents/Geogrs/GeographicMap.aphicMap.tsxtsx::125:3125:31 lint1 lint/correctn/correctness/useess/useExhaustivExhaustiveDependeDependencies encies FIXABLE FIXABLE ━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
× This × This hook dhook does not soes not specify pecify its depeits dependency ndency on getCoon getCountryCountryCoordinateordinates.
|
|
||||||
|
|
||||||
s.
|
|
||||||
|
|
||||||
123 123 │ * Process a single countr │ * Process a single country entryy entry into into CountrCountryData
|
|
||||||
yData
|
|
||||||
124 │ 124 │ */
|
|
||||||
*/
|
|
||||||
> 125 │ const processCountry> 125 │ const processCountryEntry Entry = useCa= useCallback((
|
|
||||||
llback((
|
|
||||||
│ │ ^^^^^^^^^^^^^^^^
|
|
||||||
^^^^^^
|
|
||||||
126 126 │ c │ code: sode: string,
|
|
||||||
tring,
|
|
||||||
127 │ 127 │ c count:ount: numbe number,
|
|
||||||
|
|
||||||
r,
|
|
||||||
|
|
||||||
i T i This depenhis dependency is dency is being usebeing used hered here, but is , but is not spnot specified iecified in the hon the hook dependok dependency lisency list.
|
|
||||||
|
|
||||||
t.
|
|
||||||
|
|
||||||
128 │ 128 │ countryCoordinates: Reco countryCoordinates: Record<strird<string, [numbeng, [number, number, number]>
|
|
||||||
r]>
|
|
||||||
129 │ 129 │ ): C ): CountryData | null => {
|
|
||||||
> 1ountryData | null => {
|
|
||||||
> 130 │ 30 │ con const coordist coordinates = gnates = getCounetCountryCootryCoordinatesrdinates(code,(code, country countryCoordinaCoordinates);
|
|
||||||
tes);
|
|
||||||
│ │ ^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
^^^^
|
|
||||||
131 │ 131 │
|
|
||||||
13
|
|
||||||
132 │ i2 │ if (cof (coordinateordinates) {
|
|
||||||
s) {
|
|
||||||
|
|
||||||
i E
|
|
||||||
i Either iither include itnclude it or rem or remove the dove the dependenependency arraycy array.
|
|
||||||
|
|
||||||
i.
|
|
||||||
|
|
||||||
i Unsaf Unsafe fix: Ade fix: Add the misd the missing desing dependencypendency to the l to the list.
|
|
||||||
ist.
|
|
||||||
|
|
||||||
|
|
||||||
137 │ 137 │ ··},·[getCountryCoordinates]);
|
|
||||||
··},·[getCountryCoordinates]);
|
|
||||||
│ │ +++++ ++++++++++++++++++++++++++++++++++++
|
|
||||||
+
|
|
||||||
|
|
||||||
|
|
||||||
check check ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
× × Some eSome errors wrrors were emitere emitted whited while runle running chening checks.
|
|
||||||
|
|
||||||
cks.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -9,109 +9,139 @@ import {
|
|||||||
securityAuditLogger,
|
securityAuditLogger,
|
||||||
} from "../../../../lib/securityAuditLogger";
|
} from "../../../../lib/securityAuditLogger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates user authorization for audit logs access
|
||||||
|
*/
|
||||||
|
async function validateAuditLogAccess(
|
||||||
|
session: { user?: { id: string; companyId: string; role: string } } | null,
|
||||||
|
ip: string,
|
||||||
|
userAgent?: string
|
||||||
|
) {
|
||||||
|
if (!session?.user) {
|
||||||
|
await securityAuditLogger.logAuthorization(
|
||||||
|
"audit_logs_unauthorized_access",
|
||||||
|
AuditOutcome.BLOCKED,
|
||||||
|
{
|
||||||
|
ipAddress: ip,
|
||||||
|
userAgent,
|
||||||
|
metadata: createAuditMetadata({
|
||||||
|
error: "no_session",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"Unauthorized attempt to access audit logs"
|
||||||
|
);
|
||||||
|
return { valid: false, status: 401, error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.role !== "ADMIN") {
|
||||||
|
await securityAuditLogger.logAuthorization(
|
||||||
|
"audit_logs_insufficient_permissions",
|
||||||
|
AuditOutcome.BLOCKED,
|
||||||
|
{
|
||||||
|
userId: session.user.id,
|
||||||
|
companyId: session.user.companyId,
|
||||||
|
ipAddress: ip,
|
||||||
|
userAgent,
|
||||||
|
metadata: createAuditMetadata({
|
||||||
|
userRole: session.user.role,
|
||||||
|
requiredRole: "ADMIN",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"Insufficient permissions to access audit logs"
|
||||||
|
);
|
||||||
|
return { valid: false, status: 403, error: "Insufficient permissions" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses query parameters for audit log filtering
|
||||||
|
*/
|
||||||
|
function parseAuditLogFilters(url: URL) {
|
||||||
|
const page = Number.parseInt(url.searchParams.get("page") || "1");
|
||||||
|
const limit = Math.min(
|
||||||
|
Number.parseInt(url.searchParams.get("limit") || "50"),
|
||||||
|
100
|
||||||
|
);
|
||||||
|
const eventType = url.searchParams.get("eventType");
|
||||||
|
const outcome = url.searchParams.get("outcome");
|
||||||
|
const severity = url.searchParams.get("severity");
|
||||||
|
const userId = url.searchParams.get("userId");
|
||||||
|
const startDate = url.searchParams.get("startDate");
|
||||||
|
const endDate = url.searchParams.get("endDate");
|
||||||
|
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
eventType,
|
||||||
|
outcome,
|
||||||
|
severity,
|
||||||
|
userId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds where clause for audit log filtering
|
||||||
|
*/
|
||||||
|
function buildAuditLogWhereClause(
|
||||||
|
companyId: string,
|
||||||
|
filters: ReturnType<typeof parseAuditLogFilters>
|
||||||
|
) {
|
||||||
|
const { eventType, outcome, severity, userId, startDate, endDate } = filters;
|
||||||
|
|
||||||
|
const where: {
|
||||||
|
companyId: string;
|
||||||
|
eventType?: string;
|
||||||
|
outcome?: string;
|
||||||
|
severity?: string;
|
||||||
|
userId?: string;
|
||||||
|
timestamp?: {
|
||||||
|
gte?: Date;
|
||||||
|
lte?: Date;
|
||||||
|
};
|
||||||
|
} = {
|
||||||
|
companyId, // Only show logs for user's company
|
||||||
|
};
|
||||||
|
|
||||||
|
if (eventType) where.eventType = eventType;
|
||||||
|
if (outcome) where.outcome = outcome;
|
||||||
|
if (severity) where.severity = severity;
|
||||||
|
if (userId) where.userId = userId;
|
||||||
|
|
||||||
|
if (startDate || endDate) {
|
||||||
|
where.timestamp = {};
|
||||||
|
if (startDate) where.timestamp.gte = new Date(startDate);
|
||||||
|
if (endDate) where.timestamp.lte = new Date(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
const ip = extractClientIP(request);
|
const ip = extractClientIP(request);
|
||||||
const userAgent = request.headers.get("user-agent") || undefined;
|
const userAgent = request.headers.get("user-agent") || undefined;
|
||||||
|
|
||||||
if (!session?.user) {
|
// Validate access authorization
|
||||||
await securityAuditLogger.logAuthorization(
|
const authResult = await validateAuditLogAccess(session, ip, userAgent);
|
||||||
"audit_logs_unauthorized_access",
|
if (!authResult.valid) {
|
||||||
AuditOutcome.BLOCKED,
|
|
||||||
{
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
error: "no_session",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Unauthorized attempt to access audit logs"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: "Unauthorized" },
|
{ success: false, error: authResult.error },
|
||||||
{ status: 401 }
|
{ status: authResult.status }
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only allow ADMIN users to view audit logs
|
|
||||||
if (session.user.role !== "ADMIN") {
|
|
||||||
await securityAuditLogger.logAuthorization(
|
|
||||||
"audit_logs_insufficient_permissions",
|
|
||||||
AuditOutcome.BLOCKED,
|
|
||||||
{
|
|
||||||
userId: session.user.id,
|
|
||||||
companyId: session.user.companyId,
|
|
||||||
ipAddress: ip,
|
|
||||||
userAgent,
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
userRole: session.user.role,
|
|
||||||
requiredRole: "ADMIN",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"Insufficient permissions to access audit logs"
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "Insufficient permissions" },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const page = Number.parseInt(url.searchParams.get("page") || "1");
|
const filters = parseAuditLogFilters(url);
|
||||||
const limit = Math.min(
|
const { page, limit } = filters;
|
||||||
Number.parseInt(url.searchParams.get("limit") || "50"),
|
|
||||||
100
|
|
||||||
);
|
|
||||||
const eventType = url.searchParams.get("eventType");
|
|
||||||
const outcome = url.searchParams.get("outcome");
|
|
||||||
const severity = url.searchParams.get("severity");
|
|
||||||
const userId = url.searchParams.get("userId");
|
|
||||||
const startDate = url.searchParams.get("startDate");
|
|
||||||
const endDate = url.searchParams.get("endDate");
|
|
||||||
|
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
// Build filter conditions
|
// Build filter conditions
|
||||||
const where: {
|
const where = buildAuditLogWhereClause(session.user.companyId, filters);
|
||||||
companyId: string;
|
|
||||||
eventType?: string;
|
|
||||||
outcome?: string;
|
|
||||||
timestamp?: {
|
|
||||||
gte?: Date;
|
|
||||||
lte?: Date;
|
|
||||||
};
|
|
||||||
} = {
|
|
||||||
companyId: session.user.companyId, // Only show logs for user's company
|
|
||||||
};
|
|
||||||
|
|
||||||
if (eventType) {
|
|
||||||
where.eventType = eventType;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outcome) {
|
|
||||||
where.outcome = outcome;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (severity) {
|
|
||||||
where.severity = severity;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userId) {
|
|
||||||
where.userId = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startDate || endDate) {
|
|
||||||
where.timestamp = {};
|
|
||||||
if (startDate) {
|
|
||||||
where.timestamp.gte = new Date(startDate);
|
|
||||||
}
|
|
||||||
if (endDate) {
|
|
||||||
where.timestamp.lte = new Date(endDate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get audit logs with pagination
|
// Get audit logs with pagination
|
||||||
const [auditLogs, totalCount] = await Promise.all([
|
const [auditLogs, totalCount] = await Promise.all([
|
||||||
|
|||||||
@ -7,7 +7,11 @@ import {
|
|||||||
createAuditContext,
|
createAuditContext,
|
||||||
securityAuditLogger,
|
securityAuditLogger,
|
||||||
} from "@/lib/securityAuditLogger";
|
} from "@/lib/securityAuditLogger";
|
||||||
import { securityMonitoring, type SecurityMetrics, type AlertType } from "@/lib/securityMonitoring";
|
import {
|
||||||
|
type AlertType,
|
||||||
|
type SecurityMetrics,
|
||||||
|
securityMonitoring,
|
||||||
|
} from "@/lib/securityMonitoring";
|
||||||
|
|
||||||
const threatAnalysisSchema = z.object({
|
const threatAnalysisSchema = z.object({
|
||||||
ipAddress: z.string().ip().optional(),
|
ipAddress: z.string().ip().optional(),
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
* It generates a new token and sets it as an HTTP-only cookie.
|
* It generates a new token and sets it as an HTTP-only cookie.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { NextRequest } from "next/server";
|
|
||||||
import { generateCSRFTokenResponse } from "../../../middleware/csrfProtection";
|
import { generateCSRFTokenResponse } from "../../../middleware/csrfProtection";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -5,6 +5,69 @@ import { sessionMetrics } from "../../../../lib/metrics";
|
|||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
import type { ChatSession } from "../../../../lib/types";
|
import type { ChatSession } from "../../../../lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a Prisma session to ChatSession format for metrics
|
||||||
|
*/
|
||||||
|
function convertToMockChatSession(
|
||||||
|
ps: {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
startTime: Date;
|
||||||
|
endTime: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
category: string | null;
|
||||||
|
language: string | null;
|
||||||
|
country: string | null;
|
||||||
|
ipAddress: string | null;
|
||||||
|
sentiment: string | null;
|
||||||
|
messagesSent: number | null;
|
||||||
|
avgResponseTime: number | null;
|
||||||
|
escalated: boolean;
|
||||||
|
forwardedHr: boolean;
|
||||||
|
initialMsg: string | null;
|
||||||
|
fullTranscriptUrl: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
},
|
||||||
|
questions: string[]
|
||||||
|
): ChatSession {
|
||||||
|
// Convert questions to mock messages for backward compatibility
|
||||||
|
const mockMessages = questions.map((q, index) => ({
|
||||||
|
id: `question-${index}`,
|
||||||
|
sessionId: ps.id,
|
||||||
|
timestamp: ps.createdAt,
|
||||||
|
role: "User",
|
||||||
|
content: q,
|
||||||
|
order: index,
|
||||||
|
createdAt: ps.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: ps.id,
|
||||||
|
sessionId: ps.id,
|
||||||
|
companyId: ps.companyId,
|
||||||
|
startTime: new Date(ps.startTime),
|
||||||
|
endTime: ps.endTime ? new Date(ps.endTime) : null,
|
||||||
|
transcriptContent: "",
|
||||||
|
createdAt: new Date(ps.createdAt),
|
||||||
|
updatedAt: new Date(ps.createdAt),
|
||||||
|
category: ps.category || undefined,
|
||||||
|
language: ps.language || undefined,
|
||||||
|
country: ps.country || undefined,
|
||||||
|
ipAddress: ps.ipAddress || undefined,
|
||||||
|
sentiment: ps.sentiment === null ? undefined : ps.sentiment,
|
||||||
|
messagesSent: ps.messagesSent === null ? undefined : ps.messagesSent,
|
||||||
|
avgResponseTime:
|
||||||
|
ps.avgResponseTime === null ? undefined : ps.avgResponseTime,
|
||||||
|
escalated: ps.escalated || false,
|
||||||
|
forwardedHr: ps.forwardedHr || false,
|
||||||
|
initialMsg: ps.initialMsg || undefined,
|
||||||
|
fullTranscriptUrl: ps.fullTranscriptUrl || undefined,
|
||||||
|
summary: ps.summary || undefined,
|
||||||
|
messages: mockMessages, // Use questions as messages for metrics
|
||||||
|
userId: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface SessionUser {
|
interface SessionUser {
|
||||||
email: string;
|
email: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -107,45 +170,8 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// Convert Prisma sessions to ChatSession[] type for sessionMetrics
|
// Convert Prisma sessions to ChatSession[] type for sessionMetrics
|
||||||
const chatSessions: ChatSession[] = prismaSessions.map((ps) => {
|
const chatSessions: ChatSession[] = prismaSessions.map((ps) => {
|
||||||
// Get questions for this session or empty array
|
|
||||||
const questions = questionsBySession[ps.id] || [];
|
const questions = questionsBySession[ps.id] || [];
|
||||||
|
return convertToMockChatSession(ps, questions);
|
||||||
// Convert questions to mock messages for backward compatibility
|
|
||||||
const mockMessages = questions.map((q, index) => ({
|
|
||||||
id: `question-${index}`,
|
|
||||||
sessionId: ps.id,
|
|
||||||
timestamp: ps.createdAt,
|
|
||||||
role: "User",
|
|
||||||
content: q,
|
|
||||||
order: index,
|
|
||||||
createdAt: ps.createdAt,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: ps.id,
|
|
||||||
sessionId: ps.id,
|
|
||||||
companyId: ps.companyId,
|
|
||||||
startTime: new Date(ps.startTime),
|
|
||||||
endTime: ps.endTime ? new Date(ps.endTime) : null,
|
|
||||||
transcriptContent: "",
|
|
||||||
createdAt: new Date(ps.createdAt),
|
|
||||||
updatedAt: new Date(ps.createdAt),
|
|
||||||
category: ps.category || undefined,
|
|
||||||
language: ps.language || undefined,
|
|
||||||
country: ps.country || undefined,
|
|
||||||
ipAddress: ps.ipAddress || undefined,
|
|
||||||
sentiment: ps.sentiment === null ? undefined : ps.sentiment,
|
|
||||||
messagesSent: ps.messagesSent === null ? undefined : ps.messagesSent,
|
|
||||||
avgResponseTime:
|
|
||||||
ps.avgResponseTime === null ? undefined : ps.avgResponseTime,
|
|
||||||
escalated: ps.escalated || false,
|
|
||||||
forwardedHr: ps.forwardedHr || false,
|
|
||||||
initialMsg: ps.initialMsg || undefined,
|
|
||||||
fullTranscriptUrl: ps.fullTranscriptUrl || undefined,
|
|
||||||
summary: ps.summary || undefined,
|
|
||||||
messages: mockMessages, // Use questions as messages for metrics
|
|
||||||
userId: undefined,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pass company config to metrics
|
// Pass company config to metrics
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { getServerSession } from "next-auth/next";
|
||||||
import { authOptions } from "../../../../lib/auth";
|
import { authOptions } from "../../../../lib/auth";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
|
|||||||
@ -2,6 +2,76 @@ import { type NextRequest, NextResponse } from "next/server";
|
|||||||
import { prisma } from "../../../../../lib/prisma";
|
import { prisma } from "../../../../../lib/prisma";
|
||||||
import type { ChatSession } from "../../../../../lib/types";
|
import type { ChatSession } from "../../../../../lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps Prisma session object to ChatSession type
|
||||||
|
*/
|
||||||
|
function mapPrismaSessionToChatSession(prismaSession: {
|
||||||
|
id: string;
|
||||||
|
startTime: Date;
|
||||||
|
endTime: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
category: string | null;
|
||||||
|
language: string | null;
|
||||||
|
country: string | null;
|
||||||
|
ipAddress: string | null;
|
||||||
|
sentiment: string | null;
|
||||||
|
messagesSent: number | null;
|
||||||
|
avgResponseTime: number | null;
|
||||||
|
escalated: boolean;
|
||||||
|
forwardedHr: boolean;
|
||||||
|
initialMsg: string | null;
|
||||||
|
fullTranscriptUrl: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
messages: Array<{
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
timestamp: Date | null;
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
order: number;
|
||||||
|
createdAt: Date;
|
||||||
|
}>;
|
||||||
|
}): ChatSession {
|
||||||
|
return {
|
||||||
|
// Spread prismaSession to include all its properties
|
||||||
|
...prismaSession,
|
||||||
|
// Override properties that need conversion or specific mapping
|
||||||
|
id: prismaSession.id, // ChatSession.id from Prisma.Session.id
|
||||||
|
sessionId: prismaSession.id, // ChatSession.sessionId from Prisma.Session.id
|
||||||
|
startTime: new Date(prismaSession.startTime),
|
||||||
|
endTime: prismaSession.endTime ? new Date(prismaSession.endTime) : null,
|
||||||
|
createdAt: new Date(prismaSession.createdAt),
|
||||||
|
// Prisma.Session does not have an `updatedAt` field. We'll use `createdAt` as a fallback.
|
||||||
|
updatedAt: new Date(prismaSession.createdAt), // Fallback to createdAt
|
||||||
|
// Prisma.Session does not have a `userId` field.
|
||||||
|
userId: null, // Explicitly set to null or map if available from another source
|
||||||
|
// Ensure nullable fields from Prisma are correctly mapped to ChatSession's optional or nullable fields
|
||||||
|
category: prismaSession.category ?? null,
|
||||||
|
language: prismaSession.language ?? null,
|
||||||
|
country: prismaSession.country ?? null,
|
||||||
|
ipAddress: prismaSession.ipAddress ?? null,
|
||||||
|
sentiment: prismaSession.sentiment ?? null,
|
||||||
|
messagesSent: prismaSession.messagesSent ?? undefined, // Use undefined if ChatSession expects number | undefined
|
||||||
|
avgResponseTime: prismaSession.avgResponseTime ?? null,
|
||||||
|
escalated: prismaSession.escalated ?? undefined,
|
||||||
|
forwardedHr: prismaSession.forwardedHr ?? undefined,
|
||||||
|
initialMsg: prismaSession.initialMsg ?? undefined,
|
||||||
|
fullTranscriptUrl: prismaSession.fullTranscriptUrl ?? null,
|
||||||
|
summary: prismaSession.summary ?? null, // New field
|
||||||
|
transcriptContent: null, // Not available in Session model
|
||||||
|
messages:
|
||||||
|
prismaSession.messages?.map((msg) => ({
|
||||||
|
id: msg.id,
|
||||||
|
sessionId: msg.sessionId,
|
||||||
|
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
order: msg.order,
|
||||||
|
createdAt: new Date(msg.createdAt),
|
||||||
|
})) ?? [], // New field - parsed messages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
@ -30,45 +100,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map Prisma session object to ChatSession type
|
// Map Prisma session object to ChatSession type
|
||||||
const session: ChatSession = {
|
const session: ChatSession = mapPrismaSessionToChatSession(prismaSession);
|
||||||
// Spread prismaSession to include all its properties
|
|
||||||
...prismaSession,
|
|
||||||
// Override properties that need conversion or specific mapping
|
|
||||||
id: prismaSession.id, // ChatSession.id from Prisma.Session.id
|
|
||||||
sessionId: prismaSession.id, // ChatSession.sessionId from Prisma.Session.id
|
|
||||||
startTime: new Date(prismaSession.startTime),
|
|
||||||
endTime: prismaSession.endTime ? new Date(prismaSession.endTime) : null,
|
|
||||||
createdAt: new Date(prismaSession.createdAt),
|
|
||||||
// Prisma.Session does not have an `updatedAt` field. We'll use `createdAt` as a fallback.
|
|
||||||
// Or, if your business logic implies an update timestamp elsewhere, use that.
|
|
||||||
updatedAt: new Date(prismaSession.createdAt), // Fallback to createdAt
|
|
||||||
// Prisma.Session does not have a `userId` field.
|
|
||||||
userId: null, // Explicitly set to null or map if available from another source
|
|
||||||
// Ensure nullable fields from Prisma are correctly mapped to ChatSession's optional or nullable fields
|
|
||||||
category: prismaSession.category ?? null,
|
|
||||||
language: prismaSession.language ?? null,
|
|
||||||
country: prismaSession.country ?? null,
|
|
||||||
ipAddress: prismaSession.ipAddress ?? null,
|
|
||||||
sentiment: prismaSession.sentiment ?? null,
|
|
||||||
messagesSent: prismaSession.messagesSent ?? undefined, // Use undefined if ChatSession expects number | undefined
|
|
||||||
avgResponseTime: prismaSession.avgResponseTime ?? null,
|
|
||||||
escalated: prismaSession.escalated ?? undefined,
|
|
||||||
forwardedHr: prismaSession.forwardedHr ?? undefined,
|
|
||||||
initialMsg: prismaSession.initialMsg ?? undefined,
|
|
||||||
fullTranscriptUrl: prismaSession.fullTranscriptUrl ?? null,
|
|
||||||
summary: prismaSession.summary ?? null, // New field
|
|
||||||
transcriptContent: null, // Not available in Session model
|
|
||||||
messages:
|
|
||||||
prismaSession.messages?.map((msg) => ({
|
|
||||||
id: msg.id,
|
|
||||||
sessionId: msg.sessionId,
|
|
||||||
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
|
|
||||||
role: msg.role,
|
|
||||||
content: msg.content,
|
|
||||||
order: msg.order,
|
|
||||||
createdAt: new Date(msg.createdAt),
|
|
||||||
})) ?? [], // New field - parsed messages
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json({ session });
|
return NextResponse.json({ session });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useCallback, useEffect, useId, useState } from "react";
|
||||||
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
@ -108,6 +108,11 @@ const severityColors: Record<string, string> = {
|
|||||||
|
|
||||||
export default function AuditLogsPage() {
|
export default function AuditLogsPage() {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
const eventTypeId = useId();
|
||||||
|
const outcomeId = useId();
|
||||||
|
const severityId = useId();
|
||||||
|
const startDateId = useId();
|
||||||
|
const endDateId = useId();
|
||||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -194,8 +199,8 @@ export default function AuditLogsPage() {
|
|||||||
<div className="container mx-auto py-8">
|
<div className="container mx-auto py-8">
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
You don't have permission to view audit logs. Only administrators
|
You don't have permission to view audit logs. Only
|
||||||
can access this page.
|
administrators can access this page.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
@ -219,14 +224,16 @@ export default function AuditLogsPage() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">Event Type</label>
|
<label htmlFor={eventTypeId} className="text-sm font-medium">
|
||||||
|
Event Type
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={filters.eventType}
|
value={filters.eventType}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("eventType", value)
|
handleFilterChange("eventType", value)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger id={eventTypeId}>
|
||||||
<SelectValue placeholder="All event types" />
|
<SelectValue placeholder="All event types" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@ -241,12 +248,14 @@ export default function AuditLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">Outcome</label>
|
<label htmlFor={outcomeId} className="text-sm font-medium">
|
||||||
|
Outcome
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={filters.outcome}
|
value={filters.outcome}
|
||||||
onValueChange={(value) => handleFilterChange("outcome", value)}
|
onValueChange={(value) => handleFilterChange("outcome", value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger id={outcomeId}>
|
||||||
<SelectValue placeholder="All outcomes" />
|
<SelectValue placeholder="All outcomes" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@ -261,12 +270,14 @@ export default function AuditLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">Severity</label>
|
<label htmlFor={severityId} className="text-sm font-medium">
|
||||||
|
Severity
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={filters.severity}
|
value={filters.severity}
|
||||||
onValueChange={(value) => handleFilterChange("severity", value)}
|
onValueChange={(value) => handleFilterChange("severity", value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger id={severityId}>
|
||||||
<SelectValue placeholder="All severities" />
|
<SelectValue placeholder="All severities" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@ -281,8 +292,11 @@ export default function AuditLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">Start Date</label>
|
<label htmlFor={startDateId} className="text-sm font-medium">
|
||||||
|
Start Date
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id={startDateId}
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={filters.startDate}
|
value={filters.startDate}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@ -292,8 +306,11 @@ export default function AuditLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">End Date</label>
|
<label htmlFor={endDateId} className="text-sm font-medium">
|
||||||
|
End Date
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id={endDateId}
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={filters.endDate}
|
value={filters.endDate}
|
||||||
onChange={(e) => handleFilterChange("endDate", e.target.value)}
|
onChange={(e) => handleFilterChange("endDate", e.target.value)}
|
||||||
@ -442,14 +459,14 @@ export default function AuditLogsPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium">Timestamp:</label>
|
<span className="font-medium">Timestamp:</span>
|
||||||
<p className="font-mono text-sm">
|
<p className="font-mono text-sm">
|
||||||
{new Date(selectedLog.timestamp).toLocaleString()}
|
{new Date(selectedLog.timestamp).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium">Event Type:</label>
|
<span className="font-medium">Event Type:</span>
|
||||||
<p>
|
<p>
|
||||||
{eventTypeLabels[selectedLog.eventType] ||
|
{eventTypeLabels[selectedLog.eventType] ||
|
||||||
selectedLog.eventType}
|
selectedLog.eventType}
|
||||||
@ -457,26 +474,26 @@ export default function AuditLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium">Action:</label>
|
<span className="font-medium">Action:</span>
|
||||||
<p>{selectedLog.action}</p>
|
<p>{selectedLog.action}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium">Outcome:</label>
|
<span className="font-medium">Outcome:</span>
|
||||||
<Badge className={outcomeColors[selectedLog.outcome]}>
|
<Badge className={outcomeColors[selectedLog.outcome]}>
|
||||||
{selectedLog.outcome}
|
{selectedLog.outcome}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium">Severity:</label>
|
<span className="font-medium">Severity:</span>
|
||||||
<Badge className={severityColors[selectedLog.severity]}>
|
<Badge className={severityColors[selectedLog.severity]}>
|
||||||
{selectedLog.severity}
|
{selectedLog.severity}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium">IP Address:</label>
|
<span className="font-medium">IP Address:</span>
|
||||||
<p className="font-mono text-sm">
|
<p className="font-mono text-sm">
|
||||||
{selectedLog.ipAddress || "N/A"}
|
{selectedLog.ipAddress || "N/A"}
|
||||||
</p>
|
</p>
|
||||||
@ -484,7 +501,7 @@ export default function AuditLogsPage() {
|
|||||||
|
|
||||||
{selectedLog.user && (
|
{selectedLog.user && (
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium">User:</label>
|
<span className="font-medium">User:</span>
|
||||||
<p>
|
<p>
|
||||||
{selectedLog.user.email} ({selectedLog.user.role})
|
{selectedLog.user.email} ({selectedLog.user.role})
|
||||||
</p>
|
</p>
|
||||||
@ -493,7 +510,7 @@ export default function AuditLogsPage() {
|
|||||||
|
|
||||||
{selectedLog.platformUser && (
|
{selectedLog.platformUser && (
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium">Platform User:</label>
|
<span className="font-medium">Platform User:</span>
|
||||||
<p>
|
<p>
|
||||||
{selectedLog.platformUser.email} (
|
{selectedLog.platformUser.email} (
|
||||||
{selectedLog.platformUser.role})
|
{selectedLog.platformUser.role})
|
||||||
@ -503,21 +520,21 @@ export default function AuditLogsPage() {
|
|||||||
|
|
||||||
{selectedLog.country && (
|
{selectedLog.country && (
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium">Country:</label>
|
<span className="font-medium">Country:</span>
|
||||||
<p>{selectedLog.country}</p>
|
<p>{selectedLog.country}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedLog.sessionId && (
|
{selectedLog.sessionId && (
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium">Session ID:</label>
|
<span className="font-medium">Session ID:</span>
|
||||||
<p className="font-mono text-sm">{selectedLog.sessionId}</p>
|
<p className="font-mono text-sm">{selectedLog.sessionId}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedLog.requestId && (
|
{selectedLog.requestId && (
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium">Request ID:</label>
|
<span className="font-medium">Request ID:</span>
|
||||||
<p className="font-mono text-sm">{selectedLog.requestId}</p>
|
<p className="font-mono text-sm">{selectedLog.requestId}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -525,7 +542,7 @@ export default function AuditLogsPage() {
|
|||||||
|
|
||||||
{selectedLog.errorMessage && (
|
{selectedLog.errorMessage && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<label className="font-medium">Error Message:</label>
|
<span className="font-medium">Error Message:</span>
|
||||||
<p className="text-red-600 bg-red-50 p-2 rounded text-sm">
|
<p className="text-red-600 bg-red-50 p-2 rounded text-sm">
|
||||||
{selectedLog.errorMessage}
|
{selectedLog.errorMessage}
|
||||||
</p>
|
</p>
|
||||||
@ -534,14 +551,14 @@ export default function AuditLogsPage() {
|
|||||||
|
|
||||||
{selectedLog.userAgent && (
|
{selectedLog.userAgent && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<label className="font-medium">User Agent:</label>
|
<span className="font-medium">User Agent:</span>
|
||||||
<p className="text-sm break-all">{selectedLog.userAgent}</p>
|
<p className="text-sm break-all">{selectedLog.userAgent}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedLog.metadata && (
|
{selectedLog.metadata && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<label className="font-medium">Metadata:</label>
|
<span className="font-medium">Metadata:</span>
|
||||||
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto max-h-40">
|
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto max-h-40">
|
||||||
{JSON.stringify(selectedLog.metadata, null, 2)}
|
{JSON.stringify(selectedLog.metadata, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@ -23,24 +23,24 @@ import MessageViewer from "../../../../components/MessageViewer";
|
|||||||
import SessionDetails from "../../../../components/SessionDetails";
|
import SessionDetails from "../../../../components/SessionDetails";
|
||||||
import type { ChatSession } from "../../../../lib/types";
|
import type { ChatSession } from "../../../../lib/types";
|
||||||
|
|
||||||
export default function SessionViewPage() {
|
/**
|
||||||
const params = useParams();
|
* Custom hook for managing session data fetching and state
|
||||||
const router = useRouter(); // Initialize useRouter
|
*/
|
||||||
const { status } = useSession(); // Get session status, removed unused sessionData
|
function useSessionData(id: string | undefined, authStatus: string) {
|
||||||
const id = params?.id as string;
|
|
||||||
const [session, setSession] = useState<ChatSession | null>(null);
|
const [session, setSession] = useState<ChatSession | null>(null);
|
||||||
const [loading, setLoading] = useState(true); // This will now primarily be for data fetching
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === "unauthenticated") {
|
if (authStatus === "unauthenticated") {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "authenticated" && id) {
|
if (authStatus === "authenticated" && id) {
|
||||||
const fetchSession = async () => {
|
const fetchSession = async () => {
|
||||||
setLoading(true); // Always set loading before fetch
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/dashboard/session/${id}`);
|
const response = await fetch(`/api/dashboard/session/${id}`);
|
||||||
@ -63,222 +63,247 @@ export default function SessionViewPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchSession();
|
fetchSession();
|
||||||
} else if (status === "authenticated" && !id) {
|
} else if (authStatus === "authenticated" && !id) {
|
||||||
setError("Session ID is missing.");
|
setError("Session ID is missing.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [id, status, router]); // session removed from dependencies
|
}, [id, authStatus, router]);
|
||||||
|
|
||||||
if (status === "loading") {
|
return { session, loading, error };
|
||||||
return (
|
}
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
Loading session...
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "unauthenticated") {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
Redirecting to login...
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading && status === "authenticated") {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
Loading session details...
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
|
||||||
<p className="text-destructive text-lg mb-4">Error: {error}</p>
|
|
||||||
<Link href="/dashboard/sessions">
|
|
||||||
<Button variant="outline" className="gap-2">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Back to Sessions List
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<MessageSquare className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
|
||||||
<p className="text-muted-foreground text-lg mb-4">
|
|
||||||
Session not found.
|
|
||||||
</p>
|
|
||||||
<Link href="/dashboard/sessions">
|
|
||||||
<Button variant="outline" className="gap-2">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Back to Sessions List
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for rendering loading state
|
||||||
|
*/
|
||||||
|
function LoadingCard({ message }: { message: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-6xl mx-auto">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for rendering error state
|
||||||
|
*/
|
||||||
|
function ErrorCard({ error }: { error: string }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
||||||
|
<p className="text-destructive text-lg mb-4">Error: {error}</p>
|
||||||
|
<Link href="/dashboard/sessions">
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to Sessions List
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for rendering session not found state
|
||||||
|
*/
|
||||||
|
function SessionNotFoundCard() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<MessageSquare className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<p className="text-muted-foreground text-lg mb-4">
|
||||||
|
Session not found.
|
||||||
|
</p>
|
||||||
|
<Link href="/dashboard/sessions">
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to Sessions List
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for rendering session header with navigation and badges
|
||||||
|
*/
|
||||||
|
function SessionHeader({ session }: { session: ChatSession }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Link href="/dashboard/sessions">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="gap-2 p-0 h-auto focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||||
|
aria-label="Return to sessions list"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Back to Sessions List
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Link href="/dashboard/sessions">
|
<h1 className="text-3xl font-bold">Session Details</h1>
|
||||||
<Button
|
<div className="flex items-center gap-3">
|
||||||
variant="ghost"
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
className="gap-2 p-0 h-auto focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
ID
|
||||||
aria-label="Return to sessions list"
|
</Badge>
|
||||||
>
|
<code className="text-sm text-muted-foreground font-mono">
|
||||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
{(session.sessionId || session.id).slice(0, 8)}...
|
||||||
Back to Sessions List
|
</code>
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h1 className="text-3xl font-bold">Session Details</h1>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Badge variant="outline" className="font-mono text-xs">
|
|
||||||
ID
|
|
||||||
</Badge>
|
|
||||||
<code className="text-sm text-muted-foreground font-mono">
|
|
||||||
{(session.sessionId || session.id).slice(0, 8)}...
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
</div>
|
||||||
{session.category && (
|
<div className="flex flex-wrap gap-2">
|
||||||
<Badge variant="secondary" className="gap-1">
|
{session.category && (
|
||||||
<Activity className="h-3 w-3" />
|
<Badge variant="secondary" className="gap-1">
|
||||||
{formatCategory(session.category)}
|
<Activity className="h-3 w-3" />
|
||||||
</Badge>
|
{formatCategory(session.category)}
|
||||||
)}
|
</Badge>
|
||||||
{session.language && (
|
)}
|
||||||
<Badge variant="outline" className="gap-1">
|
{session.language && (
|
||||||
<Globe className="h-3 w-3" />
|
<Badge variant="outline" className="gap-1">
|
||||||
{session.language.toUpperCase()}
|
<Globe className="h-3 w-3" />
|
||||||
</Badge>
|
{session.language.toUpperCase()}
|
||||||
)}
|
</Badge>
|
||||||
{session.sentiment && (
|
)}
|
||||||
<Badge
|
{session.sentiment && (
|
||||||
variant={
|
<Badge
|
||||||
session.sentiment === "positive"
|
variant={
|
||||||
? "default"
|
session.sentiment === "positive"
|
||||||
: session.sentiment === "negative"
|
? "default"
|
||||||
? "destructive"
|
: session.sentiment === "negative"
|
||||||
: "secondary"
|
? "destructive"
|
||||||
}
|
: "secondary"
|
||||||
className="gap-1"
|
}
|
||||||
>
|
className="gap-1"
|
||||||
{session.sentiment.charAt(0).toUpperCase() +
|
>
|
||||||
session.sentiment.slice(1)}
|
{session.sentiment.charAt(0).toUpperCase() +
|
||||||
</Badge>
|
session.sentiment.slice(1)}
|
||||||
)}
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for rendering session overview cards
|
||||||
|
*/
|
||||||
|
function SessionOverview({ session }: { session: ChatSession }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Clock className="h-8 w-8 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Start Time</p>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{new Date(session.startTime).toLocaleString()}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Session Overview */}
|
<Card>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<CardContent className="pt-6">
|
||||||
<Card>
|
<div className="flex items-center gap-3">
|
||||||
<CardContent className="pt-6">
|
<MessageSquare className="h-8 w-8 text-green-500" />
|
||||||
<div className="flex items-center gap-3">
|
<div>
|
||||||
<Clock className="h-8 w-8 text-blue-500" />
|
<p className="text-sm text-muted-foreground">Messages</p>
|
||||||
<div>
|
<p className="font-semibold">{session.messages?.length || 0}</p>
|
||||||
<p className="text-sm text-muted-foreground">Start Time</p>
|
|
||||||
<p className="font-semibold">
|
|
||||||
{new Date(session.startTime).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<MessageSquare className="h-8 w-8 text-green-500" />
|
<User className="h-8 w-8 text-purple-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Messages</p>
|
<p className="text-sm text-muted-foreground">User ID</p>
|
||||||
<p className="font-semibold">{session.messages?.length || 0}</p>
|
<p className="font-semibold truncate">
|
||||||
</div>
|
{session.userId || "N/A"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<User className="h-8 w-8 text-purple-500" />
|
<Activity className="h-8 w-8 text-orange-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">User ID</p>
|
<p className="text-sm text-muted-foreground">Duration</p>
|
||||||
<p className="font-semibold truncate">
|
<p className="font-semibold">
|
||||||
{session.userId || "N/A"}
|
{session.endTime && session.startTime
|
||||||
</p>
|
? `${Math.round(
|
||||||
</div>
|
(new Date(session.endTime).getTime() -
|
||||||
|
new Date(session.startTime).getTime()) /
|
||||||
|
60000
|
||||||
|
)} min`
|
||||||
|
: "N/A"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<Card>
|
export default function SessionViewPage() {
|
||||||
<CardContent className="pt-6">
|
const params = useParams();
|
||||||
<div className="flex items-center gap-3">
|
const { status } = useSession();
|
||||||
<Activity className="h-8 w-8 text-orange-500" />
|
const id = params?.id as string;
|
||||||
<div>
|
const { session, loading, error } = useSessionData(id, status);
|
||||||
<p className="text-sm text-muted-foreground">Duration</p>
|
|
||||||
<p className="font-semibold">
|
if (status === "loading") {
|
||||||
{session.endTime && session.startTime
|
return <LoadingCard message="Loading session..." />;
|
||||||
? `${Math.round(
|
}
|
||||||
(new Date(session.endTime).getTime() -
|
|
||||||
new Date(session.startTime).getTime()) /
|
if (status === "unauthenticated") {
|
||||||
60000
|
return <LoadingCard message="Redirecting to login..." />;
|
||||||
)} min`
|
}
|
||||||
: "N/A"}
|
|
||||||
</p>
|
if (loading && status === "authenticated") {
|
||||||
</div>
|
return <LoadingCard message="Loading session details..." />;
|
||||||
</div>
|
}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
if (error) {
|
||||||
</div>
|
return <ErrorCard error={error} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return <SessionNotFoundCard />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-6xl mx-auto">
|
||||||
|
<SessionHeader session={session} />
|
||||||
|
<SessionOverview session={session} />
|
||||||
|
|
||||||
{/* Session Details */}
|
{/* Session Details */}
|
||||||
<SessionDetails session={session} />
|
<SessionDetails session={session} />
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useId, useState } from "react";
|
import { useEffect, useId, useState } from "react";
|
||||||
|
import type { z } from "zod";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@ -21,8 +22,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { formatCategory } from "@/lib/format-enums";
|
import { formatCategory } from "@/lib/format-enums";
|
||||||
import { trpc } from "@/lib/trpc-client";
|
import { trpc } from "@/lib/trpc-client";
|
||||||
import { sessionFilterSchema } from "@/lib/validation";
|
import type { sessionFilterSchema } from "@/lib/validation";
|
||||||
import type { z } from "zod";
|
|
||||||
import type { ChatSession } from "../../../lib/types";
|
import type { ChatSession } from "../../../lib/types";
|
||||||
|
|
||||||
interface FilterOptions {
|
interface FilterOptions {
|
||||||
@ -97,13 +97,13 @@ function FilterSection({
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Label htmlFor="search-sessions" className="sr-only">
|
<Label htmlFor={searchId} className="sr-only">
|
||||||
Search sessions
|
Search sessions
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="search-sessions"
|
id={searchId}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search sessions..."
|
placeholder="Search sessions..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
@ -179,9 +179,9 @@ function FilterSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="start-date">Start Date</Label>
|
<Label htmlFor={startDateId}>Start Date</Label>
|
||||||
<Input
|
<Input
|
||||||
id="start-date"
|
id={startDateId}
|
||||||
type="date"
|
type="date"
|
||||||
value={startDate}
|
value={startDate}
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
@ -190,9 +190,9 @@ function FilterSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="end-date">End Date</Label>
|
<Label htmlFor={endDateId}>End Date</Label>
|
||||||
<Input
|
<Input
|
||||||
id="end-date"
|
id={endDateId}
|
||||||
type="date"
|
type="date"
|
||||||
value={endDate}
|
value={endDate}
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
@ -201,9 +201,9 @@ function FilterSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="sort-by">Sort By</Label>
|
<Label htmlFor={sortById}>Sort By</Label>
|
||||||
<select
|
<select
|
||||||
id="sort-by"
|
id={sortById}
|
||||||
value={sortKey}
|
value={sortKey}
|
||||||
onChange={(e) => setSortKey(e.target.value)}
|
onChange={(e) => setSortKey(e.target.value)}
|
||||||
className="w-full mt-1 p-2 border border-gray-300 rounded-md"
|
className="w-full mt-1 p-2 border border-gray-300 rounded-md"
|
||||||
@ -489,7 +489,9 @@ export default function SessionsPage() {
|
|||||||
} = trpc.dashboard.getSessions.useQuery(
|
} = trpc.dashboard.getSessions.useQuery(
|
||||||
{
|
{
|
||||||
search: debouncedSearchTerm || undefined,
|
search: debouncedSearchTerm || undefined,
|
||||||
category: selectedCategory ? selectedCategory as z.infer<typeof sessionFilterSchema>["category"] : undefined,
|
category: selectedCategory
|
||||||
|
? (selectedCategory as z.infer<typeof sessionFilterSchema>["category"])
|
||||||
|
: undefined,
|
||||||
// language: selectedLanguage || undefined, // Not supported in schema yet
|
// language: selectedLanguage || undefined, // Not supported in schema yet
|
||||||
startDate: startDate || undefined,
|
startDate: startDate || undefined,
|
||||||
endDate: endDate || undefined,
|
endDate: endDate || undefined,
|
||||||
|
|||||||
@ -39,6 +39,43 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
type ToastFunction = (props: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
interface CompanyManagementState {
|
||||||
|
company: Company | null;
|
||||||
|
setCompany: (company: Company | null) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
setIsLoading: (loading: boolean) => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
setIsSaving: (saving: boolean) => void;
|
||||||
|
editData: Partial<Company>;
|
||||||
|
setEditData: (
|
||||||
|
data: Partial<Company> | ((prev: Partial<Company>) => Partial<Company>)
|
||||||
|
) => void;
|
||||||
|
originalData: Partial<Company>;
|
||||||
|
setOriginalData: (data: Partial<Company>) => void;
|
||||||
|
showInviteUser: boolean;
|
||||||
|
setShowInviteUser: (show: boolean) => void;
|
||||||
|
inviteData: { name: string; email: string; role: string };
|
||||||
|
setInviteData: (
|
||||||
|
data:
|
||||||
|
| { name: string; email: string; role: string }
|
||||||
|
| ((prev: { name: string; email: string; role: string }) => {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
})
|
||||||
|
) => void;
|
||||||
|
showUnsavedChangesDialog: boolean;
|
||||||
|
setShowUnsavedChangesDialog: (show: boolean) => void;
|
||||||
|
pendingNavigation: string | null;
|
||||||
|
setPendingNavigation: (navigation: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -64,51 +101,10 @@ interface Company {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CompanyManagement() {
|
/**
|
||||||
const { data: session, status } = useSession();
|
* Custom hook for company management state
|
||||||
const router = useRouter();
|
*/
|
||||||
const params = useParams();
|
function useCompanyManagementState() {
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const companyNameFieldId = useId();
|
|
||||||
const companyEmailFieldId = useId();
|
|
||||||
const maxUsersFieldId = useId();
|
|
||||||
const inviteNameFieldId = useId();
|
|
||||||
const inviteEmailFieldId = useId();
|
|
||||||
|
|
||||||
const fetchCompany = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/platform/companies/${params.id}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setCompany(data);
|
|
||||||
const companyData = {
|
|
||||||
name: data.name,
|
|
||||||
email: data.email,
|
|
||||||
status: data.status,
|
|
||||||
maxUsers: data.maxUsers,
|
|
||||||
};
|
|
||||||
setEditData(companyData);
|
|
||||||
setOriginalData(companyData);
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to load company data",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch company:", error);
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to load company data",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [params.id, toast]);
|
|
||||||
|
|
||||||
const [company, setCompany] = useState<Company | null>(null);
|
const [company, setCompany] = useState<Company | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@ -126,9 +122,55 @@ export default function CompanyManagement() {
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Function to check if data has been modified
|
return {
|
||||||
|
company,
|
||||||
|
setCompany,
|
||||||
|
isLoading,
|
||||||
|
setIsLoading,
|
||||||
|
isSaving,
|
||||||
|
setIsSaving,
|
||||||
|
editData,
|
||||||
|
setEditData,
|
||||||
|
originalData,
|
||||||
|
setOriginalData,
|
||||||
|
showInviteUser,
|
||||||
|
setShowInviteUser,
|
||||||
|
inviteData,
|
||||||
|
setInviteData,
|
||||||
|
showUnsavedChangesDialog,
|
||||||
|
setShowUnsavedChangesDialog,
|
||||||
|
pendingNavigation,
|
||||||
|
setPendingNavigation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for form IDs
|
||||||
|
*/
|
||||||
|
function useCompanyFormIds() {
|
||||||
|
const companyNameFieldId = useId();
|
||||||
|
const companyEmailFieldId = useId();
|
||||||
|
const maxUsersFieldId = useId();
|
||||||
|
const inviteNameFieldId = useId();
|
||||||
|
const inviteEmailFieldId = useId();
|
||||||
|
|
||||||
|
return {
|
||||||
|
companyNameFieldId,
|
||||||
|
companyEmailFieldId,
|
||||||
|
maxUsersFieldId,
|
||||||
|
inviteNameFieldId,
|
||||||
|
inviteEmailFieldId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for data validation and comparison
|
||||||
|
*/
|
||||||
|
function useDataComparison(
|
||||||
|
editData: Partial<Company>,
|
||||||
|
originalData: Partial<Company>
|
||||||
|
) {
|
||||||
const hasUnsavedChanges = useCallback(() => {
|
const hasUnsavedChanges = useCallback(() => {
|
||||||
// Normalize data for comparison (handle null/undefined/empty string equivalence)
|
|
||||||
const normalizeValue = (value: string | number | null | undefined) => {
|
const normalizeValue = (value: string | number | null | undefined) => {
|
||||||
if (value === null || value === undefined || value === "") {
|
if (value === null || value === undefined || value === "") {
|
||||||
return "";
|
return "";
|
||||||
@ -156,24 +198,276 @@ export default function CompanyManagement() {
|
|||||||
);
|
);
|
||||||
}, [editData, originalData]);
|
}, [editData, originalData]);
|
||||||
|
|
||||||
// Handle navigation protection - must be at top level
|
return { hasUnsavedChanges };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for company data fetching
|
||||||
|
*/
|
||||||
|
function useCompanyData(
|
||||||
|
params: { id: string | string[] },
|
||||||
|
toast: ToastFunction,
|
||||||
|
state: CompanyManagementState
|
||||||
|
) {
|
||||||
|
const fetchCompany = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/platform/companies/${params.id}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
state.setCompany(data);
|
||||||
|
const companyData = {
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
status: data.status,
|
||||||
|
maxUsers: data.maxUsers,
|
||||||
|
};
|
||||||
|
state.setEditData(companyData);
|
||||||
|
state.setOriginalData(companyData);
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to load company data",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch company:", error);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to load company data",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
state.setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [params.id, toast, state]);
|
||||||
|
|
||||||
|
return { fetchCompany };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for navigation handling
|
||||||
|
*/
|
||||||
|
function useNavigationControl(
|
||||||
|
router: { push: (url: string) => void },
|
||||||
|
params: { id: string | string[] },
|
||||||
|
hasUnsavedChanges: () => boolean,
|
||||||
|
state: CompanyManagementState
|
||||||
|
) {
|
||||||
const handleNavigation = useCallback(
|
const handleNavigation = useCallback(
|
||||||
(url: string) => {
|
(url: string) => {
|
||||||
// Allow navigation within the same company (different tabs, etc.)
|
|
||||||
if (url.includes(`/platform/companies/${params.id}`)) {
|
if (url.includes(`/platform/companies/${params.id}`)) {
|
||||||
router.push(url);
|
router.push(url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there are unsaved changes, show confirmation dialog
|
|
||||||
if (hasUnsavedChanges()) {
|
if (hasUnsavedChanges()) {
|
||||||
setPendingNavigation(url);
|
state.setPendingNavigation(url);
|
||||||
setShowUnsavedChangesDialog(true);
|
state.setShowUnsavedChangesDialog(true);
|
||||||
} else {
|
} else {
|
||||||
router.push(url);
|
router.push(url);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, params.id, hasUnsavedChanges]
|
[router, params.id, hasUnsavedChanges, state]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { handleNavigation };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to render company information card
|
||||||
|
*/
|
||||||
|
function renderCompanyInfoCard(
|
||||||
|
state: CompanyManagementState,
|
||||||
|
canEdit: boolean,
|
||||||
|
companyNameFieldId: string,
|
||||||
|
companyEmailFieldId: string,
|
||||||
|
maxUsersFieldId: string,
|
||||||
|
hasUnsavedChanges: () => boolean,
|
||||||
|
handleSave: () => Promise<void>
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Company Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={companyNameFieldId}>Company Name</Label>
|
||||||
|
<Input
|
||||||
|
id={companyNameFieldId}
|
||||||
|
value={state.editData.name || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
state.setEditData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
name: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={companyEmailFieldId}>Contact Email</Label>
|
||||||
|
<Input
|
||||||
|
id={companyEmailFieldId}
|
||||||
|
type="email"
|
||||||
|
value={state.editData.email || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
state.setEditData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
email: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={maxUsersFieldId}>Max Users</Label>
|
||||||
|
<Input
|
||||||
|
id={maxUsersFieldId}
|
||||||
|
type="number"
|
||||||
|
value={state.editData.maxUsers || 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
state.setEditData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
maxUsers: Number.parseInt(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="status">Status</Label>
|
||||||
|
<Select
|
||||||
|
value={state.editData.status}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
state.setEditData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={!canEdit}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ACTIVE">Active</SelectItem>
|
||||||
|
<SelectItem value="TRIAL">Trial</SelectItem>
|
||||||
|
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
||||||
|
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canEdit && hasUnsavedChanges() && (
|
||||||
|
<div className="flex gap-2 pt-4 border-t">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
state.setEditData(state.originalData);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel Changes
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={state.isSaving}>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
{state.isSaving ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to render users tab content
|
||||||
|
*/
|
||||||
|
function renderUsersTab(state: CompanyManagementState, canEdit: boolean) {
|
||||||
|
return (
|
||||||
|
<TabsContent value="users" className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5" />
|
||||||
|
Users ({state.company?.users.length || 0})
|
||||||
|
</span>
|
||||||
|
{canEdit && (
|
||||||
|
<Button size="sm" onClick={() => state.setShowInviteUser(true)}>
|
||||||
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
|
Invite User
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{state.company?.users.map((user) => (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className="flex items-center justify-between p-4 border rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium text-blue-600 dark:text-blue-300">
|
||||||
|
{user.name?.charAt(0) ||
|
||||||
|
user.email.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{user.name || "No name"}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Badge variant="outline">{user.role}</Badge>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Joined {new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(state.company?.users.length || 0) === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No users found. Invite the first user to get started.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompanyManagement() {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const state = useCompanyManagementState();
|
||||||
|
const {
|
||||||
|
companyNameFieldId,
|
||||||
|
companyEmailFieldId,
|
||||||
|
maxUsersFieldId,
|
||||||
|
inviteNameFieldId,
|
||||||
|
inviteEmailFieldId,
|
||||||
|
} = useCompanyFormIds();
|
||||||
|
const { hasUnsavedChanges } = useDataComparison(
|
||||||
|
state.editData,
|
||||||
|
state.originalData
|
||||||
|
);
|
||||||
|
const { fetchCompany } = useCompanyData(params, toast, state);
|
||||||
|
const { handleNavigation } = useNavigationControl(
|
||||||
|
router,
|
||||||
|
params,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
state
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -188,24 +482,24 @@ export default function CompanyManagement() {
|
|||||||
}, [session, status, router, fetchCompany]);
|
}, [session, status, router, fetchCompany]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true);
|
state.setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/platform/companies/${params.id}`, {
|
const response = await fetch(`/api/platform/companies/${params.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(editData),
|
body: JSON.stringify(state.editData),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const updatedCompany = await response.json();
|
const updatedCompany = await response.json();
|
||||||
setCompany(updatedCompany);
|
state.setCompany(updatedCompany);
|
||||||
const companyData = {
|
const companyData = {
|
||||||
name: updatedCompany.name,
|
name: updatedCompany.name,
|
||||||
email: updatedCompany.email,
|
email: updatedCompany.email,
|
||||||
status: updatedCompany.status,
|
status: updatedCompany.status,
|
||||||
maxUsers: updatedCompany.maxUsers,
|
maxUsers: updatedCompany.maxUsers,
|
||||||
};
|
};
|
||||||
setOriginalData(companyData);
|
state.setOriginalData(companyData);
|
||||||
toast({
|
toast({
|
||||||
title: "Success",
|
title: "Success",
|
||||||
description: "Company updated successfully",
|
description: "Company updated successfully",
|
||||||
@ -220,7 +514,7 @@ export default function CompanyManagement() {
|
|||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
state.setIsSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -235,8 +529,10 @@ export default function CompanyManagement() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setCompany((prev) => (prev ? { ...prev, status: newStatus } : null));
|
state.setCompany((prev) =>
|
||||||
setEditData((prev) => ({ ...prev, status: newStatus }));
|
prev ? { ...prev, status: newStatus } : null
|
||||||
|
);
|
||||||
|
state.setEditData((prev) => ({ ...prev, status: newStatus }));
|
||||||
toast({
|
toast({
|
||||||
title: "Success",
|
title: "Success",
|
||||||
description: `Company ${statusAction}d successfully`,
|
description: `Company ${statusAction}d successfully`,
|
||||||
@ -254,16 +550,47 @@ export default function CompanyManagement() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const confirmNavigation = () => {
|
const confirmNavigation = () => {
|
||||||
if (pendingNavigation) {
|
if (state.pendingNavigation) {
|
||||||
router.push(pendingNavigation);
|
router.push(state.pendingNavigation);
|
||||||
setPendingNavigation(null);
|
state.setPendingNavigation(null);
|
||||||
}
|
}
|
||||||
setShowUnsavedChangesDialog(false);
|
state.setShowUnsavedChangesDialog(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelNavigation = () => {
|
const cancelNavigation = () => {
|
||||||
setPendingNavigation(null);
|
state.setPendingNavigation(null);
|
||||||
setShowUnsavedChangesDialog(false);
|
state.setShowUnsavedChangesDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInviteUser = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/platform/companies/${params.id}/users`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(state.inviteData),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
state.setShowInviteUser(false);
|
||||||
|
state.setInviteData({ name: "", email: "", role: "USER" });
|
||||||
|
fetchCompany();
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "User invited successfully",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error("Failed to invite user");
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to invite user",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Protect against browser back/forward and other navigation
|
// Protect against browser back/forward and other navigation
|
||||||
@ -281,7 +608,6 @@ export default function CompanyManagement() {
|
|||||||
"You have unsaved changes. Are you sure you want to leave this page?"
|
"You have unsaved changes. Are you sure you want to leave this page?"
|
||||||
);
|
);
|
||||||
if (!confirmLeave) {
|
if (!confirmLeave) {
|
||||||
// Push the current state back to prevent navigation
|
|
||||||
window.history.pushState(null, "", window.location.href);
|
window.history.pushState(null, "", window.location.href);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
@ -297,37 +623,6 @@ export default function CompanyManagement() {
|
|||||||
};
|
};
|
||||||
}, [hasUnsavedChanges]);
|
}, [hasUnsavedChanges]);
|
||||||
|
|
||||||
const handleInviteUser = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/platform/companies/${params.id}/users`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(inviteData),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setShowInviteUser(false);
|
|
||||||
setInviteData({ name: "", email: "", role: "USER" });
|
|
||||||
fetchCompany(); // Refresh company data
|
|
||||||
toast({
|
|
||||||
title: "Success",
|
|
||||||
description: "User invited successfully",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error("Failed to invite user");
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to invite user",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadgeVariant = (status: string) => {
|
const getStatusBadgeVariant = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "ACTIVE":
|
case "ACTIVE":
|
||||||
@ -343,7 +638,7 @@ export default function CompanyManagement() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status === "loading" || isLoading) {
|
if (status === "loading" || state.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="text-center">Loading company details...</div>
|
<div className="text-center">Loading company details...</div>
|
||||||
@ -351,7 +646,7 @@ export default function CompanyManagement() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session?.user?.isPlatformUser || !company) {
|
if (!session?.user?.isPlatformUser || !state.company) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -374,10 +669,10 @@ export default function CompanyManagement() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
{company.name}
|
{state.company.name}
|
||||||
</h1>
|
</h1>
|
||||||
<Badge variant={getStatusBadgeVariant(company.status)}>
|
<Badge variant={getStatusBadgeVariant(state.company.status)}>
|
||||||
{company.status}
|
{state.company.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
@ -390,7 +685,7 @@ export default function CompanyManagement() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowInviteUser(true)}
|
onClick={() => state.setShowInviteUser(true)}
|
||||||
>
|
>
|
||||||
<UserPlus className="w-4 h-4 mr-2" />
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
Invite User
|
Invite User
|
||||||
@ -422,10 +717,10 @@ export default function CompanyManagement() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{company.users.length}
|
{state.company.users.length}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
of {company.maxUsers} maximum
|
of {state.company.maxUsers} maximum
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -439,7 +734,7 @@ export default function CompanyManagement() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{company._count.sessions}
|
{state.company._count.sessions}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -453,7 +748,7 @@ export default function CompanyManagement() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{company._count.imports}
|
{state.company._count.imports}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -465,160 +760,25 @@ export default function CompanyManagement() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-sm font-bold">
|
<div className="text-sm font-bold">
|
||||||
{new Date(company.createdAt).toLocaleDateString()}
|
{new Date(state.company.createdAt).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Company Info */}
|
{/* Company Info */}
|
||||||
<Card>
|
{renderCompanyInfoCard(
|
||||||
<CardHeader>
|
state,
|
||||||
<CardTitle>Company Information</CardTitle>
|
canEdit,
|
||||||
</CardHeader>
|
companyNameFieldId,
|
||||||
<CardContent className="space-y-4">
|
companyEmailFieldId,
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
maxUsersFieldId,
|
||||||
<div>
|
hasUnsavedChanges,
|
||||||
<Label htmlFor={companyNameFieldId}>Company Name</Label>
|
handleSave
|
||||||
<Input
|
)}
|
||||||
id={companyNameFieldId}
|
|
||||||
value={editData.name || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
name: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={companyEmailFieldId}>Contact Email</Label>
|
|
||||||
<Input
|
|
||||||
id={companyEmailFieldId}
|
|
||||||
type="email"
|
|
||||||
value={editData.email || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
email: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={maxUsersFieldId}>Max Users</Label>
|
|
||||||
<Input
|
|
||||||
id={maxUsersFieldId}
|
|
||||||
type="number"
|
|
||||||
value={editData.maxUsers || 0}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
maxUsers: Number.parseInt(e.target.value),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="status">Status</Label>
|
|
||||||
<Select
|
|
||||||
value={editData.status}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setEditData((prev) => ({ ...prev, status: value }))
|
|
||||||
}
|
|
||||||
disabled={!canEdit}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="ACTIVE">Active</SelectItem>
|
|
||||||
<SelectItem value="TRIAL">Trial</SelectItem>
|
|
||||||
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
|
||||||
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{canEdit && hasUnsavedChanges() && (
|
|
||||||
<div className="flex gap-2 pt-4 border-t">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setEditData(originalData);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel Changes
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} disabled={isSaving}>
|
|
||||||
<Save className="w-4 h-4 mr-2" />
|
|
||||||
{isSaving ? "Saving..." : "Save Changes"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="users" className="space-y-6">
|
{renderUsersTab(state, canEdit)}
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Users className="w-5 h-5" />
|
|
||||||
Users ({company.users.length})
|
|
||||||
</span>
|
|
||||||
{canEdit && (
|
|
||||||
<Button size="sm" onClick={() => setShowInviteUser(true)}>
|
|
||||||
<UserPlus className="w-4 h-4 mr-2" />
|
|
||||||
Invite User
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{company.users.map((user) => (
|
|
||||||
<div
|
|
||||||
key={user.id}
|
|
||||||
className="flex items-center justify-between p-4 border rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
|
||||||
<span className="text-sm font-medium text-blue-600 dark:text-blue-300">
|
|
||||||
{user.name?.charAt(0) ||
|
|
||||||
user.email.charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">
|
|
||||||
{user.name || "No name"}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{user.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Badge variant="outline">{user.role}</Badge>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Joined {new Date(user.createdAt).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{company.users.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
No users found. Invite the first user to get started.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="settings" className="space-y-6">
|
<TabsContent value="settings" className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
@ -641,9 +801,9 @@ export default function CompanyManagement() {
|
|||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={company.status === "SUSPENDED"}
|
disabled={state.company.status === "SUSPENDED"}
|
||||||
>
|
>
|
||||||
{company.status === "SUSPENDED"
|
{state.company.status === "SUSPENDED"
|
||||||
? "Already Suspended"
|
? "Already Suspended"
|
||||||
: "Suspend"}
|
: "Suspend"}
|
||||||
</Button>
|
</Button>
|
||||||
@ -668,7 +828,7 @@ export default function CompanyManagement() {
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{company.status === "SUSPENDED" && (
|
{state.company.status === "SUSPENDED" && (
|
||||||
<div className="flex items-center justify-between p-4 border border-green-200 dark:border-green-800 rounded-lg">
|
<div className="flex items-center justify-between p-4 border border-green-200 dark:border-green-800 rounded-lg">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium">Reactivate Company</h3>
|
<h3 className="font-medium">Reactivate Company</h3>
|
||||||
@ -706,7 +866,7 @@ export default function CompanyManagement() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Invite User Dialog */}
|
{/* Invite User Dialog */}
|
||||||
{showInviteUser && (
|
{state.showInviteUser && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
<Card className="w-full max-w-md mx-4">
|
<Card className="w-full max-w-md mx-4">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -717,9 +877,12 @@ export default function CompanyManagement() {
|
|||||||
<Label htmlFor={inviteNameFieldId}>Name</Label>
|
<Label htmlFor={inviteNameFieldId}>Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id={inviteNameFieldId}
|
id={inviteNameFieldId}
|
||||||
value={inviteData.name}
|
value={state.inviteData.name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setInviteData((prev) => ({ ...prev, name: e.target.value }))
|
state.setInviteData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
name: e.target.value,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
placeholder="User's full name"
|
placeholder="User's full name"
|
||||||
/>
|
/>
|
||||||
@ -729,9 +892,9 @@ export default function CompanyManagement() {
|
|||||||
<Input
|
<Input
|
||||||
id={inviteEmailFieldId}
|
id={inviteEmailFieldId}
|
||||||
type="email"
|
type="email"
|
||||||
value={inviteData.email}
|
value={state.inviteData.email}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setInviteData((prev) => ({
|
state.setInviteData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
email: e.target.value,
|
email: e.target.value,
|
||||||
}))
|
}))
|
||||||
@ -742,9 +905,9 @@ export default function CompanyManagement() {
|
|||||||
<div>
|
<div>
|
||||||
<Label htmlFor="inviteRole">Role</Label>
|
<Label htmlFor="inviteRole">Role</Label>
|
||||||
<Select
|
<Select
|
||||||
value={inviteData.role}
|
value={state.inviteData.role}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setInviteData((prev) => ({ ...prev, role: value }))
|
state.setInviteData((prev) => ({ ...prev, role: value }))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@ -759,7 +922,7 @@ export default function CompanyManagement() {
|
|||||||
<div className="flex gap-2 pt-4">
|
<div className="flex gap-2 pt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowInviteUser(false)}
|
onClick={() => state.setShowInviteUser(false)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@ -767,7 +930,7 @@ export default function CompanyManagement() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleInviteUser}
|
onClick={handleInviteUser}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!inviteData.email || !inviteData.name}
|
disabled={!state.inviteData.email || !state.inviteData.name}
|
||||||
>
|
>
|
||||||
<Mail className="w-4 h-4 mr-2" />
|
<Mail className="w-4 h-4 mr-2" />
|
||||||
Send Invite
|
Send Invite
|
||||||
@ -780,8 +943,8 @@ export default function CompanyManagement() {
|
|||||||
|
|
||||||
{/* Unsaved Changes Dialog */}
|
{/* Unsaved Changes Dialog */}
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={showUnsavedChangesDialog}
|
open={state.showUnsavedChangesDialog}
|
||||||
onOpenChange={setShowUnsavedChangesDialog}
|
onOpenChange={state.setShowUnsavedChangesDialog}
|
||||||
>
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -10,7 +10,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { SecurityConfigModal } from "@/components/security/SecurityConfigModal";
|
import { SecurityConfigModal } from "@/components/security/SecurityConfigModal";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -51,7 +51,10 @@ interface SecurityAlert {
|
|||||||
acknowledged: boolean;
|
acknowledged: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SecurityMonitoringPage() {
|
/**
|
||||||
|
* Custom hook for security monitoring state
|
||||||
|
*/
|
||||||
|
function useSecurityMonitoringState() {
|
||||||
const [metrics, setMetrics] = useState<SecurityMetrics | null>(null);
|
const [metrics, setMetrics] = useState<SecurityMetrics | null>(null);
|
||||||
const [alerts, setAlerts] = useState<SecurityAlert[]>([]);
|
const [alerts, setAlerts] = useState<SecurityAlert[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -59,14 +62,29 @@ export default function SecurityMonitoringPage() {
|
|||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
return {
|
||||||
loadSecurityData();
|
metrics,
|
||||||
|
setMetrics,
|
||||||
|
alerts,
|
||||||
|
setAlerts,
|
||||||
|
loading,
|
||||||
|
setLoading,
|
||||||
|
selectedTimeRange,
|
||||||
|
setSelectedTimeRange,
|
||||||
|
showConfig,
|
||||||
|
setShowConfig,
|
||||||
|
autoRefresh,
|
||||||
|
setAutoRefresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (autoRefresh) {
|
/**
|
||||||
const interval = setInterval(loadSecurityData, 30000); // Refresh every 30 seconds
|
* Custom hook for security data fetching
|
||||||
return () => clearInterval(interval);
|
*/
|
||||||
}
|
function useSecurityData(selectedTimeRange: string, autoRefresh: boolean) {
|
||||||
}, [autoRefresh, loadSecurityData]);
|
const [metrics, setMetrics] = useState<SecurityMetrics | null>(null);
|
||||||
|
const [alerts, setAlerts] = useState<SecurityAlert[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const loadSecurityData = useCallback(async () => {
|
const loadSecurityData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -89,6 +107,228 @@ export default function SecurityMonitoringPage() {
|
|||||||
}
|
}
|
||||||
}, [selectedTimeRange]);
|
}, [selectedTimeRange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSecurityData();
|
||||||
|
|
||||||
|
if (autoRefresh) {
|
||||||
|
const interval = setInterval(loadSecurityData, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [autoRefresh, loadSecurityData]);
|
||||||
|
|
||||||
|
return { metrics, alerts, loading, loadSecurityData, setAlerts };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get date range for filtering
|
||||||
|
*/
|
||||||
|
function getStartDateForRange(range: string): string {
|
||||||
|
const now = new Date();
|
||||||
|
switch (range) {
|
||||||
|
case "1h":
|
||||||
|
return new Date(now.getTime() - 60 * 60 * 1000).toISOString();
|
||||||
|
case "24h":
|
||||||
|
return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
case "7d":
|
||||||
|
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
case "30d":
|
||||||
|
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
default:
|
||||||
|
return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get threat level color
|
||||||
|
*/
|
||||||
|
function getThreatLevelColor(level: string) {
|
||||||
|
switch (level?.toLowerCase()) {
|
||||||
|
case "critical":
|
||||||
|
return "bg-red-500";
|
||||||
|
case "high":
|
||||||
|
return "bg-orange-500";
|
||||||
|
case "moderate":
|
||||||
|
return "bg-yellow-500";
|
||||||
|
case "low":
|
||||||
|
return "bg-green-500";
|
||||||
|
default:
|
||||||
|
return "bg-gray-500";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get severity color
|
||||||
|
*/
|
||||||
|
function getSeverityColor(severity: string) {
|
||||||
|
switch (severity?.toLowerCase()) {
|
||||||
|
case "critical":
|
||||||
|
return "destructive";
|
||||||
|
case "high":
|
||||||
|
return "destructive";
|
||||||
|
case "medium":
|
||||||
|
return "secondary";
|
||||||
|
case "low":
|
||||||
|
return "outline";
|
||||||
|
default:
|
||||||
|
return "outline";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to render dashboard header
|
||||||
|
*/
|
||||||
|
function renderDashboardHeader(
|
||||||
|
autoRefresh: boolean,
|
||||||
|
setAutoRefresh: (refresh: boolean) => void,
|
||||||
|
setShowConfig: (show: boolean) => void,
|
||||||
|
exportData: (format: "json" | "csv", type: "alerts" | "metrics") => void
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
|
Security Monitoring
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Real-time security monitoring and threat detection
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||||
|
>
|
||||||
|
{autoRefresh ? (
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<BellOff className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Auto Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowConfig(true)}>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Configure
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => exportData("json", "alerts")}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to render time range selector
|
||||||
|
*/
|
||||||
|
function renderTimeRangeSelector(
|
||||||
|
selectedTimeRange: string,
|
||||||
|
setSelectedTimeRange: (range: string) => void
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{["1h", "24h", "7d", "30d"].map((range) => (
|
||||||
|
<Button
|
||||||
|
key={range}
|
||||||
|
variant={selectedTimeRange === range ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedTimeRange(range)}
|
||||||
|
>
|
||||||
|
{range}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to render security overview cards
|
||||||
|
*/
|
||||||
|
function renderSecurityOverview(metrics: SecurityMetrics | null) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Security Score</CardTitle>
|
||||||
|
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{metrics?.securityScore || 0}/100
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${getThreatLevelColor(metrics?.threatLevel || "")}`}
|
||||||
|
>
|
||||||
|
{metrics?.threatLevel || "Unknown"} Threat Level
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Active Alerts</CardTitle>
|
||||||
|
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{metrics?.activeAlerts || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{metrics?.resolvedAlerts || 0} resolved
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Security Events</CardTitle>
|
||||||
|
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{metrics?.totalEvents || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{metrics?.criticalEvents || 0} critical
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Top Threat</CardTitle>
|
||||||
|
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-sm font-bold">
|
||||||
|
{metrics?.topThreats?.[0]?.type?.replace(/_/g, " ") || "None"}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{metrics?.topThreats?.[0]?.count || 0} instances
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SecurityMonitoringPage() {
|
||||||
|
const {
|
||||||
|
selectedTimeRange,
|
||||||
|
setSelectedTimeRange,
|
||||||
|
showConfig,
|
||||||
|
setShowConfig,
|
||||||
|
autoRefresh,
|
||||||
|
setAutoRefresh,
|
||||||
|
} = useSecurityMonitoringState();
|
||||||
|
|
||||||
|
const { metrics, alerts, loading, setAlerts, loadSecurityData } =
|
||||||
|
useSecurityData(selectedTimeRange, autoRefresh);
|
||||||
|
|
||||||
const acknowledgeAlert = async (alertId: string) => {
|
const acknowledgeAlert = async (alertId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/security-monitoring/alerts", {
|
const response = await fetch("/api/admin/security-monitoring/alerts", {
|
||||||
@ -135,52 +375,6 @@ export default function SecurityMonitoringPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStartDateForRange = (range: string): string => {
|
|
||||||
const now = new Date();
|
|
||||||
switch (range) {
|
|
||||||
case "1h":
|
|
||||||
return new Date(now.getTime() - 60 * 60 * 1000).toISOString();
|
|
||||||
case "24h":
|
|
||||||
return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
|
|
||||||
case "7d":
|
|
||||||
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
||||||
case "30d":
|
|
||||||
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
||||||
default:
|
|
||||||
return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getThreatLevelColor = (level: string) => {
|
|
||||||
switch (level?.toLowerCase()) {
|
|
||||||
case "critical":
|
|
||||||
return "bg-red-500";
|
|
||||||
case "high":
|
|
||||||
return "bg-orange-500";
|
|
||||||
case "moderate":
|
|
||||||
return "bg-yellow-500";
|
|
||||||
case "low":
|
|
||||||
return "bg-green-500";
|
|
||||||
default:
|
|
||||||
return "bg-gray-500";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSeverityColor = (severity: string) => {
|
|
||||||
switch (severity?.toLowerCase()) {
|
|
||||||
case "critical":
|
|
||||||
return "destructive";
|
|
||||||
case "high":
|
|
||||||
return "destructive";
|
|
||||||
case "medium":
|
|
||||||
return "secondary";
|
|
||||||
case "low":
|
|
||||||
return "outline";
|
|
||||||
default:
|
|
||||||
return "outline";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
@ -191,132 +385,14 @@ export default function SecurityMonitoringPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-6 space-y-6">
|
<div className="container mx-auto px-4 py-6 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
{renderDashboardHeader(
|
||||||
<div>
|
autoRefresh,
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
setAutoRefresh,
|
||||||
Security Monitoring
|
setShowConfig,
|
||||||
</h1>
|
exportData
|
||||||
<p className="text-muted-foreground">
|
)}
|
||||||
Real-time security monitoring and threat detection
|
{renderTimeRangeSelector(selectedTimeRange, setSelectedTimeRange)}
|
||||||
</p>
|
{renderSecurityOverview(metrics)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
|
||||||
>
|
|
||||||
{autoRefresh ? (
|
|
||||||
<Bell className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<BellOff className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Auto Refresh
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowConfig(true)}
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
Configure
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => exportData("json", "alerts")}
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
Export
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Time Range Selector */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{["1h", "24h", "7d", "30d"].map((range) => (
|
|
||||||
<Button
|
|
||||||
key={range}
|
|
||||||
variant={selectedTimeRange === range ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setSelectedTimeRange(range)}
|
|
||||||
>
|
|
||||||
{range}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Overview Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Security Score
|
|
||||||
</CardTitle>
|
|
||||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{metrics?.securityScore || 0}/100
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${getThreatLevelColor(metrics?.threatLevel || "")}`}
|
|
||||||
>
|
|
||||||
{metrics?.threatLevel || "Unknown"} Threat Level
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Active Alerts</CardTitle>
|
|
||||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{metrics?.activeAlerts || 0}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{metrics?.resolvedAlerts || 0} resolved
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Security Events
|
|
||||||
</CardTitle>
|
|
||||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{metrics?.totalEvents || 0}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{metrics?.criticalEvents || 0} critical
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Top Threat</CardTitle>
|
|
||||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-sm font-bold">
|
|
||||||
{metrics?.topThreats?.[0]?.type?.replace(/_/g, " ") || "None"}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{metrics?.topThreats?.[0]?.count || 0} instances
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="alerts" className="space-y-4">
|
<Tabs defaultValue="alerts" className="space-y-4">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { ArrowLeft, Key, Shield, User } from "lucide-react";
|
import { ArrowLeft, Key, Shield, User } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useId, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@ -62,6 +62,13 @@ export default function PlatformSettings() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Generate unique IDs for form elements
|
||||||
|
const nameId = useId();
|
||||||
|
const emailId = useId();
|
||||||
|
const currentPasswordId = useId();
|
||||||
|
const newPasswordId = useId();
|
||||||
|
const confirmPasswordId = useId();
|
||||||
const [profileData, setProfileData] = useState({
|
const [profileData, setProfileData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
@ -223,9 +230,9 @@ export default function PlatformSettings() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleProfileUpdate} className="space-y-4">
|
<form onSubmit={handleProfileUpdate} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="name">Name</Label>
|
<Label htmlFor={nameId}>Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id={nameId}
|
||||||
value={profileData.name}
|
value={profileData.name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setProfileData({ ...profileData, name: e.target.value })
|
setProfileData({ ...profileData, name: e.target.value })
|
||||||
@ -234,9 +241,9 @@ export default function PlatformSettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor={emailId}>Email</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id={emailId}
|
||||||
type="email"
|
type="email"
|
||||||
value={profileData.email}
|
value={profileData.email}
|
||||||
disabled
|
disabled
|
||||||
@ -273,9 +280,9 @@ export default function PlatformSettings() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handlePasswordChange} className="space-y-4">
|
<form onSubmit={handlePasswordChange} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="current-password">Current Password</Label>
|
<Label htmlFor={currentPasswordId}>Current Password</Label>
|
||||||
<Input
|
<Input
|
||||||
id="current-password"
|
id={currentPasswordId}
|
||||||
type="password"
|
type="password"
|
||||||
value={passwordData.currentPassword}
|
value={passwordData.currentPassword}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@ -288,9 +295,9 @@ export default function PlatformSettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="new-password">New Password</Label>
|
<Label htmlFor={newPasswordId}>New Password</Label>
|
||||||
<Input
|
<Input
|
||||||
id="new-password"
|
id={newPasswordId}
|
||||||
type="password"
|
type="password"
|
||||||
value={passwordData.newPassword}
|
value={passwordData.newPassword}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@ -306,11 +313,11 @@ export default function PlatformSettings() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="confirm-password">
|
<Label htmlFor={confirmPasswordId}>
|
||||||
Confirm New Password
|
Confirm New Password
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="confirm-password"
|
id={confirmPasswordId}
|
||||||
type="password"
|
type="password"
|
||||||
value={passwordData.confirmPassword}
|
value={passwordData.confirmPassword}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
|||||||
@ -94,7 +94,6 @@ function displayReadyForAI(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Main orchestration function - complexity is appropriate for its scope
|
|
||||||
async function checkRefactoredPipelineStatus() {
|
async function checkRefactoredPipelineStatus() {
|
||||||
try {
|
try {
|
||||||
console.log("=== REFACTORED PIPELINE STATUS ===\n");
|
console.log("=== REFACTORED PIPELINE STATUS ===\n");
|
||||||
|
|||||||
@ -104,37 +104,43 @@ export default function GeographicMap({
|
|||||||
/**
|
/**
|
||||||
* Get coordinates for a country code
|
* Get coordinates for a country code
|
||||||
*/
|
*/
|
||||||
function getCountryCoordinates(
|
const getCountryCoordinates = useCallback(
|
||||||
code: string,
|
(
|
||||||
countryCoordinates: Record<string, [number, number]>
|
code: string,
|
||||||
): [number, number] | undefined {
|
countryCoordinates: Record<string, [number, number]>
|
||||||
// Try custom coordinates first (allows overrides)
|
): [number, number] | undefined => {
|
||||||
let coords: [number, number] | undefined = countryCoordinates[code];
|
// Try custom coordinates first (allows overrides)
|
||||||
|
let coords: [number, number] | undefined = countryCoordinates[code];
|
||||||
|
|
||||||
if (!coords) {
|
if (!coords) {
|
||||||
// Automatically get coordinates from country-coder library
|
// Automatically get coordinates from country-coder library
|
||||||
coords = getCoordinatesFromCountryCoder(code);
|
coords = getCoordinatesFromCountryCoder(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
return coords;
|
return coords;
|
||||||
}
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process a single country entry into CountryData
|
* Process a single country entry into CountryData
|
||||||
*/
|
*/
|
||||||
const processCountryEntry = useCallback((
|
const processCountryEntry = useCallback(
|
||||||
code: string,
|
(
|
||||||
count: number,
|
code: string,
|
||||||
countryCoordinates: Record<string, [number, number]>
|
count: number,
|
||||||
): CountryData | null => {
|
countryCoordinates: Record<string, [number, number]>
|
||||||
const coordinates = getCountryCoordinates(code, countryCoordinates);
|
): CountryData | null => {
|
||||||
|
const coordinates = getCountryCoordinates(code, countryCoordinates);
|
||||||
|
|
||||||
if (coordinates) {
|
if (coordinates) {
|
||||||
return { code, count, coordinates };
|
return { code, count, coordinates };
|
||||||
}
|
}
|
||||||
|
|
||||||
return null; // Skip if no coordinates found
|
return null; // Skip if no coordinates found
|
||||||
}, []);
|
},
|
||||||
|
[getCountryCoordinates]
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process all countries data into CountryData array
|
* Process all countries data into CountryData array
|
||||||
|
|||||||
@ -13,166 +13,254 @@ interface SessionDetailsProps {
|
|||||||
session: ChatSession;
|
session: ChatSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for basic session information
|
||||||
|
*/
|
||||||
|
function SessionBasicInfo({ session }: { session: ChatSession }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground mb-2">
|
||||||
|
Basic Information
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground">Session ID:</span>
|
||||||
|
<code className="ml-2 text-xs font-mono bg-muted px-1 py-0.5 rounded">
|
||||||
|
{session.id.slice(0, 8)}...
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground">Start Time:</span>
|
||||||
|
<span className="ml-2 text-sm">
|
||||||
|
{new Date(session.startTime).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{session.endTime && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground">End Time:</span>
|
||||||
|
<span className="ml-2 text-sm">
|
||||||
|
{new Date(session.endTime).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for session location and language
|
||||||
|
*/
|
||||||
|
function SessionLocationInfo({ session }: { session: ChatSession }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground mb-2">
|
||||||
|
Location & Language
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{session.countryCode && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">Country:</span>
|
||||||
|
<CountryDisplay countryCode={session.countryCode} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{session.language && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">Language:</span>
|
||||||
|
<LanguageDisplay languageCode={session.language} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{session.ipAddress && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground">IP Address:</span>
|
||||||
|
<span className="ml-2 font-mono text-sm">
|
||||||
|
{session.ipAddress}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for session metrics
|
||||||
|
*/
|
||||||
|
function SessionMetrics({ session }: { session: ChatSession }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground mb-2">
|
||||||
|
Session Metrics
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{session.messagesSent !== null &&
|
||||||
|
session.messagesSent !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Messages Sent:
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-sm font-medium">
|
||||||
|
{session.messagesSent}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{session.userId && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground">User ID:</span>
|
||||||
|
<span className="ml-2 text-sm">{session.userId}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for session analysis and status
|
||||||
|
*/
|
||||||
|
function SessionAnalysis({ session }: { session: ChatSession }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground mb-2">
|
||||||
|
AI Analysis
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{session.category && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">Category:</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{formatCategory(session.category)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{session.sentiment && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">Sentiment:</span>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
session.sentiment === "positive"
|
||||||
|
? "default"
|
||||||
|
: session.sentiment === "negative"
|
||||||
|
? "destructive"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{session.sentiment.charAt(0).toUpperCase() +
|
||||||
|
session.sentiment.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for session status flags
|
||||||
|
*/
|
||||||
|
function SessionStatusFlags({ session }: { session: ChatSession }) {
|
||||||
|
const hasStatusFlags =
|
||||||
|
session.escalated !== null || session.forwardedHr !== null;
|
||||||
|
|
||||||
|
if (!hasStatusFlags) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground mb-2">
|
||||||
|
Status Flags
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{session.escalated !== null && session.escalated !== undefined && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">Escalated:</span>
|
||||||
|
<Badge
|
||||||
|
variant={session.escalated ? "destructive" : "outline"}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{session.escalated ? "Yes" : "No"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{session.forwardedHr !== null &&
|
||||||
|
session.forwardedHr !== undefined && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Forwarded to HR:
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={session.forwardedHr ? "destructive" : "outline"}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{session.forwardedHr ? "Yes" : "No"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for session summary
|
||||||
|
*/
|
||||||
|
function SessionSummary({ session }: { session: ChatSession }) {
|
||||||
|
if (!session.summary) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">AI Summary</h4>
|
||||||
|
<p className="text-sm leading-relaxed border-l-4 border-muted pl-4 italic">
|
||||||
|
{session.summary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component to display session details with formatted country and language names
|
* Component to display session details with formatted country and language names
|
||||||
*/
|
*/
|
||||||
export default function SessionDetails({ session }: SessionDetailsProps) {
|
export default function SessionDetails({ session }: SessionDetailsProps) {
|
||||||
// Using centralized formatCategory utility
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Session Information</CardTitle>
|
<CardTitle>Session Information</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-3">
|
<SessionBasicInfo session={session} />
|
||||||
<div>
|
<SessionLocationInfo session={session} />
|
||||||
<p className="text-sm text-muted-foreground">Session ID</p>
|
|
||||||
<code className="text-sm font-mono bg-muted px-2 py-1 rounded">
|
|
||||||
{session.id.slice(0, 8)}...
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Start Time</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{new Date(session.startTime).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{session.endTime && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">End Time</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{new Date(session.endTime).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{session.category && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Category</p>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{formatCategory(session.category)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{session.language && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Language</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<LanguageDisplay languageCode={session.language} />
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{session.language.toUpperCase()}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{session.country && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Country</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CountryDisplay countryCode={session.country} />
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{session.country}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{session.sentiment !== null && session.sentiment !== undefined && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Sentiment</p>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
session.sentiment === "positive"
|
|
||||||
? "default"
|
|
||||||
: session.sentiment === "negative"
|
|
||||||
? "destructive"
|
|
||||||
: "secondary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{session.sentiment.charAt(0).toUpperCase() +
|
|
||||||
session.sentiment.slice(1)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Messages Sent</p>
|
|
||||||
<p className="font-medium">{session.messagesSent || 0}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{session.avgResponseTime !== null &&
|
|
||||||
session.avgResponseTime !== undefined && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Avg Response Time
|
|
||||||
</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
{session.avgResponseTime.toFixed(2)}s
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{session.escalated !== null && session.escalated !== undefined && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Escalated</p>
|
|
||||||
<Badge variant={session.escalated ? "destructive" : "default"}>
|
|
||||||
{session.escalated ? "Yes" : "No"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{session.forwardedHr !== null &&
|
|
||||||
session.forwardedHr !== undefined && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Forwarded to HR
|
|
||||||
</p>
|
|
||||||
<Badge
|
|
||||||
variant={session.forwardedHr ? "secondary" : "default"}
|
|
||||||
>
|
|
||||||
{session.forwardedHr ? "Yes" : "No"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{session.ipAddress && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">IP Address</p>
|
|
||||||
<code className="text-sm font-mono bg-muted px-2 py-1 rounded">
|
|
||||||
{session.ipAddress}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(session.summary || session.initialMsg) && <Separator />}
|
<Separator />
|
||||||
|
|
||||||
{session.summary && (
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<SessionMetrics session={session} />
|
||||||
<p className="text-sm text-muted-foreground mb-2">AI Summary</p>
|
<SessionAnalysis session={session} />
|
||||||
<div className="bg-muted p-3 rounded-md text-sm">
|
</div>
|
||||||
{session.summary}
|
|
||||||
</div>
|
<SessionStatusFlags session={session} />
|
||||||
</div>
|
|
||||||
)}
|
<SessionSummary session={session} />
|
||||||
|
|
||||||
{!session.summary && session.initialMsg && (
|
{!session.summary && session.initialMsg && (
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
<h4 className="text-sm font-medium text-muted-foreground">
|
||||||
Initial Message
|
Initial Message
|
||||||
</p>
|
</h4>
|
||||||
<div className="bg-muted p-3 rounded-md text-sm italic">
|
<p className="text-sm leading-relaxed border-l-4 border-muted pl-4 italic">
|
||||||
"{session.initialMsg}"
|
"{session.initialMsg}"
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
AlertCircle,
|
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
Clock,
|
||||||
Download,
|
Download,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Shield,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
XCircle,
|
XCircle,
|
||||||
Zap,
|
Zap,
|
||||||
@ -48,6 +48,21 @@ interface CircuitBreakerStatus {
|
|||||||
lastFailureTime: number;
|
lastFailureTime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SchedulerConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
intervals: {
|
||||||
|
batchCreation: number;
|
||||||
|
statusCheck: number;
|
||||||
|
resultProcessing: number;
|
||||||
|
retryFailures: number;
|
||||||
|
};
|
||||||
|
thresholds: {
|
||||||
|
maxRetries: number;
|
||||||
|
circuitBreakerThreshold: number;
|
||||||
|
batchSize: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface SchedulerStatus {
|
interface SchedulerStatus {
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
createBatchesRunning: boolean;
|
createBatchesRunning: boolean;
|
||||||
@ -58,7 +73,7 @@ interface SchedulerStatus {
|
|||||||
consecutiveErrors: number;
|
consecutiveErrors: number;
|
||||||
lastErrorTime: Date | null;
|
lastErrorTime: Date | null;
|
||||||
circuitBreakers: Record<string, CircuitBreakerStatus>;
|
circuitBreakers: Record<string, CircuitBreakerStatus>;
|
||||||
config: any;
|
config: SchedulerConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MonitoringData {
|
interface MonitoringData {
|
||||||
@ -74,6 +89,107 @@ interface MonitoringData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HealthStatusIcon({ status }: { status: string }) {
|
||||||
|
if (status === "healthy")
|
||||||
|
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||||
|
if (status === "warning")
|
||||||
|
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
|
||||||
|
if (status === "critical")
|
||||||
|
return <XCircle className="h-5 w-5 text-red-500" />;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemHealthCard({
|
||||||
|
health,
|
||||||
|
schedulerStatus,
|
||||||
|
}: {
|
||||||
|
health: { status: string; message: string };
|
||||||
|
schedulerStatus: {
|
||||||
|
csvImport?: boolean;
|
||||||
|
processing?: boolean;
|
||||||
|
batch?: boolean;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Activity className="h-5 w-5" />
|
||||||
|
System Health
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<HealthStatusIcon status={health.status} />
|
||||||
|
<span className="font-medium text-sm">{health.message}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>CSV Import Scheduler:</span>
|
||||||
|
<Badge
|
||||||
|
variant={schedulerStatus?.csvImport ? "default" : "secondary"}
|
||||||
|
>
|
||||||
|
{schedulerStatus?.csvImport ? "Running" : "Stopped"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>Processing Scheduler:</span>
|
||||||
|
<Badge
|
||||||
|
variant={schedulerStatus?.processing ? "default" : "secondary"}
|
||||||
|
>
|
||||||
|
{schedulerStatus?.processing ? "Running" : "Stopped"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>Batch Scheduler:</span>
|
||||||
|
<Badge variant={schedulerStatus?.batch ? "default" : "secondary"}>
|
||||||
|
{schedulerStatus?.batch ? "Running" : "Stopped"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CircuitBreakerCard({
|
||||||
|
circuitBreakerStatus,
|
||||||
|
}: {
|
||||||
|
circuitBreakerStatus: Record<string, string> | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5" />
|
||||||
|
Circuit Breakers
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{circuitBreakerStatus &&
|
||||||
|
Object.keys(circuitBreakerStatus).length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(circuitBreakerStatus).map(([key, status]) => (
|
||||||
|
<div key={key} className="flex justify-between text-sm">
|
||||||
|
<span>{key}:</span>
|
||||||
|
<Badge
|
||||||
|
variant={status === "CLOSED" ? "default" : "destructive"}
|
||||||
|
>
|
||||||
|
{status as string}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No circuit breakers configured
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function BatchMonitoringDashboard() {
|
export default function BatchMonitoringDashboard() {
|
||||||
const [monitoringData, setMonitoringData] = useState<MonitoringData | null>(
|
const [monitoringData, setMonitoringData] = useState<MonitoringData | null>(
|
||||||
null
|
null
|
||||||
@ -291,85 +407,8 @@ export default function BatchMonitoringDashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
<Card>
|
<SystemHealthCard health={health} schedulerStatus={schedulerStatus} />
|
||||||
<CardHeader>
|
<CircuitBreakerCard circuitBreakerStatus={circuitBreakerStatus} />
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Activity className="h-5 w-5" />
|
|
||||||
System Health
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
{health.status === "healthy" && (
|
|
||||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
|
||||||
)}
|
|
||||||
{health.status === "warning" && (
|
|
||||||
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
|
||||||
)}
|
|
||||||
{health.status === "critical" && (
|
|
||||||
<XCircle className="h-5 w-5 text-red-500" />
|
|
||||||
)}
|
|
||||||
{health.status === "unknown" && (
|
|
||||||
<AlertCircle className="h-5 w-5 text-gray-500" />
|
|
||||||
)}
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
health.status === "healthy" ? "default" : "destructive"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{health.message}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Scheduler Running:</span>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
schedulerStatus.isRunning ? "default" : "destructive"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{schedulerStatus.isRunning ? "Yes" : "No"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Paused:</span>
|
|
||||||
<Badge
|
|
||||||
variant={schedulerStatus.isPaused ? "destructive" : "default"}
|
|
||||||
>
|
|
||||||
{schedulerStatus.isPaused ? "Yes" : "No"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Consecutive Errors:</span>
|
|
||||||
<span>{schedulerStatus.consecutiveErrors}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Zap className="h-5 w-5" />
|
|
||||||
Circuit Breakers
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{Object.entries(circuitBreakerStatus).map(([name, status]) => (
|
|
||||||
<div key={name} className="flex justify-between items-center">
|
|
||||||
<span className="text-sm capitalize">
|
|
||||||
{name.replace(/([A-Z])/g, " $1").trim()}
|
|
||||||
</span>
|
|
||||||
<Badge variant={status.isOpen ? "destructive" : "default"}>
|
|
||||||
{status.isOpen ? "Open" : "Closed"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -157,8 +157,11 @@ export function TRPCDemo() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{topQuestions?.map((item, index) => (
|
{topQuestions?.map((item) => (
|
||||||
<div key={index} className="flex justify-between items-center">
|
<div
|
||||||
|
key={item.question}
|
||||||
|
className="flex justify-between items-center"
|
||||||
|
>
|
||||||
<span className="text-sm">{item.question}</span>
|
<span className="text-sm">{item.question}</span>
|
||||||
<Badge>{item.count}</Badge>
|
<Badge>{item.count}</Badge>
|
||||||
</div>
|
</div>
|
||||||
@ -223,8 +226,12 @@ export function TRPCDemo() {
|
|||||||
</p>
|
</p>
|
||||||
{session.questions && session.questions.length > 0 && (
|
{session.questions && session.questions.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{session.questions.slice(0, 3).map((question, idx) => (
|
{session.questions.slice(0, 3).map((question) => (
|
||||||
<Badge key={idx} variant="outline" className="text-xs">
|
<Badge
|
||||||
|
key={question}
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
{question.length > 50
|
{question.length > 50
|
||||||
? `${question.slice(0, 50)}...`
|
? `${question.slice(0, 50)}...`
|
||||||
: question}
|
: question}
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { FormEvent, ReactNode } from "react";
|
import type { FormEvent, ReactNode } from "react";
|
||||||
|
import { useId } from "react";
|
||||||
import { useCSRFForm } from "../../lib/hooks/useCSRF";
|
import { useCSRFForm } from "../../lib/hooks/useCSRF";
|
||||||
|
|
||||||
interface CSRFProtectedFormProps {
|
interface CSRFProtectedFormProps {
|
||||||
@ -82,6 +83,11 @@ export function CSRFProtectedForm({
|
|||||||
* Example usage component showing how to use CSRF protected forms
|
* Example usage component showing how to use CSRF protected forms
|
||||||
*/
|
*/
|
||||||
export function ExampleCSRFForm() {
|
export function ExampleCSRFForm() {
|
||||||
|
// Generate unique IDs for form elements
|
||||||
|
const nameId = useId();
|
||||||
|
const emailId = useId();
|
||||||
|
const messageId = useId();
|
||||||
|
|
||||||
const handleCustomSubmit = async (formData: FormData) => {
|
const handleCustomSubmit = async (formData: FormData) => {
|
||||||
// Custom form submission logic
|
// Custom form submission logic
|
||||||
const data = Object.fromEntries(formData.entries());
|
const data = Object.fromEntries(formData.entries());
|
||||||
@ -104,14 +110,14 @@ export function ExampleCSRFForm() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="name"
|
htmlFor={nameId}
|
||||||
className="block text-sm font-medium text-gray-700"
|
className="block text-sm font-medium text-gray-700"
|
||||||
>
|
>
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id={nameId}
|
||||||
name="name"
|
name="name"
|
||||||
required
|
required
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||||
@ -120,14 +126,14 @@ export function ExampleCSRFForm() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="email"
|
htmlFor={emailId}
|
||||||
className="block text-sm font-medium text-gray-700"
|
className="block text-sm font-medium text-gray-700"
|
||||||
>
|
>
|
||||||
Email
|
Email
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id={emailId}
|
||||||
name="email"
|
name="email"
|
||||||
required
|
required
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||||
@ -136,13 +142,13 @@ export function ExampleCSRFForm() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="message"
|
htmlFor={messageId}
|
||||||
className="block text-sm font-medium text-gray-700"
|
className="block text-sm font-medium text-gray-700"
|
||||||
>
|
>
|
||||||
Message
|
Message
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="message"
|
id={messageId}
|
||||||
name="message"
|
name="message"
|
||||||
rows={4}
|
rows={4}
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||||
|
|||||||
@ -8,7 +8,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { createContext, useContext, useEffect, useState, useCallback } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { CSRFClient } from "../../lib/csrf";
|
import { CSRFClient } from "../../lib/csrf";
|
||||||
|
|
||||||
interface CSRFContextType {
|
interface CSRFContextType {
|
||||||
|
|||||||
@ -149,7 +149,13 @@ export function GeographicThreatMap({
|
|||||||
{getCountryName(countryCode)}
|
{getCountryName(countryCode)}
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant={threat.color as "default" | "secondary" | "destructive" | "outline"}
|
variant={
|
||||||
|
threat.color as
|
||||||
|
| "default"
|
||||||
|
| "secondary"
|
||||||
|
| "destructive"
|
||||||
|
| "outline"
|
||||||
|
}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
{threat.level}
|
{threat.level}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useCallback, useEffect, useId, useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@ -58,6 +58,19 @@ export function SecurityConfigModal({
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Generate unique IDs for form elements
|
||||||
|
const failedLoginsPerMinuteId = useId();
|
||||||
|
const failedLoginsPerHourId = useId();
|
||||||
|
const rateLimitViolationsPerMinuteId = useId();
|
||||||
|
const cspViolationsPerMinuteId = useId();
|
||||||
|
const adminActionsPerHourId = useId();
|
||||||
|
const suspiciousIPThresholdId = useId();
|
||||||
|
const alertingEnabledId = useId();
|
||||||
|
const suppressDuplicateMinutesId = useId();
|
||||||
|
const escalationTimeoutMinutesId = useId();
|
||||||
|
const alertRetentionDaysId = useId();
|
||||||
|
const metricsRetentionDaysId = useId();
|
||||||
|
|
||||||
const loadConfig = useCallback(async () => {
|
const loadConfig = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/security-monitoring");
|
const response = await fetch("/api/admin/security-monitoring");
|
||||||
@ -207,11 +220,11 @@ export function SecurityConfigModal({
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="failedLoginsPerMinute">
|
<Label htmlFor={failedLoginsPerMinuteId}>
|
||||||
Failed Logins per Minute
|
Failed Logins per Minute
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="failedLoginsPerMinute"
|
id={failedLoginsPerMinuteId}
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="100"
|
max="100"
|
||||||
@ -226,11 +239,11 @@ export function SecurityConfigModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="failedLoginsPerHour">
|
<Label htmlFor={failedLoginsPerHourId}>
|
||||||
Failed Logins per Hour
|
Failed Logins per Hour
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="failedLoginsPerHour"
|
id={failedLoginsPerHourId}
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="1000"
|
max="1000"
|
||||||
@ -245,11 +258,11 @@ export function SecurityConfigModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="rateLimitViolationsPerMinute">
|
<Label htmlFor={rateLimitViolationsPerMinuteId}>
|
||||||
Rate Limit Violations per Minute
|
Rate Limit Violations per Minute
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="rateLimitViolationsPerMinute"
|
id={rateLimitViolationsPerMinuteId}
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="100"
|
max="100"
|
||||||
@ -264,11 +277,11 @@ export function SecurityConfigModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="cspViolationsPerMinute">
|
<Label htmlFor={cspViolationsPerMinuteId}>
|
||||||
CSP Violations per Minute
|
CSP Violations per Minute
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="cspViolationsPerMinute"
|
id={cspViolationsPerMinuteId}
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="100"
|
max="100"
|
||||||
@ -283,11 +296,11 @@ export function SecurityConfigModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="adminActionsPerHour">
|
<Label htmlFor={adminActionsPerHourId}>
|
||||||
Admin Actions per Hour
|
Admin Actions per Hour
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="adminActionsPerHour"
|
id={adminActionsPerHourId}
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="100"
|
max="100"
|
||||||
@ -302,11 +315,11 @@ export function SecurityConfigModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="suspiciousIPThreshold">
|
<Label htmlFor={suspiciousIPThresholdId}>
|
||||||
Suspicious IP Threshold
|
Suspicious IP Threshold
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="suspiciousIPThreshold"
|
id={suspiciousIPThresholdId}
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="100"
|
max="100"
|
||||||
@ -335,13 +348,13 @@ export function SecurityConfigModal({
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="alerting-enabled"
|
id={alertingEnabledId}
|
||||||
checked={config.alerting.enabled}
|
checked={config.alerting.enabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateAlerting("enabled", checked)
|
updateAlerting("enabled", checked)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="alerting-enabled">
|
<Label htmlFor={alertingEnabledId}>
|
||||||
Enable Security Alerting
|
Enable Security Alerting
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
@ -370,11 +383,11 @@ export function SecurityConfigModal({
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="suppressDuplicateMinutes">
|
<Label htmlFor={suppressDuplicateMinutesId}>
|
||||||
Suppress Duplicates (minutes)
|
Suppress Duplicates (minutes)
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="suppressDuplicateMinutes"
|
id={suppressDuplicateMinutesId}
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="1440"
|
max="1440"
|
||||||
@ -389,11 +402,11 @@ export function SecurityConfigModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="escalationTimeoutMinutes">
|
<Label htmlFor={escalationTimeoutMinutesId}>
|
||||||
Escalation Timeout (minutes)
|
Escalation Timeout (minutes)
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="escalationTimeoutMinutes"
|
id={escalationTimeoutMinutesId}
|
||||||
type="number"
|
type="number"
|
||||||
min="5"
|
min="5"
|
||||||
max="1440"
|
max="1440"
|
||||||
@ -422,11 +435,11 @@ export function SecurityConfigModal({
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="alertRetentionDays">
|
<Label htmlFor={alertRetentionDaysId}>
|
||||||
Alert Retention (days)
|
Alert Retention (days)
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="alertRetentionDays"
|
id={alertRetentionDaysId}
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="3650"
|
max="3650"
|
||||||
@ -441,11 +454,11 @@ export function SecurityConfigModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="metricsRetentionDays">
|
<Label htmlFor={metricsRetentionDaysId}>
|
||||||
Metrics Retention (days)
|
Metrics Retention (days)
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="metricsRetentionDays"
|
id={metricsRetentionDaysId}
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="3650"
|
max="3650"
|
||||||
|
|||||||
@ -70,7 +70,16 @@ export function ThreatLevelIndicator({
|
|||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={config.color as "default" | "secondary" | "destructive" | "outline"} className={classes.badge}>
|
<Badge
|
||||||
|
variant={
|
||||||
|
config.color as
|
||||||
|
| "default"
|
||||||
|
| "secondary"
|
||||||
|
| "destructive"
|
||||||
|
| "outline"
|
||||||
|
}
|
||||||
|
className={classes.badge}
|
||||||
|
>
|
||||||
{config.text}
|
{config.text}
|
||||||
</Badge>
|
</Badge>
|
||||||
{score !== undefined && (
|
{score !== undefined && (
|
||||||
|
|||||||
@ -3,83 +3,152 @@ import { ProcessingStatusManager } from "./lib/processingStatusManager";
|
|||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log pipeline status for each processing stage
|
||||||
|
*/
|
||||||
|
async function logPipelineStatus() {
|
||||||
|
const pipelineStatus = await ProcessingStatusManager.getPipelineStatus();
|
||||||
|
console.log(`Total Sessions: ${pipelineStatus.totalSessions}\n`);
|
||||||
|
|
||||||
|
const stages = [
|
||||||
|
"CSV_IMPORT",
|
||||||
|
"TRANSCRIPT_FETCH",
|
||||||
|
"SESSION_CREATION",
|
||||||
|
"AI_ANALYSIS",
|
||||||
|
"QUESTION_EXTRACTION",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const stage of stages) {
|
||||||
|
console.log(`${stage}:`);
|
||||||
|
const stageData = pipelineStatus.pipeline[stage] || {};
|
||||||
|
|
||||||
|
const pending = stageData.PENDING || 0;
|
||||||
|
const inProgress = stageData.IN_PROGRESS || 0;
|
||||||
|
const completed = stageData.COMPLETED || 0;
|
||||||
|
const skipped = stageData.SKIPPED || 0;
|
||||||
|
const failed = stageData.FAILED || 0;
|
||||||
|
|
||||||
|
console.log(` PENDING: ${pending}`);
|
||||||
|
console.log(` IN_PROGRESS: ${inProgress}`);
|
||||||
|
console.log(` COMPLETED: ${completed}`);
|
||||||
|
console.log(` SKIPPED: ${skipped}`);
|
||||||
|
console.log(` FAILED: ${failed}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log session import relationship analysis
|
||||||
|
*/
|
||||||
|
async function logSessionImportRelationship() {
|
||||||
|
console.log("=== SESSION <-> IMPORT RELATIONSHIP ===");
|
||||||
|
|
||||||
|
const sessionWithImport = await prisma.session.count({
|
||||||
|
where: { importId: { not: null } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionWithoutImport = await prisma.session.count({
|
||||||
|
where: { importId: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const importWithSession = await prisma.sessionImport.count({
|
||||||
|
where: { session: { isNot: null } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const importWithoutSession = await prisma.sessionImport.count({
|
||||||
|
where: { session: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Sessions with ImportId: ${sessionWithImport}`);
|
||||||
|
console.log(`Sessions without ImportId: ${sessionWithoutImport}`);
|
||||||
|
console.log(`Imports with Session: ${importWithSession}`);
|
||||||
|
console.log(`Imports without Session: ${importWithoutSession}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log failed processing sessions
|
||||||
|
*/
|
||||||
|
async function logFailedSessions() {
|
||||||
|
console.log("=== FAILED PROCESSING ANALYSIS ===");
|
||||||
|
|
||||||
|
const failedSessions = await prisma.sessionProcessingStatus.findMany({
|
||||||
|
where: { status: "FAILED" },
|
||||||
|
include: {
|
||||||
|
session: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
import: {
|
||||||
|
select: { externalSessionId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (failedSessions.length > 0) {
|
||||||
|
console.log("Sample failed sessions:");
|
||||||
|
for (const failed of failedSessions) {
|
||||||
|
console.log(
|
||||||
|
` Session ${failed.session?.import?.externalSessionId || failed.sessionId} - Stage: ${failed.stage}, Error: ${failed.error}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("No failed processing found");
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log processing pipeline needs analysis
|
||||||
|
*/
|
||||||
|
async function logProcessingNeeds(pipelineStatus: {
|
||||||
|
pipeline: Record<string, Record<string, number>>;
|
||||||
|
}) {
|
||||||
|
console.log("=== WHAT NEEDS PROCESSING? ===");
|
||||||
|
|
||||||
|
const needsTranscriptFetch =
|
||||||
|
pipelineStatus.pipeline.TRANSCRIPT_FETCH?.PENDING || 0;
|
||||||
|
const needsSessionCreation =
|
||||||
|
pipelineStatus.pipeline.SESSION_CREATION?.PENDING || 0;
|
||||||
|
const needsAIAnalysis = pipelineStatus.pipeline.AI_ANALYSIS?.PENDING || 0;
|
||||||
|
const needsQuestionExtraction =
|
||||||
|
pipelineStatus.pipeline.QUESTION_EXTRACTION?.PENDING || 0;
|
||||||
|
|
||||||
|
if (needsTranscriptFetch > 0) {
|
||||||
|
console.log(`${needsTranscriptFetch} sessions need transcript fetching`);
|
||||||
|
}
|
||||||
|
if (needsSessionCreation > 0) {
|
||||||
|
console.log(`${needsSessionCreation} sessions need session creation`);
|
||||||
|
}
|
||||||
|
if (needsAIAnalysis > 0) {
|
||||||
|
console.log(`${needsAIAnalysis} sessions need AI analysis`);
|
||||||
|
}
|
||||||
|
if (needsQuestionExtraction > 0) {
|
||||||
|
console.log(`${needsQuestionExtraction} sessions need question extraction`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
needsTranscriptFetch +
|
||||||
|
needsSessionCreation +
|
||||||
|
needsAIAnalysis +
|
||||||
|
needsQuestionExtraction ===
|
||||||
|
0
|
||||||
|
) {
|
||||||
|
console.log("All sessions are fully processed!");
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
async function debugImportStatus() {
|
async function debugImportStatus() {
|
||||||
try {
|
try {
|
||||||
console.log("=== DEBUGGING PROCESSING STATUS (REFACTORED SYSTEM) ===\n");
|
console.log("=== DEBUGGING PROCESSING STATUS (REFACTORED SYSTEM) ===\n");
|
||||||
|
|
||||||
// Get pipeline status using the new system
|
|
||||||
const pipelineStatus = await ProcessingStatusManager.getPipelineStatus();
|
const pipelineStatus = await ProcessingStatusManager.getPipelineStatus();
|
||||||
|
|
||||||
console.log(`Total Sessions: ${pipelineStatus.totalSessions}\n`);
|
await logPipelineStatus();
|
||||||
|
await logSessionImportRelationship();
|
||||||
// Display status for each stage
|
await logFailedSessions();
|
||||||
const stages = [
|
await logProcessingNeeds(pipelineStatus);
|
||||||
"CSV_IMPORT",
|
|
||||||
"TRANSCRIPT_FETCH",
|
|
||||||
"SESSION_CREATION",
|
|
||||||
"AI_ANALYSIS",
|
|
||||||
"QUESTION_EXTRACTION",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const stage of stages) {
|
|
||||||
console.log(`${stage}:`);
|
|
||||||
const stageData = pipelineStatus.pipeline[stage] || {};
|
|
||||||
|
|
||||||
const pending = stageData.PENDING || 0;
|
|
||||||
const inProgress = stageData.IN_PROGRESS || 0;
|
|
||||||
const completed = stageData.COMPLETED || 0;
|
|
||||||
const failed = stageData.FAILED || 0;
|
|
||||||
const skipped = stageData.SKIPPED || 0;
|
|
||||||
|
|
||||||
console.log(` PENDING: ${pending}`);
|
|
||||||
console.log(` IN_PROGRESS: ${inProgress}`);
|
|
||||||
console.log(` COMPLETED: ${completed}`);
|
|
||||||
console.log(` FAILED: ${failed}`);
|
|
||||||
console.log(` SKIPPED: ${skipped}`);
|
|
||||||
console.log("");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Sessions vs SessionImports
|
|
||||||
console.log("=== SESSION IMPORT RELATIONSHIP ===");
|
|
||||||
const sessionsWithImports = await prisma.session.count({
|
|
||||||
where: { importId: { not: null } },
|
|
||||||
});
|
|
||||||
const totalSessions = await prisma.session.count();
|
|
||||||
|
|
||||||
console.log(` Sessions with importId: ${sessionsWithImports}`);
|
|
||||||
console.log(` Total sessions: ${totalSessions}`);
|
|
||||||
|
|
||||||
// Show failed sessions if any
|
|
||||||
const failedSessions = await ProcessingStatusManager.getFailedSessions();
|
|
||||||
if (failedSessions.length > 0) {
|
|
||||||
console.log("\n=== FAILED SESSIONS ===");
|
|
||||||
failedSessions.slice(0, 10).forEach((failure) => {
|
|
||||||
console.log(
|
|
||||||
` ${failure.session.import?.externalSessionId || failure.sessionId}: ${failure.stage} - ${failure.errorMessage}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (failedSessions.length > 10) {
|
|
||||||
console.log(
|
|
||||||
` ... and ${failedSessions.length - 10} more failed sessions`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log("\n✓ No failed sessions found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show what needs processing
|
|
||||||
console.log("\n=== WHAT NEEDS PROCESSING ===");
|
|
||||||
|
|
||||||
for (const stage of stages) {
|
|
||||||
const stageData = pipelineStatus.pipeline[stage] || {};
|
|
||||||
const pending = stageData.PENDING || 0;
|
|
||||||
const failed = stageData.FAILED || 0;
|
|
||||||
|
|
||||||
if (pending > 0 || failed > 0) {
|
|
||||||
console.log(`• ${stage}: ${pending} pending, ${failed} failed`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error debugging processing status:", error);
|
console.error("Error debugging processing status:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -6,6 +6,30 @@ import {
|
|||||||
securityAuditLogger,
|
securityAuditLogger,
|
||||||
} from "./securityAuditLogger";
|
} from "./securityAuditLogger";
|
||||||
|
|
||||||
|
type AuditSeverity = "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" | "INFO";
|
||||||
|
|
||||||
|
interface PolicyResult {
|
||||||
|
policyName: string;
|
||||||
|
processed: number;
|
||||||
|
deleted: number;
|
||||||
|
archived: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WhereClause {
|
||||||
|
timestamp: { lt: Date };
|
||||||
|
severity?: { in: AuditSeverity[] };
|
||||||
|
eventType?: { in: SecurityEventType[] };
|
||||||
|
companyId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RetentionResults {
|
||||||
|
totalProcessed: number;
|
||||||
|
totalDeleted: number;
|
||||||
|
totalArchived: number;
|
||||||
|
policyResults: PolicyResult[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RetentionPolicy {
|
export interface RetentionPolicy {
|
||||||
name: string;
|
name: string;
|
||||||
maxAgeDays: number;
|
maxAgeDays: number;
|
||||||
@ -65,32 +89,7 @@ export class AuditLogRetentionManager {
|
|||||||
this.isDryRun = isDryRun;
|
this.isDryRun = isDryRun;
|
||||||
}
|
}
|
||||||
|
|
||||||
async executeRetentionPolicies(): Promise<{
|
private async logRetentionStart(): Promise<void> {
|
||||||
totalProcessed: number;
|
|
||||||
totalDeleted: number;
|
|
||||||
totalArchived: number;
|
|
||||||
policyResults: Array<{
|
|
||||||
policyName: string;
|
|
||||||
processed: number;
|
|
||||||
deleted: number;
|
|
||||||
archived: number;
|
|
||||||
errors: string[];
|
|
||||||
}>;
|
|
||||||
}> {
|
|
||||||
const results = {
|
|
||||||
totalProcessed: 0,
|
|
||||||
totalDeleted: 0,
|
|
||||||
totalArchived: 0,
|
|
||||||
policyResults: [] as Array<{
|
|
||||||
policyName: string;
|
|
||||||
processed: number;
|
|
||||||
deleted: number;
|
|
||||||
archived: number;
|
|
||||||
errors: string[];
|
|
||||||
}>,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Log retention policy execution start
|
|
||||||
await securityAuditLogger.log({
|
await securityAuditLogger.log({
|
||||||
eventType: SecurityEventType.SYSTEM_CONFIG,
|
eventType: SecurityEventType.SYSTEM_CONFIG,
|
||||||
action: this.isDryRun
|
action: this.isDryRun
|
||||||
@ -109,34 +108,135 @@ export class AuditLogRetentionManager {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWhereClause(
|
||||||
|
policy: RetentionPolicy,
|
||||||
|
cutoffDate: Date
|
||||||
|
): WhereClause {
|
||||||
|
const whereClause: WhereClause = {
|
||||||
|
timestamp: { lt: cutoffDate },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (policy.severityFilter && policy.severityFilter.length > 0) {
|
||||||
|
whereClause.severity = { in: policy.severityFilter };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (policy.eventTypeFilter && policy.eventTypeFilter.length > 0) {
|
||||||
|
whereClause.eventType = { in: policy.eventTypeFilter };
|
||||||
|
}
|
||||||
|
|
||||||
|
return whereClause;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processDryRun(
|
||||||
|
policy: RetentionPolicy,
|
||||||
|
logsToProcess: number,
|
||||||
|
policyResult: PolicyResult
|
||||||
|
): Promise<void> {
|
||||||
|
console.log(
|
||||||
|
`DRY RUN: Would process ${logsToProcess} logs for policy "${policy.name}"`
|
||||||
|
);
|
||||||
|
if (policy.archiveBeforeDelete) {
|
||||||
|
policyResult.archived = logsToProcess;
|
||||||
|
} else {
|
||||||
|
policyResult.deleted = logsToProcess;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processActualRetention(
|
||||||
|
policy: RetentionPolicy,
|
||||||
|
logsToProcess: number,
|
||||||
|
cutoffDate: Date,
|
||||||
|
whereClause: WhereClause,
|
||||||
|
policyResult: PolicyResult
|
||||||
|
): Promise<void> {
|
||||||
|
if (policy.archiveBeforeDelete) {
|
||||||
|
await securityAuditLogger.log({
|
||||||
|
eventType: SecurityEventType.DATA_PRIVACY,
|
||||||
|
action: "audit_logs_archived",
|
||||||
|
outcome: AuditOutcome.SUCCESS,
|
||||||
|
context: {
|
||||||
|
metadata: createAuditMetadata({
|
||||||
|
policyName: policy.name,
|
||||||
|
logsArchived: logsToProcess,
|
||||||
|
cutoffDate: cutoffDate.toISOString(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
policyResult.archived = logsToProcess;
|
||||||
|
console.log(`Policy "${policy.name}": Archived ${logsToProcess} logs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteResult = await prisma.securityAuditLog.deleteMany({
|
||||||
|
where: whereClause,
|
||||||
|
});
|
||||||
|
|
||||||
|
policyResult.deleted = deleteResult.count;
|
||||||
|
console.log(`Policy "${policy.name}": Deleted ${deleteResult.count} logs`);
|
||||||
|
|
||||||
|
await securityAuditLogger.log({
|
||||||
|
eventType: SecurityEventType.DATA_PRIVACY,
|
||||||
|
action: "audit_logs_deleted",
|
||||||
|
outcome: AuditOutcome.SUCCESS,
|
||||||
|
context: {
|
||||||
|
metadata: createAuditMetadata({
|
||||||
|
policyName: policy.name,
|
||||||
|
logsDeleted: deleteResult.count,
|
||||||
|
cutoffDate: cutoffDate.toISOString(),
|
||||||
|
wasArchived: policy.archiveBeforeDelete,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logRetentionCompletion(
|
||||||
|
results: RetentionResults
|
||||||
|
): Promise<void> {
|
||||||
|
await securityAuditLogger.log({
|
||||||
|
eventType: SecurityEventType.SYSTEM_CONFIG,
|
||||||
|
action: this.isDryRun
|
||||||
|
? "audit_log_retention_dry_run_completed"
|
||||||
|
: "audit_log_retention_completed",
|
||||||
|
outcome: AuditOutcome.SUCCESS,
|
||||||
|
context: {
|
||||||
|
metadata: createAuditMetadata({
|
||||||
|
totalProcessed: results.totalProcessed,
|
||||||
|
totalDeleted: results.totalDeleted,
|
||||||
|
totalArchived: results.totalArchived,
|
||||||
|
policiesExecuted: this.policies.length,
|
||||||
|
isDryRun: this.isDryRun,
|
||||||
|
results: results.policyResults,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeRetentionPolicies(): Promise<RetentionResults> {
|
||||||
|
const results: RetentionResults = {
|
||||||
|
totalProcessed: 0,
|
||||||
|
totalDeleted: 0,
|
||||||
|
totalArchived: 0,
|
||||||
|
policyResults: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.logRetentionStart();
|
||||||
|
|
||||||
for (const policy of this.policies) {
|
for (const policy of this.policies) {
|
||||||
const policyResult = {
|
const policyResult: PolicyResult = {
|
||||||
policyName: policy.name,
|
policyName: policy.name,
|
||||||
processed: 0,
|
processed: 0,
|
||||||
deleted: 0,
|
deleted: 0,
|
||||||
archived: 0,
|
archived: 0,
|
||||||
errors: [] as string[],
|
errors: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cutoffDate = new Date();
|
const cutoffDate = new Date();
|
||||||
cutoffDate.setDate(cutoffDate.getDate() - policy.maxAgeDays);
|
cutoffDate.setDate(cutoffDate.getDate() - policy.maxAgeDays);
|
||||||
|
const whereClause = this.buildWhereClause(policy, cutoffDate);
|
||||||
|
|
||||||
// Build where clause based on policy filters
|
|
||||||
const whereClause: any = {
|
|
||||||
timestamp: { lt: cutoffDate },
|
|
||||||
};
|
|
||||||
|
|
||||||
if (policy.severityFilter && policy.severityFilter.length > 0) {
|
|
||||||
whereClause.severity = { in: policy.severityFilter };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (policy.eventTypeFilter && policy.eventTypeFilter.length > 0) {
|
|
||||||
whereClause.eventType = { in: policy.eventTypeFilter };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count logs to be processed
|
|
||||||
const logsToProcess = await prisma.securityAuditLog.count({
|
const logsToProcess = await prisma.securityAuditLog.count({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
});
|
});
|
||||||
@ -155,68 +255,21 @@ export class AuditLogRetentionManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (this.isDryRun) {
|
if (this.isDryRun) {
|
||||||
console.log(
|
await this.processDryRun(policy, logsToProcess, policyResult);
|
||||||
`DRY RUN: Would process ${logsToProcess} logs for policy "${policy.name}"`
|
|
||||||
);
|
|
||||||
if (policy.archiveBeforeDelete) {
|
|
||||||
policyResult.archived = logsToProcess;
|
|
||||||
} else {
|
|
||||||
policyResult.deleted = logsToProcess;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (policy.archiveBeforeDelete) {
|
await this.processActualRetention(
|
||||||
// In a real implementation, you would export/archive these logs
|
policy,
|
||||||
// For now, we'll just log the archival action
|
logsToProcess,
|
||||||
await securityAuditLogger.log({
|
cutoffDate,
|
||||||
eventType: SecurityEventType.DATA_PRIVACY,
|
whereClause,
|
||||||
action: "audit_logs_archived",
|
policyResult
|
||||||
outcome: AuditOutcome.SUCCESS,
|
|
||||||
context: {
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
policyName: policy.name,
|
|
||||||
logsArchived: logsToProcess,
|
|
||||||
cutoffDate: cutoffDate.toISOString(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
policyResult.archived = logsToProcess;
|
|
||||||
console.log(
|
|
||||||
`Policy "${policy.name}": Archived ${logsToProcess} logs`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the logs
|
|
||||||
const deleteResult = await prisma.securityAuditLog.deleteMany({
|
|
||||||
where: whereClause,
|
|
||||||
});
|
|
||||||
|
|
||||||
policyResult.deleted = deleteResult.count;
|
|
||||||
console.log(
|
|
||||||
`Policy "${policy.name}": Deleted ${deleteResult.count} logs`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log deletion action
|
|
||||||
await securityAuditLogger.log({
|
|
||||||
eventType: SecurityEventType.DATA_PRIVACY,
|
|
||||||
action: "audit_logs_deleted",
|
|
||||||
outcome: AuditOutcome.SUCCESS,
|
|
||||||
context: {
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
policyName: policy.name,
|
|
||||||
logsDeleted: deleteResult.count,
|
|
||||||
cutoffDate: cutoffDate.toISOString(),
|
|
||||||
wasArchived: policy.archiveBeforeDelete,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = `Error processing policy "${policy.name}": ${error}`;
|
const errorMessage = `Error processing policy "${policy.name}": ${error}`;
|
||||||
policyResult.errors.push(errorMessage);
|
policyResult.errors.push(errorMessage);
|
||||||
console.error(errorMessage);
|
console.error(errorMessage);
|
||||||
|
|
||||||
// Log retention policy error
|
|
||||||
await securityAuditLogger.log({
|
await securityAuditLogger.log({
|
||||||
eventType: SecurityEventType.SYSTEM_CONFIG,
|
eventType: SecurityEventType.SYSTEM_CONFIG,
|
||||||
action: "audit_log_retention_policy_error",
|
action: "audit_log_retention_policy_error",
|
||||||
@ -237,25 +290,7 @@ export class AuditLogRetentionManager {
|
|||||||
results.totalArchived += policyResult.archived;
|
results.totalArchived += policyResult.archived;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log retention policy execution completion
|
await this.logRetentionCompletion(results);
|
||||||
await securityAuditLogger.log({
|
|
||||||
eventType: SecurityEventType.SYSTEM_CONFIG,
|
|
||||||
action: this.isDryRun
|
|
||||||
? "audit_log_retention_dry_run_completed"
|
|
||||||
: "audit_log_retention_completed",
|
|
||||||
outcome: AuditOutcome.SUCCESS,
|
|
||||||
context: {
|
|
||||||
metadata: createAuditMetadata({
|
|
||||||
totalProcessed: results.totalProcessed,
|
|
||||||
totalDeleted: results.totalDeleted,
|
|
||||||
totalArchived: results.totalArchived,
|
|
||||||
policiesExecuted: this.policies.length,
|
|
||||||
isDryRun: this.isDryRun,
|
|
||||||
results: results.policyResults,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -348,6 +383,55 @@ export class AuditLogRetentionManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private validatePolicyStructure(
|
||||||
|
policy: RetentionPolicy,
|
||||||
|
errors: string[]
|
||||||
|
): void {
|
||||||
|
if (!policy.name || policy.name.trim() === "") {
|
||||||
|
errors.push("Policy must have a non-empty name");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policy.maxAgeDays || policy.maxAgeDays <= 0) {
|
||||||
|
errors.push(
|
||||||
|
`Policy "${policy.name}": maxAgeDays must be a positive number`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validatePolicyFilters(
|
||||||
|
policy: RetentionPolicy,
|
||||||
|
warnings: string[]
|
||||||
|
): void {
|
||||||
|
if (policy.severityFilter && policy.eventTypeFilter) {
|
||||||
|
warnings.push(
|
||||||
|
`Policy "${policy.name}": Has both severity and event type filters, ensure this is intentional`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policy.severityFilter && !policy.eventTypeFilter) {
|
||||||
|
warnings.push(
|
||||||
|
`Policy "${policy.name}": No filters specified, will apply to all logs`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateRetentionPeriods(
|
||||||
|
policy: RetentionPolicy,
|
||||||
|
warnings: string[]
|
||||||
|
): void {
|
||||||
|
if (policy.maxAgeDays < 30) {
|
||||||
|
warnings.push(
|
||||||
|
`Policy "${policy.name}": Very short retention period (${policy.maxAgeDays} days)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (policy.maxAgeDays > 1095 && !policy.archiveBeforeDelete) {
|
||||||
|
warnings.push(
|
||||||
|
`Policy "${policy.name}": Long retention period without archiving may impact performance`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async validateRetentionPolicies(): Promise<{
|
async validateRetentionPolicies(): Promise<{
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
@ -357,46 +441,11 @@ export class AuditLogRetentionManager {
|
|||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
for (const policy of this.policies) {
|
for (const policy of this.policies) {
|
||||||
// Validate policy structure
|
this.validatePolicyStructure(policy, errors);
|
||||||
if (!policy.name || policy.name.trim() === "") {
|
this.validatePolicyFilters(policy, warnings);
|
||||||
errors.push("Policy must have a non-empty name");
|
this.validateRetentionPeriods(policy, warnings);
|
||||||
}
|
|
||||||
|
|
||||||
if (!policy.maxAgeDays || policy.maxAgeDays <= 0) {
|
|
||||||
errors.push(
|
|
||||||
`Policy "${policy.name}": maxAgeDays must be a positive number`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate filters
|
|
||||||
if (policy.severityFilter && policy.eventTypeFilter) {
|
|
||||||
warnings.push(
|
|
||||||
`Policy "${policy.name}": Has both severity and event type filters, ensure this is intentional`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!policy.severityFilter && !policy.eventTypeFilter) {
|
|
||||||
warnings.push(
|
|
||||||
`Policy "${policy.name}": No filters specified, will apply to all logs`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warn about very short retention periods
|
|
||||||
if (policy.maxAgeDays < 30) {
|
|
||||||
warnings.push(
|
|
||||||
`Policy "${policy.name}": Very short retention period (${policy.maxAgeDays} days)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warn about very long retention periods without archiving
|
|
||||||
if (policy.maxAgeDays > 1095 && !policy.archiveBeforeDelete) {
|
|
||||||
warnings.push(
|
|
||||||
`Policy "${policy.name}": Long retention period without archiving may impact performance`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for overlapping policies that might conflict
|
|
||||||
const overlaps = this.findPolicyOverlaps();
|
const overlaps = this.findPolicyOverlaps();
|
||||||
if (overlaps.length > 0) {
|
if (overlaps.length > 0) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
|
|||||||
@ -48,7 +48,7 @@ export interface BatchLogContext {
|
|||||||
statusAfter?: AIBatchRequestStatus | AIRequestStatus;
|
statusAfter?: AIBatchRequestStatus | AIRequestStatus;
|
||||||
errorCode?: string;
|
errorCode?: string;
|
||||||
circuitBreakerState?: "OPEN" | "CLOSED" | "HALF_OPEN";
|
circuitBreakerState?: "OPEN" | "CLOSED" | "HALF_OPEN";
|
||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchMetrics {
|
export interface BatchMetrics {
|
||||||
@ -429,7 +429,20 @@ class BatchLoggerService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private logToConsole(logEntry: any): void {
|
private logToConsole(logEntry: {
|
||||||
|
timestamp: string;
|
||||||
|
level: BatchLogLevel;
|
||||||
|
operation: BatchOperation;
|
||||||
|
message: string;
|
||||||
|
context: BatchLogContext;
|
||||||
|
error?: {
|
||||||
|
name: string;
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
cause?: string;
|
||||||
|
};
|
||||||
|
operationId: string;
|
||||||
|
}): void {
|
||||||
const color = this.LOG_COLORS[logEntry.level as BatchLogLevel] || "";
|
const color = this.LOG_COLORS[logEntry.level as BatchLogLevel] || "";
|
||||||
const prefix = `${color}[BATCH-${logEntry.level}]${this.RESET_COLOR}`;
|
const prefix = `${color}[BATCH-${logEntry.level}]${this.RESET_COLOR}`;
|
||||||
|
|
||||||
@ -444,7 +457,20 @@ class BatchLoggerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private logToStructured(logEntry: any): void {
|
private logToStructured(logEntry: {
|
||||||
|
timestamp: string;
|
||||||
|
level: BatchLogLevel;
|
||||||
|
operation: BatchOperation;
|
||||||
|
message: string;
|
||||||
|
context: BatchLogContext;
|
||||||
|
error?: {
|
||||||
|
name: string;
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
cause?: string;
|
||||||
|
};
|
||||||
|
operationId: string;
|
||||||
|
}): void {
|
||||||
// In production, this would write to structured logging service
|
// In production, this would write to structured logging service
|
||||||
// (e.g., Winston, Pino, or cloud logging service)
|
// (e.g., Winston, Pino, or cloud logging service)
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "production") {
|
||||||
@ -548,7 +574,12 @@ class BatchLoggerService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private sanitizeContext(context: BatchLogContext): any {
|
private sanitizeContext(context: BatchLogContext): Omit<
|
||||||
|
BatchLogContext,
|
||||||
|
"metadata"
|
||||||
|
> & {
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
} {
|
||||||
// Remove sensitive information from context before logging
|
// Remove sensitive information from context before logging
|
||||||
const sanitized = { ...context };
|
const sanitized = { ...context };
|
||||||
delete sanitized.metadata?.apiKey;
|
delete sanitized.metadata?.apiKey;
|
||||||
@ -556,7 +587,12 @@ class BatchLoggerService {
|
|||||||
return sanitized;
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatError(error: Error): any {
|
private formatError(error: Error): {
|
||||||
|
name: string;
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
cause?: string;
|
||||||
|
} {
|
||||||
return {
|
return {
|
||||||
name: error.name,
|
name: error.name,
|
||||||
message: error.message,
|
message: error.message,
|
||||||
@ -565,7 +601,7 @@ class BatchLoggerService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatContextForConsole(context: any): string {
|
private formatContextForConsole(context: BatchLogContext): string {
|
||||||
const important = {
|
const important = {
|
||||||
operation: context.operation,
|
operation: context.operation,
|
||||||
batchId: context.batchId,
|
batchId: context.batchId,
|
||||||
@ -598,12 +634,12 @@ setInterval(
|
|||||||
); // Every hour
|
); // Every hour
|
||||||
|
|
||||||
// Helper functions for common logging patterns
|
// Helper functions for common logging patterns
|
||||||
export const logBatchOperation = async (
|
export const logBatchOperation = async <T>(
|
||||||
operation: BatchOperation,
|
operation: BatchOperation,
|
||||||
operationId: string,
|
operationId: string,
|
||||||
fn: () => Promise<any>,
|
fn: () => Promise<T>,
|
||||||
context: Partial<BatchLogContext> = {}
|
context: Partial<BatchLogContext> = {}
|
||||||
): Promise<any> => {
|
): Promise<T> => {
|
||||||
batchLogger.startOperation(operationId);
|
batchLogger.startOperation(operationId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -50,6 +50,12 @@ class CircuitBreaker {
|
|||||||
private lastFailureTime = 0;
|
private lastFailureTime = 0;
|
||||||
private isOpen = false;
|
private isOpen = false;
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.failures = 0;
|
||||||
|
this.isOpen = false;
|
||||||
|
this.lastFailureTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
async execute<T>(operation: () => Promise<T>): Promise<T> {
|
async execute<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
if (this.isOpen) {
|
if (this.isOpen) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@ -159,6 +165,56 @@ const batchCreationCircuitBreaker = new CircuitBreaker();
|
|||||||
const batchStatusCircuitBreaker = new CircuitBreaker();
|
const batchStatusCircuitBreaker = new CircuitBreaker();
|
||||||
const fileDownloadCircuitBreaker = new CircuitBreaker();
|
const fileDownloadCircuitBreaker = new CircuitBreaker();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error should prevent retries
|
||||||
|
*/
|
||||||
|
function shouldNotRetry(error: Error): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof NonRetryableError ||
|
||||||
|
error instanceof CircuitBreakerOpenError ||
|
||||||
|
!isErrorRetryable(error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate exponential backoff delay
|
||||||
|
*/
|
||||||
|
function calculateRetryDelay(attempt: number): number {
|
||||||
|
return Math.min(
|
||||||
|
BATCH_CONFIG.BASE_RETRY_DELAY *
|
||||||
|
BATCH_CONFIG.EXPONENTIAL_BACKOFF_MULTIPLIER ** attempt,
|
||||||
|
BATCH_CONFIG.MAX_RETRY_DELAY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle retry attempt logging and delay
|
||||||
|
*/
|
||||||
|
async function handleRetryAttempt(
|
||||||
|
operationName: string,
|
||||||
|
attempt: number,
|
||||||
|
maxRetries: number,
|
||||||
|
error: Error
|
||||||
|
): Promise<void> {
|
||||||
|
const delay = calculateRetryDelay(attempt);
|
||||||
|
|
||||||
|
await batchLogger.logRetry(
|
||||||
|
BatchOperation.RETRY_OPERATION,
|
||||||
|
operationName,
|
||||||
|
attempt + 1,
|
||||||
|
maxRetries + 1,
|
||||||
|
delay,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`${operationName} failed on attempt ${attempt + 1}, retrying in ${delay}ms:`,
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retry utility with exponential backoff
|
* Retry utility with exponential backoff
|
||||||
*/
|
*/
|
||||||
@ -179,20 +235,8 @@ async function retryWithBackoff<T>(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error as Error;
|
lastError = error as Error;
|
||||||
|
|
||||||
// Don't retry non-retryable errors
|
if (shouldNotRetry(lastError)) {
|
||||||
if (
|
throw lastError;
|
||||||
error instanceof NonRetryableError ||
|
|
||||||
error instanceof CircuitBreakerOpenError
|
|
||||||
) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if error is retryable based on type
|
|
||||||
const isRetryable = isErrorRetryable(error as Error);
|
|
||||||
if (!isRetryable) {
|
|
||||||
throw new NonRetryableError(
|
|
||||||
`Non-retryable error in ${operationName}: ${(error as Error).message}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempt === maxRetries) {
|
if (attempt === maxRetries) {
|
||||||
@ -202,31 +246,11 @@ async function retryWithBackoff<T>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const delay = Math.min(
|
await handleRetryAttempt(operationName, attempt, maxRetries, lastError);
|
||||||
BATCH_CONFIG.BASE_RETRY_DELAY *
|
|
||||||
BATCH_CONFIG.EXPONENTIAL_BACKOFF_MULTIPLIER ** attempt,
|
|
||||||
BATCH_CONFIG.MAX_RETRY_DELAY
|
|
||||||
);
|
|
||||||
|
|
||||||
await batchLogger.logRetry(
|
|
||||||
BatchOperation.RETRY_OPERATION,
|
|
||||||
operationName,
|
|
||||||
attempt + 1,
|
|
||||||
maxRetries + 1,
|
|
||||||
delay,
|
|
||||||
error as Error
|
|
||||||
);
|
|
||||||
|
|
||||||
console.warn(
|
|
||||||
`${operationName} failed on attempt ${attempt + 1}, retrying in ${delay}ms:`,
|
|
||||||
(error as Error).message
|
|
||||||
);
|
|
||||||
|
|
||||||
await sleep(delay);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw lastError!;
|
throw lastError || new Error("Operation failed after retries");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -379,7 +403,7 @@ interface OpenAIBatchResponse {
|
|||||||
export async function getPendingBatchRequests(
|
export async function getPendingBatchRequests(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
limit: number = BATCH_CONFIG.MAX_REQUESTS_PER_BATCH
|
limit: number = BATCH_CONFIG.MAX_REQUESTS_PER_BATCH
|
||||||
): Promise<AIProcessingRequest[]> {
|
): Promise<AIProcessingRequestWithSession[]> {
|
||||||
return prisma.aIProcessingRequest.findMany({
|
return prisma.aIProcessingRequest.findMany({
|
||||||
where: {
|
where: {
|
||||||
session: {
|
session: {
|
||||||
@ -420,9 +444,20 @@ export async function getPendingBatchRequests(
|
|||||||
/**
|
/**
|
||||||
* Create a new batch request and upload to OpenAI
|
* Create a new batch request and upload to OpenAI
|
||||||
*/
|
*/
|
||||||
|
type AIProcessingRequestWithSession = AIProcessingRequest & {
|
||||||
|
session: {
|
||||||
|
messages: Array<{
|
||||||
|
id: string;
|
||||||
|
order: number;
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export async function createBatchRequest(
|
export async function createBatchRequest(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
requests: AIProcessingRequest[]
|
requests: AIProcessingRequestWithSession[]
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (requests.length === 0) {
|
if (requests.length === 0) {
|
||||||
throw new Error("Cannot create batch with no requests");
|
throw new Error("Cannot create batch with no requests");
|
||||||
@ -462,7 +497,7 @@ export async function createBatchRequest(
|
|||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: formatMessagesForProcessing(
|
content: formatMessagesForProcessing(
|
||||||
(request as any).session?.messages || []
|
request.session?.messages || []
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -1237,7 +1272,20 @@ export async function retryFailedRequests(
|
|||||||
/**
|
/**
|
||||||
* Process an individual request using the regular OpenAI API (fallback)
|
* Process an individual request using the regular OpenAI API (fallback)
|
||||||
*/
|
*/
|
||||||
async function processIndividualRequest(request: any): Promise<any> {
|
async function processIndividualRequest(request: {
|
||||||
|
id: string;
|
||||||
|
model: string;
|
||||||
|
messages: Array<{ role: string; content: string }>;
|
||||||
|
temperature?: number;
|
||||||
|
max_tokens?: number;
|
||||||
|
}): Promise<{
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
};
|
||||||
|
choices: Array<{ message: { content: string } }>;
|
||||||
|
}> {
|
||||||
if (env.OPENAI_MOCK_MODE) {
|
if (env.OPENAI_MOCK_MODE) {
|
||||||
console.log(`[OpenAI Mock] Processing individual request ${request.id}`);
|
console.log(`[OpenAI Mock] Processing individual request ${request.id}`);
|
||||||
return {
|
return {
|
||||||
@ -1316,17 +1364,10 @@ export function getCircuitBreakerStatus() {
|
|||||||
* Reset circuit breakers (for manual recovery)
|
* Reset circuit breakers (for manual recovery)
|
||||||
*/
|
*/
|
||||||
export function resetCircuitBreakers(): void {
|
export function resetCircuitBreakers(): void {
|
||||||
// Reset circuit breaker internal state by creating new instances
|
fileUploadCircuitBreaker.reset();
|
||||||
const resetCircuitBreaker = (breaker: CircuitBreaker) => {
|
batchCreationCircuitBreaker.reset();
|
||||||
(breaker as any).failures = 0;
|
batchStatusCircuitBreaker.reset();
|
||||||
(breaker as any).isOpen = false;
|
fileDownloadCircuitBreaker.reset();
|
||||||
(breaker as any).lastFailureTime = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
resetCircuitBreaker(fileUploadCircuitBreaker);
|
|
||||||
resetCircuitBreaker(batchCreationCircuitBreaker);
|
|
||||||
resetCircuitBreaker(batchStatusCircuitBreaker);
|
|
||||||
resetCircuitBreaker(fileDownloadCircuitBreaker);
|
|
||||||
|
|
||||||
console.log("All circuit breakers have been reset");
|
console.log("All circuit breakers have been reset");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -186,37 +186,37 @@ async function executeWithTracking<T>(
|
|||||||
/**
|
/**
|
||||||
* Unified interface for batch processing operations
|
* Unified interface for batch processing operations
|
||||||
*/
|
*/
|
||||||
export class IntegratedBatchProcessor {
|
export const IntegratedBatchProcessor = {
|
||||||
/**
|
/**
|
||||||
* Get pending batch requests with automatic optimization
|
* Get pending batch requests with automatic optimization
|
||||||
*/
|
*/
|
||||||
static async getPendingBatchRequests(companyId: string, limit?: number) {
|
getPendingBatchRequests: async (companyId: string, limit?: number) => {
|
||||||
return executeWithTracking(
|
return executeWithTracking(
|
||||||
() =>
|
() =>
|
||||||
OptimizedProcessor.getPendingBatchRequestsOptimized(companyId, limit),
|
OptimizedProcessor.getPendingBatchRequestsOptimized(companyId, limit),
|
||||||
() => OriginalProcessor.getPendingBatchRequests(companyId, limit),
|
() => OriginalProcessor.getPendingBatchRequests(companyId, limit),
|
||||||
"getPendingBatchRequests"
|
"getPendingBatchRequests"
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get batch processing statistics with optimization
|
* Get batch processing statistics with optimization
|
||||||
*/
|
*/
|
||||||
static async getBatchProcessingStats(companyId?: string) {
|
getBatchProcessingStats: async (companyId?: string) => {
|
||||||
return executeWithTracking(
|
return executeWithTracking(
|
||||||
() => OptimizedProcessor.getBatchProcessingStatsOptimized(companyId),
|
() => OptimizedProcessor.getBatchProcessingStatsOptimized(companyId),
|
||||||
() => OriginalProcessor.getBatchProcessingStats(companyId || ""),
|
() => OriginalProcessor.getBatchProcessingStats(companyId || ""),
|
||||||
"getBatchProcessingStats"
|
"getBatchProcessingStats"
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if we should create a batch for a company
|
* Check if we should create a batch for a company
|
||||||
*/
|
*/
|
||||||
static async shouldCreateBatch(
|
shouldCreateBatch: async (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
pendingCount: number
|
pendingCount: number
|
||||||
): Promise<boolean> {
|
): Promise<boolean> => {
|
||||||
if (performanceTracker.shouldUseOptimized()) {
|
if (performanceTracker.shouldUseOptimized()) {
|
||||||
// Always create if we have enough requests
|
// Always create if we have enough requests
|
||||||
if (pendingCount >= 10) {
|
if (pendingCount >= 10) {
|
||||||
@ -238,34 +238,34 @@ export class IntegratedBatchProcessor {
|
|||||||
}
|
}
|
||||||
// Use original implementation logic
|
// Use original implementation logic
|
||||||
return false; // Simplified fallback
|
return false; // Simplified fallback
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the appropriate scheduler based on configuration
|
* Start the appropriate scheduler based on configuration
|
||||||
*/
|
*/
|
||||||
static startScheduler(): void {
|
startScheduler: (): void => {
|
||||||
if (OPTIMIZATION_CONFIG.ENABLE_QUERY_OPTIMIZATION) {
|
if (OPTIMIZATION_CONFIG.ENABLE_QUERY_OPTIMIZATION) {
|
||||||
OptimizedScheduler.startOptimizedBatchScheduler();
|
OptimizedScheduler.startOptimizedBatchScheduler();
|
||||||
} else {
|
} else {
|
||||||
OriginalScheduler.startBatchScheduler();
|
OriginalScheduler.startBatchScheduler();
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop the appropriate scheduler
|
* Stop the appropriate scheduler
|
||||||
*/
|
*/
|
||||||
static stopScheduler(): void {
|
stopScheduler: (): void => {
|
||||||
if (OPTIMIZATION_CONFIG.ENABLE_QUERY_OPTIMIZATION) {
|
if (OPTIMIZATION_CONFIG.ENABLE_QUERY_OPTIMIZATION) {
|
||||||
OptimizedScheduler.stopOptimizedBatchScheduler();
|
OptimizedScheduler.stopOptimizedBatchScheduler();
|
||||||
} else {
|
} else {
|
||||||
OriginalScheduler.stopBatchScheduler();
|
OriginalScheduler.stopBatchScheduler();
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get scheduler status with optimization info
|
* Get scheduler status with optimization info
|
||||||
*/
|
*/
|
||||||
static getSchedulerStatus() {
|
getSchedulerStatus: () => {
|
||||||
const baseStatus = OPTIMIZATION_CONFIG.ENABLE_QUERY_OPTIMIZATION
|
const baseStatus = OPTIMIZATION_CONFIG.ENABLE_QUERY_OPTIMIZATION
|
||||||
? OptimizedScheduler.getOptimizedBatchSchedulerStatus()
|
? OptimizedScheduler.getOptimizedBatchSchedulerStatus()
|
||||||
: OriginalScheduler.getBatchSchedulerStatus();
|
: OriginalScheduler.getBatchSchedulerStatus();
|
||||||
@ -278,37 +278,37 @@ export class IntegratedBatchProcessor {
|
|||||||
performance: performanceTracker.getStats(),
|
performance: performanceTracker.getStats(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Force invalidate caches (useful for testing or manual intervention)
|
* Force invalidate caches (useful for testing or manual intervention)
|
||||||
*/
|
*/
|
||||||
static invalidateCaches(): void {
|
invalidateCaches: (): void => {
|
||||||
if (OPTIMIZATION_CONFIG.ENABLE_QUERY_OPTIMIZATION) {
|
if (OPTIMIZATION_CONFIG.ENABLE_QUERY_OPTIMIZATION) {
|
||||||
OptimizedProcessor.invalidateCompanyCache();
|
OptimizedProcessor.invalidateCompanyCache();
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cache statistics
|
* Get cache statistics
|
||||||
*/
|
*/
|
||||||
static getCacheStats() {
|
getCacheStats: () => {
|
||||||
if (OPTIMIZATION_CONFIG.ENABLE_QUERY_OPTIMIZATION) {
|
if (OPTIMIZATION_CONFIG.ENABLE_QUERY_OPTIMIZATION) {
|
||||||
return OptimizedProcessor.getCompanyCacheStats();
|
return OptimizedProcessor.getCompanyCacheStats();
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset performance tracking (useful for testing)
|
* Reset performance tracking (useful for testing)
|
||||||
*/
|
*/
|
||||||
static resetPerformanceTracking(): void {
|
resetPerformanceTracking: (): void => {
|
||||||
performanceTracker.metrics = {
|
performanceTracker.metrics = {
|
||||||
optimized: { totalTime: 0, operationCount: 0, errorCount: 0 },
|
optimized: { totalTime: 0, operationCount: 0, errorCount: 0 },
|
||||||
original: { totalTime: 0, operationCount: 0, errorCount: 0 },
|
original: { totalTime: 0, operationCount: 0, errorCount: 0 },
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export unified functions that can be used as drop-in replacements
|
* Export unified functions that can be used as drop-in replacements
|
||||||
|
|||||||
@ -122,7 +122,7 @@ export async function getPendingBatchRequestsOptimized(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return requests as any; // Type assertion since we're only including essential data
|
return requests;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -168,7 +168,7 @@ export async function getPendingBatchRequestsForAllCompanies(): Promise<
|
|||||||
if (!requestsByCompany.has(companyId)) {
|
if (!requestsByCompany.has(companyId)) {
|
||||||
requestsByCompany.set(companyId, []);
|
requestsByCompany.set(companyId, []);
|
||||||
}
|
}
|
||||||
requestsByCompany.get(companyId)?.push(request as any);
|
requestsByCompany.get(companyId)?.push(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
@ -190,7 +190,7 @@ export async function getPendingBatchRequestsForAllCompanies(): Promise<
|
|||||||
* Optimized batch status checking for all companies
|
* Optimized batch status checking for all companies
|
||||||
*/
|
*/
|
||||||
export async function getInProgressBatchesForAllCompanies(): Promise<
|
export async function getInProgressBatchesForAllCompanies(): Promise<
|
||||||
Map<string, any[]>
|
Map<string, unknown[]>
|
||||||
> {
|
> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const companies = await companyCache.getActiveCompanies();
|
const companies = await companyCache.getActiveCompanies();
|
||||||
@ -221,7 +221,7 @@ export async function getInProgressBatchesForAllCompanies(): Promise<
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Group by company
|
// Group by company
|
||||||
const batchesByCompany = new Map<string, any[]>();
|
const batchesByCompany = new Map<string, unknown[]>();
|
||||||
for (const batch of allBatches) {
|
for (const batch of allBatches) {
|
||||||
if (!batchesByCompany.has(batch.companyId)) {
|
if (!batchesByCompany.has(batch.companyId)) {
|
||||||
batchesByCompany.set(batch.companyId, []);
|
batchesByCompany.set(batch.companyId, []);
|
||||||
@ -248,7 +248,7 @@ export async function getInProgressBatchesForAllCompanies(): Promise<
|
|||||||
* Optimized completed batch processing for all companies
|
* Optimized completed batch processing for all companies
|
||||||
*/
|
*/
|
||||||
export async function getCompletedBatchesForAllCompanies(): Promise<
|
export async function getCompletedBatchesForAllCompanies(): Promise<
|
||||||
Map<string, any[]>
|
Map<string, unknown[]>
|
||||||
> {
|
> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const companies = await companyCache.getActiveCompanies();
|
const companies = await companyCache.getActiveCompanies();
|
||||||
@ -283,7 +283,7 @@ export async function getCompletedBatchesForAllCompanies(): Promise<
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Group by company
|
// Group by company
|
||||||
const batchesByCompany = new Map<string, any[]>();
|
const batchesByCompany = new Map<string, unknown[]>();
|
||||||
for (const batch of allBatches) {
|
for (const batch of allBatches) {
|
||||||
if (!batchesByCompany.has(batch.companyId)) {
|
if (!batchesByCompany.has(batch.companyId)) {
|
||||||
batchesByCompany.set(batch.companyId, []);
|
batchesByCompany.set(batch.companyId, []);
|
||||||
@ -349,9 +349,10 @@ export async function getFailedRequestsForAllCompanies(
|
|||||||
requestsByCompany.set(companyId, []);
|
requestsByCompany.set(companyId, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
const companyRequests = requestsByCompany.get(companyId)!;
|
const companyRequests = requestsByCompany.get(companyId);
|
||||||
|
if (!companyRequests) continue;
|
||||||
if (companyRequests.length < maxPerCompany) {
|
if (companyRequests.length < maxPerCompany) {
|
||||||
companyRequests.push(request as any);
|
companyRequests.push(request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -412,7 +413,13 @@ export async function getOldestPendingRequestOptimized(
|
|||||||
*/
|
*/
|
||||||
export async function getBatchProcessingStatsOptimized(
|
export async function getBatchProcessingStatsOptimized(
|
||||||
companyId?: string
|
companyId?: string
|
||||||
): Promise<any> {
|
): Promise<{
|
||||||
|
totalBatches: number;
|
||||||
|
pendingRequests: number;
|
||||||
|
inProgressBatches: number;
|
||||||
|
completedBatches: number;
|
||||||
|
failedRequests: number;
|
||||||
|
}> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
const whereClause = companyId ? { companyId } : {};
|
const whereClause = companyId ? { companyId } : {};
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export interface CSPAlert {
|
|||||||
severity: "low" | "medium" | "high" | "critical";
|
severity: "low" | "medium" | "high" | "critical";
|
||||||
type: "violation" | "bypass_attempt" | "policy_change" | "threshold_exceeded";
|
type: "violation" | "bypass_attempt" | "policy_change" | "threshold_exceeded";
|
||||||
message: string;
|
message: string;
|
||||||
metadata: Record<string, any>;
|
metadata: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CSPMonitoringService {
|
export class CSPMonitoringService {
|
||||||
|
|||||||
260
lib/csp.ts
260
lib/csp.ts
@ -174,6 +174,155 @@ export function createCSPMiddleware(config: CSPConfig = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check unsafe directives
|
||||||
|
*/
|
||||||
|
function checkUnsafeDirectives(
|
||||||
|
csp: string,
|
||||||
|
strictMode: boolean,
|
||||||
|
warnings: string[],
|
||||||
|
errors: string[],
|
||||||
|
recommendations: string[]
|
||||||
|
): number {
|
||||||
|
let scorePenalty = 0;
|
||||||
|
|
||||||
|
if (csp.includes("'unsafe-inline'") && !csp.includes("'nonce-")) {
|
||||||
|
warnings.push("Using 'unsafe-inline' without nonce is less secure");
|
||||||
|
scorePenalty += 15;
|
||||||
|
recommendations.push(
|
||||||
|
"Implement nonce-based CSP for inline scripts and styles"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (csp.includes("'unsafe-eval'")) {
|
||||||
|
if (strictMode) {
|
||||||
|
errors.push("'unsafe-eval' is not allowed in strict mode");
|
||||||
|
scorePenalty += 25;
|
||||||
|
} else {
|
||||||
|
warnings.push("'unsafe-eval' allows dangerous code execution");
|
||||||
|
scorePenalty += 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scorePenalty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check wildcard usage
|
||||||
|
*/
|
||||||
|
function checkWildcardUsage(
|
||||||
|
csp: string,
|
||||||
|
errors: string[],
|
||||||
|
recommendations: string[]
|
||||||
|
): number {
|
||||||
|
const hasProblematicWildcards =
|
||||||
|
csp.includes(" *") ||
|
||||||
|
csp.includes("*://") ||
|
||||||
|
(csp.includes("*") && !csp.includes("*.") && !csp.includes("wss: ws:"));
|
||||||
|
|
||||||
|
if (hasProblematicWildcards) {
|
||||||
|
errors.push("Wildcard (*) sources are not recommended");
|
||||||
|
recommendations.push("Replace wildcards with specific trusted domains");
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check security features
|
||||||
|
*/
|
||||||
|
function checkSecurityFeatures(
|
||||||
|
csp: string,
|
||||||
|
warnings: string[],
|
||||||
|
recommendations: string[]
|
||||||
|
): number {
|
||||||
|
let scorePenalty = 0;
|
||||||
|
|
||||||
|
if (
|
||||||
|
csp.includes("data:") &&
|
||||||
|
!csp.includes("img-src") &&
|
||||||
|
!csp.includes("font-src")
|
||||||
|
) {
|
||||||
|
warnings.push("data: URIs should be limited to specific directives");
|
||||||
|
scorePenalty += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!csp.includes("upgrade-insecure-requests")) {
|
||||||
|
warnings.push("Missing HTTPS upgrade directive");
|
||||||
|
scorePenalty += 10;
|
||||||
|
recommendations.push("Add 'upgrade-insecure-requests' directive");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!csp.includes("frame-ancestors")) {
|
||||||
|
warnings.push("Missing frame-ancestors directive");
|
||||||
|
scorePenalty += 15;
|
||||||
|
recommendations.push(
|
||||||
|
"Add 'frame-ancestors 'none'' to prevent clickjacking"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return scorePenalty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check required directives
|
||||||
|
*/
|
||||||
|
function checkRequiredDirectives(csp: string, errors: string[]): number {
|
||||||
|
const requiredDirectives = [
|
||||||
|
"default-src",
|
||||||
|
"script-src",
|
||||||
|
"style-src",
|
||||||
|
"object-src",
|
||||||
|
"base-uri",
|
||||||
|
"form-action",
|
||||||
|
];
|
||||||
|
|
||||||
|
let scorePenalty = 0;
|
||||||
|
for (const directive of requiredDirectives) {
|
||||||
|
if (!csp.includes(directive)) {
|
||||||
|
errors.push(`Missing required directive: ${directive}`);
|
||||||
|
scorePenalty += 20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scorePenalty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check additional features
|
||||||
|
*/
|
||||||
|
function checkAdditionalFeatures(
|
||||||
|
csp: string,
|
||||||
|
strictMode: boolean,
|
||||||
|
warnings: string[],
|
||||||
|
recommendations: string[]
|
||||||
|
): number {
|
||||||
|
let scorePenalty = 0;
|
||||||
|
|
||||||
|
if (csp.includes("'nonce-") && !csp.includes("'strict-dynamic'")) {
|
||||||
|
recommendations.push(
|
||||||
|
"Consider adding 'strict-dynamic' for better nonce-based security"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!csp.includes("report-uri") && !csp.includes("report-to")) {
|
||||||
|
warnings.push("Missing CSP violation reporting");
|
||||||
|
scorePenalty += 5;
|
||||||
|
recommendations.push("Add CSP violation reporting for monitoring");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strictMode) {
|
||||||
|
if (csp.includes("https:") && !csp.includes("connect-src")) {
|
||||||
|
warnings.push("Broad HTTPS allowlist detected in strict mode");
|
||||||
|
scorePenalty += 10;
|
||||||
|
recommendations.push("Replace 'https:' with specific trusted domains");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scorePenalty;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enhanced CSP validation with security best practices
|
* Enhanced CSP validation with security best practices
|
||||||
*/
|
*/
|
||||||
@ -194,101 +343,22 @@ export function validateCSP(
|
|||||||
|
|
||||||
let securityScore = 100;
|
let securityScore = 100;
|
||||||
|
|
||||||
// Check for unsafe directives
|
securityScore -= checkUnsafeDirectives(
|
||||||
if (csp.includes("'unsafe-inline'") && !csp.includes("'nonce-")) {
|
csp,
|
||||||
warnings.push("Using 'unsafe-inline' without nonce is less secure");
|
strictMode,
|
||||||
securityScore -= 15;
|
warnings,
|
||||||
recommendations.push(
|
errors,
|
||||||
"Implement nonce-based CSP for inline scripts and styles"
|
recommendations
|
||||||
);
|
);
|
||||||
}
|
securityScore -= checkWildcardUsage(csp, errors, recommendations);
|
||||||
|
securityScore -= checkSecurityFeatures(csp, warnings, recommendations);
|
||||||
if (csp.includes("'unsafe-eval'")) {
|
securityScore -= checkRequiredDirectives(csp, errors);
|
||||||
if (strictMode) {
|
securityScore -= checkAdditionalFeatures(
|
||||||
errors.push("'unsafe-eval' is not allowed in strict mode");
|
csp,
|
||||||
securityScore -= 25;
|
strictMode,
|
||||||
} else {
|
warnings,
|
||||||
warnings.push("'unsafe-eval' allows dangerous code execution");
|
recommendations
|
||||||
securityScore -= 10;
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for overly permissive directives (but exclude font wildcards and subdomain wildcards)
|
|
||||||
const hasProblematicWildcards =
|
|
||||||
csp.includes(" *") ||
|
|
||||||
csp.includes("*://") ||
|
|
||||||
(csp.includes("*") && !csp.includes("*.") && !csp.includes("wss: ws:"));
|
|
||||||
|
|
||||||
if (hasProblematicWildcards) {
|
|
||||||
errors.push("Wildcard (*) sources are not recommended");
|
|
||||||
securityScore -= 30;
|
|
||||||
recommendations.push("Replace wildcards with specific trusted domains");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
csp.includes("data:") &&
|
|
||||||
!csp.includes("img-src") &&
|
|
||||||
!csp.includes("font-src")
|
|
||||||
) {
|
|
||||||
warnings.push("data: URIs should be limited to specific directives");
|
|
||||||
securityScore -= 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for HTTPS upgrade
|
|
||||||
if (!csp.includes("upgrade-insecure-requests")) {
|
|
||||||
warnings.push("Missing HTTPS upgrade directive");
|
|
||||||
securityScore -= 10;
|
|
||||||
recommendations.push("Add 'upgrade-insecure-requests' directive");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for frame protection
|
|
||||||
if (!csp.includes("frame-ancestors")) {
|
|
||||||
warnings.push("Missing frame-ancestors directive");
|
|
||||||
securityScore -= 15;
|
|
||||||
recommendations.push(
|
|
||||||
"Add 'frame-ancestors 'none'' to prevent clickjacking"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check required directives
|
|
||||||
const requiredDirectives = [
|
|
||||||
"default-src",
|
|
||||||
"script-src",
|
|
||||||
"style-src",
|
|
||||||
"object-src",
|
|
||||||
"base-uri",
|
|
||||||
"form-action",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const directive of requiredDirectives) {
|
|
||||||
if (!csp.includes(directive)) {
|
|
||||||
errors.push(`Missing required directive: ${directive}`);
|
|
||||||
securityScore -= 20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for modern CSP features
|
|
||||||
if (csp.includes("'nonce-") && !csp.includes("'strict-dynamic'")) {
|
|
||||||
recommendations.push(
|
|
||||||
"Consider adding 'strict-dynamic' for better nonce-based security"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check reporting setup
|
|
||||||
if (!csp.includes("report-uri") && !csp.includes("report-to")) {
|
|
||||||
warnings.push("Missing CSP violation reporting");
|
|
||||||
securityScore -= 5;
|
|
||||||
recommendations.push("Add CSP violation reporting for monitoring");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strict mode additional checks
|
|
||||||
if (strictMode) {
|
|
||||||
if (csp.includes("https:") && !csp.includes("connect-src")) {
|
|
||||||
warnings.push("Broad HTTPS allowlist detected in strict mode");
|
|
||||||
securityScore -= 10;
|
|
||||||
recommendations.push("Replace 'https:' with specific trusted domains");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isValid: errors.length === 0,
|
isValid: errors.length === 0,
|
||||||
|
|||||||
20
lib/csrf.ts
20
lib/csrf.ts
@ -101,11 +101,11 @@ export async function getCSRFTokenFromCookies(): Promise<string | null> {
|
|||||||
/**
|
/**
|
||||||
* Server-side utilities for API routes
|
* Server-side utilities for API routes
|
||||||
*/
|
*/
|
||||||
export class CSRFProtection {
|
export const CSRFProtection = {
|
||||||
/**
|
/**
|
||||||
* Generate and set CSRF token in response
|
* Generate and set CSRF token in response
|
||||||
*/
|
*/
|
||||||
static generateTokenResponse(): {
|
generateTokenResponse(): {
|
||||||
token: string;
|
token: string;
|
||||||
cookie: {
|
cookie: {
|
||||||
name: string;
|
name: string;
|
||||||
@ -132,12 +132,12 @@ export class CSRFProtection {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate CSRF token from request
|
* Validate CSRF token from request
|
||||||
*/
|
*/
|
||||||
static async validateRequest(request: NextRequest): Promise<{
|
async validateRequest(request: NextRequest): Promise<{
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
@ -148,7 +148,7 @@ export class CSRFProtection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get token from request
|
// Get token from request
|
||||||
const requestToken = await CSRFProtection.getTokenFromRequest(request);
|
const requestToken = await this.getTokenFromRequest(request);
|
||||||
if (!requestToken) {
|
if (!requestToken) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
@ -188,14 +188,12 @@ export class CSRFProtection {
|
|||||||
error: `CSRF validation error: ${error instanceof Error ? error.message : "Unknown error"}`,
|
error: `CSRF validation error: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract token from request (handles different content types)
|
* Extract token from request (handles different content types)
|
||||||
*/
|
*/
|
||||||
private static async getTokenFromRequest(
|
async getTokenFromRequest(request: NextRequest): Promise<string | null> {
|
||||||
request: NextRequest
|
|
||||||
): Promise<string | null> {
|
|
||||||
// Check header first
|
// Check header first
|
||||||
const headerToken = request.headers.get(CSRF_CONFIG.headerName);
|
const headerToken = request.headers.get(CSRF_CONFIG.headerName);
|
||||||
if (headerToken) {
|
if (headerToken) {
|
||||||
@ -223,8 +221,8 @@ export class CSRFProtection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client-side utilities
|
* Client-side utilities
|
||||||
|
|||||||
@ -4,6 +4,44 @@
|
|||||||
import { parse } from "csv-parse/sync";
|
import { parse } from "csv-parse/sync";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse integer from string with null fallback
|
||||||
|
*/
|
||||||
|
function parseInteger(value: string | undefined): number | null {
|
||||||
|
return value ? Number.parseInt(value, 10) || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse float from string with null fallback
|
||||||
|
*/
|
||||||
|
function parseFloatValue(value: string | undefined): number | null {
|
||||||
|
return value ? Number.parseFloat(value) || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a CSV row to SessionImport object
|
||||||
|
*/
|
||||||
|
function mapCsvRowToSessionImport(row: string[]): RawSessionImport {
|
||||||
|
return {
|
||||||
|
externalSessionId: row[0] || "",
|
||||||
|
startTimeRaw: row[1] || "",
|
||||||
|
endTimeRaw: row[2] || "",
|
||||||
|
ipAddress: row[3] || null,
|
||||||
|
countryCode: row[4] || null,
|
||||||
|
language: row[5] || null,
|
||||||
|
messagesSent: parseInteger(row[6]),
|
||||||
|
sentimentRaw: row[7] || null,
|
||||||
|
escalatedRaw: row[8] || null,
|
||||||
|
forwardedHrRaw: row[9] || null,
|
||||||
|
fullTranscriptUrl: row[10] || null,
|
||||||
|
avgResponseTimeSeconds: parseFloatValue(row[11]),
|
||||||
|
tokens: parseInteger(row[12]),
|
||||||
|
tokensEur: parseFloatValue(row[13]),
|
||||||
|
category: row[14] || null,
|
||||||
|
initialMessage: row[15] || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Raw CSV data interface matching SessionImport schema
|
// Raw CSV data interface matching SessionImport schema
|
||||||
interface RawSessionImport {
|
interface RawSessionImport {
|
||||||
externalSessionId: string;
|
externalSessionId: string;
|
||||||
@ -62,22 +100,5 @@ export async function fetchAndParseCsv(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Map CSV columns by position to SessionImport fields
|
// Map CSV columns by position to SessionImport fields
|
||||||
return records.map((row) => ({
|
return records.map(mapCsvRowToSessionImport);
|
||||||
externalSessionId: row[0] || "",
|
|
||||||
startTimeRaw: row[1] || "",
|
|
||||||
endTimeRaw: row[2] || "",
|
|
||||||
ipAddress: row[3] || null,
|
|
||||||
countryCode: row[4] || null,
|
|
||||||
language: row[5] || null,
|
|
||||||
messagesSent: row[6] ? Number.parseInt(row[6], 10) || null : null,
|
|
||||||
sentimentRaw: row[7] || null,
|
|
||||||
escalatedRaw: row[8] || null,
|
|
||||||
forwardedHrRaw: row[9] || null,
|
|
||||||
fullTranscriptUrl: row[10] || null,
|
|
||||||
avgResponseTimeSeconds: row[11] ? Number.parseFloat(row[11]) || null : null,
|
|
||||||
tokens: row[12] ? Number.parseInt(row[12], 10) || null : null,
|
|
||||||
tokensEur: row[13] ? Number.parseFloat(row[13]) || null : null,
|
|
||||||
category: row[14] || null,
|
|
||||||
initialMessage: row[15] || null,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,15 +98,16 @@ export function useCSRFFetch() {
|
|||||||
async (url: string, options: RequestInit = {}): Promise<Response> => {
|
async (url: string, options: RequestInit = {}): Promise<Response> => {
|
||||||
// Ensure we have a token for state-changing requests
|
// Ensure we have a token for state-changing requests
|
||||||
const method = options.method || "GET";
|
const method = options.method || "GET";
|
||||||
|
let modifiedOptions = options;
|
||||||
if (["POST", "PUT", "DELETE", "PATCH"].includes(method.toUpperCase())) {
|
if (["POST", "PUT", "DELETE", "PATCH"].includes(method.toUpperCase())) {
|
||||||
const currentToken = token || (await getToken());
|
const currentToken = token || (await getToken());
|
||||||
if (currentToken) {
|
if (currentToken) {
|
||||||
options = CSRFClient.addTokenToFetch(options);
|
modifiedOptions = CSRFClient.addTokenToFetch(options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
...options,
|
...modifiedOptions,
|
||||||
credentials: "include", // Ensure cookies are sent
|
credentials: "include", // Ensure cookies are sent
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -164,8 +165,9 @@ export function useCSRFForm() {
|
|||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
// Ensure we have a token
|
// Ensure we have a token
|
||||||
const currentToken = token || (await getToken());
|
const currentToken = token || (await getToken());
|
||||||
|
let modifiedData = data;
|
||||||
if (currentToken) {
|
if (currentToken) {
|
||||||
data = CSRFClient.addTokenToObject(data);
|
modifiedData = CSRFClient.addTokenToObject(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
@ -174,7 +176,7 @@ export function useCSRFForm() {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...options.headers,
|
...options.headers,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(modifiedData),
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -66,7 +66,7 @@ class OpenAIMockServer {
|
|||||||
/**
|
/**
|
||||||
* Log mock requests for debugging
|
* Log mock requests for debugging
|
||||||
*/
|
*/
|
||||||
private logRequest(endpoint: string, data: any): void {
|
private logRequest(endpoint: string, data: unknown): void {
|
||||||
if (this.config.logRequests) {
|
if (this.config.logRequests) {
|
||||||
console.log(`[OpenAI Mock] ${endpoint}:`, JSON.stringify(data, null, 2));
|
console.log(`[OpenAI Mock] ${endpoint}:`, JSON.stringify(data, null, 2));
|
||||||
}
|
}
|
||||||
@ -260,7 +260,14 @@ class OpenAIMockServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate mock batch results
|
// Generate mock batch results
|
||||||
const results: any = [];
|
const results: Array<{
|
||||||
|
id: string;
|
||||||
|
custom_id: string;
|
||||||
|
response: {
|
||||||
|
status_code: number;
|
||||||
|
body: unknown;
|
||||||
|
};
|
||||||
|
}> = [];
|
||||||
for (let i = 0; i < batch.request_counts.total; i++) {
|
for (let i = 0; i < batch.request_counts.total; i++) {
|
||||||
const response = MOCK_RESPONSE_GENERATORS.sentiment(`Sample text ${i}`);
|
const response = MOCK_RESPONSE_GENERATORS.sentiment(`Sample text ${i}`);
|
||||||
results.push({
|
results.push({
|
||||||
@ -359,16 +366,16 @@ export const openAIMock = new OpenAIMockServer();
|
|||||||
* Drop-in replacement for OpenAI client that uses mocks when enabled
|
* Drop-in replacement for OpenAI client that uses mocks when enabled
|
||||||
*/
|
*/
|
||||||
export class MockOpenAIClient {
|
export class MockOpenAIClient {
|
||||||
private realClient: any;
|
private realClient: unknown;
|
||||||
|
|
||||||
constructor(realClient: any) {
|
constructor(realClient: unknown) {
|
||||||
this.realClient = realClient;
|
this.realClient = realClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
get chat() {
|
get chat() {
|
||||||
return {
|
return {
|
||||||
completions: {
|
completions: {
|
||||||
create: async (params: any) => {
|
create: async (params: unknown) => {
|
||||||
if (openAIMock.isEnabled()) {
|
if (openAIMock.isEnabled()) {
|
||||||
return openAIMock.mockChatCompletion(params);
|
return openAIMock.mockChatCompletion(params);
|
||||||
}
|
}
|
||||||
@ -380,7 +387,7 @@ export class MockOpenAIClient {
|
|||||||
|
|
||||||
get batches() {
|
get batches() {
|
||||||
return {
|
return {
|
||||||
create: async (params: any) => {
|
create: async (params: unknown) => {
|
||||||
if (openAIMock.isEnabled()) {
|
if (openAIMock.isEnabled()) {
|
||||||
return openAIMock.mockCreateBatch(params);
|
return openAIMock.mockCreateBatch(params);
|
||||||
}
|
}
|
||||||
@ -397,7 +404,7 @@ export class MockOpenAIClient {
|
|||||||
|
|
||||||
get files() {
|
get files() {
|
||||||
return {
|
return {
|
||||||
create: async (params: any) => {
|
create: async (params: unknown) => {
|
||||||
if (openAIMock.isEnabled()) {
|
if (openAIMock.isEnabled()) {
|
||||||
return openAIMock.mockUploadFile(params);
|
return openAIMock.mockUploadFile(params);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export interface AuditLogContext {
|
|||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
ipAddress?: string;
|
ipAddress?: string;
|
||||||
country?: string;
|
country?: string;
|
||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditLogEntry {
|
export interface AuditLogEntry {
|
||||||
@ -393,7 +393,7 @@ export const securityAuditLogger = new SecurityAuditLogger();
|
|||||||
|
|
||||||
export async function createAuditContext(
|
export async function createAuditContext(
|
||||||
request?: NextRequest,
|
request?: NextRequest,
|
||||||
session?: any,
|
session?: { user?: { id?: string; email?: string } },
|
||||||
additionalContext?: Partial<AuditLogContext>
|
additionalContext?: Partial<AuditLogContext>
|
||||||
): Promise<AuditLogContext> {
|
): Promise<AuditLogContext> {
|
||||||
const context: AuditLogContext = {
|
const context: AuditLogContext = {
|
||||||
@ -419,9 +419,9 @@ export async function createAuditContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createAuditMetadata(
|
export function createAuditMetadata(
|
||||||
data: Record<string, any>
|
data: Record<string, unknown>
|
||||||
): Record<string, any> {
|
): Record<string, unknown> {
|
||||||
const sanitized: Record<string, any> = {};
|
const sanitized: Record<string, unknown> = {};
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(data)) {
|
for (const [key, value] of Object.entries(data)) {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export interface SecurityAlert {
|
|||||||
description: string;
|
description: string;
|
||||||
eventType: SecurityEventType;
|
eventType: SecurityEventType;
|
||||||
context: AuditLogContext;
|
context: AuditLogContext;
|
||||||
metadata: Record<string, any>;
|
metadata: Record<string, unknown>;
|
||||||
acknowledged: boolean;
|
acknowledged: boolean;
|
||||||
acknowledgedBy?: string;
|
acknowledgedBy?: string;
|
||||||
acknowledgedAt?: Date;
|
acknowledgedAt?: Date;
|
||||||
@ -131,7 +131,7 @@ class SecurityMonitoringService {
|
|||||||
outcome: AuditOutcome,
|
outcome: AuditOutcome,
|
||||||
context: AuditLogContext,
|
context: AuditLogContext,
|
||||||
severity: AuditSeverity = AuditSeverity.INFO,
|
severity: AuditSeverity = AuditSeverity.INFO,
|
||||||
metadata?: Record<string, any>
|
metadata?: Record<string, unknown>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Add event to buffer for analysis
|
// Add event to buffer for analysis
|
||||||
this.eventBuffer.push({
|
this.eventBuffer.push({
|
||||||
@ -377,7 +377,10 @@ class SecurityMonitoringService {
|
|||||||
/**
|
/**
|
||||||
* Deep merge helper function for config updates
|
* Deep merge helper function for config updates
|
||||||
*/
|
*/
|
||||||
private deepMerge(target: any, source: any): any {
|
private deepMerge(
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
source: Record<string, unknown>
|
||||||
|
): Record<string, unknown> {
|
||||||
const result = { ...target };
|
const result = { ...target };
|
||||||
|
|
||||||
for (const key in source) {
|
for (const key in source) {
|
||||||
@ -474,7 +477,7 @@ class SecurityMonitoringService {
|
|||||||
eventType: SecurityEventType,
|
eventType: SecurityEventType,
|
||||||
outcome: AuditOutcome,
|
outcome: AuditOutcome,
|
||||||
context: AuditLogContext,
|
context: AuditLogContext,
|
||||||
metadata?: Record<string, any>
|
metadata?: Record<string, unknown>
|
||||||
): Promise<Array<Omit<SecurityAlert, "id" | "timestamp" | "acknowledged">>> {
|
): Promise<Array<Omit<SecurityAlert, "id" | "timestamp" | "acknowledged">>> {
|
||||||
const threats: Array<
|
const threats: Array<
|
||||||
Omit<SecurityAlert, "id" | "timestamp" | "acknowledged">
|
Omit<SecurityAlert, "id" | "timestamp" | "acknowledged">
|
||||||
@ -707,12 +710,19 @@ class SecurityMonitoringService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async calculateUserRiskScores(
|
private async calculateUserRiskScores(
|
||||||
events: any[]
|
events: Array<{
|
||||||
|
userId?: string;
|
||||||
|
user?: { email: string };
|
||||||
|
eventType: SecurityEventType;
|
||||||
|
outcome: AuditOutcome;
|
||||||
|
severity: AuditSeverity;
|
||||||
|
country?: string;
|
||||||
|
}>
|
||||||
): Promise<Array<{ userId: string; email: string; riskScore: number }>> {
|
): Promise<Array<{ userId: string; email: string; riskScore: number }>> {
|
||||||
const userEvents = events.filter((e) => e.userId);
|
const userEvents = events.filter((e) => e.userId);
|
||||||
const userScores = new Map<
|
const userScores = new Map<
|
||||||
string,
|
string,
|
||||||
{ email: string; score: number; events: any[] }
|
{ email: string; score: number; events: typeof events }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
for (const event of userEvents) {
|
for (const event of userEvents) {
|
||||||
@ -937,7 +947,7 @@ export async function enhancedSecurityLog(
|
|||||||
context: AuditLogContext,
|
context: AuditLogContext,
|
||||||
severity: AuditSeverity = AuditSeverity.INFO,
|
severity: AuditSeverity = AuditSeverity.INFO,
|
||||||
errorMessage?: string,
|
errorMessage?: string,
|
||||||
metadata?: Record<string, any>
|
metadata?: Record<string, unknown>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Log to audit system
|
// Log to audit system
|
||||||
await securityAuditLogger.log({
|
await securityAuditLogger.log({
|
||||||
|
|||||||
@ -7,6 +7,46 @@ export interface TranscriptFetchResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to prepare request headers
|
||||||
|
*/
|
||||||
|
function prepareRequestHeaders(
|
||||||
|
username?: string,
|
||||||
|
password?: string
|
||||||
|
): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"User-Agent": "LiveDash-Transcript-Fetcher/1.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (username && password) {
|
||||||
|
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
||||||
|
headers.Authorization = authHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to handle network errors
|
||||||
|
*/
|
||||||
|
function handleNetworkError(error: unknown): TranscriptFetchResult {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
if (errorMessage.includes("ENOTFOUND")) {
|
||||||
|
return { success: false, error: "Domain not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage.includes("ECONNREFUSED")) {
|
||||||
|
return { success: false, error: "Connection refused" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage.includes("timeout")) {
|
||||||
|
return { success: false, error: "Request timeout" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch transcript content from a URL
|
* Fetch transcript content from a URL
|
||||||
* @param url The transcript URL
|
* @param url The transcript URL
|
||||||
@ -21,29 +61,14 @@ export async function fetchTranscriptContent(
|
|||||||
): Promise<TranscriptFetchResult> {
|
): Promise<TranscriptFetchResult> {
|
||||||
try {
|
try {
|
||||||
if (!url || !url.trim()) {
|
if (!url || !url.trim()) {
|
||||||
return {
|
return { success: false, error: "No transcript URL provided" };
|
||||||
success: false,
|
|
||||||
error: "No transcript URL provided",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare authentication header if credentials provided
|
const headers = prepareRequestHeaders(username, password);
|
||||||
const authHeader =
|
|
||||||
username && password
|
|
||||||
? `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"User-Agent": "LiveDash-Transcript-Fetcher/1.0",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (authHeader) {
|
|
||||||
headers.Authorization = authHeader;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the transcript with timeout
|
// Fetch the transcript with timeout
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@ -63,45 +88,12 @@ export async function fetchTranscriptContent(
|
|||||||
const content = await response.text();
|
const content = await response.text();
|
||||||
|
|
||||||
if (!content || content.trim().length === 0) {
|
if (!content || content.trim().length === 0) {
|
||||||
return {
|
return { success: false, error: "Empty transcript content" };
|
||||||
success: false,
|
|
||||||
error: "Empty transcript content",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { success: true, content: content.trim() };
|
||||||
success: true,
|
|
||||||
content: content.trim(),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
return handleNetworkError(error);
|
||||||
|
|
||||||
// Handle common network errors
|
|
||||||
if (errorMessage.includes("ENOTFOUND")) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "Domain not found",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorMessage.includes("ECONNREFUSED")) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "Connection refused",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorMessage.includes("timeout")) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "Request timeout",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: errorMessage,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,28 @@ import { ProcessingStatusManager } from "./lib/processingStatusManager";
|
|||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
interface MigrationSessionImport {
|
||||||
|
rawTranscriptContent?: string;
|
||||||
|
externalSessionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MigrationMessage {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
timestamp?: Date;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MigrationSession {
|
||||||
|
id: string;
|
||||||
|
summary?: string;
|
||||||
|
sentiment?: string;
|
||||||
|
category?: string;
|
||||||
|
language?: string;
|
||||||
|
import?: MigrationSessionImport;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migrates CSV import stage for a session
|
* Migrates CSV import stage for a session
|
||||||
*/
|
*/
|
||||||
@ -25,7 +47,7 @@ async function migrateCsvImportStage(
|
|||||||
*/
|
*/
|
||||||
async function migrateTranscriptFetchStage(
|
async function migrateTranscriptFetchStage(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
sessionImport: any,
|
sessionImport: MigrationSessionImport,
|
||||||
externalSessionId?: string
|
externalSessionId?: string
|
||||||
) {
|
) {
|
||||||
if (sessionImport?.rawTranscriptContent) {
|
if (sessionImport?.rawTranscriptContent) {
|
||||||
@ -53,8 +75,8 @@ async function migrateTranscriptFetchStage(
|
|||||||
*/
|
*/
|
||||||
async function migrateSessionCreationStage(
|
async function migrateSessionCreationStage(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
messages: any[],
|
messages: MigrationMessage[],
|
||||||
sessionImport: any,
|
sessionImport: MigrationSessionImport,
|
||||||
externalSessionId?: string
|
externalSessionId?: string
|
||||||
) {
|
) {
|
||||||
if (messages.length > 0) {
|
if (messages.length > 0) {
|
||||||
@ -82,7 +104,7 @@ async function migrateSessionCreationStage(
|
|||||||
/**
|
/**
|
||||||
* Checks if session has AI analysis data
|
* Checks if session has AI analysis data
|
||||||
*/
|
*/
|
||||||
function hasAIAnalysisData(session: any): boolean {
|
function hasAIAnalysisData(session: MigrationSession): boolean {
|
||||||
return !!(
|
return !!(
|
||||||
session.summary ||
|
session.summary ||
|
||||||
session.sentiment ||
|
session.sentiment ||
|
||||||
@ -96,8 +118,8 @@ function hasAIAnalysisData(session: any): boolean {
|
|||||||
*/
|
*/
|
||||||
async function migrateAIAnalysisStage(
|
async function migrateAIAnalysisStage(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
session: any,
|
session: MigrationSession,
|
||||||
messages: any[],
|
messages: MigrationMessage[],
|
||||||
externalSessionId?: string
|
externalSessionId?: string
|
||||||
) {
|
) {
|
||||||
const hasAIAnalysis = hasAIAnalysisData(session);
|
const hasAIAnalysis = hasAIAnalysisData(session);
|
||||||
@ -126,7 +148,7 @@ async function migrateAIAnalysisStage(
|
|||||||
*/
|
*/
|
||||||
async function migrateQuestionExtractionStage(
|
async function migrateQuestionExtractionStage(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
sessionQuestions: any[],
|
sessionQuestions: { question: { content: string } }[],
|
||||||
hasAIAnalysis: boolean,
|
hasAIAnalysis: boolean,
|
||||||
externalSessionId?: string
|
externalSessionId?: string
|
||||||
) {
|
) {
|
||||||
@ -147,7 +169,7 @@ async function migrateQuestionExtractionStage(
|
|||||||
/**
|
/**
|
||||||
* Migrates a single session to the refactored processing system
|
* Migrates a single session to the refactored processing system
|
||||||
*/
|
*/
|
||||||
async function migrateSession(session: any) {
|
async function migrateSession(session: MigrationSession) {
|
||||||
const externalSessionId = session.import?.externalSessionId;
|
const externalSessionId = session.import?.externalSessionId;
|
||||||
console.log(`Migrating session ${externalSessionId || session.id}...`);
|
console.log(`Migrating session ${externalSessionId || session.id}...`);
|
||||||
|
|
||||||
@ -209,7 +231,6 @@ async function displayFinalStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Main orchestration function - complexity is needed for migration coordination
|
|
||||||
async function migrateToRefactoredSystem() {
|
async function migrateToRefactoredSystem() {
|
||||||
try {
|
try {
|
||||||
console.log("=== MIGRATING TO REFACTORED PROCESSING SYSTEM ===\n");
|
console.log("=== MIGRATING TO REFACTORED PROCESSING SYSTEM ===\n");
|
||||||
|
|||||||
@ -149,7 +149,12 @@ export const adminRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateData: any = {};
|
const updateData: {
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
password?: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
} = {};
|
||||||
|
|
||||||
if (updates.email) {
|
if (updates.email) {
|
||||||
// Check if new email is already taken
|
// Check if new email is already taken
|
||||||
@ -274,7 +279,13 @@ export const adminRouter = router({
|
|||||||
updateCompanySettings: adminProcedure
|
updateCompanySettings: adminProcedure
|
||||||
.input(companySettingsSchema)
|
.input(companySettingsSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const updateData: any = {
|
const updateData: {
|
||||||
|
name: string;
|
||||||
|
csvUrl: string;
|
||||||
|
csvUsername?: string | null;
|
||||||
|
csvPassword?: string | null;
|
||||||
|
maxUsers?: number;
|
||||||
|
} = {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
csvUrl: input.csvUrl,
|
csvUrl: input.csvUrl,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -227,7 +227,11 @@ export const authRouter = router({
|
|||||||
updateProfile: csrfProtectedAuthProcedure
|
updateProfile: csrfProtectedAuthProcedure
|
||||||
.input(userUpdateSchema)
|
.input(userUpdateSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const updateData: any = {};
|
const updateData: {
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
password?: string;
|
||||||
|
} = {};
|
||||||
|
|
||||||
if (input.email) {
|
if (input.email) {
|
||||||
// Check if new email is already taken
|
// Check if new email is already taken
|
||||||
|
|||||||
Reference in New Issue
Block a user