mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(github-app): App credentials, installation tokens, and a Select repo picker (#621)
* feat(github-app): App credentials, installation tokens, and a Select repo picker RoboCo was 100% PAT-based. A singleton Fernet-encrypted github_app_credentials row (migration 077, telegram-credentials pattern) now stores the App id + private key; github_app_auth mints RS256 app JWTs and caches installation tokens until 5 minutes before expiry. Projects can bind an installation (projects.github_installation_id): get_decrypted_token returns a minted installation token for bound projects and falls back to the stored PAT on any minting failure, so all ten token consumers work unchanged. CEO-gated routes expose credentials CRUD plus installation/repo listing, and the New Project dialog gains a Select repo picker (disabled with a HelpTip until the App is configured) that fills the git URL and binds the installation; manual URL + PAT stays the default path. * test(panel): mock the GitHub App credentials card in the settings page test --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -37,6 +37,10 @@ vi.mock("@/components/settings/feature-flags-card", () => ({
|
||||
FeatureFlagsCard: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/settings/github-app-credentials-card", () => ({
|
||||
GitHubAppCredentialsCard: () => null,
|
||||
}));
|
||||
|
||||
import SettingsPage from "../page";
|
||||
|
||||
// The Label and Switch/Select are siblings inside a flex row, so the label
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Settings, Palette, Bell, Server } from "lucide-react";
|
||||
import { API_URL, WS_URL } from "@/lib/constants";
|
||||
import { UserInfoCard } from "@/components/settings/user-info-card";
|
||||
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
|
||||
import { GitHubAppCredentialsCard } from "@/components/settings/github-app-credentials-card";
|
||||
import { FeatureFlagsCard } from "@/components/settings/feature-flags-card";
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -54,7 +55,8 @@ export default function SettingsPage() {
|
||||
|
||||
{/* Cards grid — two columns on large screens. Order (row,col):
|
||||
User Info (1,1) · Appearance (1,2) · Data & Refresh (2,1) ·
|
||||
Transcript Retention (2,2) · Notifications (3,1) · Connection Info (3,2). */}
|
||||
Transcript Retention (2,2) · Notifications (3,1) · Connection Info (3,2) ·
|
||||
GitHub App (4,1). */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* User Info */}
|
||||
<UserInfoCard />
|
||||
@@ -248,6 +250,11 @@ export default function SettingsPage() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* GitHub App — App id + private key powering the New Project
|
||||
dialog's Select repo picker and per-project installation-token
|
||||
auth (no feature flag; a standalone opt-in credential). */}
|
||||
<GitHubAppCredentialsCard />
|
||||
</div>
|
||||
|
||||
{/* Feature Flags — master switches for optional subsystems (full width;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import React from "react";
|
||||
|
||||
const {
|
||||
getCredentialsStatus,
|
||||
listInstallations,
|
||||
listInstallationRepositories,
|
||||
} = vi.hoisted(() => ({
|
||||
getCredentialsStatus: vi.fn(async () => ({ has_credentials: true })),
|
||||
listInstallations: vi.fn(async () => [{ id: 1, account_login: "acme" }]),
|
||||
listInstallationRepositories: vi.fn(async () => [
|
||||
{
|
||||
full_name: "acme/widgets",
|
||||
clone_url: "https://github.com/acme/widgets.git",
|
||||
private: true,
|
||||
},
|
||||
{
|
||||
full_name: "acme/gizmos",
|
||||
clone_url: "https://github.com/acme/gizmos.git",
|
||||
private: false,
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
githubAppApi: {
|
||||
getCredentialsStatus,
|
||||
listInstallations,
|
||||
listInstallationRepositories,
|
||||
},
|
||||
}));
|
||||
|
||||
// Functional Select mock (mirrors a2a-reply-composer.test.tsx): SelectItem
|
||||
// renders as a clickable button wired to onValueChange via context, so a
|
||||
// real "pick an installation" interaction can be simulated without Radix's
|
||||
// portal/pointer machinery.
|
||||
vi.mock("@/components/ui/select", () => {
|
||||
const Ctx = React.createContext<(v: string) => void>(() => {});
|
||||
return {
|
||||
Select: ({
|
||||
onValueChange,
|
||||
children,
|
||||
}: {
|
||||
onValueChange?: (v: string) => void;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<Ctx.Provider value={onValueChange ?? (() => {})}>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const onValueChange = React.useContext(Ctx);
|
||||
return (
|
||||
<button type="button" onClick={() => onValueChange(value)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { SelectRepoDialog } from "../select-repo-picker";
|
||||
|
||||
function withQueryClient(ui: ReactNode) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
// Waits for the credentials-status query to resolve (button starts disabled
|
||||
// until `configured` is known) before clicking — otherwise the click lands on
|
||||
// a still-disabled button and silently no-ops.
|
||||
async function openPicker(onSelect = vi.fn()) {
|
||||
render(withQueryClient(<SelectRepoDialog onSelect={onSelect} />));
|
||||
const button = await screen.findByRole("button", { name: /Select repo/i });
|
||||
await waitFor(() => expect(button).not.toBeDisabled());
|
||||
fireEvent.click(button);
|
||||
return onSelect;
|
||||
}
|
||||
|
||||
describe("SelectRepoDialog", () => {
|
||||
beforeEach(() => {
|
||||
getCredentialsStatus.mockClear();
|
||||
listInstallations.mockClear();
|
||||
listInstallationRepositories.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("disables the button when the GitHub App isn't configured", async () => {
|
||||
getCredentialsStatus.mockResolvedValueOnce({ has_credentials: false });
|
||||
render(withQueryClient(<SelectRepoDialog onSelect={vi.fn()} />));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Select repo/i }),
|
||||
).toBeDisabled(),
|
||||
);
|
||||
expect(listInstallations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-applies the sole installation and lists its repositories", async () => {
|
||||
await openPicker();
|
||||
expect(await screen.findByText("Select a repository")).toBeInTheDocument();
|
||||
expect(await screen.findByText("acme/widgets")).toBeInTheDocument();
|
||||
expect(screen.getByText("acme/gizmos")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(listInstallationRepositories).toHaveBeenCalledWith(1),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an installation picker when there is more than one, and only loads repos after picking", async () => {
|
||||
listInstallations.mockResolvedValueOnce([
|
||||
{ id: 1, account_login: "acme" },
|
||||
{ id: 2, account_login: "widgets-inc" },
|
||||
]);
|
||||
await openPicker();
|
||||
await screen.findByText("Select a repository");
|
||||
|
||||
expect(screen.getByText("acme")).toBeInTheDocument();
|
||||
expect(screen.getByText("widgets-inc")).toBeInTheDocument();
|
||||
expect(listInstallationRepositories).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByText("widgets-inc"));
|
||||
await waitFor(() =>
|
||||
expect(listInstallationRepositories).toHaveBeenCalledWith(2),
|
||||
);
|
||||
});
|
||||
|
||||
it("filters the repository list by search text", async () => {
|
||||
await openPicker();
|
||||
await screen.findByText("acme/widgets");
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Search repositories..."), {
|
||||
target: { value: "gizmo" },
|
||||
});
|
||||
|
||||
expect(screen.queryByText("acme/widgets")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("acme/gizmos")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("picking a repo hands back its clone_url + installation id and closes the dialog", async () => {
|
||||
const onSelect = await openPicker();
|
||||
await screen.findByText("acme/widgets");
|
||||
|
||||
fireEvent.click(screen.getByText("acme/widgets"));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
git_url: "https://github.com/acme/widgets.git",
|
||||
installation_id: 1,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Select a repository")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,10 @@ import { toast } from "sonner";
|
||||
import { Team, type ProjectCreate } from "@/types";
|
||||
import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor";
|
||||
import { validateLadder } from "@/components/projects/ladder-validation";
|
||||
import {
|
||||
SelectRepoDialog,
|
||||
type SelectedRepo,
|
||||
} from "@/components/projects/select-repo-picker";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
|
||||
const cells: { value: Team; label: string }[] = [
|
||||
@@ -57,6 +61,12 @@ export function CreateProjectDialog() {
|
||||
// the backend auto-detect from the Git URL host at creation time.
|
||||
const [gitProvider, setGitProvider] = useState("auto");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
// Set only via the "Select repo" picker below; a manually-typed/edited Git
|
||||
// URL clears it so the project never binds to an installation whose repo
|
||||
// no longer matches what's in the field.
|
||||
const [githubInstallationId, setGithubInstallationId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const createProject = useCreateProject();
|
||||
|
||||
@@ -68,6 +78,11 @@ export function CreateProjectDialog() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleRepoSelected = (repo: SelectedRepo) => {
|
||||
setFormData({ ...formData, git_url: repo.git_url });
|
||||
setGithubInstallationId(repo.installation_id);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -93,6 +108,7 @@ export function CreateProjectDialog() {
|
||||
slug: formData.slug,
|
||||
git_url: formData.git_url,
|
||||
git_provider: gitProvider === "auto" ? null : gitProvider,
|
||||
github_installation_id: githubInstallationId ?? undefined,
|
||||
assigned_cell: formData.assigned_cell,
|
||||
git_token: formData.git_token || undefined,
|
||||
default_branch: formData.default_branch || "main",
|
||||
@@ -116,6 +132,7 @@ export function CreateProjectDialog() {
|
||||
environments: null,
|
||||
});
|
||||
setGitProvider("auto");
|
||||
setGithubInstallationId(null);
|
||||
setShowAdvanced(false);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
@@ -177,19 +194,27 @@ export function CreateProjectDialog() {
|
||||
|
||||
{/* Git URL */}
|
||||
<div className="grid gap-2">
|
||||
<HelpTip label="Cloned into each assigned agent's workspace on first access; use HTTPS so the encrypted token below can authenticate clone, push, and PR operations.">
|
||||
<Label htmlFor="git_url">Git URL *</Label>
|
||||
</HelpTip>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<HelpTip label="Cloned into each assigned agent's workspace on first access; use HTTPS so the encrypted token below can authenticate clone, push, and PR operations.">
|
||||
<Label htmlFor="git_url">Git URL *</Label>
|
||||
</HelpTip>
|
||||
<SelectRepoDialog onSelect={handleRepoSelected} />
|
||||
</div>
|
||||
<Input
|
||||
id="git_url"
|
||||
value={formData.git_url}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, git_url: e.target.value })
|
||||
}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, git_url: e.target.value });
|
||||
// A manual edit invalidates any prior "Select repo" pick —
|
||||
// never bind an installation to a URL the user then changed.
|
||||
setGithubInstallationId(null);
|
||||
}}
|
||||
placeholder="https://github.com/org/repo.git"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use HTTPS URL for token-based authentication
|
||||
{githubInstallationId
|
||||
? "Filled from the GitHub App picker — git operations will use a minted installation token."
|
||||
: "Use HTTPS URL for token-based authentication"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { githubAppApi } from "@/lib/api";
|
||||
import type { GitHubAppInstallationRepository } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { FolderGit2, Lock, Search } from "lucide-react";
|
||||
|
||||
export interface SelectedRepo {
|
||||
git_url: string;
|
||||
installation_id: number;
|
||||
}
|
||||
|
||||
interface SelectRepoDialogProps {
|
||||
onSelect: (repo: SelectedRepo) => void;
|
||||
}
|
||||
|
||||
// "Select repo" button + picker for the New Project dialog: lists the repos
|
||||
// a configured GitHub App installation can access and, on pick, hands back
|
||||
// the clone URL + installation id (the caller fills git_url and stashes the
|
||||
// installation id into the create payload). Disabled — with a HelpTip
|
||||
// explaining why — until the App is configured on the Settings page; manual
|
||||
// Git URL + PAT entry stays the default, unaffected fallback either way.
|
||||
export function SelectRepoDialog({ onSelect }: SelectRepoDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [installationId, setInstallationId] = useState<number | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data: credStatus } = useQuery({
|
||||
queryKey: ["github-app", "credentials"],
|
||||
queryFn: () => githubAppApi.getCredentialsStatus(),
|
||||
});
|
||||
const configured = !!credStatus?.has_credentials;
|
||||
|
||||
const { data: installations = [], isLoading: loadingInstallations } =
|
||||
useQuery({
|
||||
queryKey: ["github-app", "installations"],
|
||||
queryFn: () => githubAppApi.listInstallations(),
|
||||
enabled: open && configured,
|
||||
});
|
||||
|
||||
// The sole installation applies automatically; a picker only appears when
|
||||
// there's a real choice. Derived (not synced via effect) so there's no
|
||||
// set-state-in-effect to get wrong.
|
||||
const effectiveInstallationId =
|
||||
installations.length === 1 ? installations[0].id : installationId;
|
||||
|
||||
const { data: repos = [], isLoading: loadingRepos } = useQuery({
|
||||
queryKey: [
|
||||
"github-app",
|
||||
"installations",
|
||||
effectiveInstallationId,
|
||||
"repositories",
|
||||
],
|
||||
queryFn: () =>
|
||||
githubAppApi.listInstallationRepositories(
|
||||
effectiveInstallationId as number,
|
||||
),
|
||||
enabled: open && configured && effectiveInstallationId !== null,
|
||||
});
|
||||
|
||||
const filteredRepos = repos.filter((r) =>
|
||||
r.full_name.toLowerCase().includes(search.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
const reset = () => {
|
||||
setInstallationId(null);
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
const handlePick = (repo: GitHubAppInstallationRepository) => {
|
||||
if (effectiveInstallationId === null) return;
|
||||
onSelect({
|
||||
git_url: repo.clone_url,
|
||||
installation_id: effectiveInstallationId,
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) reset();
|
||||
}}
|
||||
>
|
||||
<HelpTip
|
||||
label={
|
||||
configured
|
||||
? "Browse repos the GitHub App installation can access — picking one fills the Git URL and binds this project to that installation for token auth."
|
||||
: "Configure the GitHub App (Settings page) first to browse installation repos here."
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!configured}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<FolderGit2 className="mr-2 h-4 w-4" />
|
||||
Select repo
|
||||
</Button>
|
||||
</span>
|
||||
</HelpTip>
|
||||
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select a repository</DialogTitle>
|
||||
<DialogDescription>
|
||||
Only repositories the GitHub App installation can access are listed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{installations.length > 1 && (
|
||||
<div className="grid gap-2">
|
||||
<HelpTip label="The App can be installed on more than one account/org — pick which one to browse.">
|
||||
<span className="text-sm font-medium">Installation</span>
|
||||
</HelpTip>
|
||||
<Select
|
||||
value={installationId !== null ? String(installationId) : ""}
|
||||
onValueChange={(v) => setInstallationId(Number(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose an installation" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{installations.map((inst) => (
|
||||
<SelectItem key={inst.id} value={String(inst.id)}>
|
||||
{inst.account_login}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveInstallationId !== null && (
|
||||
<div className="grid gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search repositories..."
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="h-64 rounded-md border">
|
||||
<div className="p-1">
|
||||
{loadingRepos && (
|
||||
<p className="p-3 text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</p>
|
||||
)}
|
||||
{!loadingRepos && filteredRepos.length === 0 && (
|
||||
<p className="p-3 text-sm text-muted-foreground">
|
||||
No matching repositories
|
||||
</p>
|
||||
)}
|
||||
{filteredRepos.map((repo) => (
|
||||
<button
|
||||
key={repo.full_name}
|
||||
type="button"
|
||||
onClick={() => handlePick(repo)}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||
>
|
||||
<span className="truncate">{repo.full_name}</span>
|
||||
{repo.private && (
|
||||
<HelpTip label="Private repository">
|
||||
<Lock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</HelpTip>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open &&
|
||||
configured &&
|
||||
!loadingInstallations &&
|
||||
installations.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No installations found for the configured App.
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const { getCredentialsStatus, setCredentials, clearCredentials } = vi.hoisted(
|
||||
() => ({
|
||||
getCredentialsStatus: vi.fn(async () => ({ has_credentials: false })),
|
||||
setCredentials: vi.fn(async () => ({ has_credentials: true })),
|
||||
clearCredentials: vi.fn(async () => ({ has_credentials: false })),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
githubAppApi: { getCredentialsStatus, setCredentials, clearCredentials },
|
||||
}));
|
||||
|
||||
import { GitHubAppCredentialsCard } from "../github-app-credentials-card";
|
||||
|
||||
function withQueryClient(ui: ReactNode) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe("GitHubAppCredentialsCard", () => {
|
||||
beforeEach(() => {
|
||||
getCredentialsStatus.mockClear();
|
||||
setCredentials.mockClear();
|
||||
clearCredentials.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows 'no credentials configured' by default and no Clear button", async () => {
|
||||
render(withQueryClient(<GitHubAppCredentialsCard />));
|
||||
expect(
|
||||
await screen.findByText("No credentials configured"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Clear" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables Save until both App id and private key are filled", async () => {
|
||||
render(withQueryClient(<GitHubAppCredentialsCard />));
|
||||
await screen.findByText("No credentials configured");
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("App id"), {
|
||||
target: { value: "123456" },
|
||||
});
|
||||
expect(saveButton).toBeDisabled(); // private key still unfilled
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Private key (PEM)"), {
|
||||
target: { value: "-----BEGIN KEY-----" },
|
||||
});
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("saves both fields and clears the inputs on success", async () => {
|
||||
render(withQueryClient(<GitHubAppCredentialsCard />));
|
||||
await screen.findByText("No credentials configured");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("App id"), {
|
||||
target: { value: "123456" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Private key (PEM)"), {
|
||||
target: { value: "-----BEGIN KEY-----" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setCredentials).toHaveBeenCalledWith({
|
||||
app_id: "123456",
|
||||
private_key: "-----BEGIN KEY-----",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect((screen.getByLabelText("App id") as HTMLInputElement).value).toBe(
|
||||
"",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a Clear button once credentials are set, gated behind a confirm dialog", async () => {
|
||||
getCredentialsStatus.mockResolvedValueOnce({ has_credentials: true });
|
||||
render(withQueryClient(<GitHubAppCredentialsCard />));
|
||||
await screen.findByText("Credentials are set");
|
||||
|
||||
const clearButton = screen.getByRole("button", { name: "Clear" });
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(clearCredentials).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: "Clear" }).slice(-1)[0],
|
||||
);
|
||||
await waitFor(() => expect(clearCredentials).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("cancelling the clear confirm dialog does NOT call clearCredentials", async () => {
|
||||
getCredentialsStatus.mockResolvedValueOnce({ has_credentials: true });
|
||||
render(withQueryClient(<GitHubAppCredentialsCard />));
|
||||
await screen.findByText("Credentials are set");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(clearCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { githubAppApi } from "@/lib/api";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { FolderGit2, Key, KeyRound, Save, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// The CEO's one-time entry of a GitHub App's id + RSA private key (PEM,
|
||||
// downloaded once from the App's settings page on github.com). Write-only —
|
||||
// the key is never displayed back, only whether it's set (mirrors the
|
||||
// Telegram/X credentials cards). Once set, projects can bind to one of the
|
||||
// App's installations (New Project dialog's "Select repo" picker) and git
|
||||
// operations mint short-lived installation tokens instead of a stored PAT.
|
||||
export function GitHubAppCredentialsCard() {
|
||||
const queryClient = useQueryClient();
|
||||
const [appId, setAppId] = useState("");
|
||||
const [privateKey, setPrivateKey] = useState("");
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ["github-app", "credentials"],
|
||||
queryFn: () => githubAppApi.getCredentialsStatus(),
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["github-app", "credentials"] });
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
githubAppApi.setCredentials({ app_id: appId, private_key: privateKey }),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setAppId("");
|
||||
setPrivateKey("");
|
||||
toast.success("GitHub App credentials saved");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
`Failed to save: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: () => githubAppApi.clearCredentials(),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success("GitHub App credentials cleared");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
`Failed to clear: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const canSave = appId.trim().length > 0 && privateKey.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderGit2 className="h-5 w-5" />
|
||||
GitHub App
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
The App id + private key from a GitHub App you created on github.com.
|
||||
Once set, a project can bind to one of the App's installations
|
||||
and git operations use a short-lived installation token instead of a
|
||||
personal access token.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-2 rounded-md border p-3">
|
||||
{status?.has_credentials ? (
|
||||
<HelpTip label="App id + private key are both stored — projects can bind to an installation via the New Project dialog's Select repo button.">
|
||||
<Key className="h-4 w-4 text-green-500" />
|
||||
</HelpTip>
|
||||
) : (
|
||||
<HelpTip label="Missing — the Select repo picker stays disabled and every project falls back to its own PAT until both fields below are set.">
|
||||
<KeyRound className="h-4 w-4 text-amber-500" />
|
||||
</HelpTip>
|
||||
)}
|
||||
{status?.has_credentials ? (
|
||||
<span className="text-sm text-green-600 dark:text-green-400">
|
||||
Credentials are set
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-amber-600 dark:text-amber-400">
|
||||
{isLoading ? "Checking..." : "No credentials configured"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<HelpTip label="The numeric App id shown on the App's github.com settings page (General tab). Not a secret, but stored alongside the key.">
|
||||
<Label htmlFor="github-app-id">
|
||||
{status?.has_credentials ? "Replace App id" : "App id"}
|
||||
</Label>
|
||||
</HelpTip>
|
||||
<Input
|
||||
id="github-app-id"
|
||||
value={appId}
|
||||
onChange={(e) => setAppId(e.target.value)}
|
||||
placeholder="123456"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<HelpTip label="Paste the full .pem contents downloaded once from the App's settings page. Stored encrypted server-side; never displayed again once saved.">
|
||||
<Label htmlFor="github-app-private-key">
|
||||
{status?.has_credentials
|
||||
? "Replace private key"
|
||||
: "Private key (PEM)"}
|
||||
</Label>
|
||||
</HelpTip>
|
||||
<Textarea
|
||||
id="github-app-private-key"
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
placeholder="-----BEGIN RSA PRIVATE KEY----- ... -----END RSA PRIVATE KEY-----"
|
||||
className="min-h-32 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Both fields are required together — set both to save (or rotate).
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending || !canSave}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{saveMutation.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
{status?.has_credentials && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmClear(true)}
|
||||
disabled={clearMutation.isPending}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<AlertDialog
|
||||
open={confirmClear}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmClear(false);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear GitHub App credentials?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This clears the stored App id and private key. Projects already
|
||||
bound to an installation fall back to their own PAT (if set) until
|
||||
you configure the App again. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setConfirmClear(false);
|
||||
clearMutation.mutate();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import api from "./client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub App integration — the CEO's App id + private key credentials
|
||||
// (write-only; the API never returns the stored key) plus the "Select repo"
|
||||
// picker's installation/repository listing.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GitHubAppCredentialsStatus {
|
||||
has_credentials: boolean;
|
||||
}
|
||||
|
||||
export interface GitHubAppInstallation {
|
||||
id: number;
|
||||
account_login: string;
|
||||
}
|
||||
|
||||
export interface GitHubAppInstallationRepository {
|
||||
full_name: string;
|
||||
clone_url: string;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
export const githubAppApi = {
|
||||
getCredentialsStatus: async (): Promise<GitHubAppCredentialsStatus> => {
|
||||
const { data } = await api.get<GitHubAppCredentialsStatus>(
|
||||
"/github-app/credentials",
|
||||
);
|
||||
return data;
|
||||
},
|
||||
setCredentials: async (creds: {
|
||||
app_id: string;
|
||||
private_key: string;
|
||||
}): Promise<GitHubAppCredentialsStatus> => {
|
||||
const { data } = await api.put<GitHubAppCredentialsStatus>(
|
||||
"/github-app/credentials",
|
||||
creds,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
clearCredentials: async (): Promise<GitHubAppCredentialsStatus> => {
|
||||
const { data } = await api.delete<GitHubAppCredentialsStatus>(
|
||||
"/github-app/credentials",
|
||||
);
|
||||
return data;
|
||||
},
|
||||
listInstallations: async (): Promise<GitHubAppInstallation[]> => {
|
||||
const { data } = await api.get<GitHubAppInstallation[]>(
|
||||
"/github-app/installations",
|
||||
);
|
||||
return data;
|
||||
},
|
||||
listInstallationRepositories: async (
|
||||
installationId: number,
|
||||
): Promise<GitHubAppInstallationRepository[]> => {
|
||||
const { data } = await api.get<GitHubAppInstallationRepository[]>(
|
||||
`/github-app/installations/${installationId}/repositories`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -33,6 +33,12 @@ export type {
|
||||
} from "./x";
|
||||
export { telegramApi } from "./telegram";
|
||||
export type { TelegramCredentialsStatus } from "./telegram";
|
||||
export { githubAppApi } from "./github-app";
|
||||
export type {
|
||||
GitHubAppCredentialsStatus,
|
||||
GitHubAppInstallation,
|
||||
GitHubAppInstallationRepository,
|
||||
} from "./github-app";
|
||||
export { roadmapApi } from "./roadmap";
|
||||
export type {
|
||||
RoadmapCycle,
|
||||
|
||||
@@ -36,6 +36,7 @@ const mockProjects: Project[] = [
|
||||
slug: "roboco",
|
||||
git_url: "https://github.com/rennf93/roboco.git",
|
||||
git_provider: "github",
|
||||
github_installation_id: null,
|
||||
default_branch: "master",
|
||||
protected_branches: ["master", "slave"],
|
||||
assigned_cell: Team.BACKEND,
|
||||
@@ -55,6 +56,7 @@ const mockProjects: Project[] = [
|
||||
slug: "roboco-website",
|
||||
git_url: "https://github.com/rennf93/roboco-website.git",
|
||||
git_provider: "github",
|
||||
github_installation_id: null,
|
||||
default_branch: "master",
|
||||
protected_branches: ["master"],
|
||||
assigned_cell: Team.FRONTEND,
|
||||
@@ -72,7 +74,10 @@ const mockProjects: Project[] = [
|
||||
|
||||
// Mock task counts per project (mock-mode only — real data comes from the
|
||||
// backend's grouped query). Keyed by project id.
|
||||
const mockTaskCounts: Record<string, { done: number; active: number; blocked: number }> = {
|
||||
const mockTaskCounts: Record<
|
||||
string,
|
||||
{ done: number; active: number; blocked: number }
|
||||
> = {
|
||||
"proj-mock-1": { done: 120, active: 8, blocked: 1 },
|
||||
"proj-mock-2": { done: 34, active: 2, blocked: 0 },
|
||||
};
|
||||
@@ -150,6 +155,7 @@ export const projectsApi = {
|
||||
git_provider:
|
||||
project.git_provider ??
|
||||
(project.git_url.includes("github.com") ? "github" : null),
|
||||
github_installation_id: project.github_installation_id ?? null,
|
||||
default_branch: project.default_branch ?? "main",
|
||||
environments: project.environments ?? null,
|
||||
protected_branches: project.protected_branches ?? ["main", "master"],
|
||||
|
||||
@@ -1034,6 +1034,8 @@ export interface Project {
|
||||
// Forge provider ("github"|"gitlab"|"gitea"); null = auto-detect from
|
||||
// git_url host (github.com -> github, stamped on create). GitHub-only today.
|
||||
git_provider: string | null;
|
||||
// GitHub App installation covering this repo; null = PAT-only auth.
|
||||
github_installation_id: number | null;
|
||||
default_branch: string;
|
||||
// Ordered environment ladder (first=head/PR-target, last=prod/release-target).
|
||||
// Null/empty => degenerate 1-rung ladder synthesized from default_branch.
|
||||
@@ -1081,6 +1083,8 @@ export interface ProjectCreate {
|
||||
assigned_cell: Team;
|
||||
// Git authentication (stored encrypted, never returned)
|
||||
git_token?: string;
|
||||
// GitHub App installation covering this repo (from the Select repo picker).
|
||||
github_installation_id?: number | null;
|
||||
test_command?: string;
|
||||
lint_command?: string;
|
||||
format_command?: string;
|
||||
@@ -1101,6 +1105,8 @@ export interface ProjectUpdate {
|
||||
assigned_cell?: Team;
|
||||
// Git authentication (empty string clears, undefined leaves unchanged)
|
||||
git_token?: string;
|
||||
// null clears the installation binding; omitted leaves unchanged.
|
||||
github_installation_id?: number | null;
|
||||
is_active?: boolean;
|
||||
test_command?: string;
|
||||
lint_command?: string;
|
||||
|
||||
Reference in New Issue
Block a user