码桶
发现社区成员的开源项目
SaasTab.jsx31.6 KB
import React, { useState, useEffect, useImperativeHandle, forwardRef } from 'react';
import { Server, Edit2, Trash2, RefreshCw, AlertCircle, X, Copy } from 'lucide-react';
import CustomSelect from './CustomSelect.jsx';
const SaasTab = forwardRef(function SaasTab({ zone, hostnames, filteredSaaS, loading, fetchHostnames, getHeaders, authFetch, t, showToast, openConfirm }, ref) {
const af = authFetch || fetch;
const initialSaaS = {
hostname: '',
ssl: {
method: 'txt',
type: 'dv',
settings: {
min_tls_version: '1.0'
}
},
custom_origin_server: ''
};
const [showSaaSModal, setShowSaaSModal] = useState(false);
const [editingSaaS, setEditingSaaS] = useState(null);
const [newSaaS, setNewSaaS] = useState(initialSaaS);
const [showVerifyModal, setShowVerifyModal] = useState(false);
const [verifyingSaaS, setVerifyingSaaS] = useState(null);
const [fallback, setFallback] = useState({ value: '', status: '' });
const [fallbackLoading, setFallbackLoading] = useState(false);
const [error, setError] = useState(null);
const fetchFallback = async () => {
setError(null);
try {
const res = await af(`/api/zones/${zone.id}/fallback_origin`, { headers: getHeaders() });
const data = await res.json();
if (data.result) {
setFallback({
value: data.result.origin || '',
status: data.result.status || 'inactive'
});
} else {
setFallback({ value: '', status: 'not_set' });
}
} catch (e) {
setFallback({ value: '', status: 'error' });
}
};
// Fetch fallback on mount
useEffect(() => {
fetchFallback();
}, [zone.id]);
// Lock body scroll when SaaS modals are open
useEffect(() => {
const anyModalOpen = showSaaSModal || showVerifyModal;
if (anyModalOpen) {
document.body.classList.add('modal-open');
} else {
document.body.classList.remove('modal-open');
}
return () => {
document.body.classList.remove('modal-open');
};
}, [showSaaSModal, showVerifyModal]);
const handleUpdateFallback = async (e) => {
e.preventDefault();
setFallbackLoading(true);
try {
const res = await af(`/api/zones/${zone.id}/fallback_origin`, {
method: 'PUT',
headers: getHeaders(true),
body: JSON.stringify({ origin: fallback.value })
});
if (res.ok) {
fetchFallback();
showToast(t('updateSuccess'));
} else {
const data = await res.json().catch((err) => { console.error('Failed to parse response JSON:', err); return {}; });
showToast(data.message || t('errorOccurred'), 'error');
}
} catch (e) {
showToast(t('errorOccurred'), 'error');
}
setFallbackLoading(false);
};
const startEditSaaS = (h) => {
setEditingSaaS(h);
const originValue = h.custom_origin_server || h.custom_origin || h.custom_origin_snihost || '';
setNewSaaS({
hostname: h.hostname,
ssl: {
method: h.ssl?.method || 'txt',
type: h.ssl?.type || 'dv',
settings: {
min_tls_version: h.ssl?.settings?.min_tls_version || '1.0'
}
},
custom_origin_server: originValue
});
setShowSaaSModal(true);
};
const handleSaaSSubmit = async (e) => {
e.preventDefault();
const method = editingSaaS ? 'PATCH' : 'POST';
const url = `/api/zones/${zone.id}/custom_hostnames${editingSaaS ? `?id=${editingSaaS.id}` : ''}`;
const payload = {
hostname: newSaaS.hostname,
ssl: {
method: newSaaS.ssl.method,
type: newSaaS.ssl.type,
settings: {
min_tls_version: newSaaS.ssl.settings.min_tls_version
}
}
};
if (newSaaS.custom_origin_server && newSaaS.custom_origin_server.trim()) {
const origin = newSaaS.custom_origin_server.trim();
payload.custom_origin_server = origin;
payload.custom_origin_snihost = origin;
} else if (editingSaaS) {
payload.custom_origin_server = null;
payload.custom_origin_snihost = null;
}
const res = await af(url, {
method,
headers: getHeaders(true),
body: JSON.stringify(payload)
});
if (res.ok) {
setShowSaaSModal(false);
setEditingSaaS(null);
fetchHostnames();
showToast(editingSaaS ? t('updateSuccess') : t('addSuccess'));
} else {
const data = await res.json().catch((err) => { console.error('Failed to parse response JSON:', err); return {}; });
showToast(data.message || t('errorOccurred'), 'error');
}
};
const deleteSaaS = async (id) => {
openConfirm(t('confirmTitle'), t('confirmDeleteSaaS'), async () => {
const res = await af(`/api/zones/${zone.id}/custom_hostnames?id=${id}`, {
method: 'DELETE',
headers: getHeaders(true)
});
if (res.ok) {
fetchHostnames();
showToast(t('deleteSuccess'));
} else {
const data = await res.json().catch((err) => { console.error('Failed to parse response JSON:', err); return {}; });
showToast(data.message || t('errorOccurred'), 'error');
}
});
};
const openAddSaaS = () => {
setEditingSaaS(null);
setNewSaaS(initialSaaS);
setShowSaaSModal(true);
};
// Expose openAddSaaS to parent via ref
useImperativeHandle(ref, () => ({
openAddSaaS
}));
return (
<>
<div className="glass-card" style={{ padding: '1.25rem', marginBottom: '1.5rem', background: 'var(--subtle-bg)' }}>
<div className="flex-stack">
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '0.5rem' }}>
<h3 style={{ fontSize: '0.875rem', margin: 0 }}>{t('fallbackOrigin')}</h3>
<span className={`badge ${fallback.status === 'active' ? 'badge-green' : 'badge-orange'}`} style={{ fontSize: '0.65rem' }}>
{t(fallback.status) || 'N/A'}
</span>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={fallback.value || ''}
onChange={e => setFallback({ ...fallback, value: e.target.value })}
placeholder={t('fallbackOriginPlaceholder')}
style={{ height: '36px', fontSize: '0.8125rem', maxWidth: '300px' }}
/>
<button
className="btn btn-primary"
onClick={handleUpdateFallback}
disabled={fallbackLoading}
style={{ height: '36px', padding: '0 1rem', whiteSpace: 'nowrap', flexShrink: 0 }}
>
{fallbackLoading ? <RefreshCw className="spin" size={14} /> : t('updateFallback')}
</button>
</div>
</div>
</div>
</div>
<table className="data-table desktop-only">
<thead>
<tr>
<th>{t('hostname')}</th>
<th>{t('status')}</th>
<th>{t('sslStatus')}</th>
<th>{t('originServer')}</th>
<th>{t('actions')}</th>
</tr>
</thead>
<tbody>
{filteredSaaS.map(h => (
<tr key={h.id} className="compact-row">
<td style={{ fontWeight: 600 }}>{h.hostname}</td>
<td>
<span className={`badge ${h.status === 'active' ? 'badge-green' : 'badge-orange'}`}>
{t(h.status)}
</span>
</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span className={`badge ${h.ssl?.status === 'active' ? 'badge-green' : 'badge-orange'}`} style={{ fontSize: '0.75rem' }}>
{t(h.ssl?.status) || 'N/A'}
</span>
<span style={{ fontSize: '0.65rem', color: 'var(--text-muted)', background: 'var(--subtle-bg)', padding: '1px 4px', borderRadius: '4px', textTransform: 'uppercase' }}>
{h.ssl?.method}
</span>
</div>
</td>
<td>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Server size={12} />
<span>{h.custom_origin_server || h.custom_origin_snihost || h.custom_origin || t('defaultOrigin')}</span>
</div>
</td>
<td>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<div style={{ width: '32px', display: 'flex', justifyContent: 'flex-start' }}>
{(h.ssl?.status !== 'active' || h.ownership_verification) && (
<button className="btn btn-outline" style={{ padding: '0.4rem', border: 'none' }} onClick={() => { setVerifyingSaaS(h); setShowVerifyModal(true); }} title={t('verificationRecords')}>
<AlertCircle size={16} color="#f59e0b" />
</button>
)}
</div>
<button className="btn btn-outline" style={{ padding: '0.4rem', border: 'none' }} onClick={() => startEditSaaS(h)}>
<Edit2 size={16} color="var(--primary)" />
</button>
<button className="btn btn-outline" style={{ padding: '0.4rem', border: 'none' }} onClick={() => deleteSaaS(h.id)}>
<Trash2 size={16} color="var(--error)" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
<div className="mobile-only">
{filteredSaaS.map(h => (
<div key={h.id} className="record-card" style={{ padding: '0.875rem', display: 'flex', flexDirection: 'column', gap: '10px' }}>
{/* Row 1: Hostname & Origin */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px' }}>
<div style={{ fontWeight: 600, fontSize: '0.9375rem', color: 'var(--text)', wordBreak: 'break-all', flex: 1 }}>{h.hostname}</div>
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: '4px', background: 'var(--subtle-bg)', padding: '2px 6px', borderRadius: '4px', whiteSpace: 'nowrap' }}>
<Server size={10} />
<span>{h.custom_origin_server || h.custom_origin_snihost || h.custom_origin || t('defaultOrigin')}</span>
</div>
</div>
{/* Row 2: Statuses & Actions */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '0.65rem', color: 'var(--text-muted)' }}>Host:</span>
<span className={`badge ${h.status === 'active' ? 'badge-green' : 'badge-orange'}`} style={{ fontSize: '0.6rem', padding: '1px 4px' }}>{t(h.status)}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '0.65rem', color: 'var(--text-muted)' }}>SSL:</span>
<span className={`badge ${h.ssl?.status === 'active' ? 'badge-green' : 'badge-orange'}`} style={{ fontSize: '0.6rem', padding: '1px 4px' }}>
{t(h.ssl?.status) || 'N/A'}
</span>
<span style={{ fontSize: '0.55rem', color: 'var(--text-muted)', textTransform: 'uppercase', opacity: 0.8 }}>{h.ssl?.method}</span>
</div>
</div>
<div style={{ display: 'flex', gap: '2px', alignItems: 'center' }}>
{(h.ssl?.status !== 'active' || h.ownership_verification) && (
<button className="btn btn-outline" style={{ padding: '0.35rem', border: 'none' }} onClick={() => { setVerifyingSaaS(h); setShowVerifyModal(true); }}>
<AlertCircle size={15} color="#f59e0b" />
</button>
)}
<button className="btn btn-outline" style={{ padding: '0.35rem', border: 'none' }} onClick={() => startEditSaaS(h)}>
<Edit2 size={15} color="var(--primary)" />
</button>
<button className="btn btn-outline" style={{ padding: '0.35rem', border: 'none' }} onClick={() => deleteSaaS(h.id)}>
<Trash2 size={15} color="var(--error)" />
</button>
</div>
</div>
</div>
))}
</div>
{/* SaaS Modal */}
{showSaaSModal && (
<div
className="modal-overlay"
onClick={(e) => { if (e.target === e.currentTarget) { setShowSaaSModal(false); setEditingSaaS(null); setNewSaaS(initialSaaS); } }}
>
<div className="glass-card fade-in modal-content" role="dialog" aria-label={editingSaaS ? t('editSaaS') : t('addSaaS')} style={{ padding: '2rem', maxWidth: '450px', width: '90%', position: 'relative' }}>
<h2 style={{ marginBottom: '1.5rem' }}>{editingSaaS ? t('editSaaS') : t('addSaaS')}</h2>
<form onSubmit={handleSaaSSubmit}>
<div className="input-row">
<label>{t('hostname')}</label>
<input
type="text"
value={newSaaS.hostname}
onChange={e => setNewSaaS({ ...newSaaS, hostname: e.target.value })}
placeholder={t('hostnamePlaceholder')}
required
/>
</div>
<div className="input-row">
<label>{t('minTlsVersion')}</label>
<div style={{ flex: 1 }}>
<CustomSelect
value={newSaaS.ssl.settings.min_tls_version}
onChange={(e) => setNewSaaS({ ...newSaaS, ssl: { ...newSaaS.ssl, settings: { ...newSaaS.ssl.settings, min_tls_version: e.target.value } } })}
options={[
{ value: '1.0', label: t('tlsDefault') },
{ value: '1.1', label: 'TLS 1.1' },
{ value: '1.2', label: 'TLS 1.2' },
{ value: '1.3', label: 'TLS 1.3' }
]}
/>
</div>
</div>
<div className="input-row">
<label>{t('verifyMethod')}</label>
<div style={{ flex: 1 }}>
<CustomSelect
value={newSaaS.ssl.method}
onChange={(e) => setNewSaaS({ ...newSaaS, ssl: { ...newSaaS.ssl, method: e.target.value } })}
options={[
{ value: 'txt', label: t('sslMethodTxt') + ` (${t('recommended')})` },
{ value: 'http', label: t('sslMethodHttp') }
]}
/>
</div>
</div>
<div className="input-row">
<label>{t('originServer')}</label>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '8px' }}>
<CustomSelect
value={newSaaS.custom_origin_server ? 'custom' : 'default'}
onChange={(e) => {
if (e.target.value === 'default') {
setNewSaaS({ ...newSaaS, custom_origin_server: '' });
} else {
setNewSaaS({ ...newSaaS, custom_origin_server: ' ' });
}
}}
options={[
{ value: 'default', label: t('defaultOrigin') },
{ value: 'custom', label: t('customOrigin') }
]}
/>
{newSaaS.custom_origin_server !== '' && (
<input
type="text"
value={newSaaS.custom_origin_server === ' ' ? '' : newSaaS.custom_origin_server}
onChange={e => setNewSaaS({ ...newSaaS, custom_origin_server: e.target.value })}
placeholder={t('originPlaceholder')}
required
/>
)}
</div>
</div>
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1.5rem' }}>
<button type="button" className="btn btn-outline" style={{ flex: 1 }} onClick={() => {
setShowSaaSModal(false);
setEditingSaaS(null);
setNewSaaS(initialSaaS);
}}>{t('cancel')}</button>
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>{t('save')}</button>
</div>
</form>
</div>
</div>
)}
{/* Verification Modal */}
{showVerifyModal && verifyingSaaS && (
<div
className="modal-overlay"
onClick={(e) => { if (e.target === e.currentTarget) setShowVerifyModal(false); }}
>
<div className="glass-card fade-in modal-content" role="dialog" aria-label={t('verificationRecords')} style={{ padding: '2rem', maxWidth: '600px', width: '90%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2 style={{ margin: 0 }}>{t('verificationRecords')}</h2>
<button className="btn btn-outline" style={{ padding: '4px', border: 'none' }} onClick={() => setShowVerifyModal(false)} aria-label="Close">
<X size={20} />
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{verifyingSaaS.ownership_verification && (
<div>
<h4 style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--text-muted)', marginBottom: '0.75rem', textTransform: 'uppercase' }}>{t('ownership')}</h4>
<div style={{ background: 'var(--subtle-bg)', border: '1px solid var(--border)', borderRadius: '8px', padding: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<p style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '4px' }}>{t('verifyType')}</p>
<code style={{ fontSize: '0.8125rem', background: 'var(--code-bg)', border: '1px solid var(--border)', padding: '4px 8px', borderRadius: '4px' }}>{verifyingSaaS.ownership_verification.type}</code>
</div>
<div>
<p style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '4px' }}>{t('verifyName')}</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<code style={{ fontSize: '0.8125rem', background: 'var(--code-bg)', border: '1px solid var(--border)', padding: '4px 8px', borderRadius: '4px', wordBreak: 'break-all', flex: 1 }}>{verifyingSaaS.ownership_verification.name}</code>
<button className="btn btn-outline" style={{ padding: '6px' }} onClick={() => { navigator.clipboard.writeText(verifyingSaaS.ownership_verification.name); showToast(t('copied')); }}>
<Copy size={14} />
</button>
</div>
</div>
<div>
<p style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '4px' }}>{t('verifyValue')}</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<code style={{ fontSize: '0.8125rem', background: 'var(--code-bg)', border: '1px solid var(--border)', padding: '4px 8px', borderRadius: '4px', wordBreak: 'break-all', flex: 1 }}>{verifyingSaaS.ownership_verification.value}</code>
<button className="btn btn-outline" style={{ padding: '6px' }} onClick={() => { navigator.clipboard.writeText(verifyingSaaS.ownership_verification.value); showToast(t('copied')); }}>
<Copy size={14} />
</button>
</div>
</div>
</div>
</div>
)}
{(verifyingSaaS.ssl?.validation_records?.length > 0 || verifyingSaaS.ssl?.cname) && (
<div>
<h4 style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--text-muted)', marginBottom: '0.75rem', textTransform: 'uppercase' }}>{t('sslValidation')}</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{verifyingSaaS.ssl.validation_records?.map((rec, idx) => (
<div key={idx} style={{ background: 'var(--subtle-bg)', border: '1px solid var(--border)', borderRadius: '8px', padding: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<code style={{ fontSize: '0.8125rem', background: 'var(--code-bg)', border: '1px solid var(--border)', padding: '2px 6px', borderRadius: '4px' }}>TXT</code>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{t('verifyMethod')}</span>
</div>
<div>
<p style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '4px' }}>{t('verifyName')}</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<code style={{ fontSize: '0.8125rem', background: 'var(--code-bg)', border: '1px solid var(--border)', padding: '4px 8px', borderRadius: '4px', wordBreak: 'break-all', flex: 1 }}>{rec.txt_name}</code>
<button className="btn btn-outline" style={{ padding: '6px' }} onClick={() => { navigator.clipboard.writeText(rec.txt_name); showToast(t('copied')); }}>
<Copy size={14} />
</button>
</div>
</div>
<div>
<p style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '4px' }}>{t('verifyValue')}</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<code style={{ fontSize: '0.8125rem', background: 'var(--code-bg)', border: '1px solid var(--border)', padding: '4px 8px', borderRadius: '4px', wordBreak: 'break-all', flex: 1 }}>{rec.txt_value}</code>
<button className="btn btn-outline" style={{ padding: '6px' }} onClick={() => { navigator.clipboard.writeText(rec.txt_value); showToast(t('copied')); }}>
<Copy size={14} />
</button>
</div>
</div>
</div>
))}
{verifyingSaaS.ssl.cname && (
<div style={{ background: 'var(--subtle-bg)', border: '1px solid var(--border)', borderRadius: '8px', padding: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<code style={{ fontSize: '0.8125rem', background: 'var(--code-bg)', border: '1px solid var(--border)', padding: '2px 6px', borderRadius: '4px' }}>CNAME</code>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{t('verifyMethod')}</span>
</div>
<div>
<p style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '4px' }}>{t('verifyName')}</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<code style={{ fontSize: '0.8125rem', background: 'var(--code-bg)', border: '1px solid var(--border)', padding: '4px 8px', borderRadius: '4px', wordBreak: 'break-all', flex: 1 }}>{verifyingSaaS.hostname}</code>
<button className="btn btn-outline" style={{ padding: '6px' }} onClick={() => { navigator.clipboard.writeText(verifyingSaaS.hostname); showToast(t('copied')); }}>
<Copy size={14} />
</button>
</div>
</div>
<div>
<p style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '4px' }}>{t('verifyValue')}</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<code style={{ fontSize: '0.8125rem', background: 'var(--code-bg)', border: '1px solid var(--border)', padding: '4px 8px', borderRadius: '4px', wordBreak: 'break-all', flex: 1 }}>{verifyingSaaS.ssl.cname_target}</code>
<button className="btn btn-outline" style={{ padding: '6px' }} onClick={() => { navigator.clipboard.writeText(verifyingSaaS.ssl.cname_target); showToast(t('copied')); }}>
<Copy size={14} />
</button>
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
)}
</>
);
});
export default SaasTab;