forked from farhoodlabs/paperclip
Merge branches 'feat/skills-gitops-complete', 'feat/company-portability-complete', 'feat/board-approval-markdown' and 'fix/remove-paperclip-dev-skill' into local
This commit is contained in:
@@ -36,10 +36,15 @@ 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 },
|
||||
),
|
||||
scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) =>
|
||||
api.post<CompanySkillProjectScanResult>(
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { ApprovalPayloadRenderer, approvalLabel } from "./ApprovalPayload";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to }: { children: ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: { get: vi.fn() },
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function withProviders(children: ReactNode) {
|
||||
return (
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("approvalLabel", () => {
|
||||
it("uses payload titles for generic board approvals", () => {
|
||||
expect(
|
||||
@@ -35,17 +55,19 @@ describe("ApprovalPayloadRenderer", () => {
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
payload={{
|
||||
title: "Reply with an ASCII frog",
|
||||
summary: "Board asked for approval before posting the frog.",
|
||||
recommendedAction: "Approve the frog reply.",
|
||||
nextActionOnApproval: "Post the frog comment on the issue.",
|
||||
risks: ["The frog might be too powerful."],
|
||||
proposedComment: "(o)<",
|
||||
}}
|
||||
/>,
|
||||
withProviders(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
payload={{
|
||||
title: "Reply with an ASCII frog",
|
||||
summary: "Board asked for approval before posting the frog.",
|
||||
recommendedAction: "Approve the frog reply.",
|
||||
nextActionOnApproval: "Post the frog comment on the issue.",
|
||||
risks: ["The frog might be too powerful."],
|
||||
proposedComment: "(o)<",
|
||||
}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -67,14 +89,16 @@ describe("ApprovalPayloadRenderer", () => {
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
hidePrimaryTitle
|
||||
payload={{
|
||||
title: "Reply with an ASCII frog",
|
||||
summary: "Board asked for approval before posting the frog.",
|
||||
}}
|
||||
/>,
|
||||
withProviders(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
hidePrimaryTitle
|
||||
payload={{
|
||||
title: "Reply with an ASCII frog",
|
||||
summary: "Board asked for approval before posting the frog.",
|
||||
}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -86,3 +110,90 @@ describe("ApprovalPayloadRenderer", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("BoardApprovalPayloadContent markdown rendering", () => {
|
||||
it("renders a ## header in summary as an h2 element", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
withProviders(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
payload={{ summary: "## Analysis\n\nThis is the summary." }}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(html).toContain("<h2");
|
||||
expect(html).toContain("Analysis");
|
||||
expect(html).toContain("This is the summary.");
|
||||
});
|
||||
|
||||
it("renders a bulleted list in summary as ul and li elements", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
withProviders(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
payload={{ summary: "- Item one\n- Item two" }}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(html).toContain("<ul");
|
||||
expect(html).toContain("<li");
|
||||
expect(html).toContain("Item one");
|
||||
expect(html).toContain("Item two");
|
||||
});
|
||||
|
||||
it("renders a ## header in recommendedAction as an h2 element", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
withProviders(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
payload={{ recommendedAction: "## Approve\n\nApprove this action." }}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(html).toContain("<h2");
|
||||
expect(html).toContain("Approve");
|
||||
expect(html).toContain("Approve this action.");
|
||||
});
|
||||
|
||||
it("renders a bulleted list in recommendedAction as ul and li elements", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
withProviders(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
payload={{ recommendedAction: "- Step one\n- Step two" }}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(html).toContain("<ul");
|
||||
expect(html).toContain("<li");
|
||||
expect(html).toContain("Step one");
|
||||
});
|
||||
|
||||
it("renders plain prose summary without adding list or heading markup", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
withProviders(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
payload={{ summary: "This is a simple one-line summary." }}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(html).toContain("This is a simple one-line summary.");
|
||||
expect(html).not.toContain("<ul");
|
||||
expect(html).not.toContain("<h2");
|
||||
});
|
||||
|
||||
it("renders plain prose recommendedAction without markdown markup", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
withProviders(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
payload={{ recommendedAction: "Approve the deployment." }}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(html).toContain("Approve the deployment.");
|
||||
expect(html).not.toContain("<ul");
|
||||
expect(html).not.toContain("<h2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { UserPlus, Lightbulb, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||
import { formatCents } from "../lib/utils";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
|
||||
export const typeLabel: Record<string, string> = {
|
||||
hire_agent: "Hire Agent",
|
||||
@@ -185,7 +186,7 @@ function BoardApprovalPayloadContent({ payload }: { payload: Record<string, unkn
|
||||
{summary && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">Summary</p>
|
||||
<p className="leading-6 text-foreground/90">{summary}</p>
|
||||
<MarkdownBody softBreaks>{summary}</MarkdownBody>
|
||||
</div>
|
||||
)}
|
||||
{recommendedAction && (
|
||||
@@ -193,13 +194,13 @@ function BoardApprovalPayloadContent({ payload }: { payload: Record<string, unkn
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-amber-700 dark:text-amber-300">
|
||||
Recommended action
|
||||
</p>
|
||||
<p className="mt-1 leading-6 text-foreground">{recommendedAction}</p>
|
||||
<MarkdownBody softBreaks className="mt-1">{recommendedAction}</MarkdownBody>
|
||||
</div>
|
||||
)}
|
||||
{nextActionOnApproval && (
|
||||
<div className="rounded-lg border border-border/60 bg-background/60 px-3.5 py-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">On approval</p>
|
||||
<p className="mt-1 leading-6 text-foreground">{nextActionOnApproval}</p>
|
||||
<MarkdownBody softBreaks className="mt-1">{nextActionOnApproval}</MarkdownBody>
|
||||
</div>
|
||||
)}
|
||||
{risks.length > 0 && (
|
||||
|
||||
@@ -17,6 +17,15 @@ import { authApi } from "../api/auth";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { MarkdownBody } from "../components/MarkdownBody";
|
||||
@@ -603,6 +612,8 @@ export function CompanyExport() {
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||
const [checkedFiles, setCheckedFiles] = useState<Set<string>>(new Set());
|
||||
const [treeSearch, setTreeSearch] = useState("");
|
||||
const [includeSecrets, setIncludeSecrets] = useState(false);
|
||||
const [secretsConfirmOpen, setSecretsConfirmOpen] = useState(false);
|
||||
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
|
||||
const savedExpandedRef = useRef<Set<string> | null>(null);
|
||||
const initialFileFromUrl = useRef(filePathFromLocation(location.pathname));
|
||||
@@ -731,6 +742,7 @@ export function CompanyExport() {
|
||||
include: { company: true, agents: true, projects: true, issues: true },
|
||||
selectedFiles: Array.from(checkedFiles).sort(),
|
||||
sidebarOrder,
|
||||
includeSecrets,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
const resultCheckedFiles = new Set(Object.keys(result.files));
|
||||
@@ -945,6 +957,11 @@ export function CompanyExport() {
|
||||
{warnings.length} warning{warnings.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
{includeSecrets && (
|
||||
<span className="rounded-md border border-amber-500/30 bg-amber-500/5 px-2 py-0.5 text-xs text-amber-500">
|
||||
Secrets included
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -974,6 +991,29 @@ export function CompanyExport() {
|
||||
<div className="border-b border-border px-4 py-3 shrink-0">
|
||||
<h2 className="text-base font-semibold">Package files</h2>
|
||||
</div>
|
||||
<div className="border-b border-border px-4 py-2.5 shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ToggleSwitch
|
||||
checked={includeSecrets}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSecretsConfirmOpen(true);
|
||||
} else {
|
||||
setIncludeSecrets(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="cursor-pointer text-muted-foreground hover:text-foreground transition-colors" onClick={() => {
|
||||
if (includeSecrets) {
|
||||
setIncludeSecrets(false);
|
||||
} else {
|
||||
setSecretsConfirmOpen(true);
|
||||
}
|
||||
}}>
|
||||
Include secrets
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-border px-3 py-2 shrink-0">
|
||||
<div className="flex items-center gap-2 rounded-md border border-border px-2 py-1">
|
||||
<Search className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
@@ -1014,6 +1054,26 @@ export function CompanyExport() {
|
||||
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} allFiles={effectiveFiles} onSkillClick={handleSkillClick} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secrets confirmation dialog */}
|
||||
<Dialog open={secretsConfirmOpen} onOpenChange={setSecretsConfirmOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Include secrets?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Secrets will be exported as plaintext in the package file. Handle the exported package with care.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setSecretsConfirmOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => { setIncludeSecrets(true); setSecretsConfirmOpen(false); }}>
|
||||
Include secrets
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -866,6 +866,13 @@ export function CompanyImport() {
|
||||
title: "Import complete",
|
||||
body: `${result.company.name}: ${result.agents.length} agent${result.agents.length === 1 ? "" : "s"} processed.`,
|
||||
});
|
||||
if (result.warnings.some((w) => w.includes("could not be decrypted") || w.toLowerCase().includes("failed to create secret"))) {
|
||||
pushToast({
|
||||
tone: "warn",
|
||||
title: "Secrets import warning",
|
||||
body: "Some secrets could not be decrypted. Review warnings and recreate manually.",
|
||||
});
|
||||
}
|
||||
// Force a fresh dashboard load so newly imported agents are immediately visible.
|
||||
window.location.assign(`/${importedCompany.issuePrefix}/dashboard`);
|
||||
},
|
||||
@@ -1309,6 +1316,18 @@ export function CompanyImport() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Secrets info */}
|
||||
{importPreview.manifest.secrets && importPreview.manifest.secrets.length > 0 && (
|
||||
<div className="mx-5 mt-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||
<div className="text-xs font-medium text-amber-500 mb-1">Secrets to import</div>
|
||||
{importPreview.manifest.secrets.map((s) => (
|
||||
<div key={s.name} className="text-xs text-amber-500">
|
||||
{s.name}{s.provider !== "local_encrypted" ? ` (${s.provider})` : ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Errors */}
|
||||
{importPreview.errors.length > 0 && (
|
||||
<div className="mx-5 mt-3 rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3">
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
RefreshCw,
|
||||
Save,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -487,6 +488,103 @@ function SkillList({
|
||||
);
|
||||
}
|
||||
|
||||
function SkillAuthSection({
|
||||
companyId,
|
||||
skillId,
|
||||
hasAuth,
|
||||
}: {
|
||||
companyId: string;
|
||||
skillId: string;
|
||||
hasAuth: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
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,
|
||||
@@ -614,6 +712,13 @@ function SkillPane({
|
||||
)}
|
||||
</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 +867,7 @@ export function CompanySkills() {
|
||||
const { pushToast } = useToastActions();
|
||||
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);
|
||||
@@ -887,7 +993,8 @@ export function CompanySkills() {
|
||||
}
|
||||
|
||||
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 +1007,7 @@ export function CompanySkills() {
|
||||
pushToast({ tone: "warn", title: "Import warnings", body: result.warnings[0] });
|
||||
}
|
||||
setSource("");
|
||||
setImportAuthToken("");
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
@@ -1073,7 +1181,8 @@ export function CompanySkills() {
|
||||
setEmptySourceHelpOpen(true);
|
||||
return;
|
||||
}
|
||||
importSkill.mutate(trimmedSource);
|
||||
const token = importAuthToken.trim() || undefined;
|
||||
importSkill.mutate({ importSource: trimmedSource, authToken: token });
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1220,6 +1329,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}
|
||||
|
||||
Reference in New Issue
Block a user