feat: docker and nginx
Working on deployments
This commit is contained in:
parent
6fad123dec
commit
5500449334
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@ -0,0 +1,22 @@
|
||||
# Build stage
|
||||
FROM golang:1.25.5-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
WORKDIR /build
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/server
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates
|
||||
WORKDIR /app
|
||||
COPY --from=builder /build/server .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["./server"]
|
||||
Binary file not shown.
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@ -0,0 +1,14 @@
|
||||
services:
|
||||
backend:
|
||||
build: .
|
||||
# ports:
|
||||
# - "3000:3000"
|
||||
env_file:
|
||||
- ./.env
|
||||
|
||||
frontend:
|
||||
build: ./web
|
||||
ports:
|
||||
- "3005:80"
|
||||
depends_on:
|
||||
- backend
|
||||
@ -1,14 +1,37 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CORS allows requests from the Vite dev server during development.
|
||||
// In production, update the allowed origin to match your deployed frontend domain.
|
||||
// CORS allows requests from the frontend.
|
||||
// Supports both development (Vite dev server) and production (nginx/Docker) environments.
|
||||
func CORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
origin := r.Header.Get("Origin")
|
||||
|
||||
// Allow requests from common development and production origins
|
||||
allowedOrigins := []string{
|
||||
"http://localhost:5173", // Vite dev server
|
||||
"http://localhost", // Docker nginx
|
||||
"http://localhost:80", // Docker nginx with port
|
||||
}
|
||||
|
||||
// Check if the origin is allowed
|
||||
allowed := slices.Contains(allowedOrigins, origin)
|
||||
|
||||
// Set CORS headers if origin is allowed
|
||||
if allowed {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@ -18,3 +41,89 @@ func CORS(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// requestHistory stores timestamps of requests for each IP address
|
||||
type requestHistory struct {
|
||||
mu sync.Mutex
|
||||
timestamps map[string][]time.Time
|
||||
}
|
||||
|
||||
var rateLimiter = &requestHistory{
|
||||
timestamps: make(map[string][]time.Time),
|
||||
}
|
||||
|
||||
// RateLimit restricts requests to 10 per hour per IP address
|
||||
// Trace: SRD_FuncReq_0014 - Prevent users from grading more than 10 resumes per hour
|
||||
// Trace: SRD_SecReq_0004 - Implement rate limit of 10 requests per hour
|
||||
// Trace: SRD_SecReq_0006 - Use device sending IP to impose rate limit
|
||||
// Trace: SDD_LLD_0011 - Restrict request frequency to 10 calls per hour per unique source IP
|
||||
// Trace: SDD_HLD_0006 - Function only within call rate limit of 10 calls per hour
|
||||
func RateLimit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract client IP address
|
||||
// Trace: SRD_SecReq_0006 - Use device sending IP to impose rate limit
|
||||
ip := getClientIP(r)
|
||||
|
||||
rateLimiter.mu.Lock()
|
||||
defer rateLimiter.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
oneHourAgo := now.Add(-1 * time.Hour)
|
||||
|
||||
// Get request history for this IP
|
||||
history := rateLimiter.timestamps[ip]
|
||||
|
||||
// Filter out requests older than 1 hour
|
||||
var recentRequests []time.Time
|
||||
for _, timestamp := range history {
|
||||
if timestamp.After(oneHourAgo) {
|
||||
recentRequests = append(recentRequests, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if rate limit exceeded
|
||||
// Trace: SDD_LLD_0011 - Restrict to 10 calls per hour per IP
|
||||
if len(recentRequests) >= 10 {
|
||||
// Trace: SRD_UseCase_0005, SRD_UseCase_0006 - Rate limit error handling
|
||||
// Trace: SRD_QualAssurReq_0006 - Return error when rate limit reached
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
w.Write([]byte(`{"error": "Rate limit exceeded. You can make up to 10 requests per hour. Please try again later."}`))
|
||||
return
|
||||
}
|
||||
|
||||
// Add current request to history
|
||||
recentRequests = append(recentRequests, now)
|
||||
rateLimiter.timestamps[ip] = recentRequests
|
||||
|
||||
// Allow request to proceed
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// getClientIP extracts the real client IP address from the request
|
||||
// Trace: SRD_SecReq_0006 - Use device sending IP to impose rate limit
|
||||
func getClientIP(r *http.Request) string {
|
||||
// Check X-Forwarded-For header (for requests behind proxies/load balancers)
|
||||
xff := r.Header.Get("X-Forwarded-For")
|
||||
if xff != "" {
|
||||
// X-Forwarded-For can contain multiple IPs, take the first one
|
||||
ips := strings.Split(xff, ",")
|
||||
if len(ips) > 0 {
|
||||
return strings.TrimSpace(ips[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Check X-Real-IP header (alternative proxy header)
|
||||
xri := r.Header.Get("X-Real-IP")
|
||||
if xri != "" {
|
||||
return xri
|
||||
}
|
||||
|
||||
// Fall back to RemoteAddr (direct connection)
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
@ -8,10 +8,17 @@ import (
|
||||
|
||||
// Mount registers all API routes onto the provided router.
|
||||
// Trace: SDD_LLD_0005 - Provide HTTP handler and endpoint for form data uploads
|
||||
// Trace: SDD_LLD_0011 - Enforce per-IP rate limit (10/hr) at middleware level
|
||||
func Mount(r chi.Router) {
|
||||
r.Use(CORS)
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
// Apply rate limiting to all API endpoints
|
||||
// Trace: SRD_FuncReq_0014 - Prevent more than 10 resume gradings per hour
|
||||
// Trace: SRD_SecReq_0004 - Implement rate limit of 10 requests per hour
|
||||
// Trace: SDD_LLD_0011 - Rate limiting implemented at middleware level
|
||||
r.Use(RateLimit)
|
||||
|
||||
// Trace: SDD_LLD_0005 - HTTP endpoint that accepts multipart/form data uploads
|
||||
r.Post("/analyze", handlers.Analyze)
|
||||
})
|
||||
|
||||
23
web/Dockerfile
Normal file
23
web/Dockerfile
Normal file
@ -0,0 +1,23 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy and install files
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built files from builder and project
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
36
web/nginx.conf
Normal file
36
web/nginx.conf
Normal file
@ -0,0 +1,36 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||
|
||||
# Serve static files
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
148
web/src/assets/css/modal.css
Normal file
148
web/src/assets/css/modal.css
Normal file
@ -0,0 +1,148 @@
|
||||
/* Modal Styles - Trace: SDD_LLD_0030, SRD_InterfaceReq_0015 */
|
||||
|
||||
/* Backdrop - darkens background when modal is open */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal Container */
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1001;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -40%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal Header */
|
||||
.modal-header {
|
||||
padding: 32px 32px 24px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Modal Body */
|
||||
.modal-body {
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
.modal-message {
|
||||
font-size: 1rem;
|
||||
color: #4a5568;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Modal Footer */
|
||||
.modal-footer {
|
||||
padding: 16px 32px 32px 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Modal Type Variants */
|
||||
.modal-error .modal-header {
|
||||
border-bottom-color: #feb2b2;
|
||||
}
|
||||
|
||||
.modal-error .modal-icon {
|
||||
color: #f56565;
|
||||
}
|
||||
|
||||
.modal-warning .modal-header {
|
||||
border-bottom-color: #fbd38d;
|
||||
}
|
||||
|
||||
.modal-warning .modal-icon {
|
||||
color: #ed8936;
|
||||
}
|
||||
|
||||
.modal-info .modal-header {
|
||||
border-bottom-color: #90cdf4;
|
||||
}
|
||||
|
||||
.modal-info .modal-icon {
|
||||
color: #4299e1;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 640px) {
|
||||
.modal-container {
|
||||
width: 95%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 24px 20px 16px 20px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 12px 20px 24px 20px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
87
web/src/components/Modal.tsx
Normal file
87
web/src/components/Modal.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { useEffect } from 'react';
|
||||
import '../assets/css/modal.css';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
type?: 'error' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal component for displaying messages to the user
|
||||
* Trace: SDD_LLD_0030 - Display persistent error messages
|
||||
* Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner (not toasts or popups)
|
||||
* Trace: SRD_FuncReq_0015 - Display any errors that may occur in the process
|
||||
*/
|
||||
export default function Modal({ isOpen, onClose, title, message, type = 'error' }: ModalProps) {
|
||||
// Close modal on Escape key press
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return '⚠️';
|
||||
case 'warning':
|
||||
return '⚠️';
|
||||
case 'info':
|
||||
return 'ℹ️';
|
||||
default:
|
||||
return '⚠️';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Modal Backdrop */}
|
||||
<div className="modal-backdrop" onClick={onClose} />
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className={`modal-container modal-${type}`}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-icon">{getIcon()}</div>
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<p className="modal-message">{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="btn btn-primary"
|
||||
autoFocus
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
web/src/config/api.ts
Normal file
15
web/src/config/api.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* API configuration
|
||||
* Uses environment variable or defaults based on environment
|
||||
*/
|
||||
|
||||
// In production (Docker), use relative path so nginx can proxy
|
||||
// In development, use full URL to reach the Go backend directly
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ||
|
||||
(import.meta.env.MODE === 'production' ? '' : 'http://localhost:3000');
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
analyze: `${API_BASE_URL}/api/analyze`,
|
||||
} as const;
|
||||
|
||||
export default API_BASE_URL;
|
||||
@ -1,7 +1,8 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import type { ResumeAnalysisResult } from '../types/resumeAnalysis';
|
||||
import { mockAnalysisResult } from '../data/mockAnalysis';
|
||||
import Modal from '../components/Modal';
|
||||
import { API_ENDPOINTS } from '../config/api';
|
||||
import '../assets/css/upload.css';
|
||||
|
||||
/**
|
||||
@ -19,9 +20,29 @@ export default function UploadPage() {
|
||||
const [jobDescription, setJobDescription] = useState('');
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [modalState, setModalState] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'error' | 'warning' | 'info';
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'error'
|
||||
});
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Helper to show modal
|
||||
const showModal = (title: string, message: string, type: 'error' | 'warning' | 'info' = 'error') => {
|
||||
setModalState({ isOpen: true, title, message, type });
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalState(prev => ({ ...prev, isOpen: false }));
|
||||
};
|
||||
|
||||
// Trace: SDD_LLD_0001 - Accept only PDF uploads (validation)
|
||||
// Trace: SDD_HLD_0001 - Accept only PDF formatting for input resumes
|
||||
const handleFileSelect = (file: File) => {
|
||||
@ -43,7 +64,12 @@ export default function UploadPage() {
|
||||
}
|
||||
} else {
|
||||
// Trace: SDD_LLD_0002 - Reject non-PDF uploads
|
||||
alert('Please upload a PDF or image file');
|
||||
// Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner
|
||||
showModal(
|
||||
'Invalid File Type',
|
||||
'Please upload a PDF or image file. Other file formats are not supported.',
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -86,11 +112,19 @@ export default function UploadPage() {
|
||||
// Trace: SDD_LLD_0027 - Show loading spinner during active API calls
|
||||
const handleAnalyze = async () => {
|
||||
if (!resumeFile) {
|
||||
alert('Please upload a resume first');
|
||||
showModal(
|
||||
'Resume Required',
|
||||
'Please upload a resume before analyzing.',
|
||||
'warning'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!jobDescription.trim()) {
|
||||
alert('Please enter a job description');
|
||||
showModal(
|
||||
'Job Description Required',
|
||||
'Please enter a job description to compare your resume against.',
|
||||
'warning'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -104,7 +138,22 @@ export default function UploadPage() {
|
||||
formData.append('job_description', jobDescription);
|
||||
|
||||
// Trace: SDD_HLD_0005 - Provide structured inputs to AI grader
|
||||
const response = await fetch('http://localhost:3000/api/analyze', { method: 'POST', body: formData });
|
||||
const response = await fetch(API_ENDPOINTS.analyze, { method: 'POST', body: formData });
|
||||
|
||||
// Trace: SRD_UseCase_0006 - Display error message when rate limit reached
|
||||
// Trace: SRD_QualAssurReq_0006 - Handle rate limit error appropriately
|
||||
// Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner
|
||||
if (response.status === 429) {
|
||||
const errorData = await response.json();
|
||||
showModal(
|
||||
'Rate Limit Exceeded',
|
||||
errorData.error || 'You can make up to 10 requests per hour. Please try again later.',
|
||||
'warning'
|
||||
);
|
||||
setIsAnalyzing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error(`Server error: ${response.status}`);
|
||||
|
||||
// Trace: SDD_LLD_0019 - Receive JSON output from backend
|
||||
@ -114,8 +163,13 @@ export default function UploadPage() {
|
||||
navigate('/results', { state: analysisData });
|
||||
} catch (error) {
|
||||
// Trace: SDD_LLD_0030 - Display persistent error messages
|
||||
// Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner
|
||||
console.error('Error analyzing resume:', error);
|
||||
alert('Failed to analyze resume. Please try again.');
|
||||
showModal(
|
||||
'Analysis Failed',
|
||||
'Failed to analyze resume. Please check your connection and try again.',
|
||||
'error'
|
||||
);
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
@ -261,6 +315,17 @@ export default function UploadPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error/Warning Modal */}
|
||||
{/* Trace: SDD_LLD_0030 - Display persistent error messages */}
|
||||
{/* Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner (not toasts or popups) */}
|
||||
<Modal
|
||||
isOpen={modalState.isOpen}
|
||||
onClose={closeModal}
|
||||
title={modalState.title}
|
||||
message={modalState.message}
|
||||
type={modalState.type}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user