Package Dependencies: - Add artifact dependency management system - Add dependency API endpoints (get, resolve, reverse) - Add ensure file parsing for declaring dependencies - Add circular dependency and conflict detection - Add frontend dependency visualization with graph modal - Add migration for artifact_dependencies table Project Settings Page (#65): - Add dedicated settings page for project admins - General settings section (description, visibility) - Access management section (moved from project page) - Danger zone with inline delete confirmation - Add Settings button to project page header
309 lines
9.4 KiB
TypeScript
309 lines
9.4 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { Project } from '../types';
|
|
import {
|
|
getProject,
|
|
updateProject,
|
|
deleteProject,
|
|
getMyProjectAccess,
|
|
UnauthorizedError,
|
|
ForbiddenError,
|
|
} from '../api';
|
|
import { Breadcrumb } from '../components/Breadcrumb';
|
|
import { AccessManagement } from '../components/AccessManagement';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import './ProjectSettingsPage.css';
|
|
|
|
function ProjectSettingsPage() {
|
|
const { projectName } = useParams<{ projectName: string }>();
|
|
const navigate = useNavigate();
|
|
const { user } = useAuth();
|
|
|
|
const [project, setProject] = useState<Project | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState<string | null>(null);
|
|
const [accessDenied, setAccessDenied] = useState(false);
|
|
const [canAdmin, setCanAdmin] = useState(false);
|
|
|
|
// General settings form state
|
|
const [description, setDescription] = useState('');
|
|
const [isPublic, setIsPublic] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// Delete confirmation state
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
const loadData = useCallback(async () => {
|
|
if (!projectName) return;
|
|
|
|
try {
|
|
setLoading(true);
|
|
setAccessDenied(false);
|
|
const [projectData, accessResult] = await Promise.all([
|
|
getProject(projectName),
|
|
getMyProjectAccess(projectName),
|
|
]);
|
|
setProject(projectData);
|
|
setDescription(projectData.description || '');
|
|
setIsPublic(projectData.is_public);
|
|
|
|
const hasAdminAccess = accessResult.access_level === 'admin';
|
|
setCanAdmin(hasAdminAccess);
|
|
|
|
if (!hasAdminAccess) {
|
|
setAccessDenied(true);
|
|
}
|
|
|
|
setError(null);
|
|
} catch (err) {
|
|
if (err instanceof UnauthorizedError) {
|
|
navigate('/login', { state: { from: `/project/${projectName}/settings` } });
|
|
return;
|
|
}
|
|
if (err instanceof ForbiddenError) {
|
|
setAccessDenied(true);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setError(err instanceof Error ? err.message : 'Failed to load project');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [projectName, navigate]);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
const handleSaveSettings = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!projectName) return;
|
|
|
|
try {
|
|
setSaving(true);
|
|
setError(null);
|
|
const updatedProject = await updateProject(projectName, {
|
|
description: description || undefined,
|
|
is_public: isPublic,
|
|
});
|
|
setProject(updatedProject);
|
|
setSuccess('Settings saved successfully');
|
|
setTimeout(() => setSuccess(null), 3000);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to save settings');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteProject = async () => {
|
|
if (!projectName || deleteConfirmText !== projectName) return;
|
|
|
|
try {
|
|
setDeleting(true);
|
|
setError(null);
|
|
await deleteProject(projectName);
|
|
navigate('/');
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to delete project');
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
const handleCancelDelete = () => {
|
|
setShowDeleteConfirm(false);
|
|
setDeleteConfirmText('');
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="project-settings-page">
|
|
<Breadcrumb
|
|
items={[
|
|
{ label: 'Projects', href: '/' },
|
|
{ label: projectName || '', href: `/project/${projectName}` },
|
|
{ label: 'Settings' },
|
|
]}
|
|
/>
|
|
<div className="project-settings-loading">
|
|
<div className="project-settings-spinner" />
|
|
<span>Loading...</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (accessDenied || !canAdmin) {
|
|
return (
|
|
<div className="project-settings-page">
|
|
<Breadcrumb
|
|
items={[
|
|
{ label: 'Projects', href: '/' },
|
|
{ label: projectName || '', href: `/project/${projectName}` },
|
|
{ label: 'Settings' },
|
|
]}
|
|
/>
|
|
<div className="project-settings-access-denied">
|
|
<h2>Access Denied</h2>
|
|
<p>You must be a project admin to access settings.</p>
|
|
{!user && (
|
|
<p style={{ marginTop: '16px' }}>
|
|
<a href="/login" className="btn btn-primary">
|
|
Sign in
|
|
</a>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!project) {
|
|
return (
|
|
<div className="project-settings-page">
|
|
<Breadcrumb
|
|
items={[
|
|
{ label: 'Projects', href: '/' },
|
|
{ label: projectName || '' },
|
|
]}
|
|
/>
|
|
<div className="project-settings-error">Project not found</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="project-settings-page">
|
|
<Breadcrumb
|
|
items={[
|
|
{ label: 'Projects', href: '/' },
|
|
{ label: project.name, href: `/project/${project.name}` },
|
|
{ label: 'Settings' },
|
|
]}
|
|
/>
|
|
|
|
<div className="project-settings-header">
|
|
<h1>Project Settings</h1>
|
|
<p className="project-settings-subtitle">Manage settings for {project.name}</p>
|
|
</div>
|
|
|
|
{error && <div className="project-settings-error">{error}</div>}
|
|
{success && <div className="project-settings-success">{success}</div>}
|
|
|
|
{/* General Settings Section */}
|
|
<div className="project-settings-section">
|
|
<h2>General</h2>
|
|
<form className="project-settings-form" onSubmit={handleSaveSettings}>
|
|
<div className="project-settings-form-group">
|
|
<label htmlFor="description">Description</label>
|
|
<textarea
|
|
id="description"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="Describe your project..."
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
|
|
<div className="project-settings-form-group project-settings-checkbox-group">
|
|
<label className="project-settings-checkbox-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={isPublic}
|
|
onChange={(e) => setIsPublic(e.target.checked)}
|
|
disabled={saving}
|
|
/>
|
|
<span className="project-settings-checkbox-custom" />
|
|
<span>Public project (visible to everyone)</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="project-settings-form-actions">
|
|
<button type="submit" className="project-settings-save-button" disabled={saving}>
|
|
{saving ? (
|
|
<>
|
|
<span className="project-settings-button-spinner" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
'Save Changes'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Access Management Section */}
|
|
<AccessManagement projectName={projectName!} />
|
|
|
|
{/* Danger Zone Section */}
|
|
<div className="project-settings-danger-zone">
|
|
<h2>Danger Zone</h2>
|
|
<div className="project-settings-danger-item">
|
|
<div className="project-settings-danger-info">
|
|
<h3>Delete this project</h3>
|
|
<p>
|
|
Once you delete a project, there is no going back. This will permanently delete the
|
|
project, all packages, artifacts, and tags.
|
|
</p>
|
|
</div>
|
|
{!showDeleteConfirm && (
|
|
<button
|
|
className="project-settings-delete-button"
|
|
onClick={() => setShowDeleteConfirm(true)}
|
|
disabled={deleting}
|
|
>
|
|
Delete Project
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{showDeleteConfirm && (
|
|
<div className="project-settings-delete-confirm">
|
|
<p>
|
|
Type <strong>{project.name}</strong> to confirm deletion:
|
|
</p>
|
|
<input
|
|
type="text"
|
|
className="project-settings-delete-confirm-input"
|
|
value={deleteConfirmText}
|
|
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
|
placeholder={project.name}
|
|
disabled={deleting}
|
|
autoFocus
|
|
/>
|
|
<div className="project-settings-delete-confirm-actions">
|
|
<button
|
|
className="project-settings-confirm-delete-button"
|
|
onClick={handleDeleteProject}
|
|
disabled={deleting || deleteConfirmText !== project.name}
|
|
>
|
|
{deleting ? (
|
|
<>
|
|
<span className="project-settings-delete-spinner" />
|
|
Deleting...
|
|
</>
|
|
) : (
|
|
'Yes, delete this project'
|
|
)}
|
|
</button>
|
|
<button
|
|
className="project-settings-cancel-button"
|
|
onClick={handleCancelDelete}
|
|
disabled={deleting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default ProjectSettingsPage;
|