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:
Renzo F
2026-07-21 00:54:20 +02:00
committed by GitHub
co-authored by Renn F
parent 2d210ce6ee
commit 7b84162ae9
26 changed files with 2105 additions and 38 deletions
@@ -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
+8 -1
View File
@@ -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&apos;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-----&#10;...&#10;-----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>
);
}
+61
View File
@@ -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;
},
};
+6
View File
@@ -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,
+7 -1
View File
@@ -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"],
+6
View File
@@ -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;