Skip to content

Commit b97c021

Browse files
feat: deployment approval UI and backend handlers (#5256)
* feat: deployment approval UI and backend handlers Add the complete approval/rejection flow for gated deployments: Backend (ctrl service): - Add ApproveDeployment and RejectDeployment RPCs to DeployService proto - Implement approve handler: validates status, updates to pending, records approval, triggers deploy workflow via Restate - Implement reject handler: validates status, updates to failed - Add FindAppBuildSettingByAppEnv SQL query for approval flow Dashboard: - Add awaiting_approval to deployment status badges, filters, and collection - Add DeploymentApprovalBanner component with approve/reject buttons - Show banner on deployment detail page when awaiting approval - Add tRPC mutations for approve/reject calling ctrl service - Add deployment.approve and deployment.reject audit log events * feat: add rejected status to dashboard UI - Add 'rejected' to deployment status badges, filters, and collections - Add DeploymentRejectedBanner with red error styling - Show rejected banner on deployment detail page - Add rejected to grouped status filter with error color * feat: dashboard authorize page and cleanup old approval UI Remove deployment approval/rejection banners, status badge configs, filter options, and tRPC routes for awaiting_approval/rejected statuses. Add /projects/[projectId]/authorize page that reads branch from URL params and calls AuthorizeDeployment RPC. Add authorize tRPC route. * fix: use correct Loading import from @unkey/ui * fix: pass through actual error message from ctrl service * fix: add GitHub App credentials to ctrl-api config The ctrl API needs GitHub App credentials to fetch branch HEAD when authorizing deployments from external contributors. * fix: remove stale approval/rejected statuses, extract shared ctrl client - Remove awaiting_approval and rejected from deployment statuses, filters, and status badge configs (no longer used — authorization is handled via GitHub Check Runs, not deployment status) - Remove deployment.approve and deployment.reject audit log events - Delete unused duplicate deployment-status-badge.tsx in table components - Fix broken page.tsx referencing undefined awaitingApproval/rejected vars - Extract createCtrlClient helper to deduplicate identical ctrl client creation boilerplate across 9 trpc router files * feat: redesign deployment authorization page Centered layout with GitHub + shield icons, commit details card showing branch/SHA/message/sender from URL query params, proper Button components, success/error states, and non-member handling hint. * feat: link branch, commit SHA, and sender to GitHub on authorize page * refactor: use nuqs for authorize page search params, remove null fallbacks * fix: pass commit SHA through authorization flow and validate on frontend Frontend now validates SHA format (40-char hex) before allowing authorization and passes it to the backend which verifies it matches the branch HEAD. * feat: show dedicated stale commit UI when branch has moved Instead of a generic error, show a clear message explaining the branch has new commits and direct the user to GitHub to find the latest authorization link. * feat: redirect to latest commit authorization when stale Instead of telling the user to check GitHub, parse the new HEAD SHA from the backend error and offer a direct "View Latest Commit" button that navigates to the updated authorize page. * fix: reset mutation state before navigating to latest commit * fix: resolve TypeScript null check on authorize.error * fix: use optional chain instead of non-null assertion for biome lint * [autofix.ci] apply automated fixes * feat: rewrite authorize page to use deploymentId, fetch deployment from DB - Rewrite authorize page to take single deploymentId search param - Fetch deployment details from DB via getById instead of URL params - Handle awaiting_approval, already authorized, and failed states - Simplify authorize mutation input to just deploymentId - Add gitCommitMessage, gitCommitAuthorHandle, gitCommitAuthorAvatarUrl, projectId to getById response - Register getById in tRPC router * fix: replace dots with X icon in authorization connection indicator * feat: move approval UI inline on deployment detail page Replace the separate /authorize page with an inline DeploymentApproval component shown when deployment status is awaiting_approval. Delete the standalone authorize page. Add awaiting_approval to deriveStatusFromSteps valid statuses so it doesn't fall back to pending. * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 4ea5d2e commit b97c021

File tree

17 files changed

+296
-205
lines changed

17 files changed

+296
-205
lines changed

dev/config/ctrl-api.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,5 @@ admin_url = "http://restate:9070"
1414

1515
[github]
1616
webhook_secret = "${UNKEY_GITHUB_APP_WEBHOOK_SECRET}"
17+
app_id = ${UNKEY_GITHUB_APP_ID}
18+
private_key_pem = """${UNKEY_GITHUB_PRIVATE_KEY_PEM}"""

dev/k8s/manifests/ctrl-api.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ data:
2424
2525
[github]
2626
webhook_secret = "${UNKEY_GITHUB_APP_WEBHOOK_SECRET}"
27+
app_id = ${UNKEY_GITHUB_APP_ID}
28+
private_key_pem = """${UNKEY_GITHUB_PRIVATE_KEY_PEM}"""
2729
2830
---
2931
apiVersion: apps/v1
@@ -70,6 +72,18 @@ spec:
7072
name: github-credentials
7173
key: UNKEY_GITHUB_APP_WEBHOOK_SECRET
7274
optional: true
75+
- name: UNKEY_GITHUB_PRIVATE_KEY_PEM
76+
valueFrom:
77+
secretKeyRef:
78+
name: github-credentials
79+
key: UNKEY_GITHUB_PRIVATE_KEY_PEM
80+
optional: true
81+
- name: UNKEY_GITHUB_APP_ID
82+
valueFrom:
83+
secretKeyRef:
84+
name: github-credentials
85+
key: UNKEY_GITHUB_APP_ID
86+
optional: true
7387
volumeMounts:
7488
- name: config
7589
mountPath: /etc/unkey
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"use client";
2+
3+
import type { Deployment } from "@/lib/collections/deploy/deployments";
4+
import { trpc } from "@/lib/trpc/client";
5+
import {
6+
CircleXMark,
7+
CodeBranch,
8+
CodeCommit,
9+
Github,
10+
ShieldAlert,
11+
User,
12+
XMark,
13+
} from "@unkey/icons";
14+
import { Button } from "@unkey/ui";
15+
import { useProjectData } from "../../../data-provider";
16+
17+
export function DeploymentApproval({ deployment }: { deployment: Deployment }) {
18+
const { refetchDeployments } = useProjectData();
19+
20+
const authorize = trpc.deploy.deployment.authorize.useMutation({
21+
onSuccess: () => {
22+
refetchDeployments();
23+
},
24+
});
25+
26+
const shortSha = deployment.gitCommitSha?.slice(0, 7) ?? "";
27+
28+
return (
29+
<div className="flex items-center justify-center min-h-[40vh]">
30+
<div className="w-full max-w-md space-y-6">
31+
{/* Logo connection indicator */}
32+
<div className="flex items-center justify-center gap-4">
33+
<div className="flex items-center justify-center w-12 h-12 rounded-full border border-border bg-background">
34+
<Github iconSize="xl-thin" />
35+
</div>
36+
<div className="flex items-center gap-1.5">
37+
<div className="w-6 border-t border-dashed border-warning-9" />
38+
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-warning-3 border border-warning-6">
39+
<XMark iconSize="sm-regular" className="text-warning-9" />
40+
</div>
41+
<div className="w-6 border-t border-dashed border-warning-9" />
42+
</div>
43+
<div className="flex items-center justify-center w-12 h-12 rounded-full border border-border bg-background">
44+
<ShieldAlert iconSize="xl-thin" className="text-warning-9" />
45+
</div>
46+
</div>
47+
48+
{/* Title */}
49+
<div className="text-center space-y-2">
50+
<h1 className="text-xl font-semibold text-content">Authorization Required</h1>
51+
<p className="text-sm text-content-subtle">
52+
An external contributor pushed a commit. A team member must authorize this deployment
53+
before it can proceed.
54+
</p>
55+
</div>
56+
57+
{/* Commit details */}
58+
<div className="border border-border rounded-lg divide-y divide-border">
59+
{deployment.gitBranch && (
60+
<div className="flex items-center gap-3 px-4 py-3">
61+
<CodeBranch iconSize="md-thin" className="text-content-subtle shrink-0" />
62+
<span className="text-sm text-content-subtle">Branch</span>
63+
<span className="ml-auto text-sm font-mono text-content bg-background-subtle px-2 py-0.5 rounded">
64+
{deployment.gitBranch}
65+
</span>
66+
</div>
67+
)}
68+
69+
{shortSha && (
70+
<div className="flex items-center gap-3 px-4 py-3">
71+
<CodeCommit iconSize="md-thin" className="text-content-subtle shrink-0" />
72+
<span className="text-sm text-content-subtle">Commit</span>
73+
<span className="ml-auto text-sm font-mono text-content bg-background-subtle px-2 py-0.5 rounded">
74+
{shortSha}
75+
</span>
76+
</div>
77+
)}
78+
79+
{deployment.gitCommitMessage && (
80+
<div className="px-4 py-3">
81+
<p className="text-sm text-content truncate">{deployment.gitCommitMessage}</p>
82+
</div>
83+
)}
84+
85+
{deployment.gitCommitAuthorHandle && (
86+
<div className="flex items-center gap-3 px-4 py-3">
87+
{deployment.gitCommitAuthorAvatarUrl ? (
88+
<img
89+
src={deployment.gitCommitAuthorAvatarUrl}
90+
alt={deployment.gitCommitAuthorHandle}
91+
className="w-5 h-5 rounded-full shrink-0"
92+
/>
93+
) : (
94+
<User iconSize="md-thin" className="text-content-subtle shrink-0" />
95+
)}
96+
<span className="text-sm text-content">{deployment.gitCommitAuthorHandle}</span>
97+
</div>
98+
)}
99+
</div>
100+
101+
{/* Error */}
102+
{authorize.error && (
103+
<div className="flex items-start gap-2 p-3 bg-error-3 border border-error-6 rounded-lg">
104+
<CircleXMark iconSize="md-thin" className="text-error-9 mt-0.5 shrink-0" />
105+
<p className="text-sm text-error-11">{authorize.error.message}</p>
106+
</div>
107+
)}
108+
109+
{/* Action */}
110+
<Button
111+
variant="primary"
112+
size="xlg"
113+
className="w-full"
114+
loading={authorize.isLoading}
115+
onClick={() => authorize.mutate({ deploymentId: deployment.id })}
116+
>
117+
Authorize Deployment
118+
</Button>
119+
120+
<p className="text-xs text-center text-content-subtle">
121+
Only workspace members can authorize deployments.
122+
</p>
123+
</div>
124+
</div>
125+
);
126+
}

web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/deployment-utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const DEPLOYMENT_STATUSES: ReadonlySet<string> = new Set<DeploymentStatus>([
1010
"finalizing",
1111
"ready",
1212
"failed",
13+
"awaiting_approval",
1314
]);
1415

1516
function isDeploymentStatus(value: string): value is DeploymentStatus {
@@ -20,6 +21,11 @@ export function deriveStatusFromSteps(
2021
steps: StepsData | undefined,
2122
fallback: string,
2223
): DeploymentStatus {
24+
// awaiting_approval is authoritative from the DB — steps can't derive it
25+
if (fallback === "awaiting_approval") {
26+
return "awaiting_approval";
27+
}
28+
2329
if (!steps) {
2430
return isDeploymentStatus(fallback) ? fallback : "pending";
2531
}

web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/page.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useEffect, useMemo } from "react";
44
import { DeploymentDomainsCard } from "../../../components/deployment-domains-card";
55
import { ProjectContentWrapper } from "../../../components/project-content-wrapper";
66
import { useProjectData } from "../../data-provider";
7+
import { DeploymentApproval } from "./(deployment-progress)/deployment-approval";
78
import { DeploymentInfo } from "./(deployment-progress)/deployment-info";
89
import { DeploymentProgress } from "./(deployment-progress)/deployment-progress";
910
import { DeploymentNetworkSection } from "./(overview)/components/sections/deployment-network-section";
@@ -15,10 +16,11 @@ export default function DeploymentOverview() {
1516
const { refetchDomains } = useProjectData();
1617

1718
const ready = deployment.status === "ready";
19+
const awaitingApproval = deployment.status === "awaiting_approval";
1820

1921
const stepsQuery = trpc.deploy.deployment.steps.useQuery(
2022
{ deploymentId: deployment.id },
21-
{ refetchInterval: ready ? false : 1_000, refetchOnWindowFocus: false },
23+
{ refetchInterval: ready || awaitingApproval ? false : 1_000, refetchOnWindowFocus: false },
2224
);
2325

2426
const derivedStatus = useMemo(
@@ -33,6 +35,17 @@ export default function DeploymentOverview() {
3335
}
3436
}, [ready, refetchDomains, stepsQuery.refetch]);
3537

38+
if (awaitingApproval) {
39+
return (
40+
<ProjectContentWrapper centered>
41+
<DeploymentInfo statusOverride={derivedStatus} />
42+
<div className="animate-fade-slide-in">
43+
<DeploymentApproval deployment={deployment} />
44+
</div>
45+
</ProjectContentWrapper>
46+
);
47+
}
48+
3649
return (
3750
<ProjectContentWrapper centered>
3851
<DeploymentInfo statusOverride={derivedStatus} />
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { env } from "@/lib/env";
2+
import type { DescService } from "@bufbuild/protobuf";
3+
import { type Client, createClient } from "@connectrpc/connect";
4+
import { createConnectTransport } from "@connectrpc/connect-web";
5+
import { TRPCError } from "@trpc/server";
6+
7+
export function createCtrlClient<T extends DescService>(service: T): Client<T> {
8+
const { CTRL_URL, CTRL_API_KEY } = env();
9+
if (!CTRL_URL || !CTRL_API_KEY) {
10+
throw new TRPCError({
11+
code: "PRECONDITION_FAILED",
12+
message: "ctrl service is not configured",
13+
});
14+
}
15+
16+
return createClient(
17+
service,
18+
createConnectTransport({
19+
baseUrl: CTRL_URL,
20+
interceptors: [
21+
(next) => (req) => {
22+
req.header.set("Authorization", `Bearer ${CTRL_API_KEY}`);
23+
return next(req);
24+
},
25+
],
26+
}),
27+
);
28+
}

web/apps/dashboard/lib/trpc/routers/deploy/custom-domains/add.ts

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { CustomDomainService } from "@/gen/proto/ctrl/v1/custom_domain_pb";
2+
import { createCtrlClient } from "@/lib/ctrl-client";
23
import { db } from "@/lib/db";
3-
import { env } from "@/lib/env";
44
import { ratelimit, withRatelimit, workspaceProcedure } from "@/lib/trpc/trpc";
5-
import { Code, ConnectError, createClient } from "@connectrpc/connect";
6-
import { createConnectTransport } from "@connectrpc/connect-web";
5+
import { Code, ConnectError } from "@connectrpc/connect";
76
import { TRPCError } from "@trpc/server";
87
import { z } from "zod";
98

@@ -17,13 +16,7 @@ export const addCustomDomain = workspaceProcedure
1716
}),
1817
)
1918
.mutation(async ({ input, ctx }) => {
20-
const { CTRL_URL, CTRL_API_KEY } = env();
21-
if (!CTRL_URL || !CTRL_API_KEY) {
22-
throw new TRPCError({
23-
code: "PRECONDITION_FAILED",
24-
message: "ctrl service is not configured",
25-
});
26-
}
19+
const ctrl = createCtrlClient(CustomDomainService);
2720

2821
// Verify project belongs to workspace
2922
const project = await db.query.projects.findFirst({
@@ -60,19 +53,6 @@ export const addCustomDomain = workspaceProcedure
6053

6154
const appId = environment.appId;
6255

63-
const ctrl = createClient(
64-
CustomDomainService,
65-
createConnectTransport({
66-
baseUrl: CTRL_URL,
67-
interceptors: [
68-
(next) => (req) => {
69-
req.header.set("Authorization", `Bearer ${CTRL_API_KEY}`);
70-
return next(req);
71-
},
72-
],
73-
}),
74-
);
75-
7656
try {
7757
const response = await ctrl.addCustomDomain({
7858
workspaceId: ctx.workspace.id,

web/apps/dashboard/lib/trpc/routers/deploy/custom-domains/delete.ts

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import { CustomDomainService } from "@/gen/proto/ctrl/v1/custom_domain_pb";
2+
import { createCtrlClient } from "@/lib/ctrl-client";
23
import { db } from "@/lib/db";
3-
import { env } from "@/lib/env";
44
import { ratelimit, withRatelimit, workspaceProcedure } from "@/lib/trpc/trpc";
5-
import { createClient } from "@connectrpc/connect";
6-
import { createConnectTransport } from "@connectrpc/connect-web";
75
import { TRPCError } from "@trpc/server";
86
import { z } from "zod";
97

@@ -16,13 +14,7 @@ export const deleteCustomDomain = workspaceProcedure
1614
}),
1715
)
1816
.mutation(async ({ input, ctx }) => {
19-
const { CTRL_URL, CTRL_API_KEY } = env();
20-
if (!CTRL_URL || !CTRL_API_KEY) {
21-
throw new TRPCError({
22-
code: "PRECONDITION_FAILED",
23-
message: "ctrl service is not configured",
24-
});
25-
}
17+
const ctrl = createCtrlClient(CustomDomainService);
2618

2719
// Verify project belongs to workspace
2820
const project = await db.query.projects.findFirst({
@@ -60,19 +52,6 @@ export const deleteCustomDomain = workspaceProcedure
6052
});
6153
}
6254

63-
const ctrl = createClient(
64-
CustomDomainService,
65-
createConnectTransport({
66-
baseUrl: CTRL_URL,
67-
interceptors: [
68-
(next) => (req) => {
69-
req.header.set("Authorization", `Bearer ${CTRL_API_KEY}`);
70-
return next(req);
71-
},
72-
],
73-
}),
74-
);
75-
7655
try {
7756
await ctrl.deleteCustomDomain({
7857
workspaceId: ctx.workspace.id,

web/apps/dashboard/lib/trpc/routers/deploy/custom-domains/retry.ts

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import { CustomDomainService } from "@/gen/proto/ctrl/v1/custom_domain_pb";
2+
import { createCtrlClient } from "@/lib/ctrl-client";
23
import { db } from "@/lib/db";
3-
import { env } from "@/lib/env";
44
import { ratelimit, withRatelimit, workspaceProcedure } from "@/lib/trpc/trpc";
5-
import { createClient } from "@connectrpc/connect";
6-
import { createConnectTransport } from "@connectrpc/connect-web";
75
import { TRPCError } from "@trpc/server";
86
import { z } from "zod";
97

@@ -16,13 +14,7 @@ export const retryVerification = workspaceProcedure
1614
}),
1715
)
1816
.mutation(async ({ input, ctx }) => {
19-
const { CTRL_URL, CTRL_API_KEY } = env();
20-
if (!CTRL_URL || !CTRL_API_KEY) {
21-
throw new TRPCError({
22-
code: "PRECONDITION_FAILED",
23-
message: "ctrl service is not configured",
24-
});
25-
}
17+
const ctrl = createCtrlClient(CustomDomainService);
2618

2719
// Verify project belongs to workspace
2820
const project = await db.query.projects.findFirst({
@@ -60,19 +52,6 @@ export const retryVerification = workspaceProcedure
6052
});
6153
}
6254

63-
const ctrl = createClient(
64-
CustomDomainService,
65-
createConnectTransport({
66-
baseUrl: CTRL_URL,
67-
interceptors: [
68-
(next) => (req) => {
69-
req.header.set("Authorization", `Bearer ${CTRL_API_KEY}`);
70-
return next(req);
71-
},
72-
],
73-
}),
74-
);
75-
7655
try {
7756
const response = await ctrl.retryVerification({
7857
workspaceId: ctx.workspace.id,

0 commit comments

Comments
 (0)