From 41080e1ce3aab8f8537a6a22b4d2a3378e897abb Mon Sep 17 00:00:00 2001 From: Van Tan Minh Date: Sat, 3 Oct 2026 19:07:15 +0700 Subject: [PATCH 1/3] feat: revoke registry connections when Registry reports grant_revoked Co-Authored-By: Claude Opus 5.5 --- apps/api/src/knotree_registry.rs | 23 +++++ apps/api/src/registry_accounts.rs | 154 +++++++++++++++++++++++++++--- 2 files changed, 166 insertions(+), 11 deletions(-) diff --git a/apps/api/src/knotree_registry.rs b/apps/api/src/knotree_registry.rs index 523432c..eafc578 100644 --- a/apps/api/src/knotree_registry.rs +++ b/apps/api/src/knotree_registry.rs @@ -99,6 +99,8 @@ struct RegistryEventMetadata { owner_issuer: Option, #[serde(default)] owner_subject: Option, + #[serde(default)] + credential_id: Option, } /// An account-derived connection auto-deploys only when Registry reports the @@ -663,6 +665,26 @@ pub async fn webhook(State(state): State, headers: HeaderMap, body: By if event.schema_version != 1 || event.kind != event_header { return StatusCode::BAD_REQUEST; } + if event.kind == "grant_revoked" { + let Some(credential_id) = event.metadata.credential_id else { + return StatusCode::BAD_REQUEST; + }; + let owner = event + .metadata + .owner_issuer + .as_deref() + .zip(event.metadata.owner_subject.as_deref()); + return match crate::registry_accounts::revoke_from_registry(&state, credential_id, owner) + .await + { + Ok(()) => StatusCode::NO_CONTENT, + Err(error) => { + tracing::error!(delivery_id = %delivery_id, error = ?error, + "could not apply Knotree Registry grant revocation"); + StatusCode::INTERNAL_SERVER_ERROR + } + }; + } if event.kind != "tag_updated" { return StatusCode::NO_CONTENT; } @@ -904,6 +926,7 @@ mod tests { is_tag: Some(true), owner_issuer: issuer.map(Into::into), owner_subject: subject.map(Into::into), + credential_id: None, }; let issuer = "https://accounts.knotree.com"; let alice = Some((issuer, "alice")); diff --git a/apps/api/src/registry_accounts.rs b/apps/api/src/registry_accounts.rs index a6ab88e..4047cfd 100644 --- a/apps/api/src/registry_accounts.rs +++ b/apps/api/src/registry_accounts.rs @@ -334,13 +334,74 @@ async fn revoke_account_rows( .bind(account_id) .fetch_all(&mut **transaction) .await?; + stop_connection_deploys(transaction, &connection_ids, reason).await +} + +/// Registry reported that the user revoked a credential it issued to Cloud. +/// Revokes the account that holds it (only when the reported owner matches) +/// and any legacy repository connection, then stops their auto-deploys. +pub(crate) async fn revoke_from_registry( + state: &AppState, + credential_id: Uuid, + owner: Option<(&str, &str)>, +) -> Result<(), AppError> { + const REASON: &str = "Knotree Registry access was revoked in Registry. Reconnect to continue."; + let mut transaction = state.db.begin().await?; + let mut service_ids = Vec::new(); + if let Some((issuer, subject)) = owner { + let account_ids: Vec = sqlx::query_scalar( + "SELECT id FROM knotree_registry_accounts + WHERE delegated_credential_id=$1 AND issuer=$2 AND subject=$3 AND revoked_at IS NULL", + ) + .bind(credential_id) + .bind(issuer) + .bind(subject) + .fetch_all(&mut *transaction) + .await?; + for account_id in account_ids { + service_ids.extend(revoke_account_rows(&mut transaction, account_id, REASON).await?); + } + } + let connection_ids: Vec = sqlx::query_scalar( + "UPDATE knotree_registry_connections SET revoked_at=now(), updated_at=now() + WHERE delegated_credential_id=$1 AND account_id IS NULL AND revoked_at IS NULL + RETURNING id", + ) + .bind(credential_id) + .fetch_all(&mut *transaction) + .await?; + service_ids.extend(stop_connection_deploys(&mut transaction, &connection_ids, REASON).await?); + transaction.commit().await?; + remove_pull_secrets(state, service_ids).await; + Ok(()) +} + +async fn remove_pull_secrets(state: &AppState, service_ids: Vec) { + if !state.config.uses_kubernetes_workloads() { + return; + } + for service_id in service_ids { + if let Err(error) = + crate::cluster_kubernetes::delete_app_image_pull_secret(&state.config, service_id).await + { + tracing::warn!(app_service_id = %service_id, error = %error, + "could not remove Knotree Registry Kubernetes pull Secret"); + } + } +} + +async fn stop_connection_deploys( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + connection_ids: &[Uuid], + reason: &str, +) -> Result, AppError> { let service_ids: Vec = sqlx::query_scalar( "UPDATE project_app_services SET auto_deploy_enabled = FALSE, registry_connection_id = NULL, auto_deploy_error = $2, updated_at = now() WHERE registry_connection_id = ANY($1) RETURNING id", ) - .bind(&connection_ids) + .bind(connection_ids) .bind(reason) .fetch_all(&mut **transaction) .await?; @@ -424,16 +485,7 @@ pub async fn disconnect( ) .await?; transaction.commit().await?; - if state.config.uses_kubernetes_workloads() { - for service_id in service_ids { - if let Err(error) = - crate::cluster_kubernetes::delete_app_image_pull_secret(&state.config, service_id).await - { - tracing::warn!(app_service_id = %service_id, error = %error, - "could not remove Knotree Registry Kubernetes pull Secret"); - } - } - } + remove_pull_secrets(&state, service_ids).await; Ok(StatusCode::NO_CONTENT) } @@ -739,4 +791,84 @@ mod tests { assert!(safe_return_to(Some("/\\evil.example")).is_none()); assert!(safe_return_to(None).is_none()); } + + #[tokio::test] + async fn registry_revocation_only_revokes_the_reported_owner() { + use crate::test_support::{seed_owner_project, test_app_state}; + let Some(state) = test_app_state().await else { + return; + }; + let project = seed_owner_project(&state).await; + let issuer = "https://accounts.knotree.com"; + let subject = project.user_id.to_string(); + let credential_id = Uuid::new_v4(); + let account_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO knotree_registry_accounts + (id, user_id, issuer, subject, registry_username, credential_ciphertext, + delegated_credential_id, credential_expires_at) + VALUES ($1, $2, $3, $4, 'kt-owner', 'unused', $5, now() + interval '30 days')", + ) + .bind(account_id) + .bind(project.user_id) + .bind(issuer) + .bind(&subject) + .bind(credential_id) + .execute(&state.db) + .await + .unwrap(); + let derived = Uuid::new_v4(); + let legacy_credential = Uuid::new_v4(); + let legacy = Uuid::new_v4(); + for (id, account, delegated) in [ + (derived, Some(account_id), None), + (legacy, None, Some(legacy_credential)), + ] { + sqlx::query( + "INSERT INTO knotree_registry_connections + (id, project_id, user_id, registry_username, repository, + credential_ciphertext, account_id, delegated_credential_id) + VALUES ($1, $2, $3, 'kt-owner', 'kt-owner/app', 'unused', $4, $5)", + ) + .bind(id) + .bind(project.project_id) + .bind(project.user_id) + .bind(account) + .bind(delegated) + .execute(&state.db) + .await + .unwrap(); + } + let revoked = |table: &'static str, id: Uuid| { + let db = state.db.clone(); + async move { + sqlx::query_scalar::<_, bool>(&format!( + "SELECT revoked_at IS NOT NULL FROM {table} WHERE id = $1" + )) + .bind(id) + .fetch_one(&db) + .await + .unwrap() + } + }; + + // A different owner cannot revoke someone else's account. + revoke_from_registry(&state, credential_id, Some((issuer, "someone-else"))) + .await + .unwrap(); + assert!(!revoked("knotree_registry_accounts", account_id).await); + assert!(!revoked("knotree_registry_connections", derived).await); + + revoke_from_registry(&state, credential_id, Some((issuer, &subject))) + .await + .unwrap(); + assert!(revoked("knotree_registry_accounts", account_id).await); + assert!(revoked("knotree_registry_connections", derived).await); + assert!(!revoked("knotree_registry_connections", legacy).await); + + revoke_from_registry(&state, legacy_credential, None).await.unwrap(); + assert!(revoked("knotree_registry_connections", legacy).await); + // Replays are harmless. + revoke_from_registry(&state, legacy_credential, None).await.unwrap(); + } } From 3bfc46b82fe01456e8577fcee5ffe8de7f9d1b37 Mon Sep 17 00:00:00 2001 From: Van Tan Minh Date: Sat, 3 Oct 2026 19:18:04 +0700 Subject: [PATCH 2/3] feat: connect Knotree Registry from Integrations and import images in the deploy dialog Co-Authored-By: Claude Opus 5.5 --- apps/api/src/registry_accounts.rs | 2 +- .../app-service-create-dialog.test.tsx | 128 ++++++ .../components/app-service-create-dialog.tsx | 385 +++++++++++------- .../knotree-registry-account-card.tsx | 250 ++++++++++++ .../knotree-registry-image-picker.tsx | 219 ++++++++++ apps/web/src/index.css | 70 +++- apps/web/src/lib/resources.ts | 79 +++- apps/web/src/lib/types.ts | 31 ++ .../pages/github-integration-page.test.tsx | 46 ++- .../web/src/pages/github-integration-page.tsx | 19 +- 10 files changed, 1068 insertions(+), 161 deletions(-) create mode 100644 apps/web/src/components/knotree-registry-account-card.tsx create mode 100644 apps/web/src/components/knotree-registry-image-picker.tsx diff --git a/apps/api/src/registry_accounts.rs b/apps/api/src/registry_accounts.rs index 4047cfd..cfe84c9 100644 --- a/apps/api/src/registry_accounts.rs +++ b/apps/api/src/registry_accounts.rs @@ -250,7 +250,7 @@ pub(crate) async fn complete_callback( "connected" }; let mut destination = Url::parse(&config.frontend_url).map_err(|_| invalid())?; - let path = attempt.return_to.as_deref().unwrap_or("/integrations"); + let path = attempt.return_to.as_deref().unwrap_or("/settings/integrations"); let (path, query) = path.split_once('?').unwrap_or((path, "")); destination.set_path(path); destination.set_query((!query.is_empty()).then_some(query)); diff --git a/apps/web/src/components/app-service-create-dialog.test.tsx b/apps/web/src/components/app-service-create-dialog.test.tsx index 565a6cc..2eef384 100644 --- a/apps/web/src/components/app-service-create-dialog.test.tsx +++ b/apps/web/src/components/app-service-create-dialog.test.tsx @@ -12,6 +12,11 @@ const mocks = vi.hoisted(() => ({ getGithubConnectionStatus: vi.fn(), getGithubAuthorizationUrl: vi.fn(), listAppServices: vi.fn(), + getKnotreeRegistryAccount: vi.fn(), + listKnotreeRegistryRepositories: vi.fn(), + listKnotreeRegistryTags: vi.fn(), + importKnotreeRegistryRepository: vi.fn(), + startKnotreeRegistryAccountConsent: vi.fn(), })) vi.mock("@/lib/resources", () => ({ @@ -23,11 +28,26 @@ vi.mock("@/lib/resources", () => ({ listAppServices: mocks.listAppServices, listKnotreeRegistryConnections: mocks.listKnotreeRegistryConnections, appServiceDeploymentEventsUrl: () => "/events", + getKnotreeRegistryAccount: mocks.getKnotreeRegistryAccount, + listKnotreeRegistryRepositories: mocks.listKnotreeRegistryRepositories, + listKnotreeRegistryTags: mocks.listKnotreeRegistryTags, + importKnotreeRegistryRepository: mocks.importKnotreeRegistryRepository, + startKnotreeRegistryAccountConsent: mocks.startKnotreeRegistryAccountConsent, + isKnotreeRegistryAuthorizationUrl: (value: string) => + value.startsWith("https://registry.knotree.com/cloud/authorize/"), })) describe("AppServiceCreateDialog HTML pages", () => { beforeEach(() => { vi.clearAllMocks() + mocks.getKnotreeRegistryAccount.mockResolvedValue({ + connected: false, + consentReady: true, + autoDeployReady: false, + namespace: null, + expiresAt: null, + expired: false, + }) mocks.createAppService.mockResolvedValue({ id: "svc-1", name: "Docs", @@ -264,4 +284,112 @@ describe("AppServiceCreateDialog HTML pages", () => { ) }) }) + + it("imports an image from the connected Registry account with auto-deploy", async () => { + const user = userEvent.setup() + mocks.getKnotreeRegistryAccount.mockResolvedValue({ + connected: true, + consentReady: true, + autoDeployReady: true, + namespace: "kt-owner", + expiresAt: "2026-11-01T00:00:00Z", + expired: false, + }) + mocks.listKnotreeRegistryConnections.mockResolvedValue({ + connections: [], + autoDeployReady: true, + consentReady: true, + }) + mocks.listKnotreeRegistryRepositories.mockResolvedValue({ + namespace: "kt-owner", + registryHost: "registry.knotree.com", + repositories: [ + { + name: "kt-owner/api", + tagCount: 2, + latestTag: "production", + latestDigest: null, + size: 0, + updatedAt: null, + }, + ], + }) + mocks.listKnotreeRegistryTags.mockResolvedValue({ + repository: "kt-owner/api", + tags: [ + { + tag: "production", + digest: `sha256:${"a".repeat(64)}`, + size: 0, + createdAt: null, + }, + { + tag: "latest", + digest: `sha256:${"b".repeat(64)}`, + size: 0, + createdAt: null, + }, + ], + }) + mocks.importKnotreeRegistryRepository.mockResolvedValue({ + id: "account-connection-1", + registryHost: "registry.knotree.com", + username: "kt-owner", + repository: "kt-owner/api", + verifiedAt: "2026-10-03T00:00:00Z", + }) + + render( + + ) + await user.selectOptions( + screen.getByLabelText("Image access"), + "knotree_registry" + ) + await user.click( + await screen.findByRole("button", { name: /kt-owner\/api/ }) + ) + await waitFor(() => + expect(screen.getByLabelText("Docker image")).toHaveValue( + "registry.knotree.com/kt-owner/api:production" + ) + ) + await user.selectOptions(screen.getByLabelText("Tag"), "latest") + expect(screen.getByLabelText("Docker image")).toHaveValue( + "registry.knotree.com/kt-owner/api:latest" + ) + // The per-repository token fields are not needed for the user's own images. + expect(screen.queryByLabelText("Pull-only access token")).toBeNull() + await user.click( + screen.getByRole("checkbox", { name: /Auto-deploy new image digests/ }) + ) + await user.click(screen.getByRole("button", { name: "Deploy service" })) + + await waitFor(() => { + expect(mocks.importKnotreeRegistryRepository).toHaveBeenCalledWith( + "de305d54-75b4-431b-adb2-eb6b9e546014", + "proj", + "kt-owner/api" + ) + expect(mocks.createAppService).toHaveBeenCalledWith( + "de305d54-75b4-431b-adb2-eb6b9e546014", + "proj", + { + name: "App service", + image: "registry.knotree.com/kt-owner/api:latest", + imageSource: "knotree_registry", + appPort: 3000, + registryConnectionId: "account-connection-1", + autoDeploy: true, + } + ) + }) + expect(mocks.createKnotreeRegistryConnection).not.toHaveBeenCalled() + }) }) diff --git a/apps/web/src/components/app-service-create-dialog.tsx b/apps/web/src/components/app-service-create-dialog.tsx index c548088..c783459 100644 --- a/apps/web/src/components/app-service-create-dialog.tsx +++ b/apps/web/src/components/app-service-create-dialog.tsx @@ -4,6 +4,8 @@ import { BoxIcon, FileCodeIcon, GitBranchIcon } from "lucide-react" import { Alert, AlertDescription } from "@/components/ui/alert" import { AppServiceDeploymentLogs } from "@/components/app-service-deployment-logs" +import { connectKnotreeRegistryAccount } from "@/components/knotree-registry-account-card" +import { KnotreeRegistryImagePicker } from "@/components/knotree-registry-image-picker" import { Button } from "@/components/ui/button" import { Field, @@ -25,12 +27,15 @@ import { listKnotreeRegistryConnections, getGithubAuthorizationUrl, getGithubConnectionStatus, + getKnotreeRegistryAccount, + importKnotreeRegistryRepository, listAppServices, } from "@/lib/resources" import type { AppService, AppServiceDeployment, GithubConnectionStatus, + KnotreeRegistryAccountStatus, KnotreeRegistryConnection, } from "@/lib/types" @@ -97,6 +102,9 @@ export function AppServiceCreateDialog({ const [registryStatusError, setRegistryStatusError] = useState( null ) + const [registryAccount, setRegistryAccount] = + useState(null) + const [registryAccountLoaded, setRegistryAccountLoaded] = useState(false) const onCreatedRef = useRef(onCreated) const deploymentId = deployment?.id const deploymentIsActive = deployment?.status === "provisioning" @@ -124,6 +132,14 @@ export function AppServiceCreateDialog({ ) ? registryConnectionChoice : NEW_REGISTRY_CONNECTION + // Images in the user's own connected namespace deploy through the account + // connection; anything else keeps the per-repository token flow. + const accountCovers = Boolean( + registryAccount?.connected && + !registryAccount.expired && + registryAccount.namespace && + registryRepository.startsWith(`${registryAccount.namespace}/`) + ) useEffect(() => { onCreatedRef.current = onCreated @@ -157,6 +173,33 @@ export function AppServiceCreateDialog({ } }, [needsGithub, open]) + useEffect(() => { + if (!open || !needsKnotreeRegistry) { + return undefined + } + + let active = true + void getKnotreeRegistryAccount() + .then((account) => { + if (active) { + setRegistryAccount(account) + if (account.autoDeployReady && account.connected) { + setRegistryAutoDeployReady(true) + } + } + }) + .catch(() => { + if (active) setRegistryAccount(null) + }) + .finally(() => { + if (active) setRegistryAccountLoaded(true) + }) + + return () => { + active = false + } + }, [needsKnotreeRegistry, open]) + useEffect(() => { if (!open || !needsKnotreeRegistry) { return undefined @@ -296,7 +339,9 @@ export function AppServiceCreateDialog({ nextErrors.image = "Use registry.knotree.com/repository:tag with an explicit tag." } - if (registryChoice === NEW_REGISTRY_CONNECTION) { + if (accountCovers) { + // The account connection supplies the credential. + } else if (registryChoice === NEW_REGISTRY_CONNECTION) { if (!registryUsername.trim()) { nextErrors.username = "Enter your Knotree Registry username." } @@ -346,7 +391,7 @@ export function AppServiceCreateDialog({ ) return } - if (needsKnotreeRegistry && registryStatusLoading) { + if (needsKnotreeRegistry && registryStatusLoading && !accountCovers) { setSubmitError("Loading saved Knotree Registry connections…") return } @@ -365,7 +410,14 @@ export function AppServiceCreateDialog({ : undefined if (needsKnotreeRegistry) { let registryConnectionId = registryChoice - if (registryChoice === NEW_REGISTRY_CONNECTION) { + if (accountCovers) { + const connection = await importKnotreeRegistryRepository( + workspaceId, + projectSlug, + registryRepository + ) + registryConnectionId = connection.id + } else if (registryChoice === NEW_REGISTRY_CONNECTION) { const connection = await createKnotreeRegistryConnection( workspaceId, projectSlug, @@ -475,7 +527,9 @@ export function AppServiceCreateDialog({ {kind === "html" ? "Knotree injects analytics, serves CSS/JS/subfolders like GitHub Pages, and sets Cloudflare cache headers on static assets." : needsKnotreeRegistry - ? "Use a pull-only Knotree Registry token scoped to this image repository. Cloud stores it encrypted and keeps it out of your service container." + ? accountCovers + ? "Deploying from your connected Knotree Registry account. Cloud pulls by digest and never passes the credential to your container." + : "Pick an image from your connected Knotree Registry account, or use a pull-only token scoped to one repository. Cloud stores credentials encrypted and keeps them out of your service container." : "Public images do not need a login. Private images can use GitHub Container Registry or Knotree Registry."} @@ -591,155 +645,193 @@ export function AppServiceCreateDialog({ {needsKnotreeRegistry && ( <> - - - Knotree Registry connection - - - - {registryStatusLoading - ? "Loading saved Registry connections…" - : registryRepository - ? `This connection needs pull access to repository:${registryRepository}:pull.` - : "Enter a tagged image above to select or create a repository connection."} - - {errors.registryConnectionId && ( - - {errors.registryConnectionId} - - )} - - {registryConsentReady && - registryChoice === NEW_REGISTRY_CONNECTION && ( - - - - Review access on Registry, then return to this - project to select the saved connection. - - - )} - {registryChoice === NEW_REGISTRY_CONNECTION && ( + + setImage( + `registry.knotree.com/${repository}:${tag}` + ) + } + onConnect={() => { + setRegistryConsentBusy(true) + setSubmitError(null) + void connectKnotreeRegistryAccount( + window.location.pathname + ).catch((reason: unknown) => { + setSubmitError( + reason instanceof Error + ? reason.message + : "Registry authorization failed." + ) + setRegistryConsentBusy(false) + }) + }} + /> + {!accountCovers && ( <> - - - Registry username - - - setRegistryUsername(event.target.value) - } - /> - {errors.username && ( - {errors.username} + - - - Pull-only access token + > + + Knotree Registry connection - - setRegistryToken(event.target.value) + setRegistryConnectionChoice( + event.target.value + ) } - /> + > + + {matchingRegistryConnections.map( + (connection) => ( + + ) + )} + - Create a token with only pull permission for - this repository. Cloud encrypts it; it is - never passed to your container. + {registryStatusLoading + ? "Loading saved Registry connections…" + : registryRepository + ? `This connection needs pull access to repository:${registryRepository}:pull.` + : "Enter a tagged image above to select or create a repository connection."} - {errors.token && ( - {errors.token} + {errors.registryConnectionId && ( + + {errors.registryConnectionId} + )} + {registryConsentReady && + registryChoice === NEW_REGISTRY_CONNECTION && ( + + + + Review access on Registry, then return to + this project to select the saved + connection. + + + )} + {registryChoice === NEW_REGISTRY_CONNECTION && ( + <> + + + Registry username + + + setRegistryUsername(event.target.value) + } + /> + {errors.username && ( + {errors.username} + )} + + + + Pull-only access token + + + setRegistryToken(event.target.value) + } + /> + + Create a token with only pull permission + for this repository. Cloud encrypts it; it + is never passed to your container. + + {errors.token && ( + {errors.token} + )} + + + )} )} {registryStatusError && ( @@ -965,11 +1057,14 @@ export function AppServiceCreateDialog({ disabled={ isSubmitting || isConnecting || - (needsKnotreeRegistry && registryStatusLoading) + (needsKnotreeRegistry && + registryStatusLoading && + !accountCovers) } > {isSubmitting && } {needsKnotreeRegistry && + !accountCovers && registryChoice === NEW_REGISTRY_CONNECTION ? "Connect Knotree Registry & deploy" : "Deploy service"} diff --git a/apps/web/src/components/knotree-registry-account-card.tsx b/apps/web/src/components/knotree-registry-account-card.tsx new file mode 100644 index 0000000..562d8a3 --- /dev/null +++ b/apps/web/src/components/knotree-registry-account-card.tsx @@ -0,0 +1,250 @@ +import { useEffect, useState } from "react" +import { + CheckCircle2Icon, + ContainerIcon, + Link2OffIcon, + RefreshCwIcon, +} from "lucide-react" + +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Spinner } from "@/components/ui/spinner" +import { ApiError } from "@/lib/api" +import { + disconnectKnotreeRegistryAccount, + getKnotreeRegistryAccount, + isKnotreeRegistryAuthorizationUrl, + startKnotreeRegistryAccountConsent, +} from "@/lib/resources" +import type { KnotreeRegistryAccountStatus } from "@/lib/types" + +/** Starts the one-time, SSO-bound Registry consent and leaves for Registry. */ +export async function connectKnotreeRegistryAccount(returnTo: string) { + const { authorizationUrl } = + await startKnotreeRegistryAccountConsent(returnTo) + if (!isKnotreeRegistryAuthorizationUrl(authorizationUrl)) { + throw new Error("Registry returned an invalid authorization URL.") + } + window.location.assign(authorizationUrl) +} + +function errorMessage(error: unknown, fallback: string) { + return error instanceof ApiError || error instanceof Error + ? error.message + : fallback +} + +export function KnotreeRegistryAccountCard({ + callbackStatus, +}: { + callbackStatus: string | null +}) { + const [account, setAccount] = useState( + null + ) + const [isLoading, setIsLoading] = useState(true) + const [isConnecting, setIsConnecting] = useState(false) + const [isDisconnecting, setIsDisconnecting] = useState(false) + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + + useEffect(() => { + let active = true + void getKnotreeRegistryAccount() + .then((status) => { + if (active) setAccount(status) + }) + .catch((reason: unknown) => { + if (active) { + setError( + errorMessage( + reason, + "Knotree Registry status is currently unavailable." + ) + ) + } + }) + .finally(() => { + if (active) setIsLoading(false) + }) + return () => { + active = false + } + }, []) + + async function connect() { + setError(null) + setIsConnecting(true) + try { + await connectKnotreeRegistryAccount("/settings/integrations") + } catch (reason) { + setError(errorMessage(reason, "Registry authorization failed.")) + setIsConnecting(false) + } + } + + async function disconnect() { + setError(null) + setIsDisconnecting(true) + try { + await disconnectKnotreeRegistryAccount() + setAccount((current) => + current + ? { + ...current, + connected: false, + namespace: null, + expiresAt: null, + expired: false, + } + : current + ) + setNotice( + "Knotree Registry was disconnected. Auto-deploys from it are turned off." + ) + } catch (reason) { + setError(errorMessage(reason, "Registry could not be disconnected.")) + } finally { + setIsDisconnecting(false) + } + } + + const callbackMessage = + callbackStatus === "connected" + ? "Knotree Registry is connected. Import your images from any project." + : callbackStatus === "denied" + ? "Registry access was not granted." + : callbackStatus === "error" + ? "Knotree Registry could not be connected. Try again." + : null + const busy = isLoading || isConnecting || isDisconnecting + const expiresOn = account?.expiresAt + ? new Date(account.expiresAt).toLocaleDateString() + : null + + return ( + <> + {callbackMessage && ( + + + )} + {notice && ( + + + )} + {error && ( + + {error} + + )} + + + +
+ Knotree Registry + + Deploy your own registry.knotree.com images and redeploy + automatically when you push a tag. + +
+
+ + {isLoading ? ( +
+ + Checking Knotree Registry connection +
+ ) : account?.connected ? ( +
+ +
+ + {account.expired + ? `Expired · ${account.namespace}` + : `Connected as ${account.namespace}`} + +

+ {account.expired + ? "Reconnect to keep pulling images and auto-deploying." + : `Pull-only access to your own namespace${ + expiresOn ? ` until ${expiresOn}` : "" + }. Other users never see these images.`} +

+
+
+ ) : ( +
+ +
+ Not connected +

+ {account && !account.consentReady + ? "Sign in with Knotree Accounts to connect Registry." + : "Connect once to pick images from your Registry namespace in every project."} +

+
+
+ )} +
+ + {account?.connected ? ( + <> + + + + ) : ( + + )} + +
+ + ) +} diff --git a/apps/web/src/components/knotree-registry-image-picker.tsx b/apps/web/src/components/knotree-registry-image-picker.tsx new file mode 100644 index 0000000..b60d9d1 --- /dev/null +++ b/apps/web/src/components/knotree-registry-image-picker.tsx @@ -0,0 +1,219 @@ +import { useEffect, useState } from "react" +import { ContainerIcon, SearchIcon } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Field, FieldDescription, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Spinner } from "@/components/ui/spinner" +import { ApiError } from "@/lib/api" +import { + listKnotreeRegistryRepositories, + listKnotreeRegistryTags, +} from "@/lib/resources" +import type { + KnotreeRegistryAccountStatus, + KnotreeRegistryRepository, + KnotreeRegistryTag, +} from "@/lib/types" + +type KnotreeRegistryImagePickerProps = { + account: KnotreeRegistryAccountStatus | null + accountLoading: boolean + selectedRepository: string + disabled?: boolean + onConnect: () => void + onSelect: (repository: string, tag: string) => void +} + +function errorMessage(error: unknown, fallback: string) { + return error instanceof ApiError ? error.message : fallback +} + +/** + * Lists only the signed-in user's own Registry namespace (the API uses that + * user's account credential) and picks a repository and tag to deploy. + */ +export function KnotreeRegistryImagePicker({ + account, + accountLoading, + selectedRepository, + disabled, + onConnect, + onSelect, +}: KnotreeRegistryImagePickerProps) { + const usable = Boolean(account?.connected && !account.expired) + const [repositories, setRepositories] = useState< + KnotreeRegistryRepository[] | null + >(null) + const [query, setQuery] = useState("") + const [tags, setTags] = useState(null) + const [tagsFor, setTagsFor] = useState("") + const [selectedTag, setSelectedTag] = useState("") + const [error, setError] = useState(null) + + useEffect(() => { + if (!usable) return undefined + let active = true + void listKnotreeRegistryRepositories() + .then((result) => { + if (active) setRepositories(result.repositories) + }) + .catch((reason: unknown) => { + if (active) { + setRepositories([]) + setError( + errorMessage(reason, "Your Registry images could not be loaded.") + ) + } + }) + return () => { + active = false + } + }, [usable]) + + async function chooseRepository(repository: KnotreeRegistryRepository) { + setError(null) + setTags(null) + setTagsFor(repository.name) + try { + const result = await listKnotreeRegistryTags(repository.name) + setTags(result.tags) + const initial = + result.tags.find((tag) => tag.tag === repository.latestTag)?.tag ?? + result.tags.find((tag) => tag.tag === "latest")?.tag ?? + result.tags[0]?.tag ?? + "" + setSelectedTag(initial) + if (initial) onSelect(repository.name, initial) + } catch (reason) { + setTags([]) + setError(errorMessage(reason, "Tags could not be loaded.")) + } + } + + if (accountLoading) { + return ( +
+ Checking your Knotree Registry connection… +
+ ) + } + + if (!usable) { + return ( +
+

+ {account?.expired + ? "Your Knotree Registry connection expired. Reconnect to import images." + : "Connect your Knotree Registry account once to pick your images here and redeploy on every push."} +

+ +
+ ) + } + + const visible = (repositories ?? []).filter((repository) => + repository.name.toLowerCase().includes(query.trim().toLowerCase()) + ) + + return ( +
+ + + Import from {account?.namespace} + +
+
+ {repositories === null ? ( +
+ Loading your images… +
+ ) : visible.length === 0 ? ( + + {repositories.length === 0 + ? `No images yet. Push one with docker push registry.knotree.com/${account?.namespace}/app:latest.` + : "No images match your search."} + + ) : ( +
    + {visible.map((repository) => ( +
  • + +
  • + ))} +
+ )} +
+ {tagsFor && ( + + Tag + {tags === null ? ( +
+ Loading tags… +
+ ) : ( + + )} + + With automatic deploys on, every push to this tag rolls out the new + digest. + +
+ )} + {error && ( +

+ {error} +

+ )} +
+ ) +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index cb6cdbb..a5df246 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -228,7 +228,11 @@ .github-integration-page { min-height: 100svh; background: - radial-gradient(circle at 85% 0%, color-mix(in oklch, var(--primary) 8%, transparent), transparent 28rem), + radial-gradient( + circle at 85% 0%, + color-mix(in oklch, var(--primary) 8%, transparent), + transparent 28rem + ), var(--background); } @@ -343,7 +347,8 @@ .github-integration-card { gap: 0; border: 1px solid color-mix(in oklch, var(--border), transparent 5%); - box-shadow: 0 1.5rem 4rem color-mix(in oklch, var(--foreground) 6%, transparent); + box-shadow: 0 1.5rem 4rem + color-mix(in oklch, var(--foreground) 6%, transparent); } .github-integration-card-header { @@ -773,6 +778,67 @@ line-height: 1.4; } +.project-registry-picker { + display: grid; + gap: 0.75rem; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.project-registry-search { + position: relative; +} + +.project-registry-search svg { + position: absolute; + top: 50%; + left: 0.625rem; + width: 1rem; + height: 1rem; + color: var(--muted-foreground); + transform: translateY(-50%); +} + +.project-registry-search input { + padding-left: 2rem; +} + +.project-registry-repositories { + display: grid; + max-height: 12rem; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.project-registry-repositories li + li { + border-top: 1px solid var(--border); +} + +.project-registry-repositories button { + display: flex; + width: 100%; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + color: var(--foreground); + text-align: left; + overflow-wrap: anywhere; +} + +.project-registry-repositories button:hover, +.project-registry-repositories button[aria-pressed="true"] { + background: var(--muted); +} + +.project-registry-repositories small { + flex: 0 0 auto; + color: var(--muted-foreground); + font-size: 0.75rem; +} + .project-dialog-actions { display: flex; justify-content: flex-end; diff --git a/apps/web/src/lib/resources.ts b/apps/web/src/lib/resources.ts index 9fe0e6e..a509b0c 100644 --- a/apps/web/src/lib/resources.ts +++ b/apps/web/src/lib/resources.ts @@ -11,8 +11,11 @@ import type { AppServiceMetrics, AppServiceLogs, GithubConnectionStatus, + KnotreeRegistryAccountStatus, KnotreeRegistryConnection, KnotreeRegistryConnectionList, + KnotreeRegistryRepositoryList, + KnotreeRegistryTag, HtmlAnalyticsSummary, ImageApiKey, ImageCompressionMode, @@ -470,7 +473,11 @@ export function startKnotreeRegistryConsent( ) } -function imageStorePath(workspaceId: string, projectSlug: string, storeId = "") { +function imageStorePath( + workspaceId: string, + projectSlug: string, + storeId = "" +) { const base = `/workspaces/${workspaceId}/projects/${projectSlug}/image-stores` return storeId ? `${base}/${storeId}` : base } @@ -504,10 +511,13 @@ export function updateImageStore( storeId: string, input: CreateImageStoreInput ) { - return apiRequest(imageStorePath(workspaceId, projectSlug, storeId), { - method: "PATCH", - body: input, - }) + return apiRequest( + imageStorePath(workspaceId, projectSlug, storeId), + { + method: "PATCH", + body: input, + } + ) } export function listImageKeys( @@ -621,3 +631,62 @@ export function signImageObject( { method: "POST", body: input } ) } + +const KNOTREE_REGISTRY_ACCOUNT_PATH = "/integrations/knotree-registry" + +export function getKnotreeRegistryAccount() { + return apiRequest(KNOTREE_REGISTRY_ACCOUNT_PATH) +} + +export function startKnotreeRegistryAccountConsent(returnTo: string) { + return apiRequest<{ authorizationUrl: string }>( + `${KNOTREE_REGISTRY_ACCOUNT_PATH}/authorize`, + { method: "POST", body: { returnTo } } + ) +} + +export function disconnectKnotreeRegistryAccount() { + return apiRequest(KNOTREE_REGISTRY_ACCOUNT_PATH, { method: "DELETE" }) +} + +export function listKnotreeRegistryRepositories() { + return apiRequest( + `${KNOTREE_REGISTRY_ACCOUNT_PATH}/repositories` + ) +} + +export function listKnotreeRegistryTags(repository: string) { + const path = repository.split("/").map(encodeURIComponent).join("/") + return apiRequest<{ repository: string; tags: KnotreeRegistryTag[] }>( + `${KNOTREE_REGISTRY_ACCOUNT_PATH}/repositories/${path}` + ) +} + +/** Links one repository of the user's own Registry namespace to a project. */ +export function importKnotreeRegistryRepository( + workspaceId: string, + projectSlug: string, + repository: string +) { + return apiRequest( + `${knotreeRegistryConnectionsPath(workspaceId, projectSlug)}/from-account`, + { method: "POST", body: { repository } } + ) +} + +/** Registry consent must stay on Registry's own authorize page. */ +export function isKnotreeRegistryAuthorizationUrl(value: string) { + try { + const target = new URL(value) + return ( + target.origin === "https://registry.knotree.com" && + /^\/cloud\/authorize\/[0-9a-f-]{36}$/.test(target.pathname) && + !target.search && + !target.hash && + !target.username && + !target.password + ) + } catch { + return false + } +} diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 0793667..f4386eb 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -123,6 +123,37 @@ export type KnotreeRegistryConnection = { verifiedAt: string } +export type KnotreeRegistryAccountStatus = { + connected: boolean + consentReady: boolean + autoDeployReady: boolean + namespace: string | null + expiresAt: string | null + expired: boolean +} + +export type KnotreeRegistryRepository = { + name: string + tagCount: number + latestTag: string | null + latestDigest: string | null + size: number + updatedAt: number | null +} + +export type KnotreeRegistryRepositoryList = { + namespace: string + registryHost: string + repositories: KnotreeRegistryRepository[] +} + +export type KnotreeRegistryTag = { + tag: string + digest: string + size: number + createdAt: number | null +} + export type KnotreeRegistryConnectionList = { connections: KnotreeRegistryConnection[] autoDeployReady: boolean diff --git a/apps/web/src/pages/github-integration-page.test.tsx b/apps/web/src/pages/github-integration-page.test.tsx index e01647b..6785589 100644 --- a/apps/web/src/pages/github-integration-page.test.tsx +++ b/apps/web/src/pages/github-integration-page.test.tsx @@ -40,9 +40,11 @@ function renderPage(initialEntry = "/settings/integrations") { describe("GitHubIntegrationPage", () => { let connected = true + let registryConnected = false beforeEach(() => { connected = true + registryConnected = false mocks.getCsrfToken.mockResolvedValue("csrf-token") mocks.apiRequest.mockImplementation( (path: string, options?: { method?: string }) => { @@ -66,6 +68,20 @@ describe("GitHubIntegrationPage", () => { login: connected ? "jane-doe" : null, }) } + if (path === "/integrations/knotree-registry") { + if (options?.method === "DELETE") { + registryConnected = false + return Promise.resolve(undefined) + } + return Promise.resolve({ + connected: registryConnected, + consentReady: true, + autoDeployReady: true, + namespace: registryConnected ? "kt-jane" : null, + expiresAt: registryConnected ? "2026-11-01T00:00:00Z" : null, + expired: false, + }) + } if (path === "/auth/github/disconnect" && options?.method === "POST") { connected = false return Promise.resolve(undefined) @@ -89,7 +105,7 @@ describe("GitHubIntegrationPage", () => { "GitHub has been disconnected from your Knotree account." ) ).toBeInTheDocument() - expect(screen.getByText("Not connected")).toBeInTheDocument() + expect(screen.getAllByText("Not connected")).toHaveLength(2) expect(mocks.apiRequest).toHaveBeenCalledWith("/auth/github/disconnect", { method: "POST", }) @@ -109,4 +125,32 @@ describe("GitHubIntegrationPage", () => { ).toBeInTheDocument() expect(screen.getByText("jane@example.com")).toBeInTheDocument() }) + + it("connects Knotree Registry once and can disconnect it", async () => { + connected = false + registryConnected = true + const user = userEvent.setup() + renderPage("/settings/integrations?registry=connected") + + expect(await screen.findByText("Connected as kt-jane")).toBeInTheDocument() + expect( + screen.getByText( + "Knotree Registry is connected. Import your images from any project." + ) + ).toBeInTheDocument() + await user.click(screen.getByRole("button", { name: "Disconnect" })) + + expect( + await screen.findByText( + "Knotree Registry was disconnected. Auto-deploys from it are turned off." + ) + ).toBeInTheDocument() + expect(mocks.apiRequest).toHaveBeenCalledWith( + "/integrations/knotree-registry", + { method: "DELETE" } + ) + expect( + screen.getByRole("button", { name: "Connect Knotree Registry" }) + ).toBeInTheDocument() + }) }) diff --git a/apps/web/src/pages/github-integration-page.tsx b/apps/web/src/pages/github-integration-page.tsx index 49e2e62..b1702eb 100644 --- a/apps/web/src/pages/github-integration-page.tsx +++ b/apps/web/src/pages/github-integration-page.tsx @@ -9,6 +9,7 @@ import { import { Link, useNavigate, useSearchParams } from "react-router-dom" import { useAuth } from "@/auth/auth-context" +import { KnotreeRegistryAccountCard } from "@/components/knotree-registry-account-card" import { Alert, AlertDescription } from "@/components/ui/alert" import { Button } from "@/components/ui/button" import { @@ -141,10 +142,10 @@ export function GitHubIntegrationPage() {

Developer access

-

GitHub

+

Integrations

- Connect your GitHub account once and use its package access across - every project you deploy on Knotree. + Connect GitHub and Knotree Registry once and use their image access + across every project you deploy on Knotree.

@@ -265,12 +266,16 @@ export function GitHubIntegrationPage() { + +

- This connection belongs to {session?.user.email} and - is not shared with other Knotree users. GitHub access can be removed - at any time. + These connections belong to {session?.user.email} and + are not shared with other Knotree users. Access can be removed at any + time.

- {callbackStatus && ( + {(callbackStatus || searchParams.get("registry")) && (