feat(skills): GitHub PAT support for private skill repos + delete by source

- Add optional authToken to skill import for GitHub private repos
- Store PAT as encrypted company secret (skill-pat:{skillId})
- Thread auth token through ghFetch, fetchText, fetchJson, and all GitHub resolution functions
- Add PATCH /companies/:companyId/skills/:skillId/auth for managing PAT per skill
- Add DELETE /companies/:companyId/skills/by-source for bulk deleting skills from a repo
- Preserve sourceAuthSecretId across skill re-imports/updates
- UI: Add PAT input field in import form for GitHub URLs
- UI: Add SkillAuthSection with ShieldCheck icon for viewing/updating/removing PAT
- UI: Add trash icon next to source label for delete-by-source

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 15:35:47 -04:00
parent 6d63a4df45
commit d9341795b0
9 changed files with 386 additions and 32 deletions
+15 -2
View File
@@ -36,10 +36,23 @@ export const companySkillsApi = {
`/companies/${encodeURIComponent(companyId)}/skills`,
payload,
),
importFromSource: (companyId: string, source: string) =>
importFromSource: (companyId: string, source: string, authToken?: string) =>
api.post<CompanySkillImportResult>(
`/companies/${encodeURIComponent(companyId)}/skills/import`,
{ source },
{ source, ...(authToken ? { authToken } : {}) },
),
updateAuth: (companyId: string, skillId: string, authToken: string | null) =>
api.patch<CompanySkill>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/auth`,
{ authToken },
),
remove: (companyId: string, skillId: string) =>
api.delete<CompanySkill>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`,
),
removeBySource: (companyId: string, sourceLocator: string) =>
api.delete<{ deleted: CompanySkill[] }>(
`/companies/${encodeURIComponent(companyId)}/skills/by-source?source=${encodeURIComponent(sourceLocator)}`,
),
scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) =>
api.post<CompanySkillProjectScanResult>(
+153 -10
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type SVGProps } from "react";
import { Link, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
CompanySkill,
CompanySkillCreateRequest,
CompanySkillDetail,
CompanySkillFileDetail,
@@ -52,6 +53,7 @@ import {
RefreshCw,
Save,
Search,
ShieldCheck,
Trash2,
} from "lucide-react";
@@ -487,6 +489,103 @@ function SkillList({
);
}
function SkillAuthSection({
companyId,
skillId,
hasAuth,
}: {
companyId: string;
skillId: string;
hasAuth: boolean;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [editing, setEditing] = useState(false);
const [token, setToken] = useState("");
const updateAuth = useMutation({
mutationFn: (authToken: string | null) =>
companySkillsApi.updateAuth(companyId, skillId, authToken),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(companyId, skillId) });
setEditing(false);
setToken("");
pushToast({ tone: "success", title: "Auth updated" });
},
onError: (error) => {
pushToast({
tone: "error",
title: "Failed to update auth",
body: error instanceof Error ? error.message : "Unknown error",
});
},
});
return (
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Auth</span>
{!editing ? (
<>
{hasAuth ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(true)}
>
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" />
PAT configured
</Button>
<button
className="inline-flex items-center text-muted-foreground/50 hover:text-destructive transition-colors"
onClick={() => updateAuth.mutate(null)}
disabled={updateAuth.isPending}
title="Remove PAT"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(true)}
>
Add PAT
</Button>
)}
</>
) : (
<>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="GitHub Personal Access Token"
className="flex-1 min-w-[200px] rounded-md border border-border px-2 py-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/50"
autoComplete="off"
autoFocus
/>
<Button
size="sm"
onClick={() => updateAuth.mutate(token.trim())}
disabled={!token.trim() || updateAuth.isPending}
>
{updateAuth.isPending ? "Saving..." : "Save"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setEditing(false); setToken(""); }}
>
Cancel
</Button>
</>
)}
</div>
);
}
function SkillPane({
loading,
detail,
@@ -525,7 +624,7 @@ function SkillPane({
checkUpdatesPending: boolean;
onInstallUpdate: () => void;
installUpdatePending: boolean;
onDelete: () => void;
onDelete: (sourceLocator?: string | null) => void;
deletePending: boolean;
onSave: () => void;
savePending: boolean;
@@ -572,7 +671,7 @@ function SkillPane({
<Button
variant="ghost"
size="sm"
onClick={onDelete}
onClick={() => onDelete()}
disabled={deletePending}
title={removeDisabledReason ?? undefined}
>
@@ -612,8 +711,23 @@ function SkillPane({
) : (
<span className="truncate">{source.label}</span>
)}
<button
className="inline-flex items-center text-muted-foreground/50 hover:text-destructive transition-colors"
onClick={() => onDelete(detail.sourceLocator)}
disabled={deletePending}
title={detail.sourceLocator ? "Remove this source and all its skills" : "Remove this skill"}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</span>
</div>
{(detail.sourceType === "github" || detail.sourceType === "skills_sh") && (
<SkillAuthSection
companyId={detail.companyId}
skillId={detail.id}
hasAuth={Boolean((detail.metadata as Record<string, unknown> | null)?.sourceAuthSecretId)}
/>
)}
{detail.sourceType === "github" && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Pin</span>
@@ -762,6 +876,7 @@ export function CompanySkills() {
const { pushToast } = useToast();
const [skillFilter, setSkillFilter] = useState("");
const [source, setSource] = useState("");
const [importAuthToken, setImportAuthToken] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [emptySourceHelpOpen, setEmptySourceHelpOpen] = useState(false);
const [expandedSkillId, setExpandedSkillId] = useState<string | null>(null);
@@ -886,8 +1001,17 @@ export function CompanySkills() {
}
}
function handleDeleteSkill(sourceLocator?: string | null) {
if (sourceLocator) {
deleteSkill.mutate(sourceLocator);
} else {
openDeleteDialog();
}
}
const importSkill = useMutation({
mutationFn: (importSource: string) => companySkillsApi.importFromSource(selectedCompanyId!, importSource),
mutationFn: ({ importSource, authToken }: { importSource: string; authToken?: string }) =>
companySkillsApi.importFromSource(selectedCompanyId!, importSource, authToken),
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) });
if (result.imported[0]) navigate(skillRoute(result.imported[0].id));
@@ -900,6 +1024,7 @@ export function CompanySkills() {
pushToast({ tone: "warn", title: "Import warnings", body: result.warnings[0] });
}
setSource("");
setImportAuthToken("");
},
onError: (error) => {
pushToast({
@@ -1026,8 +1151,13 @@ export function CompanySkills() {
});
const deleteSkill = useMutation({
mutationFn: () => companySkillsApi.delete(selectedCompanyId!, deleteTargetSkillId!),
onSuccess: async (skill) => {
mutationFn: (sourceLocator?: string | null): Promise<{ deleted: CompanySkill[] }> => {
if (sourceLocator) {
return companySkillsApi.removeBySource(selectedCompanyId!, sourceLocator);
}
return companySkillsApi.remove(selectedCompanyId!, deleteTargetSkillId!).then((skill) => ({ deleted: [skill] }));
},
onSuccess: async (result) => {
closeDeleteDialog(false);
setDisplayedDetail(null);
setDisplayedFile(null);
@@ -1048,10 +1178,10 @@ export function CompanySkills() {
type: "active",
});
navigate("/skills", { replace: true });
const count = result.deleted.length;
pushToast({
tone: "success",
title: "Skill removed",
body: `${skill.name} was removed from the company skill library.`,
title: `${count} skill${count === 1 ? "" : "s"} removed`,
});
},
onError: (error) => {
@@ -1073,7 +1203,8 @@ export function CompanySkills() {
setEmptySourceHelpOpen(true);
return;
}
importSkill.mutate(trimmedSource);
const token = importAuthToken.trim() || undefined;
importSkill.mutate({ importSource: trimmedSource, authToken: token });
}
return (
@@ -1115,7 +1246,7 @@ export function CompanySkills() {
</Button>
<Button
variant="destructive"
onClick={() => deleteSkill.mutate()}
onClick={() => deleteSkill.mutate(undefined)}
disabled={deleteSkill.isPending || !deleteTargetSkillId}
>
{deleteSkill.isPending ? "Removing..." : "Remove skill"}
@@ -1220,6 +1351,18 @@ export function CompanySkills() {
{importSkill.isPending ? <RefreshCw className="h-4 w-4 animate-spin" /> : "Add"}
</Button>
</div>
{source.trim().length > 0 && /github\.com|^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+/.test(source.trim()) && (
<div className="mt-1 flex items-center gap-2 border-b border-border pb-2">
<input
type="password"
value={importAuthToken}
onChange={(event) => setImportAuthToken(event.target.value)}
placeholder="GitHub PAT (optional, for private repos)"
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
autoComplete="off"
/>
</div>
)}
{scanStatusMessage && (
<p className="mt-3 text-xs text-muted-foreground">
{scanStatusMessage}
@@ -1284,7 +1427,7 @@ export function CompanySkills() {
checkUpdatesPending={updateStatusQuery.isFetching}
onInstallUpdate={() => installUpdate.mutate()}
installUpdatePending={installUpdate.isPending}
onDelete={openDeleteDialog}
onDelete={handleDeleteSkill}
deletePending={deleteSkill.isPending}
onSave={() => saveFile.mutate()}
savePending={saveFile.isPending}