chore: implement NPM/JSON import routes and fix SMTP persistence

Phase 3 of skipped tests remediation - enables 7 previously skipped E2E tests

Backend:

Add NPM import handler with session-based upload/commit/cancel
Add JSON import handler with Charon/NPM format support
Fix SMTP SaveSMTPConfig using transaction-based upsert
Add comprehensive unit tests for new handlers
Frontend:

Add ImportNPM page component following ImportCaddy pattern
Add ImportJSON page component with format detection
Add useNPMImport and useJSONImport React Query hooks
Add API clients for npm/json import endpoints
Register routes in App.tsx and navigation in Layout.tsx
Add i18n keys for new import pages
Tests:

7 E2E tests now enabled and passing
Backend coverage: 86.8%
Reduced total skipped tests from 98 to 91
Closes: Phase 3 of skipped-tests-remediation plan
This commit is contained in:
GitHub Actions
2026-01-22 21:10:01 +00:00
parent b60e0be5fb
commit bc15e976b2
21 changed files with 3771 additions and 476 deletions

View File

@@ -16,6 +16,8 @@ const RemoteServers = lazy(() => import('./pages/RemoteServers'))
const DNS = lazy(() => import('./pages/DNS'))
const ImportCaddy = lazy(() => import('./pages/ImportCaddy'))
const ImportCrowdSec = lazy(() => import('./pages/ImportCrowdSec'))
const ImportNPM = lazy(() => import('./pages/ImportNPM'))
const ImportJSON = lazy(() => import('./pages/ImportJSON'))
const Certificates = lazy(() => import('./pages/Certificates'))
const DNSProviders = lazy(() => import('./pages/DNSProviders'))
const SystemSettings = lazy(() => import('./pages/SystemSettings'))
@@ -109,6 +111,8 @@ export default function App() {
<Route path="import">
<Route path="caddyfile" element={<ImportCaddy />} />
<Route path="crowdsec" element={<ImportCrowdSec />} />
<Route path="npm" element={<ImportNPM />} />
<Route path="json" element={<ImportJSON />} />
</Route>
</Route>

View File

@@ -0,0 +1,90 @@
import client from './client';
/** Represents a host parsed from a JSON export. */
export interface JSONHost {
domain_names: string;
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket_support: boolean;
}
/** Preview of a JSON import with hosts and conflicts. */
export interface JSONImportPreview {
session: {
id: string;
state: string;
source: string;
};
preview: {
hosts: JSONHost[];
conflicts: string[];
errors: string[];
};
conflict_details: Record<string, {
existing: {
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket: boolean;
enabled: boolean;
};
imported: {
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket: boolean;
};
}>;
}
/** Result of committing a JSON import operation. */
export interface JSONImportCommitResult {
created: number;
updated: number;
skipped: number;
errors: string[];
}
/**
* Uploads JSON export content for import preview.
* @param content - The JSON export content as a string
* @returns Promise resolving to JSONImportPreview with parsed hosts
* @throws {AxiosError} If parsing fails or content is invalid
*/
export const uploadJSONExport = async (content: string): Promise<JSONImportPreview> => {
const { data } = await client.post<JSONImportPreview>('/import/json/upload', { content });
return data;
};
/**
* Commits the JSON import, creating/updating proxy hosts.
* @param sessionUuid - The import session UUID
* @param resolutions - Map of conflict resolutions (domain -> 'keep'|'replace'|'skip')
* @param names - Map of custom names for imported hosts
* @returns Promise resolving to JSONImportCommitResult with counts
* @throws {AxiosError} If commit fails
*/
export const commitJSONImport = async (
sessionUuid: string,
resolutions: Record<string, string>,
names: Record<string, string>
): Promise<JSONImportCommitResult> => {
const { data } = await client.post<JSONImportCommitResult>('/import/json/commit', {
session_uuid: sessionUuid,
resolutions,
names,
});
return data;
};
/**
* Cancels the current JSON import session.
* @throws {AxiosError} If cancellation fails
*/
export const cancelJSONImport = async (): Promise<void> => {
await client.post('/import/json/cancel');
};

View File

@@ -0,0 +1,90 @@
import client from './client';
/** Represents a host parsed from an NPM export. */
export interface NPMHost {
domain_names: string;
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket_support: boolean;
}
/** Preview of an NPM import with hosts and conflicts. */
export interface NPMImportPreview {
session: {
id: string;
state: string;
source: string;
};
preview: {
hosts: NPMHost[];
conflicts: string[];
errors: string[];
};
conflict_details: Record<string, {
existing: {
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket: boolean;
enabled: boolean;
};
imported: {
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
websocket: boolean;
};
}>;
}
/** Result of committing an NPM import operation. */
export interface NPMImportCommitResult {
created: number;
updated: number;
skipped: number;
errors: string[];
}
/**
* Uploads NPM export content for import preview.
* @param content - The NPM export JSON content as a string
* @returns Promise resolving to NPMImportPreview with parsed hosts
* @throws {AxiosError} If parsing fails or content is invalid
*/
export const uploadNPMExport = async (content: string): Promise<NPMImportPreview> => {
const { data } = await client.post<NPMImportPreview>('/import/npm/upload', { content });
return data;
};
/**
* Commits the NPM import, creating/updating proxy hosts.
* @param sessionUuid - The import session UUID
* @param resolutions - Map of conflict resolutions (domain -> 'keep'|'replace'|'skip')
* @param names - Map of custom names for imported hosts
* @returns Promise resolving to NPMImportCommitResult with counts
* @throws {AxiosError} If commit fails
*/
export const commitNPMImport = async (
sessionUuid: string,
resolutions: Record<string, string>,
names: Record<string, string>
): Promise<NPMImportCommitResult> => {
const { data } = await client.post<NPMImportCommitResult>('/import/npm/commit', {
session_uuid: sessionUuid,
resolutions,
names,
});
return data;
};
/**
* Cancels the current NPM import session.
* @throws {AxiosError} If cancellation fails
*/
export const cancelNPMImport = async (): Promise<void> => {
await client.post('/import/npm/cancel');
};

View File

@@ -100,6 +100,8 @@ export default function Layout({ children }: LayoutProps) {
children: [
{ name: t('navigation.caddyfile'), path: '/tasks/import/caddyfile', icon: '📥' },
{ name: t('navigation.crowdsec'), path: '/tasks/import/crowdsec', icon: '🛡️' },
{ name: t('navigation.importNPM'), path: '/tasks/import/npm', icon: '📦' },
{ name: t('navigation.importJSON'), path: '/tasks/import/json', icon: '📄' },
]
},
{ name: t('navigation.backups'), path: '/tasks/backups', icon: '💾' },

View File

@@ -0,0 +1,84 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
uploadJSONExport,
commitJSONImport,
cancelJSONImport,
JSONImportPreview,
JSONImportCommitResult,
} from '../api/jsonImport';
/**
* Hook for managing JSON import workflow.
* Provides upload, commit, and cancel functionality with state management.
*/
export function useJSONImport() {
const queryClient = useQueryClient();
const [preview, setPreview] = useState<JSONImportPreview | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [commitResult, setCommitResult] = useState<JSONImportCommitResult | null>(null);
const uploadMutation = useMutation({
mutationFn: uploadJSONExport,
onSuccess: (data) => {
setPreview(data);
setSessionId(data.session.id);
},
});
const commitMutation = useMutation({
mutationFn: ({
resolutions,
names,
}: {
resolutions: Record<string, string>;
names: Record<string, string>;
}) => {
if (!sessionId) throw new Error('No active session');
return commitJSONImport(sessionId, resolutions, names);
},
onSuccess: (data) => {
setCommitResult(data);
setPreview(null);
setSessionId(null);
queryClient.invalidateQueries({ queryKey: ['proxy-hosts'] });
},
});
const cancelMutation = useMutation({
mutationFn: cancelJSONImport,
onSuccess: () => {
setPreview(null);
setSessionId(null);
},
});
const clearCommitResult = () => {
setCommitResult(null);
};
const reset = () => {
setPreview(null);
setSessionId(null);
setCommitResult(null);
};
return {
preview,
sessionId,
loading: uploadMutation.isPending,
error: uploadMutation.error,
upload: uploadMutation.mutateAsync,
commit: (resolutions: Record<string, string>, names: Record<string, string>) =>
commitMutation.mutateAsync({ resolutions, names }),
committing: commitMutation.isPending,
commitError: commitMutation.error,
commitResult,
clearCommitResult,
cancel: cancelMutation.mutateAsync,
cancelling: cancelMutation.isPending,
reset,
};
}
export type { JSONImportPreview, JSONImportCommitResult };

View File

@@ -0,0 +1,84 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
uploadNPMExport,
commitNPMImport,
cancelNPMImport,
NPMImportPreview,
NPMImportCommitResult,
} from '../api/npmImport';
/**
* Hook for managing NPM import workflow.
* Provides upload, commit, and cancel functionality with state management.
*/
export function useNPMImport() {
const queryClient = useQueryClient();
const [preview, setPreview] = useState<NPMImportPreview | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [commitResult, setCommitResult] = useState<NPMImportCommitResult | null>(null);
const uploadMutation = useMutation({
mutationFn: uploadNPMExport,
onSuccess: (data) => {
setPreview(data);
setSessionId(data.session.id);
},
});
const commitMutation = useMutation({
mutationFn: ({
resolutions,
names,
}: {
resolutions: Record<string, string>;
names: Record<string, string>;
}) => {
if (!sessionId) throw new Error('No active session');
return commitNPMImport(sessionId, resolutions, names);
},
onSuccess: (data) => {
setCommitResult(data);
setPreview(null);
setSessionId(null);
queryClient.invalidateQueries({ queryKey: ['proxy-hosts'] });
},
});
const cancelMutation = useMutation({
mutationFn: cancelNPMImport,
onSuccess: () => {
setPreview(null);
setSessionId(null);
},
});
const clearCommitResult = () => {
setCommitResult(null);
};
const reset = () => {
setPreview(null);
setSessionId(null);
setCommitResult(null);
};
return {
preview,
sessionId,
loading: uploadMutation.isPending,
error: uploadMutation.error,
upload: uploadMutation.mutateAsync,
commit: (resolutions: Record<string, string>, names: Record<string, string>) =>
commitMutation.mutateAsync({ resolutions, names }),
committing: commitMutation.isPending,
commitError: commitMutation.error,
commitResult,
clearCommitResult,
cancel: cancelMutation.mutateAsync,
cancelling: cancelMutation.isPending,
reset,
};
}
export type { NPMImportPreview, NPMImportCommitResult };

View File

@@ -69,6 +69,8 @@
"accountManagement": "Account Management",
"import": "Import",
"caddyfile": "Caddyfile",
"importNPM": "Import NPM",
"importJSON": "Import JSON",
"backups": "Backups",
"logs": "Logs",
"securityHeaders": "Security Headers",
@@ -761,6 +763,38 @@
"creatingBackup": "Creating backup...",
"importing": "Importing CrowdSec..."
},
"importNPM": {
"title": "Import from NPM",
"description": "Import proxy hosts from Nginx Proxy Manager export",
"enterContent": "Please paste NPM export JSON",
"invalidJSON": "Invalid JSON format",
"upload": "Upload & Preview",
"import": "Import",
"success": "Import completed successfully",
"previewTitle": "Preview Import",
"conflict": "Conflict",
"new": "New",
"skip": "Skip",
"keep": "Keep Existing",
"replace": "Replace",
"cancelConfirm": "Are you sure you want to cancel this import?"
},
"importJSON": {
"title": "Import from JSON",
"description": "Import configuration from JSON export",
"enterContent": "Please paste JSON configuration",
"invalidJSON": "Invalid JSON format",
"upload": "Upload & Preview",
"import": "Import",
"success": "Import completed successfully",
"previewTitle": "Preview Import",
"conflict": "Conflict",
"new": "New",
"skip": "Skip",
"keep": "Keep Existing",
"replace": "Replace",
"cancelConfirm": "Are you sure you want to cancel this import?"
},
"systemSettings": {
"title": "System Settings",
"settingsSaved": "System settings saved",

View File

@@ -0,0 +1,312 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { createBackup } from '../api/backups'
import { useJSONImport } from '../hooks/useJSONImport'
import ImportSuccessModal from '../components/dialogs/ImportSuccessModal'
export default function ImportJSON() {
const { t } = useTranslation()
const navigate = useNavigate()
const {
preview,
loading,
error,
upload,
commit,
committing,
commitResult,
clearCommitResult,
cancel,
reset,
} = useJSONImport()
const [content, setContent] = useState('')
const [showReview, setShowReview] = useState(false)
const [showSuccessModal, setShowSuccessModal] = useState(false)
const [resolutions, setResolutions] = useState<Record<string, string>>({})
const [names] = useState<Record<string, string>>({})
const handleUpload = async () => {
if (!content.trim()) {
return
}
try {
JSON.parse(content)
} catch {
alert(t('importJSON.invalidJSON'))
return
}
try {
await upload(content)
setShowReview(true)
} catch {
// Error is handled by hook
}
}
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const text = await file.text()
setContent(text)
}
const handleCommit = async () => {
try {
await createBackup()
await commit(resolutions, names)
setContent('')
setShowReview(false)
setShowSuccessModal(true)
} catch {
// Error is handled by hook
}
}
const handleCloseSuccessModal = () => {
setShowSuccessModal(false)
clearCommitResult()
}
const handleCancel = async () => {
if (confirm(t('importJSON.cancelConfirm'))) {
try {
await cancel()
setShowReview(false)
reset()
} catch {
// Error is handled by hook
}
}
}
const handleResolutionChange = (domain: string, resolution: string) => {
setResolutions((prev) => ({ ...prev, [domain]: resolution }))
}
return (
<div className="p-8">
<h1 className="text-3xl font-bold text-white mb-6">{t('importJSON.title')}</h1>
{error && (
<div
className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-6"
role="alert"
>
{error.message}
</div>
)}
{!showReview && (
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
<div className="mb-6">
<h2 className="text-xl font-semibold text-white mb-2">
{t('importJSON.title')}
</h2>
<p className="text-gray-400 text-sm">{t('importJSON.description')}</p>
</div>
<div className="space-y-4">
<div>
<label
htmlFor="json-file-upload"
className="block text-sm font-medium text-gray-300 mb-2"
>
{t('common.upload')}
</label>
<input
id="json-file-upload"
type="file"
accept=".json,application/json"
onChange={handleFileUpload}
className="w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-active file:text-white hover:file:bg-blue-hover file:cursor-pointer cursor-pointer"
data-testid="json-import-dropzone"
/>
</div>
<div className="flex items-center gap-4">
<div className="flex-1 border-t border-gray-700" />
<span className="text-gray-500 text-sm">
{t('importCaddy.orPasteContent')}
</span>
<div className="flex-1 border-t border-gray-700" />
</div>
<div>
<label
htmlFor="json-content"
className="block text-sm font-medium text-gray-300 mb-2"
>
{t('importJSON.enterContent')}
</label>
<textarea
id="json-content"
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full h-96 bg-gray-900 border border-gray-700 rounded-lg p-4 text-white font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={`{
"proxy_hosts": [
{
"domain_names": ["example.com"],
"forward_host": "192.168.1.100",
"forward_port": 8080,
"forward_scheme": "http"
}
]
}`}
/>
</div>
<button
type="button"
onClick={handleUpload}
disabled={loading || !content.trim()}
className="px-6 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{loading ? t('common.loading') : t('importJSON.upload')}
</button>
</div>
</div>
)}
{showReview && preview?.preview && (
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
<h2 className="text-xl font-semibold text-white mb-4">
{t('importJSON.previewTitle')}
</h2>
{preview.preview.errors.length > 0 && (
<div
className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-4"
role="alert"
>
<ul className="list-disc list-inside">
{preview.preview.errors.map((err, idx) => (
<li key={idx}>{err}</li>
))}
</ul>
</div>
)}
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm text-left text-gray-300">
<caption className="sr-only">JSON Import Preview</caption>
<thead className="text-xs uppercase bg-gray-800 text-gray-400">
<tr>
<th scope="col" className="px-4 py-3">
{t('proxyHosts.domainNames')}
</th>
<th scope="col" className="px-4 py-3">
{t('proxyHosts.forwardHost')}
</th>
<th scope="col" className="px-4 py-3">
{t('proxyHosts.forwardPort')}
</th>
<th scope="col" className="px-4 py-3">
{t('proxyHosts.sslForced')}
</th>
<th scope="col" className="px-4 py-3">
{t('common.status')}
</th>
{preview.preview.conflicts.length > 0 && (
<th scope="col" className="px-4 py-3">
{t('common.actions')}
</th>
)}
</tr>
</thead>
<tbody>
{preview.preview.hosts.map((host, idx) => {
const isConflict = preview.preview.conflicts.includes(
host.domain_names
)
return (
<tr key={idx} className="border-b border-gray-700">
<td className="px-4 py-3">{host.domain_names}</td>
<td className="px-4 py-3">
{host.forward_scheme}://{host.forward_host}
</td>
<td className="px-4 py-3">{host.forward_port}</td>
<td className="px-4 py-3">
{host.ssl_forced ? t('common.yes') : t('common.no')}
</td>
<td className="px-4 py-3">
{isConflict ? (
<span className="text-yellow-400">
{t('importJSON.conflict')}
</span>
) : (
<span className="text-green-400">
{t('importJSON.new')}
</span>
)}
</td>
{preview.preview.conflicts.length > 0 && (
<td className="px-4 py-3">
{isConflict && (
<select
value={resolutions[host.domain_names] || 'skip'}
onChange={(e) =>
handleResolutionChange(
host.domain_names,
e.target.value
)
}
className="bg-gray-800 border border-gray-600 text-white rounded px-2 py-1 text-sm"
aria-label={`Resolution for ${host.domain_names}`}
>
<option value="skip">{t('importJSON.skip')}</option>
<option value="keep">{t('importJSON.keep')}</option>
<option value="replace">
{t('importJSON.replace')}
</option>
</select>
)}
</td>
)}
</tr>
)
})}
</tbody>
</table>
</div>
<div className="flex gap-4">
<button
type="button"
onClick={handleCommit}
disabled={committing}
className="px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{committing ? t('common.loading') : t('importJSON.import')}
</button>
<button
type="button"
onClick={handleCancel}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
>
{t('common.cancel')}
</button>
</div>
</div>
)}
<ImportSuccessModal
visible={showSuccessModal}
onClose={handleCloseSuccessModal}
onNavigateDashboard={() => {
handleCloseSuccessModal()
navigate('/')
}}
onNavigateHosts={() => {
handleCloseSuccessModal()
navigate('/proxy-hosts')
}}
results={commitResult}
/>
</div>
)
}

View File

@@ -0,0 +1,312 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { createBackup } from '../api/backups'
import { useNPMImport } from '../hooks/useNPMImport'
import ImportSuccessModal from '../components/dialogs/ImportSuccessModal'
export default function ImportNPM() {
const { t } = useTranslation()
const navigate = useNavigate()
const {
preview,
loading,
error,
upload,
commit,
committing,
commitResult,
clearCommitResult,
cancel,
reset,
} = useNPMImport()
const [content, setContent] = useState('')
const [showReview, setShowReview] = useState(false)
const [showSuccessModal, setShowSuccessModal] = useState(false)
const [resolutions, setResolutions] = useState<Record<string, string>>({})
const [names] = useState<Record<string, string>>({})
const handleUpload = async () => {
if (!content.trim()) {
return
}
try {
JSON.parse(content)
} catch {
alert(t('importNPM.invalidJSON'))
return
}
try {
await upload(content)
setShowReview(true)
} catch {
// Error is handled by hook
}
}
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const text = await file.text()
setContent(text)
}
const handleCommit = async () => {
try {
await createBackup()
await commit(resolutions, names)
setContent('')
setShowReview(false)
setShowSuccessModal(true)
} catch {
// Error is handled by hook
}
}
const handleCloseSuccessModal = () => {
setShowSuccessModal(false)
clearCommitResult()
}
const handleCancel = async () => {
if (confirm(t('importNPM.cancelConfirm'))) {
try {
await cancel()
setShowReview(false)
reset()
} catch {
// Error is handled by hook
}
}
}
const handleResolutionChange = (domain: string, resolution: string) => {
setResolutions((prev) => ({ ...prev, [domain]: resolution }))
}
return (
<div className="p-8">
<h1 className="text-3xl font-bold text-white mb-6">{t('importNPM.title')}</h1>
{error && (
<div
className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-6"
role="alert"
>
{error.message}
</div>
)}
{!showReview && (
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
<div className="mb-6">
<h2 className="text-xl font-semibold text-white mb-2">
{t('importNPM.title')}
</h2>
<p className="text-gray-400 text-sm">{t('importNPM.description')}</p>
</div>
<div className="space-y-4">
<div>
<label
htmlFor="npm-file-upload"
className="block text-sm font-medium text-gray-300 mb-2"
>
{t('common.upload')}
</label>
<input
id="npm-file-upload"
type="file"
accept=".json,application/json"
onChange={handleFileUpload}
className="w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-active file:text-white hover:file:bg-blue-hover file:cursor-pointer cursor-pointer"
data-testid="npm-import-dropzone"
/>
</div>
<div className="flex items-center gap-4">
<div className="flex-1 border-t border-gray-700" />
<span className="text-gray-500 text-sm">
{t('importCaddy.orPasteContent')}
</span>
<div className="flex-1 border-t border-gray-700" />
</div>
<div>
<label
htmlFor="npm-content"
className="block text-sm font-medium text-gray-300 mb-2"
>
{t('importNPM.enterContent')}
</label>
<textarea
id="npm-content"
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full h-96 bg-gray-900 border border-gray-700 rounded-lg p-4 text-white font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={`{
"proxy_hosts": [
{
"domain_names": ["example.com"],
"forward_host": "192.168.1.100",
"forward_port": 8080,
"forward_scheme": "http"
}
]
}`}
/>
</div>
<button
type="button"
onClick={handleUpload}
disabled={loading || !content.trim()}
className="px-6 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{loading ? t('common.loading') : t('importNPM.upload')}
</button>
</div>
</div>
)}
{showReview && preview?.preview && (
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
<h2 className="text-xl font-semibold text-white mb-4">
{t('importNPM.previewTitle')}
</h2>
{preview.preview.errors.length > 0 && (
<div
className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-4"
role="alert"
>
<ul className="list-disc list-inside">
{preview.preview.errors.map((err, idx) => (
<li key={idx}>{err}</li>
))}
</ul>
</div>
)}
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm text-left text-gray-300">
<caption className="sr-only">NPM Import Preview</caption>
<thead className="text-xs uppercase bg-gray-800 text-gray-400">
<tr>
<th scope="col" className="px-4 py-3">
{t('proxyHosts.domainNames')}
</th>
<th scope="col" className="px-4 py-3">
{t('proxyHosts.forwardHost')}
</th>
<th scope="col" className="px-4 py-3">
{t('proxyHosts.forwardPort')}
</th>
<th scope="col" className="px-4 py-3">
{t('proxyHosts.sslForced')}
</th>
<th scope="col" className="px-4 py-3">
{t('common.status')}
</th>
{preview.preview.conflicts.length > 0 && (
<th scope="col" className="px-4 py-3">
{t('common.actions')}
</th>
)}
</tr>
</thead>
<tbody>
{preview.preview.hosts.map((host, idx) => {
const isConflict = preview.preview.conflicts.includes(
host.domain_names
)
return (
<tr key={idx} className="border-b border-gray-700">
<td className="px-4 py-3">{host.domain_names}</td>
<td className="px-4 py-3">
{host.forward_scheme}://{host.forward_host}
</td>
<td className="px-4 py-3">{host.forward_port}</td>
<td className="px-4 py-3">
{host.ssl_forced ? t('common.yes') : t('common.no')}
</td>
<td className="px-4 py-3">
{isConflict ? (
<span className="text-yellow-400">
{t('importNPM.conflict')}
</span>
) : (
<span className="text-green-400">
{t('importNPM.new')}
</span>
)}
</td>
{preview.preview.conflicts.length > 0 && (
<td className="px-4 py-3">
{isConflict && (
<select
value={resolutions[host.domain_names] || 'skip'}
onChange={(e) =>
handleResolutionChange(
host.domain_names,
e.target.value
)
}
className="bg-gray-800 border border-gray-600 text-white rounded px-2 py-1 text-sm"
aria-label={`Resolution for ${host.domain_names}`}
>
<option value="skip">{t('importNPM.skip')}</option>
<option value="keep">{t('importNPM.keep')}</option>
<option value="replace">
{t('importNPM.replace')}
</option>
</select>
)}
</td>
)}
</tr>
)
})}
</tbody>
</table>
</div>
<div className="flex gap-4">
<button
type="button"
onClick={handleCommit}
disabled={committing}
className="px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{committing ? t('common.loading') : t('importNPM.import')}
</button>
<button
type="button"
onClick={handleCancel}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
>
{t('common.cancel')}
</button>
</div>
</div>
)}
<ImportSuccessModal
visible={showSuccessModal}
onClose={handleCloseSuccessModal}
onNavigateDashboard={() => {
handleCloseSuccessModal()
navigate('/')
}}
onNavigateHosts={() => {
handleCloseSuccessModal()
navigate('/proxy-hosts')
}}
results={commitResult}
/>
</div>
)
}