security sections are fully functional
This commit is contained in:
@@ -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'] }),
|
||||
})
|
||||
}
|
||||
@@ -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:<TOKEN>@{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 <TOKEN></code></p>
|
||||
<p>Git: <code className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">git clone http://x-token:<TOKEN>@{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 }) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user