@cryptotaxi247 / CoPilot / commits / e6aed3c6

Refactor 2 (#532)

* Fix: Prevents token refresh on missing token Ensures token refresh logic only executes when a user token exists. This prevents unnecessary refresh attempts when the user is not authenticated, which could lead to errors or unexpected behavior. Also, makes the userToken state nullable to avoid undefined value. * Refactors customer portal codebase Refactors the customer portal codebase for improved code quality and maintainability. - Updates import paths to use aliases. - Introduces a new Vue component for case comments with edit/delete functionality, improving UI consistency and user experience. - Modernizes the Pinia store setup using `createPersistedState`. - Enhances JWT handling and refresh logic. - Improves code consistency across various API calls and components. * feat: Implements authentication API Introduces authentication functionality including login and token decoding. Refactors the login process in the LoginPage component to use the new authentication API for improved code organization and maintainability. Improves error handling to provide more specific feedback to the user. --------- Co-authored-by: Davide Di Modica <webmaster.ddm@gmail.com>

taylor_socfortress committed Nov 30, 2025 at 12:37 UTC e6aed3c61cd94a28889b0dda3f2909ee78cde312
26 files changed +1446 -1357
customer_portal/src/api/agents.ts
+80 -80
@@ -1,95 +1,95 @@
1 -import { httpClient } from '@/utils/httpClient'
1 +import { httpClient } from "@/utils/httpClient"
2
3 export interface Agent {
4 - id: number
5 - agent_id: string
6 - ip_address: string
7 - os: string
8 - hostname: string
9 - label: string
10 - critical_asset: boolean
11 - wazuh_last_seen: string
12 - velociraptor_id: string | null
13 - velociraptor_last_seen: string | null
14 - wazuh_agent_version: string
15 - wazuh_agent_status: string
16 - velociraptor_agent_version: string | null
17 - customer_code: string
18 - quarantined: boolean
19 - velociraptor_org: string | null
4 + id: number
5 + agent_id: string
6 + ip_address: string
7 + os: string
8 + hostname: string
9 + label: string
10 + critical_asset: boolean
11 + wazuh_last_seen: string
12 + velociraptor_id: string | null
13 + velociraptor_last_seen: string | null
14 + wazuh_agent_version: string
15 + wazuh_agent_status: string
16 + velociraptor_agent_version: string | null
17 + customer_code: string
18 + quarantined: boolean
19 + velociraptor_org: string | null
20 }
21
22 export interface AgentsResponse {
23 - agents: Agent[]
24 - success: boolean
25 - message: string
23 + agents: Agent[]
24 + success: boolean
25 + message: string
26 }
27
28 class AgentsAPI {
29 - /**
30 - * Get all agents for the authenticated customer
31 - */
32 - async getAgents(): Promise<AgentsResponse> {
33 - try {
34 - const response = await httpClient.get('/agents')
35 - return response.data
36 - } catch (error: any) {
37 - console.error('Error fetching agents:', error)
38 - throw error
39 - }
40 - }
29 + /**
30 + * Get all agents for the authenticated customer
31 + */
32 + async getAgents(): Promise<AgentsResponse> {
33 + try {
34 + const response = await httpClient.get("/agents")
35 + return response.data
36 + } catch (error: any) {
37 + console.error("Error fetching agents:", error)
38 + throw error
39 + }
40 + }
41
42 - /**
43 - * Get a specific agent by ID
44 - */
45 - async getAgentById(agentId: string): Promise<{ agent: Agent; success: boolean; message: string }> {
46 - try {
47 - const response = await httpClient.get(`/agents/${agentId}`)
48 - return response.data
49 - } catch (error: any) {
50 - console.error('Error fetching agent:', error)
51 - throw error
52 - }
53 - }
42 + /**
43 + * Get a specific agent by ID
44 + */
45 + async getAgentById(agentId: string): Promise<{ agent: Agent; success: boolean; message: string }> {
46 + try {
47 + const response = await httpClient.get(`/agents/${agentId}`)
48 + return response.data
49 + } catch (error: any) {
50 + console.error("Error fetching agent:", error)
51 + throw error
52 + }
53 + }
54
55 - /**
56 - * Get agent by hostname
57 - */
58 - async getAgentByHostname(hostname: string): Promise<{ agent: Agent; success: boolean; message: string }> {
59 - try {
60 - const response = await httpClient.get(`/agents/hostname/${hostname}`)
61 - return response.data
62 - } catch (error: any) {
63 - console.error('Error fetching agent by hostname:', error)
64 - throw error
65 - }
66 - }
55 + /**
56 + * Get agent by hostname
57 + */
58 + async getAgentByHostname(hostname: string): Promise<{ agent: Agent; success: boolean; message: string }> {
59 + try {
60 + const response = await httpClient.get(`/agents/hostname/${hostname}`)
61 + return response.data
62 + } catch (error: any) {
63 + console.error("Error fetching agent by hostname:", error)
64 + throw error
65 + }
66 + }
67
68 - /**
69 - * Mark agent as critical
70 - */
71 - async markAgentAsCritical(agentId: string): Promise<{ success: boolean; message: string }> {
72 - try {
73 - const response = await httpClient.post(`/agents/${agentId}/critical`)
74 - return response.data
75 - } catch (error: any) {
76 - console.error('Error marking agent as critical:', error)
77 - throw error
78 - }
79 - }
68 + /**
69 + * Mark agent as critical
70 + */
71 + async markAgentAsCritical(agentId: string): Promise<{ success: boolean; message: string }> {
72 + try {
73 + const response = await httpClient.post(`/agents/${agentId}/critical`)
74 + return response.data
75 + } catch (error: any) {
76 + console.error("Error marking agent as critical:", error)
77 + throw error
78 + }
79 + }
80
81 - /**
82 - * Mark agent as not critical
83 - */
84 - async markAgentAsNotCritical(agentId: string): Promise<{ success: boolean; message: string }> {
85 - try {
86 - const response = await httpClient.post(`/agents/${agentId}/noncritical`)
87 - return response.data
88 - } catch (error: any) {
89 - console.error('Error marking agent as not critical:', error)
90 - throw error
91 - }
92 - }
81 + /**
82 + * Mark agent as not critical
83 + */
84 + async markAgentAsNotCritical(agentId: string): Promise<{ success: boolean; message: string }> {
85 + try {
86 + const response = await httpClient.post(`/agents/${agentId}/noncritical`)
87 + return response.data
88 + } catch (error: any) {
89 + console.error("Error marking agent as not critical:", error)
90 + throw error
91 + }
92 + }
93 }
94
95 export default new AgentsAPI()
customer_portal/src/api/alerts.ts
+140 -138
@@ -1,173 +1,175 @@
1 -import { httpClient } from '@/utils/httpClient'
1 +import { httpClient } from "@/utils/httpClient"
2
3 export interface AlertComment {
4 - id: number
5 - alert_id: number
6 - comment: string
7 - user_name: string
8 - created_at: string
4 + id: number
5 + alert_id: number
6 + comment: string
7 + user_name: string
8 + created_at: string
9 }
10
11 export interface AlertAsset {
12 - id: number
13 - asset_name: string
14 - agent_id: string
15 - customer_code: string
16 - index_id: string
17 - alert_linked: number
18 - alert_context_id: number
19 - velociraptor_id: string
20 - index_name: string
12 + id: number
13 + asset_name: string
14 + agent_id: string
15 + customer_code: string
16 + index_id: string
17 + alert_linked: number
18 + alert_context_id: number
19 + velociraptor_id: string
20 + index_name: string
21 }
22
23 export interface AlertTag {
24 - id: number
25 - tag: string
24 + id: number
25 + tag: string
26 }
27
28 export interface AlertIoC {
29 - id: number
30 - ioc_value: string
31 - ioc_type: string
32 - ioc_description: string
29 + id: number
30 + ioc_value: string
31 + ioc_type: string
32 + ioc_description: string
33 }
34
35 export interface LinkedCase {
36 - id: number
37 - case_name: string
38 - case_description: string
39 - case_creation_time: string
40 - case_status: string
41 - assigned_to: string | null
36 + id: number
37 + case_name: string
38 + case_description: string
39 + case_creation_time: string
40 + case_status: string
41 + assigned_to: string | null
42 }
43
44 export interface Alert {
45 - id: number
46 - alert_creation_time: string
47 - time_closed: string | null
48 - alert_name: string
49 - alert_description: string
50 - status: 'OPEN' | 'IN_PROGRESS' | 'CLOSED'
51 - customer_code: string
52 - source: string
53 - assigned_to: string | null
54 - time_stamp?: string
55 - index_id?: string
56 - index_name?: string
57 - asset_name?: string
58 - case_ids?: number[]
59 - tag?: string[]
60 - comments: AlertComment[]
61 - assets: AlertAsset[]
62 - tags: AlertTag[]
63 - linked_cases: LinkedCase[]
64 - iocs: AlertIoC[]
45 + id: number
46 + alert_creation_time: string
47 + time_closed: string | null
48 + alert_name: string
49 + alert_description: string
50 + status: "OPEN" | "IN_PROGRESS" | "CLOSED"
51 + customer_code: string
52 + source: string
53 + assigned_to: string | null
54 + time_stamp?: string
55 + index_id?: string
56 + index_name?: string
57 + asset_name?: string
58 + case_ids?: number[]
59 + tag?: string[]
60 + comments: AlertComment[]
61 + assets: AlertAsset[]
62 + tags: AlertTag[]
63 + linked_cases: LinkedCase[]
64 + iocs: AlertIoC[]
65 }
66
67 export interface AlertsResponse {
68 - alerts: Alert[]
69 - total: number
70 - open: number
71 - in_progress: number
72 - closed: number
73 - success: boolean
74 - message: string
68 + alerts: Alert[]
69 + total: number
70 + open: number
71 + in_progress: number
72 + closed: number
73 + success: boolean
74 + message: string
75 }
76
77 export interface AlertResponse {
78 - alerts: Alert[]
79 - success: boolean
80 - message: string
78 + alerts: Alert[]
79 + success: boolean
80 + message: string
81 }
82
83 export interface AlertStatusUpdate {
84 - alert_id: number
85 - status: 'OPEN' | 'IN_PROGRESS' | 'CLOSED'
84 + alert_id: number
85 + status: "OPEN" | "IN_PROGRESS" | "CLOSED"
86 }
87
88 export interface AlertCommentPayload {
89 - alert_id: number
90 - comment: string
91 - user_name: string
89 + alert_id: number
90 + comment: string
91 + user_name: string
92 }
93
94 export class AlertsAPI {
95 - /**
96 - * Get all alerts with customer access control
97 - */
98 - static async getAlerts(
99 - page: number = 1,
100 - pageSize: number = 25,
101 - order: 'asc' | 'desc' = 'desc'
102 - ): Promise<AlertsResponse> {
103 - const response = await httpClient.get('/incidents/db_operations/alerts', {
104 - params: {
105 - page,
106 - page_size: pageSize,
107 - order
108 - }
109 - })
110 - return response.data
111 - }
112 -
113 - /**
114 - * Get specific alert by ID (with customer access validation)
115 - */
116 - static async getAlert(alertId: number): Promise<AlertResponse> {
117 - const response = await httpClient.get(`/incidents/db_operations/alert/${alertId}`)
118 - return response.data
119 - }
120 -
121 - /**
122 - * Update alert status (customer access controlled)
123 - */
124 - static async updateAlertStatus(alertId: number, status: 'OPEN' | 'IN_PROGRESS' | 'CLOSED'): Promise<AlertResponse> {
125 - const response = await httpClient.put('/incidents/db_operations/alert/status', {
126 - alert_id: alertId,
127 - status
128 - })
129 - return response.data
130 - }
131 -
132 - /**
133 - * Add comment to alert (customer access controlled)
134 - */
135 - static async addComment(payload: AlertCommentPayload): Promise<{ comment: AlertComment; success: boolean; message: string }> {
136 - const response = await httpClient.post('/incidents/db_operations/alert/comment', payload)
137 - return response.data
138 - }
139 -
140 - /**
141 - * Delete alert comment (customer access controlled)
142 - */
143 - static async deleteComment(commentId: number): Promise<{ success: boolean; message: string }> {
144 - const response = await httpClient.delete(`/incidents/db_operations/alert/comment/${commentId}`)
145 - return response.data
146 - }
147 -
148 - /**
149 - * Get alerts by status with customer filtering
150 - */
151 - static async getAlertsByStatus(status: 'OPEN' | 'IN_PROGRESS' | 'CLOSED'): Promise<AlertsResponse> {
152 - const response = await httpClient.get(`/incidents/db_operations/alerts/status/${status}`)
153 - return response.data
154 - }
155 -
156 - /**
157 - * Get alerts by asset name with customer filtering
158 - */
159 - static async getAlertsByAsset(assetName: string): Promise<AlertsResponse> {
160 - const response = await httpClient.get(`/incidents/db_operations/alerts/asset/${assetName}`)
161 - return response.data
162 - }
163 -
164 - /**
165 - * Get alerts by source with customer filtering
166 - */
167 - static async getAlertsBySource(source: string): Promise<AlertsResponse> {
168 - const response = await httpClient.get(`/incidents/db_operations/alerts/source/${source}`)
169 - return response.data
170 - }
95 + /**
96 + * Get all alerts with customer access control
97 + */
98 + static async getAlerts(
99 + page: number = 1,
100 + pageSize: number = 25,
101 + order: "asc" | "desc" = "desc"
102 + ): Promise<AlertsResponse> {
103 + const response = await httpClient.get("/incidents/db_operations/alerts", {
104 + params: {
105 + page,
106 + page_size: pageSize,
107 + order
108 + }
109 + })
110 + return response.data
111 + }
112 +
113 + /**
114 + * Get specific alert by ID (with customer access validation)
115 + */
116 + static async getAlert(alertId: number): Promise<AlertResponse> {
117 + const response = await httpClient.get(`/incidents/db_operations/alert/${alertId}`)
118 + return response.data
119 + }
120 +
121 + /**
122 + * Update alert status (customer access controlled)
123 + */
124 + static async updateAlertStatus(alertId: number, status: "OPEN" | "IN_PROGRESS" | "CLOSED"): Promise<AlertResponse> {
125 + const response = await httpClient.put("/incidents/db_operations/alert/status", {
126 + alert_id: alertId,
127 + status
128 + })
129 + return response.data
130 + }
131 +
132 + /**
133 + * Add comment to alert (customer access controlled)
134 + */
135 + static async addComment(
136 + payload: AlertCommentPayload
137 + ): Promise<{ comment: AlertComment; success: boolean; message: string }> {
138 + const response = await httpClient.post("/incidents/db_operations/alert/comment", payload)
139 + return response.data
140 + }
141 +
142 + /**
143 + * Delete alert comment (customer access controlled)
144 + */
145 + static async deleteComment(commentId: number): Promise<{ success: boolean; message: string }> {
146 + const response = await httpClient.delete(`/incidents/db_operations/alert/comment/${commentId}`)
147 + return response.data
148 + }
149 +
150 + /**
151 + * Get alerts by status with customer filtering
152 + */
153 + static async getAlertsByStatus(status: "OPEN" | "IN_PROGRESS" | "CLOSED"): Promise<AlertsResponse> {
154 + const response = await httpClient.get(`/incidents/db_operations/alerts/status/${status}`)
155 + return response.data
156 + }
157 +
158 + /**
159 + * Get alerts by asset name with customer filtering
160 + */
161 + static async getAlertsByAsset(assetName: string): Promise<AlertsResponse> {
162 + const response = await httpClient.get(`/incidents/db_operations/alerts/asset/${assetName}`)
163 + return response.data
164 + }
165 +
166 + /**
167 + * Get alerts by source with customer filtering
168 + */
169 + static async getAlertsBySource(source: string): Promise<AlertsResponse> {
170 + const response = await httpClient.get(`/incidents/db_operations/alerts/source/${source}`)
171 + return response.data
172 + }
173 }
174
175 export default AlertsAPI
customer_portal/src/api/auth.ts new
+63
@@ -0,0 +1,63 @@
1 +import { httpClient } from "@/utils/httpClient"
2 +
3 +export interface LoginCredentials {
4 + username: string
5 + password: string
6 +}
7 +
8 +export interface TokenResponse {
9 + access_token: string
10 + token_type: string
11 +}
12 +
13 +export interface DecodedToken {
14 + username: string
15 + scopes: string[]
16 + exp?: number
17 + sub?: string
18 +}
19 +
20 +export class AuthAPI {
21 + /**
22 + * Login with username and password
23 + */
24 + static async login(credentials: LoginCredentials): Promise<TokenResponse> {
25 + const formData = new URLSearchParams()
26 + formData.append("username", credentials.username)
27 + formData.append("password", credentials.password)
28 +
29 + const response = await httpClient.post("/auth/token", formData, {
30 + headers: {
31 + "Content-Type": "application/x-www-form-urlencoded"
32 + }
33 + })
34 + return response.data
35 + }
36 +
37 + /**
38 + * Decode JWT token to extract user information
39 + */
40 + static decodeToken(token: string): DecodedToken | null {
41 + try {
42 + const payload = JSON.parse(atob(token.split(".")[1]))
43 + return {
44 + username: payload.sub || "",
45 + scopes: payload.scopes || [],
46 + exp: payload.exp,
47 + sub: payload.sub
48 + }
49 + } catch (err) {
50 + console.error("Failed to decode token:", err)
51 + return null
52 + }
53 + }
54 +
55 + /**
56 + * Validate if user has customer_user scope
57 + */
58 + static hasCustomerAccess(scopes: string[]): boolean {
59 + return scopes.includes("customer_user")
60 + }
61 +}
62 +
63 +export default AuthAPI
customer_portal/src/api/caseDataStore.ts
+73 -73
@@ -1,91 +1,91 @@
1 -import { httpClient } from '@/utils/httpClient'
1 +import { httpClient } from "@/utils/httpClient"
2
3 export interface CaseDataStoreFile {
4 - id: number
5 - case_id: number
6 - bucket_name: string
7 - object_key: string
8 - file_name: string
9 - content_type: string | null
10 - file_size: number | null
11 - upload_time: string
12 - file_hash: string
4 + id: number
5 + case_id: number
6 + bucket_name: string
7 + object_key: string
8 + file_name: string
9 + content_type: string | null
10 + file_size: number | null
11 + upload_time: string
12 + file_hash: string
13 }
14
15 export interface CaseDataStoreResponse {
16 - case_data_store: CaseDataStoreFile[]
17 - success: boolean
18 - message: string
16 + case_data_store: CaseDataStoreFile[]
17 + success: boolean
18 + message: string
19 }
20
21 export class CaseDataStoreAPI {
22 - /**
23 - * Get files associated with a specific case
24 - */
25 - static async getCaseFiles(caseId: number): Promise<CaseDataStoreResponse> {
26 - const response = await httpClient.get(`/incidents/db_operations/case/data-store/${caseId}`)
27 - return response.data
28 - }
22 + /**
23 + * Get files associated with a specific case
24 + */
25 + static async getCaseFiles(caseId: number): Promise<CaseDataStoreResponse> {
26 + const response = await httpClient.get(`/incidents/db_operations/case/data-store/${caseId}`)
27 + return response.data
28 + }
29
30 - /**
31 - * Download a specific file from a case
32 - * Returns the blob data for download
33 - */
34 - static async downloadCaseFile(caseId: number, fileName: string): Promise<Blob> {
35 - const response = await httpClient.get(
36 - `/incidents/db_operations/case/data-store/download/${caseId}/${fileName}`,
37 - {
38 - responseType: 'blob'
39 - }
40 - )
41 - return response.data
42 - }
30 + /**
31 + * Download a specific file from a case
32 + * Returns the blob data for download
33 + */
34 + static async downloadCaseFile(caseId: number, fileName: string): Promise<Blob> {
35 + const response = await httpClient.get(
36 + `/incidents/db_operations/case/data-store/download/${caseId}/${fileName}`,
37 + {
38 + responseType: "blob"
39 + }
40 + )
41 + return response.data
42 + }
43
44 - /**
45 - * Upload a file to a case data store
46 - */
47 - static async uploadCaseFile(caseId: number, file: File): Promise<CaseDataStoreResponse> {
48 - const formData = new FormData()
49 - formData.append('file', file)
44 + /**
45 + * Upload a file to a case data store
46 + */
47 + static async uploadCaseFile(caseId: number, file: File): Promise<CaseDataStoreResponse> {
48 + const formData = new FormData()
49 + formData.append("file", file)
50
51 - const response = await httpClient.post(
52 - `/incidents/db_operations/case/data-store/upload?case_id=${caseId}`,
53 - formData,
54 - {
55 - headers: {
56 - 'Content-Type': 'multipart/form-data'
57 - }
58 - }
59 - )
60 - return response.data
61 - }
51 + const response = await httpClient.post(
52 + `/incidents/db_operations/case/data-store/upload?case_id=${caseId}`,
53 + formData,
54 + {
55 + headers: {
56 + "Content-Type": "multipart/form-data"
57 + }
58 + }
59 + )
60 + return response.data
61 + }
62
63 - /**
64 - * Trigger file download in browser
65 - */
66 - static downloadFileBlob(blob: Blob, fileName: string): void {
67 - const url = window.URL.createObjectURL(blob)
68 - const link = document.createElement('a')
69 - link.href = url
70 - link.setAttribute('download', fileName)
71 - document.body.appendChild(link)
72 - link.click()
73 - link.remove()
74 - window.URL.revokeObjectURL(url)
75 - }
63 + /**
64 + * Trigger file download in browser
65 + */
66 + static downloadFileBlob(blob: Blob, fileName: string): void {
67 + const url = window.URL.createObjectURL(blob)
68 + const link = document.createElement("a")
69 + link.href = url
70 + link.setAttribute("download", fileName)
71 + document.body.appendChild(link)
72 + link.click()
73 + link.remove()
74 + window.URL.revokeObjectURL(url)
75 + }
76
77 - /**
78 - * Format file size for display
79 - */
80 - static formatFileSize(bytes: number | null): string {
81 - if (!bytes) return 'Unknown size'
77 + /**
78 + * Format file size for display
79 + */
80 + static formatFileSize(bytes: number | null): string {
81 + if (!bytes) return "Unknown size"
82
83 - const sizes = ['Bytes', 'KB', 'MB', 'GB']
84 - if (bytes === 0) return '0 Bytes'
83 + const sizes = ["Bytes", "KB", "MB", "GB"]
84 + if (bytes === 0) return "0 Bytes"
85
86 - const i = Math.floor(Math.log(bytes) / Math.log(1024))
87 - return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]
88 - }
86 + const i = Math.floor(Math.log(bytes) / Math.log(1024))
87 + return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i]
88 + }
89 }
90
91 export default CaseDataStoreAPI
customer_portal/src/api/httpClient.ts
+6 -1
@@ -19,7 +19,12 @@ HttpClient.interceptors.request.use(
19 config.headers.Authorization = `Bearer ${store.userToken}`
20 }
21
22 - if (isJwtExpiring(store.userToken, 60 * 60) && !__TOKEN_REFRESHING && isDebounceTimeOver(__TOKEN_LAST_CHECK)) {
22 + if (
23 + store.userToken &&
24 + isJwtExpiring(store.userToken, 60 * 60) &&
25 + !__TOKEN_REFRESHING &&
26 + isDebounceTimeOver(__TOKEN_LAST_CHECK)
27 + ) {
28 __TOKEN_REFRESHING = true
29 __TOKEN_LAST_CHECK = new Date()
30
customer_portal/src/components/CaseComment.vue
+180 -159
@@ -1,101 +1,122 @@
1 <template>
2 - <div class="bg-white border border-gray-200 rounded-lg p-4 mb-3">
3 - <div class="flex items-start justify-between">
4 - <div class="flex items-start space-x-3">
5 - <!-- User Avatar -->
6 - <div class="flex-shrink-0">
7 - <div class="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center">
8 - <span class="text-white text-sm font-medium">
9 - {{ comment.user_name.charAt(0).toUpperCase() }}
10 - </span>
11 - </div>
12 - </div>
13 -
14 - <!-- Comment Content -->
15 - <div class="flex-grow">
16 - <div class="flex items-center space-x-2 mb-1">
17 - <h4 class="text-sm font-medium text-gray-900">{{ comment.user_name }}</h4>
18 - <span class="text-xs text-gray-500">{{ formatDate(comment.created_at) }}</span>
19 - </div>
20 -
21 - <!-- Edit Mode -->
22 - <div v-if="isEditing" class="space-y-2">
23 - <textarea
24 - v-model="editText"
25 - class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
26 - rows="3"
27 - placeholder="Edit your comment..."
28 - ></textarea>
29 - <div class="flex space-x-2">
30 - <button
31 - @click="saveEdit"
32 - :disabled="!editText.trim() || isLoading"
33 - class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
34 - >
35 - <span v-if="isLoading" class="mr-1">
36 - <svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
37 - <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
38 - <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
39 - </svg>
40 - </span>
41 - Save
42 - </button>
43 - <button
44 - @click="cancelEdit"
45 - :disabled="isLoading"
46 - class="inline-flex items-center px-3 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
47 - >
48 - Cancel
49 - </button>
50 - </div>
51 - </div>
52 -
53 - <!-- View Mode -->
54 - <div v-else class="text-sm text-gray-700 whitespace-pre-wrap">{{ comment.comment }}</div>
55 - </div>
56 - </div>
57 -
58 - <!-- Actions -->
59 - <div v-if="canEdit && !isEditing" class="flex items-center space-x-1 ml-2">
60 - <button
61 - @click="startEdit"
62 - class="p-1 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 rounded"
63 - title="Edit comment"
64 - >
65 - <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
66 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
67 - </svg>
68 - </button>
69 - <button
70 - @click="confirmDelete"
71 - class="p-1 text-gray-400 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 rounded"
72 - title="Delete comment"
73 - >
74 - <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
75 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
76 - </svg>
77 - </button>
78 - </div>
79 - </div>
80 -
81 - <!-- Error message -->
82 - <div v-if="error" class="mt-2 text-sm text-red-600">{{ error }}</div>
83 - </div>
2 + <div class="mb-3 rounded-lg border border-gray-200 bg-white p-4">
3 + <div class="flex items-start justify-between">
4 + <div class="flex items-start space-x-3">
5 + <!-- User Avatar -->
6 + <div class="shrink-0">
7 + <div class="flex h-8 w-8 items-center justify-center rounded-full bg-indigo-500">
8 + <span class="text-sm font-medium text-white">
9 + {{ comment.user_name.charAt(0).toUpperCase() }}
10 + </span>
11 + </div>
12 + </div>
13 +
14 + <!-- Comment Content -->
15 + <div class="grow">
16 + <div class="mb-1 flex items-center space-x-2">
17 + <h4 class="text-sm font-medium text-gray-900">{{ comment.user_name }}</h4>
18 + <span class="text-xs text-gray-500">{{ formatDate(comment.created_at) }}</span>
19 + </div>
20 +
21 + <!-- Edit Mode -->
22 + <div v-if="isEditing" class="space-y-2">
23 + <textarea
24 + v-model="editText"
25 + class="w-full rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none sm:text-sm"
26 + rows="3"
27 + placeholder="Edit your comment..."
28 + ></textarea>
29 + <div class="flex space-x-2">
30 + <button
31 + @click="saveEdit"
32 + :disabled="!editText.trim() || isLoading"
33 + class="inline-flex items-center rounded border border-transparent bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
34 + >
35 + <span v-if="isLoading" class="mr-1">
36 + <svg class="h-3 w-3 animate-spin" fill="none" viewBox="0 0 24 24">
37 + <circle
38 + class="opacity-25"
39 + cx="12"
40 + cy="12"
41 + r="10"
42 + stroke="currentColor"
43 + stroke-width="4"
44 + ></circle>
45 + <path
46 + class="opacity-75"
47 + fill="currentColor"
48 + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
49 + ></path>
50 + </svg>
51 + </span>
52 + Save
53 + </button>
54 + <button
55 + @click="cancelEdit"
56 + :disabled="isLoading"
57 + class="inline-flex items-center rounded border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
58 + >
59 + Cancel
60 + </button>
61 + </div>
62 + </div>
63 +
64 + <!-- View Mode -->
65 + <div v-else class="text-sm whitespace-pre-wrap text-gray-700">{{ comment.comment }}</div>
66 + </div>
67 + </div>
68 +
69 + <!-- Actions -->
70 + <div v-if="canEdit && !isEditing" class="ml-2 flex items-center space-x-1">
71 + <button
72 + @click="startEdit"
73 + class="rounded p-1 text-gray-400 hover:text-gray-600 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none"
74 + title="Edit comment"
75 + >
76 + <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
77 + <path
78 + stroke-linecap="round"
79 + stroke-linejoin="round"
80 + stroke-width="2"
81 + d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
82 + ></path>
83 + </svg>
84 + </button>
85 + <button
86 + @click="confirmDelete"
87 + class="rounded p-1 text-gray-400 hover:text-red-600 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:outline-none"
88 + title="Delete comment"
89 + >
90 + <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
91 + <path
92 + stroke-linecap="round"
93 + stroke-linejoin="round"
94 + stroke-width="2"
95 + d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
96 + ></path>
97 + </svg>
98 + </button>
99 + </div>
100 + </div>
101 +
102 + <!-- Error message -->
103 + <div v-if="error" class="mt-2 text-sm text-red-600">{{ error }}</div>
104 + </div>
105 </template>
106
107 <script setup lang="ts">
87 -import { ref, computed } from 'vue'
88 -import { useAuthStore } from '@/stores/auth'
89 -import type { CaseComment } from '@/api/cases'
90 -import { CasesAPI } from '@/api/cases'
108 +import { ref, computed } from "vue"
109 +import { useAuthStore } from "@/stores/auth"
110 +import type { CaseComment } from "@/api/cases"
111 +import { CasesAPI } from "@/api/cases"
112
113 interface Props {
93 - comment: CaseComment
114 + comment: CaseComment
115 }
116
117 interface Emits {
97 - (e: 'updated', comment: CaseComment): void
98 - (e: 'deleted', commentId: number): void
118 + (e: "updated", comment: CaseComment): void
119 + (e: "deleted", commentId: number): void
120 }
121
122 const props = defineProps<Props>()
@@ -104,96 +125,96 @@ const emit = defineEmits<Emits>()
125 const authStore = useAuthStore()
126
127 const isEditing = ref(false)
107 -const editText = ref('')
128 +const editText = ref("")
129 const isLoading = ref(false)
109 -const error = ref('')
130 +const error = ref("")
131
132 const canEdit = computed(() => {
112 - return authStore.user?.username === props.comment.user_name
133 + return authStore.user?.username === props.comment.user_name
134 })
135
136 const formatDate = (dateString: string) => {
116 - try {
117 - const date = new Date(dateString)
118 - const now = new Date()
119 - const diff = now.getTime() - date.getTime()
120 -
121 - const minutes = Math.floor(diff / (1000 * 60))
122 - const hours = Math.floor(diff / (1000 * 60 * 60))
123 - const days = Math.floor(diff / (1000 * 60 * 60 * 24))
124 -
125 - if (minutes < 1) return 'Just now'
126 - if (minutes < 60) return `${minutes}m ago`
127 - if (hours < 24) return `${hours}h ago`
128 - if (days < 7) return `${days}d ago`
129 -
130 - return date.toLocaleDateString()
131 - } catch {
132 - return 'Unknown'
133 - }
137 + try {
138 + const date = new Date(dateString)
139 + const now = new Date()
140 + const diff = now.getTime() - date.getTime()
141 +
142 + const minutes = Math.floor(diff / (1000 * 60))
143 + const hours = Math.floor(diff / (1000 * 60 * 60))
144 + const days = Math.floor(diff / (1000 * 60 * 60 * 24))
145 +
146 + if (minutes < 1) return "Just now"
147 + if (minutes < 60) return `${minutes}m ago`
148 + if (hours < 24) return `${hours}h ago`
149 + if (days < 7) return `${days}d ago`
150 +
151 + return date.toLocaleDateString()
152 + } catch {
153 + return "Unknown"
154 + }
155 }
156
157 const startEdit = () => {
137 - editText.value = props.comment.comment
138 - isEditing.value = true
139 - error.value = ''
158 + editText.value = props.comment.comment
159 + isEditing.value = true
160 + error.value = ""
161 }
162
163 const cancelEdit = () => {
143 - isEditing.value = false
144 - editText.value = ''
145 - error.value = ''
164 + isEditing.value = false
165 + editText.value = ""
166 + error.value = ""
167 }
168
169 const saveEdit = async () => {
149 - if (!editText.value.trim()) return
150 -
151 - isLoading.value = true
152 - error.value = ''
153 -
154 - try {
155 - const response = await CasesAPI.updateCaseComment(
156 - props.comment.id,
157 - props.comment.case_id,
158 - editText.value.trim()
159 - )
160 -
161 - if (response.success) {
162 - emit('updated', response.comment)
163 - isEditing.value = false
164 - editText.value = ''
165 - } else {
166 - error.value = response.message || 'Failed to update comment'
167 - }
168 - } catch (err: any) {
169 - error.value = err.response?.data?.detail || 'Failed to update comment'
170 - } finally {
171 - isLoading.value = false
172 - }
170 + if (!editText.value.trim()) return
171 +
172 + isLoading.value = true
173 + error.value = ""
174 +
175 + try {
176 + const response = await CasesAPI.updateCaseComment(
177 + props.comment.id,
178 + props.comment.case_id,
179 + editText.value.trim()
180 + )
181 +
182 + if (response.success) {
183 + emit("updated", response.comment)
184 + isEditing.value = false
185 + editText.value = ""
186 + } else {
187 + error.value = response.message || "Failed to update comment"
188 + }
189 + } catch (err: any) {
190 + error.value = err.response?.data?.detail || "Failed to update comment"
191 + } finally {
192 + isLoading.value = false
193 + }
194 }
195
196 const confirmDelete = () => {
176 - if (confirm('Are you sure you want to delete this comment?')) {
177 - deleteComment()
178 - }
197 + if (confirm("Are you sure you want to delete this comment?")) {
198 + deleteComment()
199 + }
200 }
201
202 const deleteComment = async () => {
182 - isLoading.value = true
183 - error.value = ''
184 -
185 - try {
186 - const response = await CasesAPI.deleteCaseComment(props.comment.id)
187 -
188 - if (response.success) {
189 - emit('deleted', props.comment.id)
190 - } else {
191 - error.value = response.message || 'Failed to delete comment'
192 - }
193 - } catch (err: any) {
194 - error.value = err.response?.data?.detail || 'Failed to delete comment'
195 - } finally {
196 - isLoading.value = false
197 - }
203 + isLoading.value = true
204 + error.value = ""
205 +
206 + try {
207 + const response = await CasesAPI.deleteCaseComment(props.comment.id)
208 +
209 + if (response.success) {
210 + emit("deleted", props.comment.id)
211 + } else {
212 + error.value = response.message || "Failed to delete comment"
213 + }
214 + } catch (err: any) {
215 + error.value = err.response?.data?.detail || "Failed to delete comment"
216 + } finally {
217 + isLoading.value = false
218 + }
219 }
220 </script>
customer_portal/src/components/CaseCommentsList.vue
+108 -94
@@ -1,122 +1,136 @@
1 <template>
2 - <div class="space-y-4">
3 - <!-- Header -->
4 - <div class="flex items-center justify-between">
5 - <h3 class="text-lg font-medium text-gray-900">Comments</h3>
6 - <span class="text-sm text-gray-500">{{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}</span>
7 - </div>
8 -
9 - <!-- New Comment Form -->
10 - <div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
11 - <div class="space-y-3">
12 - <textarea
13 - v-model="newComment"
14 - class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
15 - rows="3"
16 - placeholder="Add a comment..."
17 - ></textarea>
18 - <div class="flex justify-end">
19 - <button
20 - @click="addComment"
21 - :disabled="!newComment.trim() || isSubmitting"
22 - class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
23 - >
24 - <span v-if="isSubmitting" class="mr-2">
25 - <svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
26 - <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
27 - <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
28 - </svg>
29 - </span>
30 - Add Comment
31 - </button>
32 - </div>
33 - </div>
34 -
35 - <!-- Error message -->
36 - <div v-if="error" class="mt-2 text-sm text-red-600">{{ error }}</div>
37 - </div>
38 -
39 - <!-- Comments List -->
40 - <div v-if="comments.length === 0" class="text-center py-8 text-gray-500">
41 - <svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
42 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
43 - </svg>
44 - <p>No comments yet</p>
45 - <p class="text-sm">Be the first to add a comment to this case.</p>
46 - </div>
47 -
48 - <div v-else class="space-y-3">
49 - <CaseComment
50 - v-for="comment in sortedComments"
51 - :key="comment.id"
52 - :comment="comment"
53 - @updated="handleCommentUpdated"
54 - @deleted="handleCommentDeleted"
55 - />
56 - </div>
57 - </div>
2 + <div class="space-y-4">
3 + <!-- Header -->
4 + <div class="flex items-center justify-between">
5 + <h3 class="text-lg font-medium text-gray-900">Comments</h3>
6 + <span class="text-sm text-gray-500">
7 + {{ comments.length }} {{ comments.length === 1 ? "comment" : "comments" }}
8 + </span>
9 + </div>
10 +
11 + <!-- New Comment Form -->
12 + <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
13 + <div class="space-y-3">
14 + <textarea
15 + v-model="newComment"
16 + class="w-full rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none sm:text-sm"
17 + rows="3"
18 + placeholder="Add a comment..."
19 + ></textarea>
20 + <div class="flex justify-end">
21 + <button
22 + @click="addComment"
23 + :disabled="!newComment.trim() || isSubmitting"
24 + class="inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
25 + >
26 + <span v-if="isSubmitting" class="mr-2">
27 + <svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
28 + <circle
29 + class="opacity-25"
30 + cx="12"
31 + cy="12"
32 + r="10"
33 + stroke="currentColor"
34 + stroke-width="4"
35 + ></circle>
36 + <path
37 + class="opacity-75"
38 + fill="currentColor"
39 + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
40 + ></path>
41 + </svg>
42 + </span>
43 + Add Comment
44 + </button>
45 + </div>
46 + </div>
47 +
48 + <!-- Error message -->
49 + <div v-if="error" class="mt-2 text-sm text-red-600">{{ error }}</div>
50 + </div>
51 +
52 + <!-- Comments List -->
53 + <div v-if="comments.length === 0" class="py-8 text-center text-gray-500">
54 + <svg class="mx-auto mb-4 h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
55 + <path
56 + stroke-linecap="round"
57 + stroke-linejoin="round"
58 + stroke-width="2"
59 + d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
60 + ></path>
61 + </svg>
62 + <p>No comments yet</p>
63 + <p class="text-sm">Be the first to add a comment to this case.</p>
64 + </div>
65 +
66 + <div v-else class="space-y-3">
67 + <CaseComment
68 + v-for="comment in sortedComments"
69 + :key="comment.id"
70 + :comment="comment"
71 + @updated="handleCommentUpdated"
72 + @deleted="handleCommentDeleted"
73 + />
74 + </div>
75 + </div>
76 </template>
77
78 <script setup lang="ts">
61 -import { ref, computed } from 'vue'
62 -import CaseComment from './CaseComment.vue'
63 -import type { CaseComment as CaseCommentType } from '@/api/cases'
64 -import { CasesAPI } from '@/api/cases'
79 +import { ref, computed } from "vue"
80 +import CaseComment from "./CaseComment.vue"
81 +import type { CaseComment as CaseCommentType } from "@/api/cases"
82 +import { CasesAPI } from "@/api/cases"
83
84 interface Props {
67 - caseId: number
68 - comments: CaseCommentType[]
85 + caseId: number
86 + comments: CaseCommentType[]
87 }
88
89 interface Emits {
72 - (e: 'commentsUpdated', comments: CaseCommentType[]): void
90 + (e: "commentsUpdated", comments: CaseCommentType[]): void
91 }
92
93 const props = defineProps<Props>()
94 const emit = defineEmits<Emits>()
95
78 -const newComment = ref('')
96 +const newComment = ref("")
97 const isSubmitting = ref(false)
80 -const error = ref('')
98 +const error = ref("")
99
100 const sortedComments = computed(() => {
83 - return [...props.comments].sort((a, b) =>
84 - new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
85 - )
101 + return [...props.comments].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
102 })
103
104 const addComment = async () => {
89 - if (!newComment.value.trim()) return
90 -
91 - isSubmitting.value = true
92 - error.value = ''
93 -
94 - try {
95 - const response = await CasesAPI.createCaseComment(props.caseId, newComment.value.trim())
96 -
97 - if (response.success) {
98 - const updatedComments = [...props.comments, response.comment]
99 - emit('commentsUpdated', updatedComments)
100 - newComment.value = ''
101 - } else {
102 - error.value = response.message || 'Failed to add comment'
103 - }
104 - } catch (err: any) {
105 - error.value = err.response?.data?.detail || 'Failed to add comment'
106 - } finally {
107 - isSubmitting.value = false
108 - }
105 + if (!newComment.value.trim()) return
106 +
107 + isSubmitting.value = true
108 + error.value = ""
109 +
110 + try {
111 + const response = await CasesAPI.createCaseComment(props.caseId, newComment.value.trim())
112 +
113 + if (response.success) {
114 + const updatedComments = [...props.comments, response.comment]
115 + emit("commentsUpdated", updatedComments)
116 + newComment.value = ""
117 + } else {
118 + error.value = response.message || "Failed to add comment"
119 + }
120 + } catch (err: any) {
121 + error.value = err.response?.data?.detail || "Failed to add comment"
122 + } finally {
123 + isSubmitting.value = false
124 + }
125 }
126
127 const handleCommentUpdated = (updatedComment: CaseCommentType) => {
112 - const updatedComments = props.comments.map(comment =>
113 - comment.id === updatedComment.id ? updatedComment : comment
114 - )
115 - emit('commentsUpdated', updatedComments)
128 + const updatedComments = props.comments.map(comment => (comment.id === updatedComment.id ? updatedComment : comment))
129 + emit("commentsUpdated", updatedComments)
130 }
131
132 const handleCommentDeleted = (commentId: number) => {
119 - const updatedComments = props.comments.filter(comment => comment.id !== commentId)
120 - emit('commentsUpdated', updatedComments)
133 + const updatedComments = props.comments.filter(comment => comment.id !== commentId)
134 + emit("commentsUpdated", updatedComments)
135 }
136 </script>
customer_portal/src/components/LoginPage.vue
+35 -44
@@ -114,6 +114,7 @@
114 import { ref, computed } from "vue"
115 import { useRouter } from "vue-router"
116 import { usePortalSettingsStore } from "../stores/portalSettings"
117 +import { AuthAPI } from "../api/auth"
118
119 const router = useRouter()
120 const portalSettingsStore = usePortalSettingsStore()
@@ -132,56 +133,46 @@ const handleLogin = async () => {
133 error.value = ""
134
135 try {
135 - // Use the Vite proxy in development, direct URL in production
136 - const apiUrl = import.meta.env.DEV ? "" : import.meta.env.VITE_API_URL || "http://localhost:5000"
137 - const response = await fetch(`${apiUrl}/api/auth/token`, {
138 - method: "POST",
139 - headers: {
140 - "Content-Type": "application/x-www-form-urlencoded"
141 - },
142 - body: new URLSearchParams({
143 - username: username.value,
144 - password: password.value
145 - })
136 + const data = await AuthAPI.login({
137 + username: username.value,
138 + password: password.value
139 })
140
148 - if (response.ok) {
149 - const data = (await response.json()) as { access_token: string; token_type: string }
150 -
151 - // Check if user is customer_user by decoding the token
152 - if (data.access_token) {
153 - try {
154 - const payload = JSON.parse(atob(data.access_token.split(".")[1]))
155 - const userScopes = payload.scopes || []
156 -
157 - // Check if user has customer_user scope
158 - if (userScopes.includes("customer_user")) {
159 - // Store the token and user info
160 - localStorage.setItem("customer-portal-auth-token", data.access_token)
161 - localStorage.setItem(
162 - "customer-portal-user",
163 - JSON.stringify({
164 - username: username.value,
165 - scopes: userScopes
166 - })
167 - )
168 -
169 - router.push("/")
170 - } else {
171 - error.value = "Access denied. Customer portal is for customer users only."
172 - }
173 - } catch (err) {
174 - error.value = "Invalid token received"
175 - }
141 + if (data.access_token) {
142 + const decoded = AuthAPI.decodeToken(data.access_token)
143 +
144 + if (!decoded) {
145 + error.value = "Invalid token received"
146 + return
147 + }
148 +
149 + // Check if user has customer_user scope
150 + if (AuthAPI.hasCustomerAccess(decoded.scopes)) {
151 + // Store the token and user info
152 + localStorage.setItem("customer-portal-auth-token", data.access_token)
153 + localStorage.setItem(
154 + "customer-portal-user",
155 + JSON.stringify({
156 + username: username.value,
157 + scopes: decoded.scopes
158 + })
159 + )
160 +
161 + router.push("/")
162 } else {
177 - error.value = "Login failed"
163 + error.value = "Access denied. Customer portal is for customer users only."
164 }
165 } else {
180 - const errorData = (await response.json()) as { detail?: string }
181 - error.value = errorData.detail || "Login failed"
166 + error.value = "Login failed"
167 + }
168 + } catch (err: any) {
169 + if (err.response?.data?.detail) {
170 + error.value = err.response.data.detail
171 + } else if (err.message) {
172 + error.value = err.message
173 + } else {
174 + error.value = "Network error. Please try again."
175 }
183 - } catch (err) {
184 - error.value = "Network error. Please try again."
176 console.error("Login error:", err)
177 } finally {
178 loading.value = false
customer_portal/src/main.ts
+10 -10
@@ -1,19 +1,19 @@
1 -import { createApp } from 'vue'
2 -import { createPinia } from 'pinia'
3 -import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
4 -import App from './App.vue'
5 -import router from './router'
1 +import { createApp } from "vue"
2 +import { createPinia } from "pinia"
3 +import { createPersistedState } from "pinia-plugin-persistedstate"
4 +import App from "./App.vue"
5 +import router from "./router"
6
7 // Import basic CSS
8 -import './styles/main.css'
9 -
10 -const app = createApp(App)
8 +import "./styles/main.css"
9
10 // Setup Pinia store
11 const pinia = createPinia()
14 -pinia.use(piniaPluginPersistedstate)
12 +pinia.use(createPersistedState())
13 +
14 +const app = createApp(App)
15
16 app.use(pinia)
17 app.use(router)
18
19 -app.mount('#app')
19 +app.mount("#app")
customer_portal/src/router/index.ts
+26 -26
@@ -1,10 +1,10 @@
1 -import { createRouter, createWebHistory } from 'vue-router'
2 -import LoginPage from '@/components/LoginPage.vue'
3 -import OverviewPage from '@/views/OverviewPage.vue'
4 -import AlertsPage from '@/views/AlertsPage.vue'
5 -import CasesPage from '@/views/CasesPage.vue'
6 -import CaseDetailsView from '@/views/CaseDetailsView.vue'
7 -import AgentsPage from '@/views/AgentsPage.vue'
1 +import { createRouter, createWebHistory } from "vue-router"
2 +import LoginPage from "@/components/LoginPage.vue"
3 +import OverviewPage from "@/views/OverviewPage.vue"
4 +import AlertsPage from "@/views/AlertsPage.vue"
5 +import CasesPage from "@/views/CasesPage.vue"
6 +import CaseDetailsView from "@/views/CaseDetailsView.vue"
7 +import AgentsPage from "@/views/AgentsPage.vue"
8
9 const NotFound = {
10 template: `
@@ -22,48 +22,48 @@ const NotFound = {
22
23 const routes = [
24 {
25 - path: '/login',
26 - name: 'Login',
25 + path: "/login",
26 + name: "Login",
27 component: LoginPage,
28 meta: { requiresGuest: true }
29 },
30 {
31 - path: '/',
32 - name: 'Overview',
31 + path: "/",
32 + name: "Overview",
33 component: OverviewPage,
34 meta: { requiresAuth: true }
35 },
36 {
37 - path: '/overview',
38 - redirect: '/'
37 + path: "/overview",
38 + redirect: "/"
39 },
40 {
41 - path: '/alerts',
42 - name: 'Alerts',
41 + path: "/alerts",
42 + name: "Alerts",
43 component: AlertsPage,
44 meta: { requiresAuth: true }
45 },
46 {
47 - path: '/cases',
48 - name: 'Cases',
47 + path: "/cases",
48 + name: "Cases",
49 component: CasesPage,
50 meta: { requiresAuth: true }
51 },
52 {
53 - path: '/cases/:id',
54 - name: 'CaseDetails',
53 + path: "/cases/:id",
54 + name: "CaseDetails",
55 component: CaseDetailsView,
56 meta: { requiresAuth: true }
57 },
58 {
59 - path: '/agents',
60 - name: 'Agents',
59 + path: "/agents",
60 + name: "Agents",
61 component: AgentsPage,
62 meta: { requiresAuth: true }
63 },
64 {
65 - path: '/:pathMatch(.*)*',
66 - name: 'NotFound',
65 + path: "/:pathMatch(.*)*",
66 + name: "NotFound",
67 component: NotFound
68 }
69 ]
@@ -75,13 +75,13 @@ const router = createRouter({
75
76 // Simple navigation guards
77 router.beforeEach((to, _from, next) => {
78 - const token = localStorage.getItem('customer-portal-auth-token')
78 + const token = localStorage.getItem("customer-portal-auth-token")
79 const isAuthenticated = !!token
80
81 if (to.meta.requiresAuth && !isAuthenticated) {
82 - next('/login')
82 + next("/login")
83 } else if (to.meta.requiresGuest && isAuthenticated) {
84 - next('/')
84 + next("/")
85 } else {
86 next()
87 }
customer_portal/src/stores/auth.ts
+16 -16
@@ -1,5 +1,5 @@
1 -import { defineStore } from 'pinia'
2 -import axios from 'axios'
1 +import { defineStore } from "pinia"
2 +import axios from "axios"
3
4 interface User {
5 id: number
@@ -15,7 +15,7 @@ interface AuthState {
15 isAuthenticated: boolean
16 }
17
18 -export const useAuthStore = defineStore('auth', {
18 +export const useAuthStore = defineStore("auth", {
19 state: (): AuthState => ({
20 userToken: null,
21 user: null,
@@ -23,18 +23,18 @@ export const useAuthStore = defineStore('auth', {
23 }),
24
25 getters: {
26 - isLogged: (state) => state.isAuthenticated && !!state.userToken,
27 - isCustomerUser: (state) => state.user?.role_name === 'customer_user'
26 + isLogged: state => state.isAuthenticated && !!state.userToken,
27 + isCustomerUser: state => state.user?.role_name === "customer_user"
28 },
29
30 actions: {
31 async login(username: string, password: string) {
32 try {
33 const formData = new FormData()
34 - formData.append('username', username)
35 - formData.append('password', password)
34 + formData.append("username", username)
35 + formData.append("password", password)
36
37 - const response = await axios.post('/api/auth/token', formData)
37 + const response = await axios.post("/api/auth/token", formData)
38
39 if (response.data.access_token) {
40 this.userToken = response.data.access_token
@@ -43,31 +43,31 @@ export const useAuthStore = defineStore('auth', {
43 return { success: true }
44 }
45
46 - return { success: false, message: 'Login failed' }
46 + return { success: false, message: "Login failed" }
47 } catch (error: any) {
48 return {
49 success: false,
50 - message: error.response?.data?.detail || 'Login failed'
50 + message: error.response?.data?.detail || "Login failed"
51 }
52 }
53 },
54
55 async fetchUser() {
56 try {
57 - const response = await axios.get('/api/auth/me', {
57 + const response = await axios.get("/api/auth/me", {
58 headers: {
59 Authorization: `Bearer ${this.userToken}`
60 }
61 })
62 this.user = response.data
63 } catch (error) {
64 - console.error('Failed to fetch user:', error)
64 + console.error("Failed to fetch user:", error)
65 }
66 },
67
68 async refreshToken() {
69 try {
70 - const response = await axios.get('/api/auth/refresh', {
70 + const response = await axios.get("/api/auth/refresh", {
71 headers: {
72 Authorization: `Bearer ${this.userToken}`
73 }
@@ -77,7 +77,7 @@ export const useAuthStore = defineStore('auth', {
77 this.userToken = response.data.access_token
78 }
79 } catch (error) {
80 - console.error('Failed to refresh token:', error)
80 + console.error("Failed to refresh token:", error)
81 this.logout()
82 }
83 },
@@ -94,8 +94,8 @@ export const useAuthStore = defineStore('auth', {
94 },
95
96 persist: {
97 - key: 'customer-portal-auth',
97 + key: "customer-portal-auth",
98 storage: localStorage,
99 - paths: ['userToken', 'user', 'isAuthenticated']
99 + pick: ["userToken", "user", "isAuthenticated"]
100 }
101 })
customer_portal/src/stores/portalSettings.ts
+1
@@ -35,6 +35,7 @@ export const usePortalSettingsStore = defineStore("portalSettings", {
35 },
36
37 actions: {
38 + // TODO: use persist with custom storage
39 loadFromSessionStorage() {
40 try {
41 const stored = sessionStorage.getItem(STORAGE_KEY)
customer_portal/src/styles/main.css
+6 -5
@@ -1,16 +1,17 @@
1 -@import 'tailwindcss';
1 +@import "tailwindcss";
2
3 /* Base styles */
4 * {
5 box-sizing: border-box;
6 }
7
8 -html, body {
8 +html,
9 +body {
10 margin: 0;
11 padding: 0;
11 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
12 - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
13 - sans-serif;
12 + font-family:
13 + -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
14 + "Droid Sans", "Helvetica Neue", sans-serif;
15 -webkit-font-smoothing: antialiased;
16 -moz-osx-font-smoothing: grayscale;
17 }
customer_portal/src/utils/httpClient.ts
+9 -9
@@ -10,7 +10,7 @@ let __TOKEN_LAST_CHECK: Date | null = null
10
11 // Helper function to get token from localStorage
12 function getToken(): string | null {
13 - return localStorage.getItem('customer-portal-auth-token')
13 + return localStorage.getItem("customer-portal-auth-token")
14 }
15
16 // Helper function to check if JWT is expiring
@@ -18,12 +18,12 @@ function isJwtExpiring(token: string | null, expiryThresholdSeconds: number): bo
18 if (!token) return false
19
20 try {
21 - const payload = JSON.parse(atob(token.split('.')[1]))
21 + const payload = JSON.parse(atob(token.split(".")[1]))
22 const expiryTime = payload.exp * 1000 // Convert to milliseconds
23 const currentTime = Date.now()
24 const thresholdTime = expiryThresholdSeconds * 1000
25
26 - return (expiryTime - currentTime) <= thresholdTime
26 + return expiryTime - currentTime <= thresholdTime
27 } catch {
28 return false
29 }
@@ -32,7 +32,7 @@ function isJwtExpiring(token: string | null, expiryThresholdSeconds: number): bo
32 // Helper function for debouncing token checks
33 function isDebounceTimeOver(lastCheck: Date | null): boolean {
34 if (!lastCheck) return true
35 - return (Date.now() - lastCheck.getTime()) > 30000 // 30 seconds
35 + return Date.now() - lastCheck.getTime() > 30000 // 30 seconds
36 }
37
38 httpClient.interceptors.request.use(
@@ -42,9 +42,9 @@ httpClient.interceptors.request.use(
42 if (!config.headers) config.headers = {} as AxiosRequestHeaders
43 if (token) {
44 config.headers.Authorization = `Bearer ${token}`
45 - console.log('Adding Authorization header:', `Bearer ${token.substring(0, 20)}...`)
45 + console.log("Adding Authorization header:", `Bearer ${token.substring(0, 20)}...`)
46 } else {
47 - console.warn('No token found in localStorage')
47 + console.warn("No token found in localStorage")
48 }
49
50 // Optional: Check for token expiry and handle refresh if needed
@@ -54,7 +54,7 @@ httpClient.interceptors.request.use(
54
55 // For customer portal, we'll just let the token expire and redirect to login
56 // since customer users typically don't have refresh tokens
57 - console.warn('JWT token is expiring soon')
57 + console.warn("JWT token is expiring soon")
58 __TOKEN_REFRESHING = false
59 }
60
@@ -69,8 +69,8 @@ httpClient.interceptors.response.use(
69 if (error.response && error.response.status === 401) {
70 if (!window.location.pathname.includes("login")) {
71 // Clear stored auth data and redirect to login
72 - localStorage.removeItem('customer-portal-auth-token')
73 - localStorage.removeItem('customer-portal-user')
72 + localStorage.removeItem("customer-portal-auth-token")
73 + localStorage.removeItem("customer-portal-user")
74 window.location.href = "/login"
75 }
76 }
customer_portal/src/views/AgentsPage.vue
+6 -6
@@ -76,7 +76,7 @@
76 <!-- Error State -->
77 <div v-else-if="error" class="mb-6 rounded-lg border border-red-200 bg-red-50 p-4">
78 <div class="flex">
79 - <div class="flex-shrink-0">
79 + <div class="shrink-0">
80 <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
81 <path
82 fill-rule="evenodd"
@@ -107,7 +107,7 @@
107 <div class="overflow-hidden rounded-lg bg-white shadow-sm">
108 <div class="p-6">
109 <div class="flex items-center">
110 - <div class="flex-shrink-0">
110 + <div class="shrink-0">
111 <div class="flex h-8 w-8 items-center justify-center rounded-md bg-blue-500">
112 <svg
113 class="h-5 w-5 text-white"
@@ -137,7 +137,7 @@
137 <div class="overflow-hidden rounded-lg bg-white shadow-sm">
138 <div class="p-6">
139 <div class="flex items-center">
140 - <div class="flex-shrink-0">
140 + <div class="shrink-0">
141 <div class="flex h-8 w-8 items-center justify-center rounded-md bg-green-500">
142 <svg
143 class="h-5 w-5 text-white"
@@ -167,7 +167,7 @@
167 <div class="overflow-hidden rounded-lg bg-white shadow-sm">
168 <div class="p-6">
169 <div class="flex items-center">
170 - <div class="flex-shrink-0">
170 + <div class="shrink-0">
171 <div class="flex h-8 w-8 items-center justify-center rounded-md bg-yellow-500">
172 <svg
173 class="h-5 w-5 text-white"
@@ -197,7 +197,7 @@
197 <div class="overflow-hidden rounded-lg bg-white shadow-sm">
198 <div class="p-6">
199 <div class="flex items-center">
200 - <div class="flex-shrink-0">
200 + <div class="shrink-0">
201 <div class="flex h-8 w-8 items-center justify-center rounded-md bg-red-500">
202 <svg
203 class="h-5 w-5 text-white"
@@ -343,7 +343,7 @@
343 <tr v-for="agent in paginatedAgents" :key="agent.id" class="hover:bg-gray-50">
344 <td class="px-6 py-4 whitespace-nowrap">
345 <div class="flex items-center">
346 - <div class="h-10 w-10 flex-shrink-0">
346 + <div class="h-10 w-10 shrink-0">
347 <div
348 class="flex h-10 w-10 items-center justify-center rounded-full text-sm font-medium text-white"
349 :class="{
customer_portal/src/views/AlertsPage.vue
+5 -12
@@ -75,7 +75,7 @@
75 <div class="overflow-hidden rounded-lg bg-white shadow">
76 <div class="p-5">
77 <div class="flex items-center">
78 - <div class="flex-shrink-0">
78 + <div class="shrink-0">
79 <div class="flex h-8 w-8 items-center justify-center rounded-md bg-blue-500">
80 <svg class="h-5 w-5 text-white" fill="currentColor" viewBox="0 0 20 20">
81 <path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
@@ -95,7 +95,7 @@
95 <div class="overflow-hidden rounded-lg bg-white shadow">
96 <div class="p-5">
97 <div class="flex items-center">
98 - <div class="flex-shrink-0">
98 + <div class="shrink-0">
99 <div class="flex h-8 w-8 items-center justify-center rounded-md bg-red-500">
100 <svg class="h-5 w-5 text-white" fill="currentColor" viewBox="0 0 20 20">
101 <path
@@ -119,7 +119,7 @@
119 <div class="overflow-hidden rounded-lg bg-white shadow">
120 <div class="p-5">
121 <div class="flex items-center">
122 - <div class="flex-shrink-0">
122 + <div class="shrink-0">
123 <div class="flex h-8 w-8 items-center justify-center rounded-md bg-yellow-500">
124 <svg class="h-5 w-5 text-white" fill="currentColor" viewBox="0 0 20 20">
125 <path
@@ -143,7 +143,7 @@
143 <div class="overflow-hidden rounded-lg bg-white shadow">
144 <div class="p-5">
145 <div class="flex items-center">
146 - <div class="flex-shrink-0">
146 + <div class="shrink-0">
147 <div class="flex h-8 w-8 items-center justify-center rounded-md bg-green-500">
148 <svg class="h-5 w-5 text-white" fill="currentColor" viewBox="0 0 20 20">
149 <path
@@ -245,7 +245,7 @@
245 <li v-for="alert in alerts" :key="alert.id" class="px-4 py-4 hover:bg-gray-50 sm:px-6">
246 <div class="flex items-center justify-between">
247 <div class="flex items-center">
248 - <div class="flex-shrink-0">
248 + <div class="shrink-0">
249 <span
250 class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
251 :class="{
@@ -670,11 +670,9 @@
670
671 <script setup lang="ts">
672 import { ref, onMounted, computed } from "vue"
673 -import { useRouter } from "vue-router"
673 import { usePortalSettingsStore } from "@/stores/portalSettings"
674 import AlertsAPI, { type Alert, type AlertsResponse } from "@/api/alerts"
675
677 -const router = useRouter()
676 const portalSettingsStore = usePortalSettingsStore()
677
678 // Reactive data
@@ -722,11 +720,6 @@ const availableAssets = computed(() => {
720 return Array.from(assets).sort()
721 })
722
725 -// Methods
726 -const goBack = () => {
727 - router.push("/")
728 -}
729 -
723 const loadAlerts = async () => {
724 loading.value = true
725 error.value = null
customer_portal/src/views/AlertsView.vue
+219 -226
@@ -1,211 +1,204 @@
1 <template>
2 - <div class="min-h-screen bg-gray-50">
3 - <!-- Header -->
4 - <header class="bg-white shadow">
5 - <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
6 - <div class="flex justify-between h-16">
7 - <div class="flex items-center">
8 - <router-link
9 - to="/"
10 - class="text-indigo-600 hover:text-indigo-500 mr-4"
11 - >
12 - ← Back to Dashboard
13 - </router-link>
14 - <h1 class="text-xl font-semibold">Security Alerts</h1>
15 - </div>
16 - <div class="flex items-center space-x-4">
17 - <span class="text-sm text-gray-700">{{ user?.username }}</span>
18 - <button
19 - @click="logout"
20 - class="bg-red-600 hover:bg-red-700 text-white px-3 py-2 rounded-md text-sm font-medium"
21 - >
22 - Logout
23 - </button>
24 - </div>
25 - </div>
26 - </div>
27 - </header>
28 -
29 - <!-- Main Content -->
30 - <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
31 - <div class="px-4 py-6 sm:px-0">
32 - <!-- Loading State -->
33 - <div v-if="loading" class="text-center py-8">
34 - <div class="inline-flex items-center px-4 py-2 font-semibold leading-6 text-sm shadow rounded-md text-white bg-indigo-500">
35 - Loading alerts...
36 - </div>
37 - </div>
38 -
39 - <!-- Error State -->
40 - <div v-else-if="error" class="bg-red-50 border border-red-200 rounded-md p-4">
41 - <div class="flex">
42 - <div class="ml-3">
43 - <h3 class="text-sm font-medium text-red-800">
44 - Error loading alerts
45 - </h3>
46 - <div class="mt-2 text-sm text-red-700">
47 - {{ error }}
48 - </div>
49 - </div>
50 - </div>
51 - </div>
52 -
53 - <!-- Alerts List -->
54 - <div v-else>
55 - <!-- Stats Cards -->
56 - <div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
57 - <div class="bg-white overflow-hidden shadow rounded-lg">
58 - <div class="p-5">
59 - <div class="flex items-center">
60 - <div class="w-8 h-8 bg-red-500 rounded-md flex items-center justify-center">
61 - <span class="text-white text-sm font-medium">H</span>
62 - </div>
63 - <div class="ml-3">
64 - <p class="text-sm font-medium text-gray-500">High</p>
65 - <p class="text-lg font-semibold text-gray-900">{{ getAlertCount('high') }}</p>
66 - </div>
67 - </div>
68 - </div>
69 - </div>
70 - <div class="bg-white overflow-hidden shadow rounded-lg">
71 - <div class="p-5">
72 - <div class="flex items-center">
73 - <div class="w-8 h-8 bg-yellow-500 rounded-md flex items-center justify-center">
74 - <span class="text-white text-sm font-medium">M</span>
75 - </div>
76 - <div class="ml-3">
77 - <p class="text-sm font-medium text-gray-500">Medium</p>
78 - <p class="text-lg font-semibold text-gray-900">{{ getAlertCount('medium') }}</p>
79 - </div>
80 - </div>
81 - </div>
82 - </div>
83 - <div class="bg-white overflow-hidden shadow rounded-lg">
84 - <div class="p-5">
85 - <div class="flex items-center">
86 - <div class="w-8 h-8 bg-blue-500 rounded-md flex items-center justify-center">
87 - <span class="text-white text-sm font-medium">L</span>
88 - </div>
89 - <div class="ml-3">
90 - <p class="text-sm font-medium text-gray-500">Low</p>
91 - <p class="text-lg font-semibold text-gray-900">{{ getAlertCount('low') }}</p>
92 - </div>
93 - </div>
94 - </div>
95 - </div>
96 - <div class="bg-white overflow-hidden shadow rounded-lg">
97 - <div class="p-5">
98 - <div class="flex items-center">
99 - <div class="w-8 h-8 bg-gray-500 rounded-md flex items-center justify-center">
100 - <span class="text-white text-sm font-medium">T</span>
101 - </div>
102 - <div class="ml-3">
103 - <p class="text-sm font-medium text-gray-500">Total</p>
104 - <p class="text-lg font-semibold text-gray-900">{{ alerts.length }}</p>
105 - </div>
106 - </div>
107 - </div>
108 - </div>
109 - </div>
110 -
111 - <!-- Alerts Table -->
112 - <div class="bg-white shadow overflow-hidden sm:rounded-md">
113 - <div class="px-4 py-5 sm:px-6">
114 - <h3 class="text-lg leading-6 font-medium text-gray-900">
115 - Recent Alerts
116 - </h3>
117 - <p class="mt-1 max-w-2xl text-sm text-gray-500">
118 - Security alerts for your organization
119 - </p>
120 - </div>
121 -
122 - <div v-if="alerts.length === 0" class="px-4 py-5 sm:px-6 text-center text-gray-500">
123 - No alerts found
124 - </div>
125 -
126 - <ul v-else class="divide-y divide-gray-200">
127 - <li v-for="alert in alerts" :key="alert.id" class="px-4 py-4 sm:px-6">
128 - <div class="flex items-center justify-between">
129 - <div class="flex items-center">
130 - <div
131 - class="w-3 h-3 rounded-full mr-3"
132 - :class="{
133 - 'bg-red-500': alert.alert_severity === 'high',
134 - 'bg-yellow-500': alert.alert_severity === 'medium',
135 - 'bg-blue-500': alert.alert_severity === 'low',
136 - 'bg-gray-500': !alert.alert_severity
137 - }"
138 - ></div>
139 - <div>
140 - <p class="text-sm font-medium text-gray-900">
141 - {{ alert.alert_name || 'Unnamed Alert' }}
142 - </p>
143 - <p class="text-sm text-gray-500">
144 - {{ alert.alert_description || 'No description available' }}
145 - </p>
146 - <p class="text-xs text-gray-400 mt-1">
147 - Created: {{ formatDate(alert.alert_creation_time) }}
148 - </p>
149 - </div>
150 - </div>
151 - <div class="flex items-center space-x-2">
152 - <span
153 - class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
154 - :class="{
155 - 'bg-red-100 text-red-800': alert.alert_severity === 'high',
156 - 'bg-yellow-100 text-yellow-800': alert.alert_severity === 'medium',
157 - 'bg-blue-100 text-blue-800': alert.alert_severity === 'low',
158 - 'bg-gray-100 text-gray-800': !alert.alert_severity
159 - }"
160 - >
161 - {{ alert.alert_severity || 'Unknown' }}
162 - </span>
163 - <span
164 - class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
165 - :class="{
166 - 'bg-green-100 text-green-800': alert.alert_status === 'resolved',
167 - 'bg-red-100 text-red-800': alert.alert_status === 'open',
168 - 'bg-yellow-100 text-yellow-800': alert.alert_status === 'in_progress',
169 - 'bg-gray-100 text-gray-800': !alert.alert_status
170 - }"
171 - >
172 - {{ alert.alert_status || 'Unknown' }}
173 - </span>
174 - </div>
175 - </div>
176 - </li>
177 - </ul>
178 - </div>
179 -
180 - <!-- Pagination (if needed) -->
181 - <div v-if="alerts.length > 0" class="mt-6 flex justify-center">
182 - <button
183 - @click="refreshAlerts"
184 - class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md text-sm font-medium"
185 - >
186 - Refresh
187 - </button>
188 - </div>
189 - </div>
190 - </div>
191 - </main>
192 - </div>
2 + <div class="min-h-screen bg-gray-50">
3 + <!-- Header -->
4 + <header class="bg-white shadow">
5 + <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
6 + <div class="flex h-16 justify-between">
7 + <div class="flex items-center">
8 + <router-link to="/" class="mr-4 text-indigo-600 hover:text-indigo-500">
9 + ← Back to Dashboard
10 + </router-link>
11 + <h1 class="text-xl font-semibold">Security Alerts</h1>
12 + </div>
13 + <div class="flex items-center space-x-4">
14 + <span class="text-sm text-gray-700">{{ user?.username }}</span>
15 + <button
16 + @click="logout"
17 + class="rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700"
18 + >
19 + Logout
20 + </button>
21 + </div>
22 + </div>
23 + </div>
24 + </header>
25 +
26 + <!-- Main Content -->
27 + <main class="mx-auto max-w-7xl py-6 sm:px-6 lg:px-8">
28 + <div class="px-4 py-6 sm:px-0">
29 + <!-- Loading State -->
30 + <div v-if="loading" class="py-8 text-center">
31 + <div
32 + class="inline-flex items-center rounded-md bg-indigo-500 px-4 py-2 text-sm leading-6 font-semibold text-white shadow"
33 + >
34 + Loading alerts...
35 + </div>
36 + </div>
37 +
38 + <!-- Error State -->
39 + <div v-else-if="error" class="rounded-md border border-red-200 bg-red-50 p-4">
40 + <div class="flex">
41 + <div class="ml-3">
42 + <h3 class="text-sm font-medium text-red-800">Error loading alerts</h3>
43 + <div class="mt-2 text-sm text-red-700">
44 + {{ error }}
45 + </div>
46 + </div>
47 + </div>
48 + </div>
49 +
50 + <!-- Alerts List -->
51 + <div v-else>
52 + <!-- Stats Cards -->
53 + <div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
54 + <div class="overflow-hidden rounded-lg bg-white shadow">
55 + <div class="p-5">
56 + <div class="flex items-center">
57 + <div class="flex h-8 w-8 items-center justify-center rounded-md bg-red-500">
58 + <span class="text-sm font-medium text-white">H</span>
59 + </div>
60 + <div class="ml-3">
61 + <p class="text-sm font-medium text-gray-500">High</p>
62 + <p class="text-lg font-semibold text-gray-900">{{ getAlertCount("high") }}</p>
63 + </div>
64 + </div>
65 + </div>
66 + </div>
67 + <div class="overflow-hidden rounded-lg bg-white shadow">
68 + <div class="p-5">
69 + <div class="flex items-center">
70 + <div class="flex h-8 w-8 items-center justify-center rounded-md bg-yellow-500">
71 + <span class="text-sm font-medium text-white">M</span>
72 + </div>
73 + <div class="ml-3">
74 + <p class="text-sm font-medium text-gray-500">Medium</p>
75 + <p class="text-lg font-semibold text-gray-900">{{ getAlertCount("medium") }}</p>
76 + </div>
77 + </div>
78 + </div>
79 + </div>
80 + <div class="overflow-hidden rounded-lg bg-white shadow">
81 + <div class="p-5">
82 + <div class="flex items-center">
83 + <div class="flex h-8 w-8 items-center justify-center rounded-md bg-blue-500">
84 + <span class="text-sm font-medium text-white">L</span>
85 + </div>
86 + <div class="ml-3">
87 + <p class="text-sm font-medium text-gray-500">Low</p>
88 + <p class="text-lg font-semibold text-gray-900">{{ getAlertCount("low") }}</p>
89 + </div>
90 + </div>
91 + </div>
92 + </div>
93 + <div class="overflow-hidden rounded-lg bg-white shadow">
94 + <div class="p-5">
95 + <div class="flex items-center">
96 + <div class="flex h-8 w-8 items-center justify-center rounded-md bg-gray-500">
97 + <span class="text-sm font-medium text-white">T</span>
98 + </div>
99 + <div class="ml-3">
100 + <p class="text-sm font-medium text-gray-500">Total</p>
101 + <p class="text-lg font-semibold text-gray-900">{{ alerts.length }}</p>
102 + </div>
103 + </div>
104 + </div>
105 + </div>
106 + </div>
107 +
108 + <!-- Alerts Table -->
109 + <div class="overflow-hidden bg-white shadow sm:rounded-md">
110 + <div class="px-4 py-5 sm:px-6">
111 + <h3 class="text-lg leading-6 font-medium text-gray-900">Recent Alerts</h3>
112 + <p class="mt-1 max-w-2xl text-sm text-gray-500">Security alerts for your organization</p>
113 + </div>
114 +
115 + <div v-if="alerts.length === 0" class="px-4 py-5 text-center text-gray-500 sm:px-6">
116 + No alerts found
117 + </div>
118 +
119 + <ul v-else class="divide-y divide-gray-200">
120 + <li v-for="alert in alerts" :key="alert.id" class="px-4 py-4 sm:px-6">
121 + <div class="flex items-center justify-between">
122 + <div class="flex items-center">
123 + <div
124 + class="mr-3 h-3 w-3 rounded-full"
125 + :class="{
126 + 'bg-red-500': alert.alert_severity === 'high',
127 + 'bg-yellow-500': alert.alert_severity === 'medium',
128 + 'bg-blue-500': alert.alert_severity === 'low',
129 + 'bg-gray-500': !alert.alert_severity
130 + }"
131 + ></div>
132 + <div>
133 + <p class="text-sm font-medium text-gray-900">
134 + {{ alert.alert_name || "Unnamed Alert" }}
135 + </p>
136 + <p class="text-sm text-gray-500">
137 + {{ alert.alert_description || "No description available" }}
138 + </p>
139 + <p class="mt-1 text-xs text-gray-400">
140 + Created: {{ formatDate(alert.alert_creation_time) }}
141 + </p>
142 + </div>
143 + </div>
144 + <div class="flex items-center space-x-2">
145 + <span
146 + class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
147 + :class="{
148 + 'bg-red-100 text-red-800': alert.alert_severity === 'high',
149 + 'bg-yellow-100 text-yellow-800': alert.alert_severity === 'medium',
150 + 'bg-blue-100 text-blue-800': alert.alert_severity === 'low',
151 + 'bg-gray-100 text-gray-800': !alert.alert_severity
152 + }"
153 + >
154 + {{ alert.alert_severity || "Unknown" }}
155 + </span>
156 + <span
157 + class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
158 + :class="{
159 + 'bg-green-100 text-green-800': alert.alert_status === 'resolved',
160 + 'bg-red-100 text-red-800': alert.alert_status === 'open',
161 + 'bg-yellow-100 text-yellow-800': alert.alert_status === 'in_progress',
162 + 'bg-gray-100 text-gray-800': !alert.alert_status
163 + }"
164 + >
165 + {{ alert.alert_status || "Unknown" }}
166 + </span>
167 + </div>
168 + </div>
169 + </li>
170 + </ul>
171 + </div>
172 +
173 + <!-- Pagination (if needed) -->
174 + <div v-if="alerts.length > 0" class="mt-6 flex justify-center">
175 + <button
176 + @click="refreshAlerts"
177 + class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
178 + >
179 + Refresh
180 + </button>
181 + </div>
182 + </div>
183 + </div>
184 + </main>
185 + </div>
186 </template>
187
188 <script setup lang="ts">
196 -import { ref, onMounted, computed } from 'vue'
197 -import { useRouter } from 'vue-router'
198 -import { useAuthStore } from '@/stores/auth'
199 -import { httpClient } from '@/utils/httpClient'
189 +import { ref, onMounted, computed } from "vue"
190 +import { useRouter } from "vue-router"
191 +import { useAuthStore } from "@/stores/auth"
192 +import { httpClient } from "@/utils/httpClient"
193
194 interface Alert {
202 - id: number
203 - alert_name: string
204 - alert_description: string
205 - alert_severity: string
206 - alert_status: string
207 - alert_creation_time: string
208 - customer_code?: string
195 + id: number
196 + alert_name: string
197 + alert_description: string
198 + alert_severity: string
199 + alert_status: string
200 + alert_creation_time: string
201 + customer_code?: string
202 }
203
204 const router = useRouter()
@@ -213,48 +206,48 @@ const authStore = useAuthStore()
206
207 const alerts = ref<Alert[]>([])
208 const loading = ref(false)
216 -const error = ref('')
209 +const error = ref("")
210
211 const user = computed(() => authStore.user)
212
213 const getAlertCount = (severity: string) => {
221 - return alerts.value.filter(alert => alert.alert_severity === severity).length
214 + return alerts.value.filter(alert => alert.alert_severity === severity).length
215 }
216
217 const formatDate = (dateString: string) => {
225 - if (!dateString) return 'Unknown'
226 - try {
227 - return new Date(dateString).toLocaleDateString()
228 - } catch {
229 - return 'Invalid date'
230 - }
218 + if (!dateString) return "Unknown"
219 + try {
220 + return new Date(dateString).toLocaleDateString()
221 + } catch {
222 + return "Invalid date"
223 + }
224 }
225
226 const fetchAlerts = async () => {
234 - loading.value = true
235 - error.value = ''
236 -
237 - try {
238 - const response = await httpClient.get('/alerts/')
239 - alerts.value = response.data || []
240 - } catch (err: any) {
241 - error.value = err.response?.data?.detail || 'Failed to fetch alerts'
242 - console.error('Failed to fetch alerts:', err)
243 - } finally {
244 - loading.value = false
245 - }
227 + loading.value = true
228 + error.value = ""
229 +
230 + try {
231 + const response = await httpClient.get("/alerts/")
232 + alerts.value = response.data || []
233 + } catch (err: any) {
234 + error.value = err.response?.data?.detail || "Failed to fetch alerts"
235 + console.error("Failed to fetch alerts:", err)
236 + } finally {
237 + loading.value = false
238 + }
239 }
240
241 const refreshAlerts = () => {
249 - fetchAlerts()
242 + fetchAlerts()
243 }
244
245 const logout = () => {
253 - authStore.logout()
254 - router.push('/login')
246 + authStore.logout()
247 + router.push("/login")
248 }
249
250 onMounted(() => {
258 - fetchAlerts()
251 + fetchAlerts()
252 })
253 </script>
customer_portal/src/views/CaseDetailsView.vue
+202 -203
@@ -1,172 +1,171 @@
1 <template>
2 - <div class="min-h-screen bg-gray-50">
3 - <!-- Header -->
4 - <header class="bg-white shadow">
5 - <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
6 - <div class="flex justify-between h-16">
7 - <div class="flex items-center">
8 - <router-link
9 - to="/cases"
10 - class="text-indigo-600 hover:text-indigo-500 mr-4"
11 - >
12 - ← Back to Cases
13 - </router-link>
14 - <h1 class="text-xl font-semibold">Case Details</h1>
15 - </div>
16 - <div class="flex items-center space-x-4">
17 - <span class="text-sm text-gray-700">{{ user?.username }}</span>
18 - <button
19 - @click="logout"
20 - class="bg-red-600 hover:bg-red-700 text-white px-3 py-2 rounded-md text-sm font-medium"
21 - >
22 - Logout
23 - </button>
24 - </div>
25 - </div>
26 - </div>
27 - </header>
28 -
29 - <!-- Main Content -->
30 - <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
31 - <div class="px-4 py-6 sm:px-0">
32 - <!-- Loading State -->
33 - <div v-if="loading" class="text-center py-8">
34 - <div class="inline-flex items-center px-4 py-2 font-semibold leading-6 text-sm shadow rounded-md text-white bg-indigo-500">
35 - Loading case details...
36 - </div>
37 - </div>
38 -
39 - <!-- Error State -->
40 - <div v-else-if="error" class="bg-red-50 border border-red-200 rounded-md p-4">
41 - <div class="flex">
42 - <div class="ml-3">
43 - <h3 class="text-sm font-medium text-red-800">
44 - Error loading case
45 - </h3>
46 - <div class="mt-2 text-sm text-red-700">
47 - {{ error }}
48 - </div>
49 - </div>
50 - </div>
51 - </div>
52 -
53 - <!-- Case Details -->
54 - <div v-else-if="caseData" class="space-y-6">
55 - <!-- Case Header -->
56 - <div class="bg-white shadow overflow-hidden sm:rounded-lg">
57 - <div class="px-4 py-5 sm:px-6">
58 - <div class="flex items-center justify-between">
59 - <div>
60 - <h3 class="text-lg leading-6 font-medium text-gray-900">
61 - {{ caseData.case_name || 'Unnamed Case' }}
62 - </h3>
63 - <p class="mt-1 max-w-2xl text-sm text-gray-500">
64 - Case #{{ caseData.id }}
65 - </p>
66 - </div>
67 - <div class="flex items-center space-x-2">
68 - <span
69 - class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
70 - :class="{
71 - 'bg-red-100 text-red-800': caseData.case_status === 'open',
72 - 'bg-yellow-100 text-yellow-800': caseData.case_status === 'in_progress',
73 - 'bg-green-100 text-green-800': caseData.case_status === 'closed',
74 - 'bg-gray-100 text-gray-800': !caseData.case_status
75 - }"
76 - >
77 - {{ caseData.case_status || 'Unknown' }}
78 - </span>
79 - </div>
80 - </div>
81 - </div>
82 - <div class="border-t border-gray-200 px-4 py-5 sm:p-0">
83 - <dl class="sm:divide-y sm:divide-gray-200">
84 - <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
85 - <dt class="text-sm font-medium text-gray-500">Description</dt>
86 - <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
87 - {{ caseData.case_description || 'No description available' }}
88 - </dd>
89 - </div>
90 - <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
91 - <dt class="text-sm font-medium text-gray-500">Created</dt>
92 - <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
93 - {{ formatDate(caseData.case_creation_time) }}
94 - </dd>
95 - </div>
96 - <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
97 - <dt class="text-sm font-medium text-gray-500">Assigned to</dt>
98 - <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
99 - {{ caseData.assigned_to || 'Unassigned' }}
100 - </dd>
101 - </div>
102 - <div v-if="caseData.customer_code" class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
103 - <dt class="text-sm font-medium text-gray-500">Customer</dt>
104 - <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
105 - {{ caseData.customer_code }}
106 - </dd>
107 - </div>
108 - </dl>
109 - </div>
110 - </div>
111 -
112 - <!-- Alerts Section -->
113 - <div v-if="caseData.alerts && caseData.alerts.length > 0" class="bg-white shadow overflow-hidden sm:rounded-lg">
114 - <div class="px-4 py-5 sm:px-6">
115 - <h3 class="text-lg leading-6 font-medium text-gray-900">
116 - Related Alerts ({{ caseData.alerts.length }})
117 - </h3>
118 - </div>
119 - <div class="border-t border-gray-200">
120 - <ul class="divide-y divide-gray-200">
121 - <li v-for="alert in caseData.alerts" :key="alert.id" class="px-4 py-4 sm:px-6">
122 - <div class="flex items-center justify-between">
123 - <div>
124 - <p class="text-sm font-medium text-gray-900">
125 - {{ alert.alert_name || 'Unnamed Alert' }}
126 - </p>
127 - <p class="text-sm text-gray-500">
128 - Alert #{{ alert.id }}
129 - </p>
130 - </div>
131 - <span
132 - class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
133 - :class="{
134 - 'bg-red-100 text-red-800': alert.status === 'open',
135 - 'bg-yellow-100 text-yellow-800': alert.status === 'in_progress',
136 - 'bg-green-100 text-green-800': alert.status === 'closed',
137 - 'bg-gray-100 text-gray-800': !alert.status
138 - }"
139 - >
140 - {{ alert.status || 'Unknown' }}
141 - </span>
142 - </div>
143 - </li>
144 - </ul>
145 - </div>
146 - </div>
147 -
148 - <!-- Comments Section -->
149 - <div class="bg-white shadow overflow-hidden sm:rounded-lg">
150 - <div class="px-4 py-5 sm:px-6">
151 - <CaseCommentsList
152 - :case-id="caseData.id"
153 - :comments="comments"
154 - @comments-updated="handleCommentsUpdated"
155 - />
156 - </div>
157 - </div>
158 - </div>
159 - </div>
160 - </main>
161 - </div>
2 + <div class="min-h-screen bg-gray-50">
3 + <!-- Header -->
4 + <header class="bg-white shadow">
5 + <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
6 + <div class="flex h-16 justify-between">
7 + <div class="flex items-center">
8 + <router-link to="/cases" class="mr-4 text-indigo-600 hover:text-indigo-500">
9 + ← Back to Cases
10 + </router-link>
11 + <h1 class="text-xl font-semibold">Case Details</h1>
12 + </div>
13 + <div class="flex items-center space-x-4">
14 + <span class="text-sm text-gray-700">{{ user?.username }}</span>
15 + <button
16 + @click="logout"
17 + class="rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700"
18 + >
19 + Logout
20 + </button>
21 + </div>
22 + </div>
23 + </div>
24 + </header>
25 +
26 + <!-- Main Content -->
27 + <main class="mx-auto max-w-7xl py-6 sm:px-6 lg:px-8">
28 + <div class="px-4 py-6 sm:px-0">
29 + <!-- Loading State -->
30 + <div v-if="loading" class="py-8 text-center">
31 + <div
32 + class="inline-flex items-center rounded-md bg-indigo-500 px-4 py-2 text-sm leading-6 font-semibold text-white shadow"
33 + >
34 + Loading case details...
35 + </div>
36 + </div>
37 +
38 + <!-- Error State -->
39 + <div v-else-if="error" class="rounded-md border border-red-200 bg-red-50 p-4">
40 + <div class="flex">
41 + <div class="ml-3">
42 + <h3 class="text-sm font-medium text-red-800">Error loading case</h3>
43 + <div class="mt-2 text-sm text-red-700">
44 + {{ error }}
45 + </div>
46 + </div>
47 + </div>
48 + </div>
49 +
50 + <!-- Case Details -->
51 + <div v-else-if="caseData" class="space-y-6">
52 + <!-- Case Header -->
53 + <div class="overflow-hidden bg-white shadow sm:rounded-lg">
54 + <div class="px-4 py-5 sm:px-6">
55 + <div class="flex items-center justify-between">
56 + <div>
57 + <h3 class="text-lg leading-6 font-medium text-gray-900">
58 + {{ caseData.case_name || "Unnamed Case" }}
59 + </h3>
60 + <p class="mt-1 max-w-2xl text-sm text-gray-500">Case #{{ caseData.id }}</p>
61 + </div>
62 + <div class="flex items-center space-x-2">
63 + <span
64 + class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
65 + :class="{
66 + 'bg-red-100 text-red-800': caseData.case_status === 'OPEN',
67 + 'bg-yellow-100 text-yellow-800': caseData.case_status === 'IN_PROGRESS',
68 + 'bg-green-100 text-green-800': caseData.case_status === 'CLOSED',
69 + 'bg-gray-100 text-gray-800': !caseData.case_status
70 + }"
71 + >
72 + {{ caseData.case_status || "Unknown" }}
73 + </span>
74 + </div>
75 + </div>
76 + </div>
77 + <div class="border-t border-gray-200 px-4 py-5 sm:p-0">
78 + <dl class="sm:divide-y sm:divide-gray-200">
79 + <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
80 + <dt class="text-sm font-medium text-gray-500">Description</dt>
81 + <dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
82 + {{ caseData.case_description || "No description available" }}
83 + </dd>
84 + </div>
85 + <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
86 + <dt class="text-sm font-medium text-gray-500">Created</dt>
87 + <dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
88 + {{ formatDate(caseData.case_creation_time) }}
89 + </dd>
90 + </div>
91 + <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
92 + <dt class="text-sm font-medium text-gray-500">Assigned to</dt>
93 + <dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
94 + {{ caseData.assigned_to || "Unassigned" }}
95 + </dd>
96 + </div>
97 + <div
98 + v-if="caseData.customer_code"
99 + class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5"
100 + >
101 + <dt class="text-sm font-medium text-gray-500">Customer</dt>
102 + <dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
103 + {{ caseData.customer_code }}
104 + </dd>
105 + </div>
106 + </dl>
107 + </div>
108 + </div>
109 +
110 + <!-- Alerts Section -->
111 + <div
112 + v-if="caseData.alerts && caseData.alerts.length > 0"
113 + class="overflow-hidden bg-white shadow sm:rounded-lg"
114 + >
115 + <div class="px-4 py-5 sm:px-6">
116 + <h3 class="text-lg leading-6 font-medium text-gray-900">
117 + Related Alerts ({{ caseData.alerts.length }})
118 + </h3>
119 + </div>
120 + <div class="border-t border-gray-200">
121 + <ul class="divide-y divide-gray-200">
122 + <li v-for="alert in caseData.alerts" :key="alert.id" class="px-4 py-4 sm:px-6">
123 + <div class="flex items-center justify-between">
124 + <div>
125 + <p class="text-sm font-medium text-gray-900">
126 + {{ alert.alert_name || "Unnamed Alert" }}
127 + </p>
128 + <p class="text-sm text-gray-500">Alert #{{ alert.id }}</p>
129 + </div>
130 + <span
131 + class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
132 + :class="{
133 + 'bg-red-100 text-red-800': alert.status === 'OPEN',
134 + 'bg-yellow-100 text-yellow-800': alert.status === 'IN_PROGRESS',
135 + 'bg-green-100 text-green-800': alert.status === 'CLOSED',
136 + 'bg-gray-100 text-gray-800': !alert.status
137 + }"
138 + >
139 + {{ alert.status || "Unknown" }}
140 + </span>
141 + </div>
142 + </li>
143 + </ul>
144 + </div>
145 + </div>
146 +
147 + <!-- Comments Section -->
148 + <div class="overflow-hidden bg-white shadow sm:rounded-lg">
149 + <div class="px-4 py-5 sm:px-6">
150 + <CaseCommentsList
151 + :case-id="caseData.id"
152 + :comments="comments"
153 + @comments-updated="handleCommentsUpdated"
154 + />
155 + </div>
156 + </div>
157 + </div>
158 + </div>
159 + </main>
160 + </div>
161 </template>
162
163 <script setup lang="ts">
165 -import { ref, onMounted, computed } from 'vue'
166 -import { useRouter, useRoute } from 'vue-router'
167 -import { useAuthStore } from '@/stores/auth'
168 -import { CasesAPI, type Case, type CaseComment } from '@/api/cases'
169 -import CaseCommentsList from '@/components/CaseCommentsList.vue'
164 +import { ref, onMounted, computed } from "vue"
165 +import { useRouter, useRoute } from "vue-router"
166 +import { useAuthStore } from "@/stores/auth"
167 +import { CasesAPI, type Case, type CaseComment } from "@/api/cases"
168 +import CaseCommentsList from "@/components/CaseCommentsList.vue"
169
170 const router = useRouter()
171 const route = useRoute()
@@ -175,59 +174,59 @@ const authStore = useAuthStore()
174 const caseData = ref<Case | null>(null)
175 const comments = ref<CaseComment[]>([])
176 const loading = ref(false)
178 -const error = ref('')
177 +const error = ref("")
178
179 const user = computed(() => authStore.user)
180
181 const formatDate = (dateString: string) => {
183 - if (!dateString) return 'Unknown'
184 - try {
185 - return new Date(dateString).toLocaleString()
186 - } catch {
187 - return 'Invalid date'
188 - }
182 + if (!dateString) return "Unknown"
183 + try {
184 + return new Date(dateString).toLocaleString()
185 + } catch {
186 + return "Invalid date"
187 + }
188 }
189
190 const fetchCaseDetails = async () => {
192 - const caseId = Number(route.params.id)
193 - if (!caseId) {
194 - error.value = 'Invalid case ID'
195 - return
196 - }
197 -
198 - loading.value = true
199 - error.value = ''
200 -
201 - try {
202 - const response = await CasesAPI.getCase(caseId)
203 - if (response.success && response.cases.length > 0) {
204 - caseData.value = response.cases[0]
205 - comments.value = response.cases[0].comments || []
206 - } else {
207 - error.value = 'Case not found'
208 - }
209 - } catch (err: any) {
210 - error.value = err.response?.data?.detail || 'Failed to fetch case details'
211 - console.error('Failed to fetch case details:', err)
212 - } finally {
213 - loading.value = false
214 - }
191 + const caseId = Number(route.params.id)
192 + if (!caseId) {
193 + error.value = "Invalid case ID"
194 + return
195 + }
196 +
197 + loading.value = true
198 + error.value = ""
199 +
200 + try {
201 + const response = await CasesAPI.getCase(caseId)
202 + if (response.success && response.cases.length > 0) {
203 + caseData.value = response.cases[0]
204 + comments.value = response.cases[0].comments || []
205 + } else {
206 + error.value = "Case not found"
207 + }
208 + } catch (err: any) {
209 + error.value = err.response?.data?.detail || "Failed to fetch case details"
210 + console.error("Failed to fetch case details:", err)
211 + } finally {
212 + loading.value = false
213 + }
214 }
215
216 const handleCommentsUpdated = (updatedComments: CaseComment[]) => {
218 - comments.value = updatedComments
219 - // Also update the case data if it has comments
220 - if (caseData.value) {
221 - caseData.value.comments = updatedComments
222 - }
217 + comments.value = updatedComments
218 + // Also update the case data if it has comments
219 + if (caseData.value) {
220 + caseData.value.comments = updatedComments
221 + }
222 }
223
224 const logout = () => {
226 - authStore.logout()
227 - router.push('/login')
225 + authStore.logout()
226 + router.push("/login")
227 }
228
229 onMounted(() => {
231 - fetchCaseDetails()
230 + fetchCaseDetails()
231 })
232 </script>
customer_portal/src/views/CasesPage.vue
+6 -13
@@ -75,7 +75,7 @@
75 <div class="overflow-hidden rounded-lg bg-white shadow">
76 <div class="p-5">
77 <div class="flex items-center">
78 - <div class="flex-shrink-0">
78 + <div class="shrink-0">
79 <div class="flex h-8 w-8 items-center justify-center rounded-md bg-blue-500">
80 <svg class="h-5 w-5 text-white" fill="currentColor" viewBox="0 0 20 20">
81 <path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"></path>
@@ -100,7 +100,7 @@
100 <div class="overflow-hidden rounded-lg bg-white shadow">
101 <div class="p-5">
102 <div class="flex items-center">
103 - <div class="flex-shrink-0">
103 + <div class="shrink-0">
104 <div class="flex h-8 w-8 items-center justify-center rounded-md bg-red-500">
105 <svg class="h-5 w-5 text-white" fill="currentColor" viewBox="0 0 20 20">
106 <path
@@ -124,7 +124,7 @@
124 <div class="overflow-hidden rounded-lg bg-white shadow">
125 <div class="p-5">
126 <div class="flex items-center">
127 - <div class="flex-shrink-0">
127 + <div class="shrink-0">
128 <div class="flex h-8 w-8 items-center justify-center rounded-md bg-yellow-500">
129 <svg class="h-5 w-5 text-white" fill="currentColor" viewBox="0 0 20 20">
130 <path
@@ -148,7 +148,7 @@
148 <div class="overflow-hidden rounded-lg bg-white shadow">
149 <div class="p-5">
150 <div class="flex items-center">
151 - <div class="flex-shrink-0">
151 + <div class="shrink-0">
152 <div class="flex h-8 w-8 items-center justify-center rounded-md bg-green-500">
153 <svg class="h-5 w-5 text-white" fill="currentColor" viewBox="0 0 20 20">
154 <path
@@ -238,7 +238,7 @@
238 <li v-for="case_ in filteredCases" :key="case_.id" class="px-4 py-4 hover:bg-gray-50 sm:px-6">
239 <div class="flex items-center justify-between">
240 <div class="flex items-center">
241 - <div class="flex-shrink-0">
241 + <div class="shrink-0">
242 <span
243 class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
244 :class="{
@@ -583,7 +583,7 @@
583 <div class="min-w-0 flex-1">
584 <div class="flex items-center space-x-2">
585 <svg
586 - class="h-4 w-4 flex-shrink-0 text-gray-500"
586 + class="h-4 w-4 shrink-0 text-gray-500"
587 fill="none"
588 stroke="currentColor"
589 viewBox="0 0 24 24"
@@ -885,13 +885,11 @@
885
886 <script setup lang="ts">
887 import { ref, onMounted, computed } from "vue"
888 -import { useRouter } from "vue-router"
888 import { usePortalSettingsStore } from "@/stores/portalSettings"
889 import CasesAPI, { type Case, type CasesResponse } from "@/api/cases"
890 import CaseDataStoreAPI, { type CaseDataStoreFile } from "@/api/caseDataStore"
891 import AlertsAPI, { type Alert } from "@/api/alerts"
892
894 -const router = useRouter()
893 const portalSettingsStore = usePortalSettingsStore()
894
895 // Reactive data
@@ -949,11 +947,6 @@ const filteredCases = computed(() => {
947 return filtered.sort((a, b) => new Date(b.case_creation_time).getTime() - new Date(a.case_creation_time).getTime())
948 })
949
952 -// Methods
953 -const goBack = () => {
954 - router.push("/")
955 -}
956 -
950 const loadCases = async () => {
951 loading.value = true
952 error.value = null
customer_portal/src/views/CasesView.vue
+235 -228
@@ -1,220 +1,227 @@
1 <template>
2 - <div class="min-h-screen bg-gray-50">
3 - <!-- Header -->
4 - <header class="bg-white shadow">
5 - <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
6 - <div class="flex justify-between h-16">
7 - <div class="flex items-center">
8 - <router-link
9 - to="/"
10 - class="text-indigo-600 hover:text-indigo-500 mr-4"
11 - >
12 - ← Back to Dashboard
13 - </router-link>
14 - <h1 class="text-xl font-semibold">Security Cases</h1>
15 - </div>
16 - <div class="flex items-center space-x-4">
17 - <span class="text-sm text-gray-700">{{ user?.username }}</span>
18 - <button
19 - @click="logout"
20 - class="bg-red-600 hover:bg-red-700 text-white px-3 py-2 rounded-md text-sm font-medium"
21 - >
22 - Logout
23 - </button>
24 - </div>
25 - </div>
26 - </div>
27 - </header>
2 + <div class="min-h-screen bg-gray-50">
3 + <!-- Header -->
4 + <header class="bg-white shadow">
5 + <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
6 + <div class="flex h-16 justify-between">
7 + <div class="flex items-center">
8 + <router-link to="/" class="mr-4 text-indigo-600 hover:text-indigo-500">
9 + ← Back to Dashboard
10 + </router-link>
11 + <h1 class="text-xl font-semibold">Security Cases</h1>
12 + </div>
13 + <div class="flex items-center space-x-4">
14 + <span class="text-sm text-gray-700">{{ user?.username }}</span>
15 + <button
16 + @click="logout"
17 + class="rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700"
18 + >
19 + Logout
20 + </button>
21 + </div>
22 + </div>
23 + </div>
24 + </header>
25
29 - <!-- Main Content -->
30 - <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
31 - <div class="px-4 py-6 sm:px-0">
32 - <!-- Loading State -->
33 - <div v-if="loading" class="text-center py-8">
34 - <div class="inline-flex items-center px-4 py-2 font-semibold leading-6 text-sm shadow rounded-md text-white bg-indigo-500">
35 - Loading cases...
36 - </div>
37 - </div>
26 + <!-- Main Content -->
27 + <main class="mx-auto max-w-7xl py-6 sm:px-6 lg:px-8">
28 + <div class="px-4 py-6 sm:px-0">
29 + <!-- Loading State -->
30 + <div v-if="loading" class="py-8 text-center">
31 + <div
32 + class="inline-flex items-center rounded-md bg-indigo-500 px-4 py-2 text-sm leading-6 font-semibold text-white shadow"
33 + >
34 + Loading cases...
35 + </div>
36 + </div>
37
39 - <!-- Error State -->
40 - <div v-else-if="error" class="bg-red-50 border border-red-200 rounded-md p-4">
41 - <div class="flex">
42 - <div class="ml-3">
43 - <h3 class="text-sm font-medium text-red-800">
44 - Error loading cases
45 - </h3>
46 - <div class="mt-2 text-sm text-red-700">
47 - {{ error }}
48 - </div>
49 - </div>
50 - </div>
51 - </div>
38 + <!-- Error State -->
39 + <div v-else-if="error" class="rounded-md border border-red-200 bg-red-50 p-4">
40 + <div class="flex">
41 + <div class="ml-3">
42 + <h3 class="text-sm font-medium text-red-800">Error loading cases</h3>
43 + <div class="mt-2 text-sm text-red-700">
44 + {{ error }}
45 + </div>
46 + </div>
47 + </div>
48 + </div>
49
53 - <!-- Cases List -->
54 - <div v-else>
55 - <!-- Stats Cards -->
56 - <div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
57 - <div class="bg-white overflow-hidden shadow rounded-lg">
58 - <div class="p-5">
59 - <div class="flex items-center">
60 - <div class="w-8 h-8 bg-red-500 rounded-md flex items-center justify-center">
61 - <span class="text-white text-sm font-medium">O</span>
62 - </div>
63 - <div class="ml-3">
64 - <p class="text-sm font-medium text-gray-500">Open</p>
65 - <p class="text-lg font-semibold text-gray-900">{{ getCaseCount('open') }}</p>
66 - </div>
67 - </div>
68 - </div>
69 - </div>
70 - <div class="bg-white overflow-hidden shadow rounded-lg">
71 - <div class="p-5">
72 - <div class="flex items-center">
73 - <div class="w-8 h-8 bg-yellow-500 rounded-md flex items-center justify-center">
74 - <span class="text-white text-sm font-medium">P</span>
75 - </div>
76 - <div class="ml-3">
77 - <p class="text-sm font-medium text-gray-500">In Progress</p>
78 - <p class="text-lg font-semibold text-gray-900">{{ getCaseCount('in_progress') }}</p>
79 - </div>
80 - </div>
81 - </div>
82 - </div>
83 - <div class="bg-white overflow-hidden shadow rounded-lg">
84 - <div class="p-5">
85 - <div class="flex items-center">
86 - <div class="w-8 h-8 bg-green-500 rounded-md flex items-center justify-center">
87 - <span class="text-white text-sm font-medium">C</span>
88 - </div>
89 - <div class="ml-3">
90 - <p class="text-sm font-medium text-gray-500">Closed</p>
91 - <p class="text-lg font-semibold text-gray-900">{{ getCaseCount('closed') }}</p>
92 - </div>
93 - </div>
94 - </div>
95 - </div>
96 - <div class="bg-white overflow-hidden shadow rounded-lg">
97 - <div class="p-5">
98 - <div class="flex items-center">
99 - <div class="w-8 h-8 bg-gray-500 rounded-md flex items-center justify-center">
100 - <span class="text-white text-sm font-medium">T</span>
101 - </div>
102 - <div class="ml-3">
103 - <p class="text-sm font-medium text-gray-500">Total</p>
104 - <p class="text-lg font-semibold text-gray-900">{{ cases.length }}</p>
105 - </div>
106 - </div>
107 - </div>
108 - </div>
109 - </div>
50 + <!-- Cases List -->
51 + <div v-else>
52 + <!-- Stats Cards -->
53 + <div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
54 + <div class="overflow-hidden rounded-lg bg-white shadow">
55 + <div class="p-5">
56 + <div class="flex items-center">
57 + <div class="flex h-8 w-8 items-center justify-center rounded-md bg-red-500">
58 + <span class="text-sm font-medium text-white">O</span>
59 + </div>
60 + <div class="ml-3">
61 + <p class="text-sm font-medium text-gray-500">Open</p>
62 + <p class="text-lg font-semibold text-gray-900">{{ getCaseCount("open") }}</p>
63 + </div>
64 + </div>
65 + </div>
66 + </div>
67 + <div class="overflow-hidden rounded-lg bg-white shadow">
68 + <div class="p-5">
69 + <div class="flex items-center">
70 + <div class="flex h-8 w-8 items-center justify-center rounded-md bg-yellow-500">
71 + <span class="text-sm font-medium text-white">P</span>
72 + </div>
73 + <div class="ml-3">
74 + <p class="text-sm font-medium text-gray-500">In Progress</p>
75 + <p class="text-lg font-semibold text-gray-900">
76 + {{ getCaseCount("in_progress") }}
77 + </p>
78 + </div>
79 + </div>
80 + </div>
81 + </div>
82 + <div class="overflow-hidden rounded-lg bg-white shadow">
83 + <div class="p-5">
84 + <div class="flex items-center">
85 + <div class="flex h-8 w-8 items-center justify-center rounded-md bg-green-500">
86 + <span class="text-sm font-medium text-white">C</span>
87 + </div>
88 + <div class="ml-3">
89 + <p class="text-sm font-medium text-gray-500">Closed</p>
90 + <p class="text-lg font-semibold text-gray-900">{{ getCaseCount("closed") }}</p>
91 + </div>
92 + </div>
93 + </div>
94 + </div>
95 + <div class="overflow-hidden rounded-lg bg-white shadow">
96 + <div class="p-5">
97 + <div class="flex items-center">
98 + <div class="flex h-8 w-8 items-center justify-center rounded-md bg-gray-500">
99 + <span class="text-sm font-medium text-white">T</span>
100 + </div>
101 + <div class="ml-3">
102 + <p class="text-sm font-medium text-gray-500">Total</p>
103 + <p class="text-lg font-semibold text-gray-900">{{ cases.length }}</p>
104 + </div>
105 + </div>
106 + </div>
107 + </div>
108 + </div>
109
111 - <!-- Cases Table -->
112 - <div class="bg-white shadow overflow-hidden sm:rounded-md">
113 - <div class="px-4 py-5 sm:px-6">
114 - <h3 class="text-lg leading-6 font-medium text-gray-900">
115 - Security Cases
116 - </h3>
117 - <p class="mt-1 max-w-2xl text-sm text-gray-500">
118 - Security incident cases for your organization
119 - </p>
120 - </div>
110 + <!-- Cases Table -->
111 + <div class="overflow-hidden bg-white shadow sm:rounded-md">
112 + <div class="px-4 py-5 sm:px-6">
113 + <h3 class="text-lg leading-6 font-medium text-gray-900">Security Cases</h3>
114 + <p class="mt-1 max-w-2xl text-sm text-gray-500">
115 + Security incident cases for your organization
116 + </p>
117 + </div>
118
122 - <div v-if="cases.length === 0" class="px-4 py-5 sm:px-6 text-center text-gray-500">
123 - No cases found
124 - </div>
119 + <div v-if="cases.length === 0" class="px-4 py-5 text-center text-gray-500 sm:px-6">
120 + No cases found
121 + </div>
122
126 - <ul v-else class="divide-y divide-gray-200">
127 - <li v-for="case_ in cases" :key="case_.id" class="px-4 py-4 sm:px-6 hover:bg-gray-50 cursor-pointer" @click="viewCaseDetails(case_.id)">
128 - <div class="flex items-center justify-between">
129 - <div class="flex items-center">
130 - <div
131 - class="w-3 h-3 rounded-full mr-3"
132 - :class="{
133 - 'bg-red-500': case_.case_status === 'open',
134 - 'bg-yellow-500': case_.case_status === 'in_progress',
135 - 'bg-green-500': case_.case_status === 'closed',
136 - 'bg-gray-500': !case_.case_status
137 - }"
138 - ></div>
139 - <div>
140 - <p class="text-sm font-medium text-gray-900 hover:text-indigo-600">
141 - {{ case_.case_name || 'Unnamed Case' }}
142 - </p>
143 - <p class="text-sm text-gray-500">
144 - {{ case_.case_description || 'No description available' }}
145 - </p>
146 - <p class="text-xs text-gray-400 mt-1">
147 - Created: {{ formatDate(case_.case_creation_time) }}
148 - <span v-if="case_.assigned_to"> • Assigned to: {{ case_.assigned_to }}</span>
149 - <span v-if="case_.comments && case_.comments.length > 0"> • {{ case_.comments.length }} {{ case_.comments.length === 1 ? 'comment' : 'comments' }}</span>
150 - </p>
151 - </div>
152 - </div>
153 - <div class="flex items-center space-x-2">
154 - <span
155 - class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
156 - :class="{
157 - 'bg-red-100 text-red-800': case_.case_status === 'open',
158 - 'bg-yellow-100 text-yellow-800': case_.case_status === 'in_progress',
159 - 'bg-green-100 text-green-800': case_.case_status === 'closed',
160 - 'bg-gray-100 text-gray-800': !case_.case_status
161 - }"
162 - >
163 - {{ case_.case_status || 'Unknown' }}
164 - </span>
165 - <span
166 - v-if="case_.escalation_level"
167 - class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
168 - :class="{
169 - 'bg-red-100 text-red-800': case_.escalation_level === 'high',
170 - 'bg-yellow-100 text-yellow-800': case_.escalation_level === 'medium',
171 - 'bg-blue-100 text-blue-800': case_.escalation_level === 'low'
172 - }"
173 - >
174 - {{ case_.escalation_level }}
175 - </span>
176 - </div>
177 - </div>
178 - </li>
179 - </ul>
180 - </div>
123 + <ul v-else class="divide-y divide-gray-200">
124 + <li
125 + v-for="case_ in cases"
126 + :key="case_.id"
127 + class="cursor-pointer px-4 py-4 hover:bg-gray-50 sm:px-6"
128 + @click="viewCaseDetails(case_.id)"
129 + >
130 + <div class="flex items-center justify-between">
131 + <div class="flex items-center">
132 + <div
133 + class="mr-3 h-3 w-3 rounded-full"
134 + :class="{
135 + 'bg-red-500': case_.case_status === 'open',
136 + 'bg-yellow-500': case_.case_status === 'in_progress',
137 + 'bg-green-500': case_.case_status === 'closed',
138 + 'bg-gray-500': !case_.case_status
139 + }"
140 + ></div>
141 + <div>
142 + <p class="text-sm font-medium text-gray-900 hover:text-indigo-600">
143 + {{ case_.case_name || "Unnamed Case" }}
144 + </p>
145 + <p class="text-sm text-gray-500">
146 + {{ case_.case_description || "No description available" }}
147 + </p>
148 + <p class="mt-1 text-xs text-gray-400">
149 + Created: {{ formatDate(case_.case_creation_time) }}
150 + <span v-if="case_.assigned_to">
151 + • Assigned to: {{ case_.assigned_to }}
152 + </span>
153 + <span v-if="case_.comments && case_.comments.length > 0">
154 + • {{ case_.comments.length }}
155 + {{ case_.comments.length === 1 ? "comment" : "comments" }}
156 + </span>
157 + </p>
158 + </div>
159 + </div>
160 + <div class="flex items-center space-x-2">
161 + <span
162 + class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
163 + :class="{
164 + 'bg-red-100 text-red-800': case_.case_status === 'open',
165 + 'bg-yellow-100 text-yellow-800': case_.case_status === 'in_progress',
166 + 'bg-green-100 text-green-800': case_.case_status === 'closed',
167 + 'bg-gray-100 text-gray-800': !case_.case_status
168 + }"
169 + >
170 + {{ case_.case_status || "Unknown" }}
171 + </span>
172 + <span
173 + v-if="case_.escalation_level"
174 + class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
175 + :class="{
176 + 'bg-red-100 text-red-800': case_.escalation_level === 'high',
177 + 'bg-yellow-100 text-yellow-800': case_.escalation_level === 'medium',
178 + 'bg-blue-100 text-blue-800': case_.escalation_level === 'low'
179 + }"
180 + >
181 + {{ case_.escalation_level }}
182 + </span>
183 + </div>
184 + </div>
185 + </li>
186 + </ul>
187 + </div>
188
182 - <!-- Pagination (if needed) -->
183 - <div v-if="cases.length > 0" class="mt-6 flex justify-center">
184 - <button
185 - @click="refreshCases"
186 - class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md text-sm font-medium"
187 - >
188 - Refresh
189 - </button>
190 - </div>
191 - </div>
192 - </div>
193 - </main>
194 - </div>
189 + <!-- Pagination (if needed) -->
190 + <div v-if="cases.length > 0" class="mt-6 flex justify-center">
191 + <button
192 + @click="refreshCases"
193 + class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
194 + >
195 + Refresh
196 + </button>
197 + </div>
198 + </div>
199 + </div>
200 + </main>
201 + </div>
202 </template>
203
204 <script setup lang="ts">
198 -import { ref, onMounted, computed } from 'vue'
199 -import { useRouter } from 'vue-router'
200 -import { useAuthStore } from '@/stores/auth'
201 -import { httpClient } from '@/utils/httpClient'
205 +import { ref, onMounted, computed } from "vue"
206 +import { useRouter } from "vue-router"
207 +import { useAuthStore } from "@/stores/auth"
208 +import { httpClient } from "@/utils/httpClient"
209
210 interface Case {
204 - id: number
205 - case_name: string
206 - case_description: string
207 - case_status: string
208 - case_creation_time: string
209 - assigned_to?: string
210 - escalation_level?: string
211 - customer_code?: string
212 - comments?: Array<{
213 - id: number
214 - comment: string
215 - user_name?: string
216 - created_at: string
217 - }>
211 + id: number
212 + case_name: string
213 + case_description: string
214 + case_status: string
215 + case_creation_time: string
216 + assigned_to?: string
217 + escalation_level?: string
218 + customer_code?: string
219 + comments?: Array<{
220 + id: number
221 + comment: string
222 + user_name?: string
223 + created_at: string
224 + }>
225 }
226
227 const router = useRouter()
@@ -222,52 +229,52 @@ const authStore = useAuthStore()
229
230 const cases = ref<Case[]>([])
231 const loading = ref(false)
225 -const error = ref('')
232 +const error = ref("")
233
234 const user = computed(() => authStore.user)
235
236 const getCaseCount = (status: string) => {
230 - return cases.value.filter(case_ => case_.case_status === status).length
237 + return cases.value.filter(case_ => case_.case_status === status).length
238 }
239
240 const formatDate = (dateString: string) => {
234 - if (!dateString) return 'Unknown'
235 - try {
236 - return new Date(dateString).toLocaleDateString()
237 - } catch {
238 - return 'Invalid date'
239 - }
241 + if (!dateString) return "Unknown"
242 + try {
243 + return new Date(dateString).toLocaleDateString()
244 + } catch {
245 + return "Invalid date"
246 + }
247 }
248
249 const fetchCases = async () => {
243 - loading.value = true
244 - error.value = ''
250 + loading.value = true
251 + error.value = ""
252
246 - try {
247 - const response = await httpClient.get('/incidents/db_operations/cases')
248 - cases.value = response.data.cases || []
249 - } catch (err: any) {
250 - error.value = err.response?.data?.detail || 'Failed to fetch cases'
251 - console.error('Failed to fetch cases:', err)
252 - } finally {
253 - loading.value = false
254 - }
253 + try {
254 + const response = await httpClient.get("/incidents/db_operations/cases")
255 + cases.value = response.data.cases || []
256 + } catch (err: any) {
257 + error.value = err.response?.data?.detail || "Failed to fetch cases"
258 + console.error("Failed to fetch cases:", err)
259 + } finally {
260 + loading.value = false
261 + }
262 }
263
264 const refreshCases = () => {
258 - fetchCases()
265 + fetchCases()
266 }
267
268 const viewCaseDetails = (caseId: number) => {
262 - router.push(`/cases/${caseId}`)
269 + router.push(`/cases/${caseId}`)
270 }
271
272 const logout = () => {
266 - authStore.logout()
267 - router.push('/login')
273 + authStore.logout()
274 + router.push("/login")
275 }
276
277 onMounted(() => {
271 - fetchCases()
278 + fetchCases()
279 })
280 </script>
customer_portal/src/views/OverviewPage.vue
+6 -6
@@ -76,7 +76,7 @@
76 <!-- Error State -->
77 <div v-else-if="error" class="mb-6 rounded-lg border border-red-200 bg-red-50 p-4">
78 <div class="flex">
79 - <div class="flex-shrink-0">
79 + <div class="shrink-0">
80 <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
81 <path
82 fill-rule="evenodd"
@@ -108,7 +108,7 @@
108 <div class="overflow-hidden rounded-lg bg-white shadow-sm">
109 <div class="p-6">
110 <div class="flex items-center">
111 - <div class="flex-shrink-0">
111 + <div class="shrink-0">
112 <div class="flex h-8 w-8 items-center justify-center rounded-md bg-red-500">
113 <svg
114 class="h-5 w-5 text-white"
@@ -149,7 +149,7 @@
149 <div class="overflow-hidden rounded-lg bg-white shadow-sm">
150 <div class="p-6">
151 <div class="flex items-center">
152 - <div class="flex-shrink-0">
152 + <div class="shrink-0">
153 <div class="flex h-8 w-8 items-center justify-center rounded-md bg-orange-500">
154 <svg
155 class="h-5 w-5 text-white"
@@ -182,7 +182,7 @@
182 <div class="overflow-hidden rounded-lg bg-white shadow-sm">
183 <div class="p-6">
184 <div class="flex items-center">
185 - <div class="flex-shrink-0">
185 + <div class="shrink-0">
186 <div class="flex h-8 w-8 items-center justify-center rounded-md bg-blue-500">
187 <svg
188 class="h-5 w-5 text-white"
@@ -213,7 +213,7 @@
213 <div class="overflow-hidden rounded-lg bg-white shadow-sm">
214 <div class="p-6">
215 <div class="flex items-center">
216 - <div class="flex-shrink-0">
216 + <div class="shrink-0">
217 <div class="flex h-8 w-8 items-center justify-center rounded-md bg-green-500">
218 <svg
219 class="h-5 w-5 text-white"
@@ -253,7 +253,7 @@
253 <div class="overflow-hidden rounded-lg bg-white shadow-sm">
254 <div class="p-6">
255 <div class="flex items-center">
256 - <div class="flex-shrink-0">
256 + <div class="shrink-0">
257 <div class="flex h-8 w-8 items-center justify-center rounded-md bg-purple-500">
258 <svg
259 class="h-5 w-5 text-white"
customer_portal/src/vite-env.d.ts
+3 -3
@@ -1,12 +1,12 @@
1 /// <reference types="vite/client" />
2
3 interface ImportMetaEnv {
4 - readonly VITE_API_URL: string
5 - // more env variables...
4 + readonly VITE_API_URL: string
5 + // more env variables...
6 }
7
8 interface ImportMeta {
9 - readonly env: ImportMetaEnv
9 + readonly env: ImportMetaEnv
10 }
11
12 export {}
customer_portal/vite.config.ts
+2 -1
@@ -40,7 +40,8 @@ export default defineConfig(({ mode }) => {
40 : undefined,
41 proxy: {
42 "/api": {
43 - target: process.env.VITE_API_URL || "http://localhost:5000",
43 + // target: "http://copilot-backend:5000",
44 + target: process.env.VITE_API_URL,
45 changeOrigin: true
46 }
47 }
frontend/src/api/httpClient.ts
+6 -1
@@ -20,7 +20,12 @@ HttpClient.interceptors.request.use(
20 config.headers.Authorization = `Bearer ${store.userToken}`
21 }
22
23 - if (isJwtExpiring(store.userToken, 60 * 60) && !__TOKEN_REFRESHING && isDebounceTimeOver(__TOKEN_LAST_CHECK)) {
23 + if (
24 + store.userToken &&
25 + isJwtExpiring(store.userToken, 60 * 60) &&
26 + !__TOKEN_REFRESHING &&
27 + isDebounceTimeOver(__TOKEN_LAST_CHECK)
28 + ) {
29 __TOKEN_REFRESHING = true
30 __TOKEN_LAST_CHECK = new Date()
31
frontend/src/stores/auth.ts
+2 -2
@@ -91,8 +91,8 @@ export const useAuthStore = defineStore("auth", {
91 isLogged(state): boolean {
92 return !!state.user?.access_token
93 },
94 - userToken(state): string {
95 - return state.user?.access_token
94 + userToken(state): string | null {
95 + return state.user?.access_token || null
96 },
97 userName(state): string {
98 return state.user?.username
frontend/vite.config.ts
+1 -1
@@ -51,7 +51,7 @@ export default defineConfig(({ mode }) => {
51 proxy: {
52 "/api": {
53 // target: "http://copilot-backend:5000",
54 - target: process.env.VITE_API_URL, // for local development
54 + target: process.env.VITE_API_URL,
55 changeOrigin: true
56 }
57 }