security sections are fully functional

This commit is contained in:
2026-05-07 15:06:45 +02:00
parent 5e60b814ed
commit 53aa5cbbf5
20 changed files with 946 additions and 41 deletions
+79
View File
@@ -0,0 +1,79 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
import type { DeployKey, AccessToken } from '../../types/api'
// ─── Deploy keys ──────────────────────────────────────────────────────────────
const deployKeySchema = z.object({
id: z.number(),
title: z.string(),
readOnly: z.boolean(),
createdAt: z.string(),
token: z.string().optional(),
})
const deployKeysSchema = z.array(deployKeySchema)
export function useDeployKeys(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'keys'],
queryFn: () => api.get<DeployKey[]>(`/api/v1/repos/${owner}/${repo}/keys`, deployKeysSchema),
enabled: Boolean(owner && repo),
})
}
export function useCreateDeployKey(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: { title: string; readOnly: boolean }) =>
api.post<DeployKey>(`/api/v1/repos/${owner}/${repo}/keys`, deployKeySchema, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'keys'] }),
})
}
export function useDeleteDeployKey(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (keyId: number) =>
api.delete(`/api/v1/repos/${owner}/${repo}/keys/${keyId}`, z.any()),
onSuccess: () => qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'keys'] }),
})
}
// ─── Access tokens ────────────────────────────────────────────────────────────
const accessTokenSchema = z.object({
id: z.number(),
title: z.string(),
scopes: z.string(),
expiresAt: z.string().nullable().optional(),
createdAt: z.string(),
token: z.string().optional(),
})
const accessTokensSchema = z.array(accessTokenSchema)
export function useAccessTokens(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'tokens'],
queryFn: () => api.get<AccessToken[]>(`/api/v1/repos/${owner}/${repo}/tokens`, accessTokensSchema),
enabled: Boolean(owner && repo),
})
}
export function useCreateAccessToken(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: { title: string; scopes: string; expiresAt?: string }) =>
api.post<AccessToken>(`/api/v1/repos/${owner}/${repo}/tokens`, accessTokenSchema, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'tokens'] }),
})
}
export function useDeleteAccessToken(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (tokenId: number) =>
api.delete(`/api/v1/repos/${owner}/${repo}/tokens/${tokenId}`, z.any()),
onSuccess: () => qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'tokens'] }),
})
}
+316 -1
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'
import { useRepo, useUpdateRepo, useDeleteRepo, useUploadRepoAvatar } from '../api/queries/repos'
import { useRepoMembers, useAddMember, useUpdateMember, useRemoveMember } from '../api/queries/members'
import { useDeployKeys, useCreateDeployKey, useDeleteDeployKey, useAccessTokens, useCreateAccessToken, useDeleteAccessToken } from '../api/queries/keys'
import { useRecentRepos } from '../hooks/useRecentRepos'
import { useAuth } from '../contexts/AuthContext'
import { Skeleton } from '../ui/Skeleton'
@@ -132,7 +133,9 @@ export default function RepoSettingsPage() {
</div>
{section === 'repository-details' && <RepositoryDetailsSection owner={owner} repo={repoName} />}
{section === 'repository-permissions' && <RepositoryPermissionsSection owner={owner} repo={repoName} />}
{section !== 'repository-details' && section !== 'repository-permissions' && <ComingSoon sectionId={section} />}
{section === 'access-keys' && <AccessKeysSection owner={owner} repo={repoName} />}
{section === 'access-tokens' && <AccessTokensSection owner={owner} repo={repoName} />}
{!['repository-details','repository-permissions','access-keys','access-tokens'].includes(section) && <ComingSoon sectionId={section} />}
</main>
</div>
)
@@ -709,6 +712,318 @@ function RepositoryPermissionsSection({ owner, repo }: { owner: string; repo: st
)
}
// ─── Shared helpers ───────────────────────────────────────────────────────────
function TokenReveal({ token, onDismiss }: { token: string; onDismiss: () => void }) {
const [copied, setCopied] = useState(false)
function copy() {
navigator.clipboard.writeText(token)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="border border-[var(--c-success)] rounded-lg overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 bg-[var(--c-surface-raised)] border-b border-[var(--c-success)]/30">
<svg width="14" height="14" fill="none" stroke="var(--c-success)" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<span className="text-sm font-semibold text-[var(--c-success)]">Token created copy it now, it won't be shown again</span>
</div>
<div className="p-4 space-y-3 bg-[var(--c-surface)]">
<div className="flex items-center gap-2">
<code className="flex-1 font-mono text-xs bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded px-3 py-2 text-[var(--c-text)] break-all">
{token}
</code>
<button onClick={copy}
className="shrink-0 px-3 py-2 rounded border border-[var(--c-border)] text-xs font-medium text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors">
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<button onClick={onDismiss} className="text-xs text-[var(--c-muted)] hover:text-[var(--c-text)] underline">
I've copied it, dismiss
</button>
</div>
</div>
)
}
// ─── Access keys (deploy keys) section ───────────────────────────────────────
function AccessKeysSection({ owner, repo }: { owner: string; repo: string }) {
const { data: keys, isLoading } = useDeployKeys(owner, repo)
const createKey = useCreateDeployKey(owner, repo)
const deleteKey = useDeleteDeployKey(owner, repo)
const [title, setTitle] = useState('')
const [readOnly, setReadOnly] = useState(true)
const [newToken, setNewToken] = useState<string | null>(null)
const [formError, setFormError] = useState('')
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
setFormError('')
if (!title.trim()) return
try {
const key = await createKey.mutateAsync({ title: title.trim(), readOnly })
if (key.token) setNewToken(key.token)
setTitle('')
setReadOnly(true)
} catch (err) {
setFormError((err as Error).message)
}
}
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Access keys</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">
Deploy keys grant git clone and push access to this repository over HTTP. Each key is shown only once.
</p>
<div className="mt-3 p-3 rounded-lg bg-[var(--c-surface-raised)] border border-[var(--c-border)] text-xs text-[var(--c-muted)]">
Use as git credentials: <code className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">git clone http://x-deploy-key:&lt;TOKEN&gt;@{window.location.hostname}/{owner}/{repo}.git</code>
</div>
</div>
<div className="border-t border-[var(--c-border)]" />
{/* Token reveal banner */}
{newToken && <TokenReveal token={newToken} onDismiss={() => setNewToken(null)} />}
{/* Existing keys */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex items-center justify-between">
<h2 className="text-sm font-semibold text-[var(--c-text)]">Active keys</h2>
<span className="text-xs text-[var(--c-muted)]">{keys?.length ?? 0} key{keys?.length !== 1 ? 's' : ''}</span>
</div>
{isLoading ? (
<div className="p-4 space-y-3">
{[1, 2].map(i => <div key={i} className="flex gap-3"><Skeleton className="h-4 w-32" /><Skeleton className="h-4 w-16 ml-auto" /></div>)}
</div>
) : !keys?.length ? (
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">No deploy keys yet.</div>
) : (
<ul className="divide-y divide-[var(--c-border)]">
{keys.map(key => (
<li key={key.id} className="flex items-center gap-3 px-4 py-3">
<svg width="16" height="16" fill="none" stroke="var(--c-muted)" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 0 1 21.75 8.25Z" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--c-text)]">{key.title}</p>
<p className="text-xs text-[var(--c-muted)] mt-0.5">
{key.readOnly ? 'Read-only' : 'Read & write'} · Added {new Date(key.createdAt).toLocaleDateString()}
</p>
</div>
<span className={`text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full ${key.readOnly ? 'bg-[var(--c-surface-muted)] text-[var(--c-muted)]' : 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'}`}>
{key.readOnly ? 'Read' : 'Write'}
</span>
<button
onClick={() => deleteKey.mutate(key.id)}
disabled={deleteKey.isPending}
className="text-[var(--c-danger)] hover:text-[var(--c-danger-dark)] p-1 rounded hover:bg-[var(--c-danger-tint)] transition-colors disabled:opacity-40"
title="Revoke key"
>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</li>
))}
</ul>
)}
</div>
{/* Create form */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)]">
<h2 className="text-sm font-semibold text-[var(--c-text)]">Add a deploy key</h2>
</div>
<form onSubmit={handleCreate} className="p-4 space-y-4">
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Label</label>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. CI/CD pipeline"
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] focus:ring-1 focus:ring-[var(--c-brand-focus)]" />
</div>
<label className="flex items-start gap-2.5 cursor-pointer">
<input type="checkbox" checked={readOnly} onChange={e => setReadOnly(e.target.checked)}
className="mt-0.5 w-4 h-4 accent-[var(--c-brand)]" />
<div>
<span className="text-sm font-medium text-[var(--c-text)]">Read-only</span>
<p className="text-xs text-[var(--c-muted)] mt-0.5">Uncheck to allow pushes with this key.</p>
</div>
</label>
{formError && <p className="text-xs text-[var(--c-danger)]">{formError}</p>}
<button type="submit" disabled={createKey.isPending || !title.trim()}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
{createKey.isPending ? 'Generating…' : 'Generate key'}
</button>
</form>
</div>
</div>
)
}
// ─── Access tokens section ────────────────────────────────────────────────────
function AccessTokensSection({ owner, repo }: { owner: string; repo: string }) {
const { data: tokens, isLoading } = useAccessTokens(owner, repo)
const createToken = useCreateAccessToken(owner, repo)
const deleteToken = useDeleteAccessToken(owner, repo)
const [title, setTitle] = useState('')
const [scopes, setScopes] = useState<string[]>(['read'])
const [expiresAt, setExpiresAt] = useState('')
const [newToken, setNewToken] = useState<string | null>(null)
const [formError, setFormError] = useState('')
function toggleScope(scope: string) {
setScopes(prev =>
prev.includes(scope) ? prev.filter(s => s !== scope) : [...prev, scope]
)
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
setFormError('')
if (!title.trim() || scopes.length === 0) return
try {
const tok = await createToken.mutateAsync({
title: title.trim(),
scopes: scopes.join(','),
expiresAt: expiresAt || undefined,
})
if (tok.token) setNewToken(tok.token)
setTitle('')
setScopes(['read'])
setExpiresAt('')
} catch (err) {
setFormError((err as Error).message)
}
}
const scopeLabels: Record<string, { label: string; description: string }> = {
read: { label: 'Read', description: 'Read repo contents, branches, commits, PRs, issues' },
write: { label: 'Write', description: 'Push code, create PRs and issues' },
}
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Access tokens</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">
Tokens grant programmatic access to this repository's API and git operations. Each token is shown only once.
</p>
<div className="mt-3 p-3 rounded-lg bg-[var(--c-surface-raised)] border border-[var(--c-border)] text-xs text-[var(--c-muted)] space-y-1">
<p>API: <code className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">Authorization: Bearer &lt;TOKEN&gt;</code></p>
<p>Git: <code className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">git clone http://x-token:&lt;TOKEN&gt;@{window.location.hostname}/{owner}/{repo}.git</code></p>
</div>
</div>
<div className="border-t border-[var(--c-border)]" />
{newToken && <TokenReveal token={newToken} onDismiss={() => setNewToken(null)} />}
{/* Token list */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex items-center justify-between">
<h2 className="text-sm font-semibold text-[var(--c-text)]">Active tokens</h2>
<span className="text-xs text-[var(--c-muted)]">{tokens?.length ?? 0} token{tokens?.length !== 1 ? 's' : ''}</span>
</div>
{isLoading ? (
<div className="p-4 space-y-3">
{[1, 2].map(i => <div key={i} className="flex gap-3"><Skeleton className="h-4 w-40" /><Skeleton className="h-4 w-20 ml-auto" /></div>)}
</div>
) : !tokens?.length ? (
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">No access tokens yet.</div>
) : (
<ul className="divide-y divide-[var(--c-border)]">
{tokens.map(tok => {
const expired = tok.expiresAt && new Date(tok.expiresAt) < new Date()
return (
<li key={tok.id} className="flex items-center gap-3 px-4 py-3">
<svg width="16" height="16" fill="none" stroke="var(--c-muted)" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--c-text)]">{tok.title}</p>
<p className="text-xs text-[var(--c-muted)] mt-0.5">
Scopes: {tok.scopes}
{tok.expiresAt && (
<span className={expired ? ' · Expired' : ` · Expires ${new Date(tok.expiresAt).toLocaleDateString()}`} />
)}
{!tok.expiresAt && ' · No expiry'}
</p>
</div>
{expired && (
<span className="text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-[var(--c-danger-tint)] text-[var(--c-danger)]">Expired</span>
)}
<button
onClick={() => deleteToken.mutate(tok.id)}
disabled={deleteToken.isPending}
className="text-[var(--c-danger)] hover:text-[var(--c-danger-dark)] p-1 rounded hover:bg-[var(--c-danger-tint)] transition-colors disabled:opacity-40"
title="Revoke token"
>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</li>
)
})}
</ul>
)}
</div>
{/* Create form */}
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)]">
<h2 className="text-sm font-semibold text-[var(--c-text)]">Create an access token</h2>
</div>
<form onSubmit={handleCreate} className="p-4 space-y-4">
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Token name</label>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. GitHub Actions"
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] focus:ring-1 focus:ring-[var(--c-brand-focus)]" />
</div>
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-2">Scopes</label>
<div className="space-y-2">
{Object.entries(scopeLabels).map(([key, val]) => (
<label key={key} className="flex items-start gap-2.5 cursor-pointer">
<input type="checkbox" checked={scopes.includes(key)} onChange={() => toggleScope(key)}
className="mt-0.5 w-4 h-4 accent-[var(--c-brand)]" />
<div>
<span className="text-sm font-medium text-[var(--c-text)]">{val.label}</span>
<p className="text-xs text-[var(--c-muted)]">{val.description}</p>
</div>
</label>
))}
</div>
</div>
<div>
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Expiry date <span className="text-[var(--c-subtle)]">(optional)</span></label>
<input type="date" value={expiresAt} onChange={e => setExpiresAt(e.target.value)}
min={new Date().toISOString().split('T')[0]}
className="border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]" />
</div>
{formError && <p className="text-xs text-[var(--c-danger)]">{formError}</p>}
<button type="submit" disabled={createToken.isPending || !title.trim() || scopes.length === 0}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
{createToken.isPending ? 'Generating' : 'Generate token'}
</button>
</form>
</div>
</div>
)
}
// ─── Coming soon ──────────────────────────────────────────────────────────────
function ComingSoon({ sectionId }: { sectionId: SectionId }) {
+17
View File
@@ -92,6 +92,23 @@ export interface SSHKey {
createdAt: string
}
export interface DeployKey {
id: number
title: string
readOnly: boolean
createdAt: string
token?: string
}
export interface AccessToken {
id: number
title: string
scopes: string
expiresAt?: string | null
createdAt: string
token?: string
}
export interface RepoMember {
userId: number
username: string