// @ts-nocheck
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../lib/api';
import { navigate } from '../lib/navigation';
import { copyToClipboard, formatISO8601, formatRelativeTimeRounded } from '../lib/utils';
import { useToast } from '../components/toast';
import { AutocompleteInput, Button, ConfirmDialog, FormField, Modal, ModalActions, ModalSection, SegmentedRadioGroup } from '../components/ui';
import { ProjectTable } from '../components/project-table';
import { ActiveDeploymentsSummary, DeploymentDetail, DeploymentsList } from './deployments';
import { DomainsList, EnvVarsList, ExtensionDetailPage, ExtensionsList, ServiceAccountsList } from './resources';
import { MonoTable, MonoTableBody, MonoTableFrame, MonoTableHead, MonoTableRow, MonoTd, MonoTh } from '../components/table';
import { EmptyState, ErrorState, LoadingState } from '../components/states';
import { useRowKeyboardNavigation, useSortableData } from '../lib/table';
// Projects List Component
export function ProjectsList({ openCreate = false }) {
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [formData, setFormData] = useState({ name: '', access_class: 'public', owner: 'self' });
const [teams, setTeams] = useState([]);
const [currentUser, setCurrentUser] = useState(null);
const [accessClasses, setAccessClasses] = useState([]);
const [saving, setSaving] = useState(false);
const [actionStatus, setActionStatus] = useState('');
const { showToast } = useToast();
const { sortedItems: sortedProjects, sortKey, sortDirection, requestSort } = useSortableData(projects, 'name');
const { activeIndex, setActiveIndex, onKeyDown } = useRowKeyboardNavigation(
(idx) => {
const project = sortedProjects[idx];
if (project) navigate(`/project/${project.name}`);
},
sortedProjects.length
);
const loadProjects = useCallback(async () => {
try {
const data = await api.getProjects();
setProjects(data);
setLoading(false);
} catch (err) {
setError(err.message);
setLoading(false);
}
}, []);
useEffect(() => {
loadProjects();
}, [loadProjects]);
useEffect(() => {
async function loadTeams() {
try {
const data = await api.getTeams();
setTeams(data);
} catch (err) {
console.error('Failed to load teams:', err);
}
}
loadTeams();
}, []);
useEffect(() => {
async function loadCurrentUser() {
try {
const user = await api.getMe();
setCurrentUser(user);
} catch (err) {
console.error('Failed to load current user:', err);
}
}
loadCurrentUser();
}, []);
useEffect(() => {
async function loadAccessClasses() {
try {
const data = await api.getAccessClasses();
setAccessClasses(data.access_classes || []);
} catch (err) {
console.error('Failed to load access classes:', err);
}
}
loadAccessClasses();
}, []);
const handleCreateClick = () => {
setFormData({ name: '', access_class: 'public', owner: 'self' });
setIsModalOpen(true);
};
useEffect(() => {
if (!openCreate) return;
handleCreateClick();
window.history.replaceState({}, '', window.location.pathname);
}, [openCreate]);
const handleCreate = async () => {
if (!formData.name) {
showToast('Project name is required', 'error');
return;
}
// Validate project name (lowercase alphanumeric and hyphens)
if (!/^[a-z0-9-]+$/.test(formData.name)) {
showToast('Project name must contain only lowercase letters, numbers, and hyphens', 'error');
return;
}
if (!currentUser) {
showToast('Unable to determine current user', 'error');
return;
}
setSaving(true);
setActionStatus(`Creating project ${formData.name}...`);
try {
// Format owner correctly for the API
let owner;
if (formData.owner === 'self') {
owner = { user: currentUser.id };
} else {
// formData.owner is the team ID
owner = { team: formData.owner };
}
await api.createProject(formData.name, formData.access_class, owner);
showToast(`Project ${formData.name} created successfully`, 'success');
setActionStatus(`Created project ${formData.name}.`);
setIsModalOpen(false);
loadProjects();
} catch (err) {
showToast(`Failed to create project: ${err.message}`, 'error');
setActionStatus(`Failed to create project ${formData.name}.`);
} finally {
setSaving(false);
}
};
if (loading) return <LoadingState label="Loading projects..." />;
if (error) return <ErrorState message={`Error loading projects: ${error}`} onRetry={loadProjects} />;
return (
<section>
<div className="flex justify-end items-center mb-6">
<Button variant="primary" size="sm" onClick={handleCreateClick}>
Create Project
</Button>
</div>
{actionStatus && <p className="mono-inline-status mb-3">{actionStatus}</p>}
<ProjectTable
projects={sortedProjects}
sortKey={sortKey}
sortDirection={sortDirection}
requestSort={requestSort}
onRowClick={(project) => navigate(`/project/${project.name}`)}
onKeyDown={onKeyDown}
activeIndex={activeIndex}
setActiveIndex={setActiveIndex}
emptyMessage="No projects."
/>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Create Project"
>
<ModalSection>
<FormField
label="Project Name"
id="project-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value.toLowerCase() })}
placeholder="my-awesome-app"
required
/>
<p className="text-sm text-gray-600 dark:text-gray-500 -mt-2">
Only lowercase letters, numbers, and hyphens allowed
</p>
<FormField
label="Access Class"
id="project-access-class"
type="select"
value={formData.access_class}
onChange={(e) => setFormData({ ...formData, access_class: e.target.value })}
required
>
{accessClasses.map(ac => (
<option key={ac.id} value={ac.id} title={ac.description}>
{ac.display_name}
</option>
))}
</FormField>
{accessClasses.find(ac => ac.id === formData.access_class) && (
<p className="text-sm text-gray-600 dark:text-gray-500 -mt-2">
{accessClasses.find(ac => ac.id === formData.access_class).description}
</p>
)}
<FormField
label="Owner"
id="project-owner"
type="select"
value={formData.owner}
onChange={(e) => setFormData({ ...formData, owner: e.target.value })}
required
>
<option value="self">Self</option>
{teams.map(team => (
<option key={team.id} value={team.id}>team:{team.name}</option>
))}
</FormField>
<ModalActions>
<Button
variant="secondary"
onClick={() => setIsModalOpen(false)}
disabled={saving}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleCreate}
loading={saving}
>
Create
</Button>
</ModalActions>
</ModalSection>
</Modal>
</section>
);
}
// Project Detail Component
export function ProjectDetail({ projectName, initialTab }) {
const [project, setProject] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [activeTab, setActiveTab] = useState(initialTab || 'overview');
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [accessClasses, setAccessClasses] = useState([]);
const [editingOwner, setEditingOwner] = useState(false);
const [ownerType, setOwnerType] = useState('user');
const [ownerUserEmail, setOwnerUserEmail] = useState('');
const [ownerTeamId, setOwnerTeamId] = useState('');
const [teams, setTeams] = useState([]);
const [currentUserEmail, setCurrentUserEmail] = useState('');
const [updatingOwner, setUpdatingOwner] = useState(false);
const [detailActionStatus, setDetailActionStatus] = useState('');
const { showToast } = useToast();
const handleCopy = useCallback(async (value, label) => {
if (!value || value === '-') return;
try {
await copyToClipboard(value);
showToast(`${label} copied`, 'success');
} catch (err) {
showToast(`Failed to copy ${label.toLowerCase()}: ${err.message}`, 'error');
}
}, [showToast]);
const getStatusTone = (status) => {
const statusTones = {
Healthy: 'ok',
Running: 'ok',
Deploying: 'warn',
Pending: 'warn',
Building: 'warn',
Pushing: 'warn',
Pushed: 'warn',
Unhealthy: 'bad',
Failed: 'bad',
Stopped: 'muted',
Cancelled: 'muted',
Superseded: 'muted',
Expired: 'muted',
Terminating: 'muted',
};
return statusTones[status] || 'muted';
};
const getOwnerInfo = (projectData) => {
if (!projectData?.owner) return null;
const owner = projectData.owner;
if (owner.email) return { type: 'user', label: owner.email, userId: owner.id || null };
if (owner.name) return { type: 'team', label: owner.name, teamId: owner.id || null };
return null;
};
const loadProject = useCallback(async () => {
try {
const data = await api.getProject(projectName);
setProject(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [projectName]);
useEffect(() => {
loadProject();
}, [loadProject]);
useEffect(() => {
async function loadAccessClasses() {
try {
const data = await api.getAccessClasses();
setAccessClasses(data.access_classes || []);
} catch (err) {
console.error('Failed to load access classes:', err);
}
}
loadAccessClasses();
}, []);
useEffect(() => {
async function loadTeams() {
try {
const data = await api.getTeams();
setTeams(data || []);
} catch (err) {
console.error('Failed to load teams:', err);
}
}
loadTeams();
}, []);
useEffect(() => {
async function loadCurrentUser() {
try {
const user = await api.getMe();
setCurrentUserEmail(user?.email || '');
} catch (err) {
console.error('Failed to load current user:', err);
}
}
loadCurrentUser();
}, []);
// Update activeTab when initialTab changes (e.g., browser back/forward)
useEffect(() => {
if (initialTab) {
setActiveTab(initialTab);
}
}, [initialTab]);
// Helper function to change tab and update URL
const changeTab = (tab) => {
setActiveTab(tab);
navigate(`/project/${projectName}/${tab}`);
};
const handleDeleteClick = () => {
setConfirmDialogOpen(true);
};
const handleDeleteConfirm = async () => {
if (!project) return;
setDeleting(true);
setDetailActionStatus(`Deleting project ${project.name}...`);
try {
await api.deleteProject(project.name);
showToast(`Project ${project.name} deleted successfully`, 'success');
setDetailActionStatus(`Deleted project ${project.name}.`);
setConfirmDialogOpen(false);
// Redirect to projects list
navigate('/projects');
} catch (err) {
showToast(`Failed to delete project: ${err.message}`, 'error');
setDetailActionStatus(`Failed to delete project ${project.name}.`);
} finally {
setDeleting(false);
}
};
const handleEditOwner = () => {
const currentOwner = getOwnerInfo(project);
const initialType = currentOwner?.type || 'user';
setOwnerType(initialType);
setOwnerUserEmail(initialType === 'user' ? (currentOwner?.label || '') : '');
if (initialType === 'team' && teams.length > 0) {
const matchingTeam = teams.find((t) => t.name === currentOwner?.label || t.id === currentOwner?.teamId);
setOwnerTeamId(matchingTeam?.id || currentOwner?.teamId || '');
} else {
setOwnerTeamId('');
}
setEditingOwner(true);
};
const handleCancelEditOwner = () => {
setEditingOwner(false);
setOwnerType('user');
setOwnerUserEmail('');
setOwnerTeamId('');
};
const handleSaveOwner = async () => {
if (!project) return;
if (ownerType === 'user' && !ownerUserEmail.trim()) {
showToast('User email is required', 'error');
return;
}
if (ownerType === 'team' && !ownerTeamId) {
showToast('Team is required', 'error');
return;
}
setUpdatingOwner(true);
setDetailActionStatus(`Transferring ownership for ${project.name}...`);
try {
let ownerPayload;
if (ownerType === 'user') {
ownerPayload = { user: ownerUserEmail.trim() };
} else {
ownerPayload = { team: ownerTeamId };
}
await api.updateProject(project.name, { owner: ownerPayload });
const updatedProject = await api.getProject(projectName);
setProject(updatedProject);
showToast('Project owner updated', 'success');
setDetailActionStatus(`Ownership transferred for ${project.name}.`);
handleCancelEditOwner();
} catch (err) {
showToast(`Failed to update owner: ${err.message}`, 'error');
setDetailActionStatus(`Failed to transfer ownership for ${project.name}.`);
} finally {
setUpdatingOwner(false);
}
};
if (loading) return <LoadingState label="Loading project..." />;
if (error) return <ErrorState message={`Error loading project: ${error}`} onRetry={loadProject} />;
if (!project) return <EmptyState message="Project not found." />;
const ownerInfo = getOwnerInfo(project);
const appUsers = project.app_users || [];
const appTeams = project.app_teams || [];
const owner = project.owner || null;
const ownerAccessUserEmail = owner?.email ? owner.email.trim().toLowerCase() : null;
const ownerAccessTeamName = owner?.name ? owner.name.trim().toLowerCase() : null;
const ownerAccessTeamId = owner?.id || null;
const userCount = (() => {
if (!ownerAccessUserEmail) return appUsers.length;
const ownerAlreadyIncluded = appUsers.some((u) => (u.email || '').trim().toLowerCase() === ownerAccessUserEmail);
return ownerAlreadyIncluded ? appUsers.length : appUsers.length + 1;
})();
const teamCount = (() => {
if (!ownerAccessTeamName) return appTeams.length;
const ownerAlreadyIncluded = appTeams.some((t) => {
if (ownerAccessTeamId && t.id) return t.id === ownerAccessTeamId;
return (t.name || '').trim().toLowerCase() === ownerAccessTeamName;
});
return ownerAlreadyIncluded ? appTeams.length : appTeams.length + 1;
})();
return (
<section>
<div className="flex justify-end items-start mb-4">
<Button
variant="danger"
size="sm"
onClick={handleDeleteClick}
>
Delete Project
</Button>
</div>
{detailActionStatus && <p className="mono-inline-status mb-4">{detailActionStatus}</p>}
<div className="mono-status-strip mono-status-strip-normalcase mb-6">
<div className={`mono-status-card mono-status-card-${getStatusTone(project.status)}`}>
<span>status</span>
<strong>{project.status}</strong>
</div>
<div>
<span>owner</span>
<strong className="mono-copyable-value">
<span className="inline-flex items-center gap-2">
{ownerInfo?.type === 'user' && (
<span
className="w-3 h-3 svg-mask inline-block"
aria-hidden="true"
style={{
maskImage: 'url(/assets/user.svg)',
WebkitMaskImage: 'url(/assets/user.svg)',
}}
/>
)}
{ownerInfo?.type === 'team' && (
<svg className="w-3 h-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M7 10a3 3 0 1 0-3-3 3 3 0 0 0 3 3Zm6 0a3 3 0 1 0-3-3 3 3 0 0 0 3 3ZM1.5 16.5a5.5 5.5 0 0 1 11 0v.5h-11Zm12 0a5.5 5.5 0 0 1 5-5.48 5.53 5.53 0 0 1 .5.02V17h-5.5Z" />
</svg>
)}
{ownerInfo?.type === 'team' ? (
<button
type="button"
className="underline"
onClick={() => navigate(`/team/${ownerInfo.label}`)}
>
{ownerInfo.label}
</button>
) : (
<span>{ownerInfo?.label || '-'}</span>
)}
</span>
<button
type="button"
className="mono-copy-button"
title="Transfer ownership"
aria-label="Transfer ownership"
onClick={handleEditOwner}
>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 20h9" />
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
</button>
</strong>
</div>
<div
className="cursor-pointer"
onClick={() => changeTab('access')}
title="Edit access settings"
>
<span>access</span>
<strong className="inline-flex items-center gap-3">
<span>{accessClasses.find(ac => ac.id === project.access_class)?.display_name || project.access_class}</span>
<span className="text-gray-400">|</span>
<span className="inline-flex items-center gap-1">
<svg className="w-3 h-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M7 10a3 3 0 1 0-3-3 3 3 0 0 0 3 3Zm6 0a3 3 0 1 0-3-3 3 3 0 0 0 3 3ZM1.5 16.5a5.5 5.5 0 0 1 11 0v.5h-11Zm12 0a5.5 5.5 0 0 1 5-5.48 5.53 5.53 0 0 1 .5.02V17h-5.5Z" />
</svg>
{teamCount}
</span>
<span className="inline-flex items-center gap-1">
<span
className="w-3 h-3 svg-mask inline-block"
aria-hidden="true"
style={{
maskImage: 'url(/assets/user.svg)',
WebkitMaskImage: 'url(/assets/user.svg)',
}}
/>
{userCount}
</span>
</strong>
</div>
<div>
<span>created</span>
<strong className="mono-copyable-value" title={formatISO8601(project.created)}>
<span>{formatRelativeTimeRounded(project.created)}</span>
<button
type="button"
className="mono-copy-button"
title="Copy created timestamp (ISO8601)"
aria-label="Copy created timestamp (ISO8601)"
onClick={() => handleCopy(formatISO8601(project.created), 'Created timestamp')}
>
<span
className="mono-copy-icon svg-mask"
style={{ maskImage: 'url(/assets/copy.svg)', WebkitMaskImage: 'url(/assets/copy.svg)' }}
/>
</button>
</strong>
</div>
<div
className="cursor-pointer"
onClick={() => changeTab('domains')}
title="View domains"
>
<span>primary_url</span>
<strong className="mono-copyable-value">
<span>
{project.primary_url ? (
<a href={project.primary_url} target="_blank" rel="noopener noreferrer" className="underline"
onClick={(e) => e.stopPropagation()}
>
{project.primary_url}
</a>
) : '-'}
</span>
{project.primary_url && (
<button
type="button"
className="mono-copy-button"
title="Copy primary URL"
aria-label="Copy primary URL"
onClick={(e) => { e.stopPropagation(); handleCopy(project.primary_url, 'Primary URL'); }}
>
<span
className="mono-copy-icon svg-mask"
style={{ maskImage: 'url(/assets/copy.svg)', WebkitMaskImage: 'url(/assets/copy.svg)' }}
/>
</button>
)}
</strong>
</div>
</div>
<div className="mono-tabbar mb-6">
<div className="flex flex-wrap gap-2">
<button
className={`mono-tab-button ${activeTab === 'overview' ? 'active' : ''}`}
onClick={() => changeTab('overview')}
>
Overview
</button>
<button
className={`mono-tab-button ${activeTab === 'deployments' ? 'active' : ''}`}
onClick={() => changeTab('deployments')}
>
Deployments
</button>
<button
className={`mono-tab-button ${activeTab === 'service-accounts' ? 'active' : ''}`}
onClick={() => changeTab('service-accounts')}
>
Service Accounts
</button>
<button
className={`mono-tab-button ${activeTab === 'env-vars' ? 'active' : ''}`}
onClick={() => changeTab('env-vars')}
>
Environment Variables
</button>
<button
className={`mono-tab-button ${activeTab === 'domains' ? 'active' : ''}`}
onClick={() => changeTab('domains')}
>
Domains
</button>
<button
className={`mono-tab-button ${activeTab === 'extensions' ? 'active' : ''}`}
onClick={() => changeTab('extensions')}
>
Extensions
</button>
<button
className={`mono-tab-button ${activeTab === 'access' ? 'active' : ''}`}
onClick={() => changeTab('access')}
>
Access
</button>
</div>
</div>
<div className="mono-tab-panel">
{activeTab === 'overview' && (
<div>
<h3 className="text-xl font-bold mb-4">Active Deployments</h3>
<ActiveDeploymentsSummary projectName={projectName} />
</div>
)}
{activeTab === 'deployments' && (
<div>
<DeploymentsList projectName={projectName} />
</div>
)}
{activeTab === 'service-accounts' && (
<div>
<ServiceAccountsList projectName={projectName} />
</div>
)}
{activeTab === 'env-vars' && (
<div>
<EnvVarsList projectName={projectName} />
</div>
)}
{activeTab === 'domains' && (
<div>
<DomainsList projectName={projectName} defaultUrl={project.default_url} />
</div>
)}
{activeTab === 'extensions' && (
<div>
<ExtensionsList projectName={projectName} />
</div>
)}
{activeTab === 'access' && (
<div>
<AppUsersList
projectName={projectName}
project={project}
accessClasses={accessClasses}
currentUserEmail={currentUserEmail}
onProjectUpdated={loadProject}
/>
</div>
)}
</div>
<ConfirmDialog
isOpen={confirmDialogOpen}
onClose={() => setConfirmDialogOpen(false)}
onConfirm={handleDeleteConfirm}
title="Delete Project"
message={`Delete project "${project.name}"? Impact: this removes associated deployments, service accounts, and environment variables.`}
confirmText="Delete Project"
variant="danger"
requireConfirmation={true}
confirmationText={project.name}
loading={deleting}
/>
<Modal
isOpen={editingOwner}
onClose={handleCancelEditOwner}
title="Transfer Project Ownership"
maxWidth="max-w-lg"
>
<ModalSection>
<SegmentedRadioGroup
label="Owner Type"
name="owner-type"
value={ownerType}
onChange={setOwnerType}
options={[
{ value: 'user', label: 'USER' },
{ value: 'team', label: 'TEAM' },
]}
/>
{ownerType === 'user' ? (
<div className="form-field">
<label htmlFor="project-owner-user-email" className="mono-label">
Owner User Email
<span className="text-red-300 ml-1">*</span>
</label>
<AutocompleteInput
type="email"
id="project-owner-user-email"
value={ownerUserEmail}
onChange={setOwnerUserEmail}
options={currentUserEmail ? [currentUserEmail] : []}
placeholder="owner@example.com"
onEnter={handleSaveOwner}
/>
</div>
) : (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Owner Team
</label>
<Combobox
value={ownerTeamId}
onChange={setOwnerTeamId}
options={teams.map(t => ({ value: t.id, label: t.name }))}
placeholder="Search teams..."
/>
</div>
)}
<ModalActions>
<Button variant="secondary" onClick={handleCancelEditOwner} disabled={updatingOwner}>
Cancel
</Button>
<Button variant="primary" onClick={handleSaveOwner} loading={updatingOwner}>
Transfer Ownership
</Button>
</ModalActions>
</ModalSection>
</Modal>
</section>
);
}
// Combobox component for team selection with autocomplete
function Combobox({ value, onChange, options, placeholder = '', disabled = false, loading = false }) {
const [isOpen, setIsOpen] = useState(false);
const [inputText, setInputText] = useState('');
const ref = useRef(null);
const selectedOption = options.find(opt => opt.value === value);
const filteredOptions = options.filter(opt =>
opt.label.toLowerCase().includes(inputText.toLowerCase())
);
useEffect(() => {
function handleClickOutside(e) {
if (ref.current && !ref.current.contains(e.target)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Sync input text when value changes externally (e.g. selection or reset)
useEffect(() => {
if (selectedOption) {
setInputText(selectedOption.label);
} else if (!value) {
setInputText('');
}
}, [value, selectedOption]);
return (
<div ref={ref} className="relative">
<input
type="text"
className="mono-input w-full"
placeholder={placeholder}
value={inputText}
onChange={(e) => {
setInputText(e.target.value);
// Clear the selected value when the user types freely
if (value) onChange('');
if (!isOpen) setIsOpen(true);
}}
onFocus={() => {
setIsOpen(true);
}}
disabled={disabled || loading}
/>
{isOpen && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded shadow-lg max-h-48 overflow-y-auto">
{loading ? (
<div className="px-3 py-2 text-sm text-gray-500">Loading...</div>
) : filteredOptions.length === 0 ? (
<div className="px-3 py-2 text-sm text-gray-500">No matches</div>
) : (
filteredOptions.map(opt => (
<button
key={opt.value}
type="button"
className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
opt.value === value ? 'bg-gray-100 dark:bg-gray-700' : ''
}`}
onClick={() => {
onChange(opt.value);
setInputText(opt.label);
setIsOpen(false);
}}
>
{opt.label}
</button>
))
)}
</div>
)}
</div>
);
}
// Access tab component - manages access class and app-level user/team access
function AppUsersList({ projectName, project, accessClasses, currentUserEmail, onProjectUpdated }) {
const [newUserEmail, setNewUserEmail] = useState('');
const [selectedTeamId, setSelectedTeamId] = useState('');
const [teams, setTeams] = useState([]);
const { showToast } = useToast();
const appUsers = project?.app_users || [];
const appTeams = project?.app_teams || [];
const owner = project?.owner || null;
const ownerUserEmail = owner?.email ? owner.email.trim() : null;
const ownerTeamName = owner?.name ? owner.name.trim() : null;
const ownerTeamId = owner?.id || null;
const displayedUsers = (() => {
if (!ownerUserEmail) return appUsers;
const ownerEmailLower = ownerUserEmail.toLowerCase();
const nonOwnerUsers = appUsers.filter((u) => (u.email || '').toLowerCase() !== ownerEmailLower);
return [{ id: owner?.id || `owner-user-${ownerUserEmail}`, email: ownerUserEmail, isOwnerFixed: true }, ...nonOwnerUsers];
})();
const displayedTeams = (() => {
if (!ownerTeamName) return appTeams;
const nonOwnerTeams = appTeams.filter((t) => {
if (ownerTeamId && t.id) return t.id !== ownerTeamId;
return (t.name || '').toLowerCase() !== ownerTeamName.toLowerCase();
});
return [{ id: ownerTeamId || `owner-team-${ownerTeamName}`, name: ownerTeamName, isOwnerFixed: true }, ...nonOwnerTeams];
})();
useEffect(() => {
async function loadTeams() {
try {
const data = await api.getTeams();
setTeams(data || []);
} catch (err) {
console.error('Failed to load teams:', err);
}
}
loadTeams();
}, []);
const handleChangeAccessClass = async (newAccessClass) => {
if (!project || !newAccessClass || newAccessClass === project.access_class) return;
try {
await api.updateProject(projectName, { access_class: newAccessClass });
const ac = accessClasses.find(a => a.id === newAccessClass);
showToast(`Access class updated to ${ac ? ac.display_name : newAccessClass}`, 'success');
onProjectUpdated();
} catch (err) {
showToast(`Failed to update access class: ${err.message}`, 'error');
}
};
const handleAddUser = async () => {
if (!newUserEmail.trim()) {
showToast('User email is required', 'error');
return;
}
try {
const currentUserEmails = appUsers.map(u => u.email);
await api.updateProject(projectName, {
app_users: [...currentUserEmails, newUserEmail.trim()]
});
showToast(`Added app user ${newUserEmail}`, 'success');
setNewUserEmail('');
onProjectUpdated();
} catch (err) {
showToast(`Failed to add app user: ${err.message}`, 'error');
}
};
const handleRemoveUser = async (email) => {
try {
const updatedEmails = appUsers.filter(u => u.email !== email).map(u => u.email);
await api.updateProject(projectName, { app_users: updatedEmails });
showToast(`Removed app user ${email}`, 'success');
onProjectUpdated();
} catch (err) {
showToast(`Failed to remove app user: ${err.message}`, 'error');
}
};
const handleAddTeam = async () => {
if (!selectedTeamId) {
showToast('Team selection is required', 'error');
return;
}
const selectedTeam = teams.find(t => t.id === selectedTeamId);
try {
const currentTeamIds = appTeams.map(t => t.id);
await api.updateProject(projectName, {
app_teams: [...currentTeamIds, selectedTeamId]
});
showToast(`Added app team ${selectedTeam?.name || ''}`, 'success');
onProjectUpdated();
} catch (err) {
showToast(`Failed to add app team: ${err.message}`, 'error');
}
};
const handleRemoveTeam = async (teamId, teamName) => {
try {
const updatedTeamIds = appTeams.filter(t => t.id !== teamId).map(t => t.id);
await api.updateProject(projectName, { app_teams: updatedTeamIds });
showToast(`Removed app team ${teamName}`, 'success');
onProjectUpdated();
} catch (err) {
showToast(`Failed to remove app team: ${err.message}`, 'error');
}
};
return (
<div>
<div className="mb-6 flex items-center gap-3">
<SegmentedRadioGroup
label="Access Class"
name="access-class"
value={project?.access_class}
onChange={handleChangeAccessClass}
options={accessClasses.map(ac => ({ value: ac.id, label: ac.display_name }))}
/>
{accessClasses.find(ac => ac.id === project?.access_class)?.description && (
<span className="text-sm text-gray-400 self-end mb-1">
{accessClasses.find(ac => ac.id === project?.access_class).description}
</span>
)}
</div>
<p className="text-sm text-gray-500 mb-4">
The project owner always has access and is shown as a fixed entry.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="flex justify-between items-center mb-4">
<h4 className="text-lg font-bold">Users ({displayedUsers.length})</h4>
</div>
{displayedUsers.length > 0 ? (
<MonoTableFrame className="mb-4">
<MonoTable>
<MonoTableHead>
<tr>
<MonoTh className="px-6 py-3 text-left">Email</MonoTh>
<MonoTh className="px-6 py-3 text-right">Actions</MonoTh>
</tr>
</MonoTableHead>
<MonoTableBody>
{displayedUsers.map(user => (
<MonoTableRow key={user.id} interactive className="transition-colors">
<MonoTd className="px-6 py-4 text-sm text-gray-900 dark:text-gray-200">
<span className="inline-flex items-center gap-2">
<span>{user.email}</span>
{user.isOwnerFixed && (
<span className="px-2 py-0.5 rounded text-xs border border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-300">
Owner
</span>
)}
</span>
</MonoTd>
<MonoTd className="px-6 py-4">
<div className="flex justify-end">
{user.isOwnerFixed ? (
<span className="text-xs text-gray-500">Always has access</span>
) : (
<Button
variant="danger"
size="sm"
onClick={() => handleRemoveUser(user.email)}
>
Remove
</Button>
)}
</div>
</MonoTd>
</MonoTableRow>
))}
</MonoTableBody>
</MonoTable>
</MonoTableFrame>
) : (
<p className="text-gray-600 dark:text-gray-400 mb-4">No users</p>
)}
<div className="flex justify-end">
<div className="flex gap-2">
<AutocompleteInput
type="email"
value={newUserEmail}
onChange={setNewUserEmail}
options={currentUserEmail ? [currentUserEmail] : []}
placeholder="user@example.com"
className="w-64"
onEnter={handleAddUser}
/>
<Button variant="primary" size="sm" onClick={handleAddUser}>
Add
</Button>
</div>
</div>
</div>
<div>
<div className="flex justify-between items-center mb-4">
<h4 className="text-lg font-bold">Teams ({displayedTeams.length})</h4>
</div>
{displayedTeams.length > 0 ? (
<MonoTableFrame className="mb-4">
<MonoTable>
<MonoTableHead>
<tr>
<MonoTh className="px-6 py-3 text-left">Name</MonoTh>
<MonoTh className="px-6 py-3 text-right">Actions</MonoTh>
</tr>
</MonoTableHead>
<MonoTableBody>
{displayedTeams.map(team => (
<MonoTableRow key={team.id} interactive className="transition-colors">
<MonoTd className="px-6 py-4 text-sm">
<span className="inline-flex items-center gap-2">
<button
type="button"
className="text-gray-900 dark:text-gray-200 underline"
onClick={() => navigate(`/team/${team.name}`)}
>
{team.name}
</button>
{team.isOwnerFixed && (
<span className="px-2 py-0.5 rounded text-xs border border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-300">
Owner
</span>
)}
</span>
</MonoTd>
<MonoTd className="px-6 py-4">
<div className="flex justify-end">
{team.isOwnerFixed ? (
<span className="text-xs text-gray-500">Always has access</span>
) : (
<Button
variant="danger"
size="sm"
onClick={() => handleRemoveTeam(team.id, team.name)}
>
Remove
</Button>
)}
</div>
</MonoTd>
</MonoTableRow>
))}
</MonoTableBody>
</MonoTable>
</MonoTableFrame>
) : (
<p className="text-gray-600 dark:text-gray-400 mb-4">No teams</p>
)}
<div className="flex justify-end">
<div className="flex gap-2 items-center">
<div className="w-64">
<Combobox
value={selectedTeamId}
onChange={setSelectedTeamId}
options={teams.map(t => ({ value: t.id, label: t.name }))}
placeholder="Search teams..."
loading={teams.length === 0}
/>
</div>
<Button variant="primary" size="sm" onClick={handleAddTeam} disabled={teams.length === 0}>
Add
</Button>
</div>
</div>
</div>
</div>
</div>
);
}