mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(git): GitHub App manifest self-provisioning + app-level webhook (#44)
* feat(branding): white-label — public /branding, admin write, shell theming app_settings branding.* keys (product name, logo/favicon URLs, accent color, docs/support links). Public GET /branding so login and shell theme pre-auth; admin writes via PUT /settings. useBranding() applies document.title, accent CSS var and favicon; BrandWordmark replaces hardcoded wordmarks; admin Platform settings gain a Branding form. MCP containr_branding_get + settings_update. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(invites): team invite links — admin create/revoke, public accept Admins mint single-use invite links (sha256-hashed token at rest, optional email binding, configurable TTL). Public endpoints let the accept page validate the link and register — working even while public signup is disabled. - migration + sqlc queries/model for user_invites - POST /admin/invites (returns one-time token+url), GET list, DELETE revoke - GET /auth/invites/:token public validation, POST /auth/accept-invite - CLI containr invites list|create|revoke; MCP invites tools - /auth/accept-invite page (expired/used/invalid states, bound-email lock) - Admin page Team invites section: create, copy link, revoke - OpenAPI + generated types + CLI guide Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(admin): impersonate user — 15-min token, audit-logged POST /admin/users/:id/impersonate mints a short-lived bearer token (impersonated_by claim) for support/debugging. Admin accounts cannot be impersonated; every mint is audit-logged with actor + target. - CLI: containr admin impersonate <user-id> (works via CONTAINR_TOKEN) - MCP: containr_admin_impersonate - Admin UI: per-user Impersonate action → token modal with usage hint - OpenAPI + generated types Verified live: token resolves to target, admin target 403, audit row. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(git): GitHub App manifest self-provisioning + app-level webhook Admins create the instance GitHub App in-browser instead of hand- setting GITHUB_APP_* env vars: - POST /admin/git/github-app/manifest builds the manifest + URL; the frontend auto-submits it to github.com - /git/github-app/callback page exchanges the code via POST /admin/git/github-app/convert; credentials persist in app_settings (precedence over env) - GET /admin/git/github-app reports configured/source - POST /api/git/github-app/webhook — single app-level push receiver, HMAC-verified, dispatches via extracted dispatchPushToServices - install-url + installation-token paths resolve settings→env - CLI: containr admin github-app status|manifest|convert; MCP tools - Git providers UI shows "Set up GitHub App" for admins when unconfigured Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
50e39fe140
commit
00e69b3733
16 files changed
+1184
-28
No files matched your search
@@ -23,6 +23,7 @@ import {
|
||||
HighAvailabilityPage,
|
||||
SecurityPage,
|
||||
ComponentShowcase,
|
||||
GitHubAppCallbackPage,
|
||||
} from '@/features/secondary/pages';
|
||||
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
@@ -156,6 +157,7 @@ export default function App() {
|
||||
<Route path="/builds" element={<BuildsPage />} />
|
||||
<Route path="/people" element={<PeoplePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/git/github-app/callback" element={<GitHubAppCallbackPage />} />
|
||||
<Route path="/settings/audit-logs" element={<AuditLogsPage />} />
|
||||
<Route path="/settings/webhooks" element={<WebhooksPage />} />
|
||||
<Route path="/showcase" element={<ComponentShowcase />} />
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
deleteBanner,
|
||||
deleteProject,
|
||||
getAdminOverview,
|
||||
impersonateUser,
|
||||
type ImpersonationResult,
|
||||
listAdminBanners,
|
||||
listAdminUsers,
|
||||
listInvites,
|
||||
@@ -77,6 +79,7 @@ export function AdminPage() {
|
||||
const [bannerForm, setBannerForm] = useState({ title: '', body: '', level: 'info' as Banner['level'] });
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [impersonation, setImpersonation] = useState<ImpersonationResult | null>(null);
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-overview'] });
|
||||
@@ -107,6 +110,12 @@ export function AdminPage() {
|
||||
onError: (error) => showToast('error', 'Revoke failed', error instanceof Error ? error.message : undefined),
|
||||
});
|
||||
|
||||
const impersonateMutation = useMutation({
|
||||
mutationFn: (id: string) => impersonateUser(id),
|
||||
onSuccess: (result) => setImpersonation(result),
|
||||
onError: (error) => showToast('error', 'Impersonation failed', error instanceof Error ? error.message : undefined),
|
||||
});
|
||||
|
||||
const bannerCreateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createBanner({
|
||||
@@ -520,6 +529,7 @@ export function AdminPage() {
|
||||
isSelf={user.id === myId}
|
||||
pending={adminMutation.isPending}
|
||||
onToggle={(isAdmin) => adminMutation.mutate({ id: user.id, isAdmin })}
|
||||
onImpersonate={user.is_admin || user.id === myId ? undefined : () => impersonateMutation.mutate(user.id)}
|
||||
/>
|
||||
))}
|
||||
{users.length === 0 ? (
|
||||
@@ -528,6 +538,52 @@ export function AdminPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{impersonation ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
|
||||
<div className="border-b border-[var(--border-subtle)] px-5 py-4">
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">
|
||||
Impersonating {impersonation.user.email}
|
||||
</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
Token valid until {new Date(impersonation.expires_at).toLocaleString()} — audit-logged.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3 px-5 py-4">
|
||||
<div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5">
|
||||
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--text-primary)]">
|
||||
{impersonation.token}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(impersonation.token);
|
||||
showToast('success', 'Token copied');
|
||||
}}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<Copy size={11} /> Copy
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[12px] leading-relaxed text-[var(--text-tertiary)]">
|
||||
Use it as a bearer token — e.g. <code className="text-[var(--text-secondary)]">CONTAINR_TOKEN=<token>
|
||||
containr <command></code> or <code className="text-[var(--text-secondary)]">Authorization: Bearer
|
||||
<token></code>. Requests run with that user's permissions for 15 minutes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImpersonation(null)}
|
||||
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)]"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{editingProject ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
|
||||
@@ -583,11 +639,13 @@ function UserRow({
|
||||
isSelf,
|
||||
pending,
|
||||
onToggle,
|
||||
onImpersonate,
|
||||
}: {
|
||||
user: AdminUser;
|
||||
isSelf: boolean;
|
||||
pending: boolean;
|
||||
onToggle: (isAdmin: boolean) => void;
|
||||
onImpersonate?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0">
|
||||
@@ -606,6 +664,15 @@ function UserRow({
|
||||
<ShieldCheck size={11} /> admin
|
||||
</span>
|
||||
) : null}
|
||||
{onImpersonate ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImpersonate}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
Impersonate
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(!user.is_admin)}
|
||||
|
||||
@@ -44,6 +44,9 @@ import {
|
||||
listServiceVariables,
|
||||
updateServiceVariables,
|
||||
getGitHubAppInstallUrl,
|
||||
getGitHubAppStatus,
|
||||
createGitHubAppManifest,
|
||||
convertGitHubAppCode,
|
||||
connectGitHubApp,
|
||||
updateCurrentUserProfile,
|
||||
getPlatformSettings,
|
||||
@@ -950,6 +953,35 @@ function GitProvidersSection() {
|
||||
}
|
||||
};
|
||||
|
||||
// Admins can self-provision the instance's GitHub App via the manifest
|
||||
// flow when GITHUB_APP_* env vars were never configured.
|
||||
const profileQuery = useQuery({ queryKey: ['user-profile'], queryFn: getCurrentUserProfile, retry: false });
|
||||
const isAdmin = Boolean(profileQuery.data?.isAdmin);
|
||||
const ghAppQuery = useQuery({
|
||||
queryKey: ['github-app-status'],
|
||||
queryFn: getGitHubAppStatus,
|
||||
enabled: isAdmin,
|
||||
retry: false,
|
||||
});
|
||||
const provisionMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { url, manifest } = await createGitHubAppManifest({ base_url: window.location.origin });
|
||||
// GitHub requires a form POST carrying the manifest JSON.
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = url;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'manifest';
|
||||
input.value = manifest;
|
||||
form.appendChild(input);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
},
|
||||
});
|
||||
const ghAppStatus = ghAppQuery.data;
|
||||
const showProvision = isAdmin && ghAppStatus && !ghAppStatus.configured;
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: 'github' as (typeof gitProviderTypes)[number]['value'],
|
||||
displayName: '',
|
||||
@@ -996,13 +1028,25 @@ function GitProvidersSection() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => void installApp()}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
<GitBranch size={14} />
|
||||
Install GitHub App
|
||||
</button>
|
||||
{showProvision ? (
|
||||
<button
|
||||
onClick={() => provisionMutation.mutate()}
|
||||
disabled={provisionMutation.isPending}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<GitBranch size={14} />
|
||||
{provisionMutation.isPending ? 'Opening GitHub…' : 'Set up GitHub App'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => void installApp()}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
<GitBranch size={14} />
|
||||
Install GitHub App
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setFormOpen((open) => !open)}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all"
|
||||
@@ -1019,6 +1063,11 @@ function GitProvidersSection() {
|
||||
{installUrlError}
|
||||
</div>
|
||||
)}
|
||||
{provisionMutation.isError && (
|
||||
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
|
||||
{provisionMutation.error instanceof Error ? provisionMutation.error.message : 'Failed to start GitHub App setup'}
|
||||
</div>
|
||||
)}
|
||||
{connectAppMutation.isPending && (
|
||||
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--surface-muted)] text-sm text-[var(--text-secondary)]">
|
||||
Completing GitHub App installation…
|
||||
@@ -1151,6 +1200,66 @@ function GitProvidersSection() {
|
||||
);
|
||||
}
|
||||
|
||||
// GitHub redirects here after the admin completes the manifest form —
|
||||
// /git/github-app/callback?code=…&state=…
|
||||
export function GitHubAppCallbackPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const code = searchParams.get('code');
|
||||
|
||||
const convertMutation = useMutation({
|
||||
mutationFn: (appCode: string) => convertGitHubAppCode(appCode),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['github-app-status'] });
|
||||
navigate('/settings', { replace: true });
|
||||
},
|
||||
});
|
||||
const firedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (code && !firedRef.current) {
|
||||
firedRef.current = true;
|
||||
convertMutation.mutate(code);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- fire once per code
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md px-5 py-16 text-center">
|
||||
{!code ? (
|
||||
<>
|
||||
<AlertCircle size={28} className="mx-auto text-[var(--error)]" />
|
||||
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Missing code</h1>
|
||||
<p className="mt-2 text-sm text-[var(--text-tertiary)]">
|
||||
GitHub did not return an app code. Start the setup again from Git providers.
|
||||
</p>
|
||||
</>
|
||||
) : convertMutation.isError ? (
|
||||
<>
|
||||
<AlertCircle size={28} className="mx-auto text-[var(--error)]" />
|
||||
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Setup failed</h1>
|
||||
<p className="mt-2 text-sm text-[var(--text-tertiary)]">
|
||||
{convertMutation.error instanceof Error ? convertMutation.error.message : 'Could not provision the GitHub App'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/settings', { replace: true })}
|
||||
className="mt-6 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-sm font-semibold text-[var(--accent-on)]"
|
||||
>
|
||||
Back to settings
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 size={28} className="mx-auto animate-spin text-[var(--accent-primary)]" />
|
||||
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Setting up GitHub App</h1>
|
||||
<p className="mt-2 text-sm text-[var(--text-tertiary)]">Exchanging the app credentials…</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformSettingsSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const settingsQuery = useQuery({
|
||||
|
||||
@@ -2717,6 +2717,63 @@ export interface paths {
|
||||
};
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/users/{id}/impersonate": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Impersonate a user
|
||||
* @description Mint a 15-minute bearer token acting as the target user. Admin-only; admin accounts cannot be impersonated and every mint is audit-logged. The token carries an `impersonated_by` claim.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Impersonation token */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
token?: string;
|
||||
/** Format: date-time */
|
||||
expires_at?: string;
|
||||
user?: {
|
||||
id?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Target is an admin account */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/projects/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -5661,6 +5718,162 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/git/github-app": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* GitHub App provisioning status
|
||||
* @description Shows whether the instance GitHub App is configured, and whether credentials come from app_settings or environment variables.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Provisioning status */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
configured?: boolean;
|
||||
/** @enum {string} */
|
||||
source?: "settings" | "env" | "none";
|
||||
app_id?: string;
|
||||
slug?: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/git/github-app/manifest": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Build GitHub App manifest
|
||||
* @description Returns a GitHub App manifest for self-provisioning. The client POSTs `manifest` verbatim as a form field to `url`; GitHub then redirects to the instance's /git/github-app/callback with a code.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @description Public https base URL of this instance */
|
||||
base_url: string;
|
||||
name?: string;
|
||||
/** @description Create the app under this GitHub organization */
|
||||
organization?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Manifest + submission URL */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
url?: string;
|
||||
/** @description JSON string — POST as form field `manifest` */
|
||||
manifest?: string;
|
||||
state?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/git/github-app/convert": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Exchange manifest callback code
|
||||
* @description Exchanges the `code` from the manifest redirect for the app credentials and stores them in app_settings (overriding env vars).
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
code: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description App provisioned */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
app_id?: number;
|
||||
slug?: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description GitHub conversion failed */
|
||||
502: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/git/providers/{providerId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -8386,6 +8599,68 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/git/github-app/webhook": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* GitHub App webhook receiver
|
||||
* @description Single app-level webhook all installed repositories deliver to (configured via the manifest's hook_attributes). Verified with X-Hub-Signature-256 against the provisioned webhook secret. Push events enqueue deployments for services tracking the repo+branch.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": Record<string, never>;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Event accepted */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
received?: boolean;
|
||||
branch?: string;
|
||||
deployments?: number;
|
||||
ignored?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Invalid signature */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description GitHub App webhook secret not configured */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/agents/{id}/commands/{commandId}/result": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -2258,6 +2258,36 @@ export async function getGitHubAppInstallUrl(): Promise<string> {
|
||||
return payload.install_url;
|
||||
}
|
||||
|
||||
export type GitHubAppStatus = {
|
||||
configured: boolean;
|
||||
source: 'env' | 'settings' | 'none';
|
||||
app_id?: string;
|
||||
slug?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export async function getGitHubAppStatus(): Promise<GitHubAppStatus> {
|
||||
return requestJson<GitHubAppStatus>('/admin/git/github-app');
|
||||
}
|
||||
|
||||
export async function createGitHubAppManifest(input: {
|
||||
base_url: string;
|
||||
name?: string;
|
||||
organization?: string;
|
||||
}): Promise<{ url: string; manifest: string; state: string }> {
|
||||
return requestJson('/admin/git/github-app/manifest', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function convertGitHubAppCode(code: string): Promise<{ app_id: number; slug: string; name: string }> {
|
||||
return requestJson('/admin/git/github-app/convert', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function connectGitHubApp(installationId: number, displayName?: string): Promise<GitProviderEntity> {
|
||||
const payload = await requestJson<{ provider?: GitProviderEntity }>('/git/github-app/connect', {
|
||||
method: 'POST',
|
||||
@@ -2622,6 +2652,18 @@ export async function listAdminUsers(): Promise<AdminUser[]> {
|
||||
return payload.users ?? [];
|
||||
}
|
||||
|
||||
export type ImpersonationResult = {
|
||||
token: string;
|
||||
expires_at: string;
|
||||
user: { id: string; email: string; name: string };
|
||||
};
|
||||
|
||||
export async function impersonateUser(userId: string): Promise<ImpersonationResult> {
|
||||
return requestJson<ImpersonationResult>(`/admin/users/${encodeURIComponent(userId)}/impersonate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function setUserAdmin(userId: string, isAdmin: boolean): Promise<void> {
|
||||
await requestJson(`/admin/users/${encodeURIComponent(userId)}`, {
|
||||
method: 'PATCH',
|
||||
|
||||
Reference in new issue
Block a user