EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
Tenants.jsx
Go to the documentation of this file.
1import { useEffect, useState } from 'react';
2// import removed: TenantSelector
3import { useNavigate } from 'react-router-dom';
4import MainLayout from '../components/Layout/MainLayout';
5import './Tickets.css';
6import { apiFetch } from '../lib/api';
7
8function Tenants() {
9 // Use tenantId from JWT
10 const token = localStorage.getItem('token');
11 let tenantId = '';
12 if (token) {
13 try {
14 const payload = token.split('.')[1];
15 const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
16 tenantId = decoded.tenant_id || decoded.tenantId || '';
17 } catch { }
18 }
19 const navigate = useNavigate();
20 const [tenants, setTenants] = useState([]);
21 const [loading, setLoading] = useState(false);
22 const [error, setError] = useState('');
23 const [total, setTotal] = useState(0);
24
25 // Pagination
26 const [page, setPage] = useState(1);
27 const [limit] = useState(10);
28
29 // Filters
30 const [search, setSearch] = useState('');
31 const [statusFilter, setStatusFilter] = useState('');
32
33 // Delete confirmation modal
34 const [showDeleteModal, setShowDeleteModal] = useState(false);
35 const [tenantToDelete, setTenantToDelete] = useState(null);
36 const [deleteRelatedData, setDeleteRelatedData] = useState(null);
37 const [forceDelete, setForceDelete] = useState(false);
38 const [deleting, setDeleting] = useState(false);
39
40 useEffect(() => {
41 fetchTenants();
42 }, [page, search, statusFilter, tenantId]);
43
44 async function fetchTenants() {
45 setLoading(true);
46 setError('');
47 try {
48 const token = localStorage.getItem('token');
49 const params = new URLSearchParams({
50 page,
51 limit
52 });
53 if (search) params.append('search', search);
54 if (statusFilter) params.append('status', statusFilter);
55 // Only send tenant_id if not root tenant
56 if (tenantId && tenantId !== '00000000-0000-0000-0000-000000000001') {
57 params.append('tenant_id', tenantId);
58 }
59
60 const res = await apiFetch(`/tenants?${params}`, {
61 headers: {
62 Authorization: `Bearer ${token}`,
63 'X-Tenant-Subdomain': 'admin'
64 }
65 });
66
67 if (!res.ok) {
68 if (res.status === 403) {
69 throw new Error('Access denied. Root tenant privileges required.');
70 }
71 throw new Error('Failed to load tenants');
72 }
73
74 const data = await res.json();
75 setTenants(data.tenants || []);
76 setTotal(data.total || data.tenants?.length || 0);
77 } catch (err) {
78 console.error(err);
79 setError(err.message || 'Load error');
80 } finally {
81 setLoading(false);
82 }
83 }
84
85 async function handleDelete(tenantId, tenantName) {
86 // First, check what data exists for this tenant
87 setTenantToDelete({ id: tenantId, name: tenantName });
88 setDeleting(true);
89
90 try {
91 const token = localStorage.getItem('token');
92 const res = await apiFetch(`/tenants/${tenantId}`, {
93 headers: {
94 Authorization: `Bearer ${token}`,
95 'X-Tenant-Subdomain': 'admin'
96 }
97 });
98
99 if (res.ok) {
100 const data = await res.json();
101 setDeleteRelatedData(data.stats);
102 setShowDeleteModal(true);
103 }
104 } catch (err) {
105 console.error(err);
106 alert('Failed to check tenant data');
107 } finally {
108 setDeleting(false);
109 }
110 }
111
112 async function confirmDelete() {
113 if (!tenantToDelete) return;
114
115 setDeleting(true);
116 try {
117 const token = localStorage.getItem('token');
118 const url = forceDelete
119 ? `/tenants/${tenantToDelete.id}?force=true`
120 : `/tenants/${tenantToDelete.id}`;
121
122 const res = await apiFetch(url, {
123 method: 'DELETE',
124 headers: {
125 Authorization: `Bearer ${token}`,
126 'X-Tenant-Subdomain': 'admin'
127 }
128 });
129
130 if (!res.ok) {
131 const data = await res.json();
132
133 // If there are related records preventing deletion, show detailed message
134 if (data.details) {
135 const counts = [];
136 if (parseInt(data.details.user_count) > 0) counts.push(`${data.details.user_count} users`);
137 if (parseInt(data.details.customer_count) > 0) counts.push(`${data.details.customer_count} customers`);
138 if (parseInt(data.details.ticket_count) > 0) counts.push(`${data.details.ticket_count} tickets`);
139 if (parseInt(data.details.invoice_count) > 0) counts.push(`${data.details.invoice_count} invoices`);
140 if (parseInt(data.details.contract_count) > 0) counts.push(`${data.details.contract_count} contracts`);
141 if (parseInt(data.details.agent_count) > 0) counts.push(`${data.details.agent_count} agents`);
142 if (parseInt(data.details.domain_count) > 0) counts.push(`${data.details.domain_count} domains`);
143 if (parseInt(data.details.purchase_order_count) > 0) counts.push(`${data.details.purchase_order_count} purchase orders`);
144
145 const message = counts.length > 0
146 ? `Cannot delete tenant.\n\nThis tenant has:\n• ${counts.join('\n• ')}\n\n${data.message || 'Please delete these resources first or use force delete.'}`
147 : data.message || data.error || 'Failed to delete tenant';
148
149 alert(message);
150 } else {
151 alert(data.message || data.error || 'Failed to delete tenant');
152 }
153 return;
154 }
155
156 const data = await res.json();
157 alert(`āœ“ Tenant "${tenantToDelete.name}" deleted successfully${data.cascade_deleted ? ' (including all related data)' : ''}`);
158
159 setShowDeleteModal(false);
160 setTenantToDelete(null);
161 setDeleteRelatedData(null);
162 setForceDelete(false);
163 fetchTenants();
164 } catch (err) {
165 console.error(err);
166 alert(err.message || 'Failed to delete tenant');
167 } finally {
168 setDeleting(false);
169 }
170 }
171
172 function cancelDelete() {
173 setShowDeleteModal(false);
174 setTenantToDelete(null);
175 setDeleteRelatedData(null);
176 setForceDelete(false);
177 }
178
179 const totalPages = Math.ceil(total / limit);
180
181 return (
182 <MainLayout>
183 <div className="page-content">
184 <div className="page-header">
185 <h2>Tenant Management</h2>
186 <button className="btn" onClick={() => navigate('/tenants/new')}>
187 <span className="material-symbols-outlined">add</span> Create Tenant
188 </button>
189 </div>
190
191 {error && <div className="error-message">{error}</div>}
192
193 {/* Filters */}
194 <div className="filters-container">
195 <div className="filter-group">
196 <input
197 type="text"
198 placeholder="Search tenants..."
199 value={search}
200 onChange={(e) => {
201 setSearch(e.target.value);
202 setPage(1);
203 }}
204 className="filter-input"
205 />
206 </div>
207
208 <div className="filter-group">
209 <select
210 value={statusFilter}
211 onChange={(e) => {
212 setStatusFilter(e.target.value);
213 setPage(1);
214 }}
215 className="filter-select"
216 >
217 <option value="">All Statuses</option>
218 <option value="active">Active</option>
219 <option value="suspended">Suspended</option>
220 <option value="inactive">Inactive</option>
221 </select>
222 </div>
223
224 <button className="btn" onClick={fetchTenants}>
225 <span className="material-symbols-outlined">refresh</span> Refresh
226 </button>
227 </div>
228
229 {/* Tenants Table */}
230 {loading ? (
231 <div className="loading">Loading tenants...</div>
232 ) : tenants.length === 0 ? (
233 <div className="empty-state">
234 <span className="material-symbols-outlined">domain</span>
235 <p>No tenants found</p>
236 </div>
237 ) : (
238 <>
239 <div className="table-container">
240 <table className="data-table">
241 <thead>
242 <tr>
243 <th>Tenant Name</th>
244 <th>Subdomain</th>
245 <th>Status</th>
246 <th>Type</th>
247 <th>Users</th>
248 <th>Customers</th>
249 <th>Tickets</th>
250 <th>Created</th>
251 <th>Actions</th>
252 </tr>
253 </thead>
254 <tbody>
255 {tenants.map((tenant) => (
256 <tr key={tenant.tenant_id}>
257 <td>
258 <strong>{tenant.name}</strong>
259 </td>
260 <td>
261 <code>{tenant.subdomain}</code>
262 </td>
263 <td>
264 <span className={`status-badge status-${tenant.status?.toLowerCase()}`}>
265 {tenant.status}
266 </span>
267 </td>
268 <td>
269 {tenant.is_msp ? (
270 <span style={{
271 padding: '4px 8px',
272 borderRadius: '4px',
273 backgroundColor: 'var(--primary)',
274 color: 'white',
275 fontSize: '0.85em',
276 fontWeight: 'bold'
277 }}>
278 ROOT TENANT
279 </span>
280 ) : (
281 <span style={{
282 padding: '4px 8px',
283 borderRadius: '4px',
284 backgroundColor: 'var(--bg-secondary)',
285 color: 'var(--text-muted)',
286 fontSize: '0.85em'
287 }}>
288 Client
289 </span>
290 )}
291 </td>
292 <td>{tenant.user_count || 0}</td>
293 <td>{tenant.customer_count || 0}</td>
294 <td>{tenant.ticket_count || 0}</td>
295 <td>{new Date(tenant.created_at).toLocaleDateString()}</td>
296 <td>
297 <div style={{ display: 'flex', gap: '8px' }}>
298 <button
299 className="btn btn-sm"
300 onClick={() => navigate(`/tenants/${tenant.tenant_id}`)}
301 title="View Details"
302 >
303 <span className="material-symbols-outlined">visibility</span>
304 </button>
305 <button
306 className="btn btn-sm"
307 onClick={() => navigate(`/tenants/${tenant.tenant_id}/edit`)}
308 title="Edit"
309 >
310 <span className="material-symbols-outlined">edit</span>
311 </button>
312 <button
313 className="btn btn-sm"
314 onClick={() => handleDelete(tenant.tenant_id, tenant.name)}
315 title="Delete"
316 style={{ color: 'var(--danger)' }}
317 disabled={tenant.is_msp}
318 >
319 <span className="material-symbols-outlined">delete</span>
320 </button>
321 </div>
322 </td>
323 </tr>
324 ))}
325 </tbody>
326 </table>
327 </div>
328
329 {/* Pagination */}
330 {totalPages > 1 && (
331 <div className="pagination">
332 <button
333 className="btn"
334 onClick={() => setPage(p => Math.max(1, p - 1))}
335 disabled={page === 1}
336 >
337 Previous
338 </button>
339 <span className="pagination-info">
340 Page {page} of {totalPages} ({total} total)
341 </span>
342 <button
343 className="btn"
344 onClick={() => setPage(p => Math.min(totalPages, p + 1))}
345 disabled={page === totalPages}
346 >
347 Next
348 </button>
349 </div>
350 )}
351 </>
352 )}
353
354 {/* Delete Confirmation Modal */}
355 {showDeleteModal && tenantToDelete && (
356 <div className="modal-overlay" onClick={cancelDelete}>
357 <div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '600px' }}>
358 <div className="modal-header">
359 <h2>
360 <span className="material-symbols-outlined" style={{ color: 'var(--danger)', verticalAlign: 'middle' }}>
361 warning
362 </span>
363 {' '}Delete Tenant
364 </h2>
365 <button className="modal-close" onClick={cancelDelete}>
366 <span className="material-symbols-outlined">close</span>
367 </button>
368 </div>
369
370 <div className="modal-body">
371 <div style={{ marginBottom: '20px' }}>
372 <p style={{ fontSize: '1.1em', marginBottom: '10px' }}>
373 Are you sure you want to delete tenant <strong>"{tenantToDelete.name}"</strong>?
374 </p>
375 <p style={{ color: 'var(--text-muted)', marginBottom: '20px' }}>
376 This action cannot be undone.
377 </p>
378 </div>
379
380 {deleteRelatedData && (
381 <div style={{
382 backgroundColor: 'var(--bg-secondary)',
383 padding: '15px',
384 borderRadius: '8px',
385 marginBottom: '20px'
386 }}>
387 <h3 style={{ marginBottom: '10px', fontSize: '1em' }}>
388 <span className="material-symbols-outlined" style={{ verticalAlign: 'middle', fontSize: '1.2em' }}>
389 info
390 </span>
391 {' '}Tenant Data Summary
392 </h3>
393
394 <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', fontSize: '0.95em' }}>
395 <div>šŸ‘„ Users: <strong>{deleteRelatedData.user_count || 0}</strong></div>
396 <div>šŸ¢ Customers: <strong>{deleteRelatedData.customer_count || 0}</strong></div>
397 <div>šŸŽ« Tickets: <strong>{deleteRelatedData.ticket_count || 0}</strong></div>
398 <div>šŸ“„ Invoices: <strong>{deleteRelatedData.invoice_count || 0}</strong></div>
399 <div>šŸ“‹ Contracts: <strong>{deleteRelatedData.contract_count || 0}</strong></div>
400 <div>šŸ–„ļø Agents: <strong>{deleteRelatedData.agent_count || 0}</strong></div>
401 </div>
402
403 {(parseInt(deleteRelatedData.user_count) > 0 ||
404 parseInt(deleteRelatedData.customer_count) > 0 ||
405 parseInt(deleteRelatedData.ticket_count) > 0 ||
406 parseInt(deleteRelatedData.invoice_count) > 0 ||
407 parseInt(deleteRelatedData.contract_count) > 0 ||
408 parseInt(deleteRelatedData.agent_count) > 0) && (
409 <>
410 <div style={{
411 marginTop: '15px',
412 padding: '12px',
413 backgroundColor: 'var(--warning-bg, #fff3e0)',
414 border: '1px solid var(--warning, #ff9800)',
415 borderRadius: '4px',
416 color: 'var(--warning-text, #e65100)'
417 }}>
418 <strong>āš ļø Warning:</strong> This tenant has data that will be affected.
419 </div>
420
421 <label style={{
422 display: 'flex',
423 alignItems: 'center',
424 marginTop: '15px',
425 padding: '10px',
426 backgroundColor: 'var(--danger-bg, #ffebee)',
427 border: '1px solid var(--danger, #f44336)',
428 borderRadius: '4px',
429 cursor: 'pointer'
430 }}>
431 <input
432 type="checkbox"
433 checked={forceDelete}
434 onChange={(e) => setForceDelete(e.target.checked)}
435 style={{ marginRight: '10px' }}
436 />
437 <span style={{ fontSize: '0.95em' }}>
438 <strong>Force delete</strong> - Permanently delete all related data (users, customers, tickets, invoices, contracts, agents, domains, etc.)
439 </span>
440 </label>
441 </>
442 )}
443 </div>
444 )}
445 </div>
446
447 <div className="modal-footer">
448 <button
449 className="btn"
450 onClick={cancelDelete}
451 disabled={deleting}
452 >
453 Cancel
454 </button>
455 <button
456 className="btn btn-danger"
457 onClick={confirmDelete}
458 disabled={deleting || (deleteRelatedData &&
459 (parseInt(deleteRelatedData.user_count) > 0 ||
460 parseInt(deleteRelatedData.customer_count) > 0 ||
461 parseInt(deleteRelatedData.ticket_count) > 0 ||
462 parseInt(deleteRelatedData.invoice_count) > 0 ||
463 parseInt(deleteRelatedData.contract_count) > 0 ||
464 parseInt(deleteRelatedData.agent_count) > 0) && !forceDelete)}
465 >
466 {deleting ? (
467 <>
468 <span className="material-symbols-outlined spinning">progress_activity</span>
469 Deleting...
470 </>
471 ) : (
472 <>
473 <span className="material-symbols-outlined">delete_forever</span>
474 {forceDelete ? 'Force Delete Tenant' : 'Delete Tenant'}
475 </>
476 )}
477 </button>
478 </div>
479 </div>
480 </div>
481 )}
482 </div>
483 </MainLayout>
484 );
485}
486
487export default Tenants;