feat(FN-007): add repository connection frontend UI

- Create RepositoryListPage with connection status display
- Create RepositoryDetailPage with SSH key management
- Add repository API methods to client
- Update router with repository routes
- Add Repository types to frontend
This commit is contained in:
2026-05-16 13:42:08 +02:00
parent 3728c245d3
commit ed642dcc66
5 changed files with 410 additions and 3 deletions
+193
View File
@@ -0,0 +1,193 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../api/client.ts'
export default function RepositoryDetailPage() {
const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [providerKind, setProviderKind] = useState('github')
const [credentialPayload, setCredentialPayload] = useState('')
const [showSshKey, setShowSshKey] = useState(false)
const { data: repository } = useQuery({
queryKey: ['repository', projectId, repoId],
queryFn: () => api.getRepository(projectId!, repoId!),
enabled: !!projectId && !!repoId,
})
const { data: connections } = useQuery({
queryKey: ['repository-connections', projectId],
queryFn: () => api.getRepositoryConnections(projectId!),
enabled: !!projectId,
})
const createConnectionMutation = useMutation({
mutationFn: (data: {
repository_id: string
provider_kind: string
credential_kind: string
credential_payload: string
}) => api.createRepositoryConnection(projectId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repository-connections', projectId] })
setCredentialPayload('')
},
})
const deleteConnectionMutation = useMutation({
mutationFn: (connectionId: string) =>
api.deleteRepositoryConnection(projectId!, connectionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repository-connections', projectId] })
},
})
const generateSshKeyMutation = useMutation({
mutationFn: (connectionId: string) =>
api.generateSshKey(projectId!, connectionId),
onSuccess: () => {
setShowSshKey(true)
},
})
const handleCreateConnection = (e: React.FormEvent) => {
e.preventDefault()
createConnectionMutation.mutate({
repository_id: repoId!,
provider_kind: providerKind,
credential_kind: 'access_token',
credential_payload: credentialPayload,
})
}
const repoConnections = connections?.filter(
(conn) => conn.repository_id === repoId
)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{repository?.name || 'Repository'}</h1>
<button
onClick={() => navigate(`/projects/${projectId}/repositories`)}
className="text-indigo-600 hover:text-indigo-900"
>
Back to Repositories
</button>
</div>
{repository && (
<div className="bg-white p-6 rounded-lg shadow">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Git URL</label>
<p className="mt-1 text-sm text-gray-900">{repository.git_url}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Provider</label>
<p className="mt-1 text-sm text-gray-900">{repository.provider_type}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Default Branch</label>
<p className="mt-1 text-sm text-gray-900">{repository.default_branch}</p>
</div>
</div>
</div>
)}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">Create Connection</h2>
<form onSubmit={handleCreateConnection} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Provider</label>
<select
value={providerKind}
onChange={(e) => setProviderKind(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option value="github">GitHub</option>
<option value="gitlab">GitLab</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Access Token</label>
<input
type="password"
value={credentialPayload}
onChange={(e) => setCredentialPayload(e.target.value)}
placeholder="ghp_xxxxxxxxxxxx"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
required
/>
</div>
<button
type="submit"
disabled={createConnectionMutation.isPending}
className="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
>
{createConnectionMutation.isPending ? 'Creating...' : 'Create Connection'}
</button>
</form>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">Connections</h2>
{repoConnections?.length === 0 ? (
<p className="text-gray-500">No connections yet.</p>
) : (
<div className="space-y-4">
{repoConnections?.map((conn) => (
<div
key={conn.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div>
<p className="font-medium">{conn.provider_kind}</p>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
conn.connection_status === 'connected'
? 'bg-green-100 text-green-800'
: conn.connection_status === 'error'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{conn.connection_status}
</span>
</div>
<div className="flex space-x-2">
<button
onClick={() => generateSshKeyMutation.mutate(conn.id)}
className="text-indigo-600 hover:text-indigo-900"
>
Generate SSH Key
</button>
<button
onClick={() => deleteConnectionMutation.mutate(conn.id)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
{showSshKey && generateSshKeyMutation.data && (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">SSH Public Key</h2>
<pre className="bg-gray-100 p-4 rounded text-sm overflow-x-auto">
{generateSshKeyMutation.data.public_key}
</pre>
<p className="text-sm text-gray-600 mt-2">
Add this key to your repository's deploy keys.
</p>
</div>
)}
</div>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../api/client.ts'
export default function RepositoryListPage() {
const { projectId } = useParams<{ projectId: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [gitUrl, setGitUrl] = useState('')
const [providerType, setProviderType] = useState('github')
const { data: repositories, isLoading } = useQuery({
queryKey: ['repositories', projectId],
queryFn: () => api.getRepositories(projectId!),
enabled: !!projectId,
})
const createMutation = useMutation({
mutationFn: (data: { name: string; git_url: string; provider_type: string }) =>
api.createRepository(projectId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repositories', projectId] })
setName('')
setGitUrl('')
},
})
const deleteMutation = useMutation({
mutationFn: (repoId: string) => api.deleteRepository(projectId!, repoId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repositories', projectId] })
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
createMutation.mutate({ name, git_url: gitUrl, provider_type: providerType })
}
if (isLoading) return <div>Loading repositories...</div>
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Repositories</h1>
<form onSubmit={handleSubmit} className="space-y-4 bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold">Add Repository</h2>
<div>
<label className="block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Git URL</label>
<input
type="url"
value={gitUrl}
onChange={(e) => setGitUrl(e.target.value)}
placeholder="https://github.com/user/repo.git"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Provider</label>
<select
value={providerType}
onChange={(e) => setProviderType(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option value="github">GitHub</option>
<option value="gitlab">GitLab</option>
<option value="generic">Generic</option>
</select>
</div>
<button
type="submit"
disabled={createMutation.isPending}
className="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
>
{createMutation.isPending ? 'Adding...' : 'Add Repository'}
</button>
</form>
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-lg font-semibold mb-4">Repository List</h2>
{repositories?.length === 0 ? (
<p className="text-gray-500">No repositories yet.</p>
) : (
<div className="space-y-4">
{repositories?.map((repo) => (
<div
key={repo.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 cursor-pointer"
onClick={() => navigate(`/projects/${projectId}/repositories/${repo.id}`)}
>
<div>
<h3 className="font-medium">{repo.name}</h3>
<p className="text-sm text-gray-500">{repo.git_url}</p>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{repo.provider_type}
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation()
deleteMutation.mutate(repo.id)
}}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}