From 4805ab7b7f11b41960e065a7c4272f2331b7023b Mon Sep 17 00:00:00 2001 From: Blocks Task Runner Date: Wed, 16 Sep 2026 07:01:56 +0000 Subject: [PATCH 1/7] feat(dashboard): add ephemeral invitation links --- .../members/invitation-link-result.tsx | 43 +++++++++ .../members/invite-member-dialog.tsx | 47 ++++++++++ .../(dashboard)/members/member-actions.tsx | 35 ++++++- .../app/(dashboard)/members/members-tabs.tsx | 12 ++- .../app/(dashboard)/members/page.tsx | 33 ++----- apps/dashboard/app/actions.ts | 69 +++++++++++--- apps/dashboard/lib/members-view.test.ts | 16 ++-- apps/dashboard/lib/members-view.ts | 3 +- apps/docs/openapi/next.yaml | 27 +++++- deploy/contract/governance.openapi.yaml | 27 +++++- services/control-api/src/lib.rs | 93 ++++++++++++++++++- 11 files changed, 343 insertions(+), 62 deletions(-) create mode 100644 apps/dashboard/app/(dashboard)/members/invitation-link-result.tsx create mode 100644 apps/dashboard/app/(dashboard)/members/invite-member-dialog.tsx diff --git a/apps/dashboard/app/(dashboard)/members/invitation-link-result.tsx b/apps/dashboard/app/(dashboard)/members/invitation-link-result.tsx new file mode 100644 index 0000000..9f0a23d --- /dev/null +++ b/apps/dashboard/app/(dashboard)/members/invitation-link-result.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export function InvitationLinkResult({ email, url }: { email: string; url: string }) { + const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">("idle"); + const resetTimer = useRef(null); + + useEffect(() => () => { + if (resetTimer.current) window.clearTimeout(resetTimer.current); + }, []); + + async function copy() { + try { + await navigator.clipboard.writeText(url); + setCopyStatus("copied"); + } catch { + setCopyStatus("failed"); + } + if (resetTimer.current) window.clearTimeout(resetTimer.current); + resetTimer.current = window.setTimeout(() => setCopyStatus("idle"), 2000); + } + + return ( +
+

Share this link with {email}. The email address is pinned to this invitation.

+
+ +
+ event.currentTarget.select()} /> + +
+

+ {copyStatus === "copied" && "Copied to clipboard."} + {copyStatus === "failed" && "Copy failed. Select and copy the link manually."} +

+
+
+ ); +} diff --git a/apps/dashboard/app/(dashboard)/members/invite-member-dialog.tsx b/apps/dashboard/app/(dashboard)/members/invite-member-dialog.tsx new file mode 100644 index 0000000..29dda72 --- /dev/null +++ b/apps/dashboard/app/(dashboard)/members/invite-member-dialog.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useActionState, useState } from "react"; +import { Plus } from "lucide-react"; +import { createUser, type InvitationLinkState } from "@/app/actions"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { InvitationLinkResult } from "./invitation-link-result"; + +const initialState: InvitationLinkState = {}; + +export function InviteMemberDialog() { + const [open, setOpen] = useState(false); + const [generation, setGeneration] = useState(0); + return ( + { setOpen(next); if (next) setGeneration((value) => value + 1); }}> + }> Invite member + {open && } + + ); +} + +function InviteMemberDialogContent() { + const [state, action, pending] = useActionState(createUser, initialState); + + return ( + + + {state.invitationUrl ? "Share invitation link" : "Create an invitation"} + {state.invitationUrl ? "Copy this link now; it is not available from the invitation list later." : "Create a pinned-email link to share with the new member."} + + {state.invitationUrl && state.email ? ( + + ) : ( +
+
+
+ {state.error &&

{state.error}

} + +
+ )} +
+ ); +} diff --git a/apps/dashboard/app/(dashboard)/members/member-actions.tsx b/apps/dashboard/app/(dashboard)/members/member-actions.tsx index db16bfa..e25a5c1 100644 --- a/apps/dashboard/app/(dashboard)/members/member-actions.tsx +++ b/apps/dashboard/app/(dashboard)/members/member-actions.tsx @@ -1,11 +1,11 @@ "use client"; -import { useState } from "react"; +import { useActionState, useState } from "react"; import { Ellipsis } from "lucide-react"; import { cancelInvitation, deleteUser, - resendInvitation, + regenerateInvitation, revokeUserSessions, updateUser, } from "@/app/actions"; @@ -14,6 +14,8 @@ import { type ConfirmationAction, } from "@/components/confirmation-dialog"; import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { InvitationLinkResult } from "./invitation-link-result"; import { DropdownMenu, DropdownMenuContent, @@ -99,16 +101,43 @@ export function InvitationActions({ invitation }: { invitation: { id: string; email: string; status: string }; }) { const [confirmation, setConfirmation] = useState(); + const [regenerateOpen, setRegenerateOpen] = useState(false); return ( <> }> - {(invitation.status === "pending" || invitation.status === "expired") && Resend} + {(invitation.status === "pending" || invitation.status === "expired") && setRegenerateOpen(true)}>Regenerate invitation link} {invitation.status === "pending" && setConfirmation({ action: cancelInvitation, fields: { invitation_id: invitation.id }, title: "Cancel invitation?", description: `${invitation.email} will no longer be able to accept this invitation.`, confirmLabel: "Cancel invitation", destructive: true })}>Cancel} { if (!open) setConfirmation(undefined); }} /> + {regenerateOpen && } ); } + +function RegenerateInvitationDialog({ invitation, open, onOpenChange }: { + invitation: { id: string; email: string }; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [state, action, pending] = useActionState(regenerateInvitation, {}); + return ( + { if (!pending) onOpenChange(next); }}> + + + {state.invitationUrl ? "Share replacement invitation link" : "Regenerate invitation link?"} + {state.invitationUrl ? "Copy the replacement link now; it will not be shown in the invitation list." : `The previous link for ${invitation.email} will stop working immediately.`} + + {state.invitationUrl && state.email ? : ( +
+ + {state.error &&

{state.error}

} + +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/app/(dashboard)/members/members-tabs.tsx b/apps/dashboard/app/(dashboard)/members/members-tabs.tsx index 6cdb77c..b2b5cd4 100644 --- a/apps/dashboard/app/(dashboard)/members/members-tabs.tsx +++ b/apps/dashboard/app/(dashboard)/members/members-tabs.tsx @@ -8,6 +8,7 @@ import { resolveMembersTab } from "@/lib/members-view"; export function MembersTabs({ managed, + invitationsEnabled, memberCount, invitationCount, members, @@ -15,6 +16,7 @@ export function MembersTabs({ identity, }: { managed: boolean; + invitationsEnabled: boolean; memberCount: number; invitationCount: number; members: ReactNode; @@ -24,12 +26,12 @@ export function MembersTabs({ const pathname = usePathname(); const router = useRouter(); const searchParams = useSearchParams(); - const tab = resolveMembersTab(searchParams.get("tab") ?? "", managed); + const tab = resolveMembersTab(searchParams.get("tab") ?? "", managed, invitationsEnabled); function selectTab(next: string | number) { if ( (next !== "members" && next !== "invited" && next !== "identity") || - (managed && next === "invited") || + (!invitationsEnabled && next === "invited") || (!managed && next === "identity") ) { return; @@ -49,9 +51,9 @@ export function MembersTabs({ Members {memberCount} - + {invitationsEnabled && Invited {invitationCount} - + } {managed && ( Identity & provisioning @@ -60,7 +62,7 @@ export function MembersTabs({ {members} - {!managed && {invitations}} + {invitationsEnabled && {invitations}} {managed && {identity}} ); diff --git a/apps/dashboard/app/(dashboard)/members/page.tsx b/apps/dashboard/app/(dashboard)/members/page.tsx index 45e72d2..e50c6c8 100644 --- a/apps/dashboard/app/(dashboard)/members/page.tsx +++ b/apps/dashboard/app/(dashboard)/members/page.tsx @@ -1,7 +1,6 @@ import { api, requireAdminIdentity } from "../../../lib/api"; import Link from "next/link"; import { redirect } from "next/navigation"; -import { createUser } from "../../actions"; import { identityConfig } from "../../../lib/identity-config"; import { buildManagedIdentityOverview } from "../../../lib/identity-overview"; import { @@ -12,7 +11,7 @@ import { } from "../../../lib/members-view"; import { InvitationActions, MemberActions } from "./member-actions"; import { MembersTabs } from "./members-tabs"; -import { Info, Plus, Search, SlidersHorizontal, X } from "lucide-react"; +import { Info, Search, SlidersHorizontal, X } from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -43,6 +42,7 @@ import { TableRow, } from "@/components/ui/table"; import { TablePaginationFooter } from "@/components/table-pagination-footer"; +import { InviteMemberDialog } from "./invite-member-dialog"; type SearchParams = Record; const allowedPageSizes = new Set([25, 50, 75, 100]); @@ -198,12 +198,14 @@ export default async function Members({ identityStatus?.auth_mode === "oidc" && identityStatus.scim.configured, ); + const invitationsEnabled = configuredIdentity.mode !== "oidc"; const requestedSize = positiveInteger(queryValue(raw, "per_page"), 25); const perPage = allowedPageSizes.has(requestedSize) ? requestedSize : 25; const requestedTab = queryValue(raw, "tab"); const activeTab: MembersTab = resolveMembersTab( requestedTab, managedWorkspace, + invitationsEnabled, ); const requestedPage = positiveInteger(queryValue(raw, "page"), 1); const filters = resolveMemberFilters({ @@ -258,31 +260,9 @@ export default async function Members({ const invitationActiveFilters = memberActiveFilters.filter((filter) => filter.key === "q" || filter.key === "role"); return (
- {!managedWorkspace && ( + {invitationsEnabled && (
- - }> Invite member - - - Invite a member - Send an invitation to join this organization. - -
-
- - -
-
- - -
- -
-
-
+
)} {managedWorkspace && identityOverview && ( @@ -297,6 +277,7 @@ export default async function Members({ )} { + try { + const invitation = await api("/admin/invitations", { + method: "POST", + body: JSON.stringify({ + email: String(form.get("email")), + role: String(form.get("role")) as "admin" | "member", + }), + }); + revalidatePath("/members"); + return { invitationId: invitation.id, email: invitation.email, invitationUrl: invitation.invitation_url }; + } catch (error) { + return { error: invitationError(error) }; + } } export async function updateUser(form: FormData) { @@ -351,11 +383,20 @@ export async function removeClientStatus(form: FormData) { revalidatePath("/clients"); } -export async function resendInvitation(form: FormData) { - await api(`/admin/invitations/${form.get("invitation_id")}/resend`, { - method: "POST", - }); - revalidatePath("/members"); +export async function regenerateInvitation( + _: InvitationLinkState, + form: FormData, +): Promise { + try { + const invitation = await api( + `/admin/invitations/${form.get("invitation_id")}/regenerate`, + { method: "POST" }, + ); + revalidatePath("/members"); + return { invitationId: invitation.id, email: invitation.email, invitationUrl: invitation.invitation_url }; + } catch (error) { + return { error: invitationError(error) }; + } } export async function cancelInvitation(form: FormData) { diff --git a/apps/dashboard/lib/members-view.test.ts b/apps/dashboard/lib/members-view.test.ts index 7dce5ad..5d309b5 100644 --- a/apps/dashboard/lib/members-view.test.ts +++ b/apps/dashboard/lib/members-view.test.ts @@ -3,18 +3,22 @@ import test from "node:test"; import { resolveMemberFilters, resolveMembersTab } from "./members-view.ts"; test("managed organizations can open identity but not invitations", () => { - assert.equal(resolveMembersTab("identity", true), "identity"); - assert.equal(resolveMembersTab("invited", true), "members"); + assert.equal(resolveMembersTab("identity", true, false), "identity"); + assert.equal(resolveMembersTab("invited", true, false), "members"); }); test("unmanaged organizations can open invitations but not identity", () => { - assert.equal(resolveMembersTab("invited", false), "invited"); - assert.equal(resolveMembersTab("identity", false), "members"); + assert.equal(resolveMembersTab("invited", false, true), "invited"); + assert.equal(resolveMembersTab("identity", false, true), "members"); }); test("members remains the default tab", () => { - assert.equal(resolveMembersTab("", true), "members"); - assert.equal(resolveMembersTab("unknown", false), "members"); + assert.equal(resolveMembersTab("", true, false), "members"); + assert.equal(resolveMembersTab("unknown", false, true), "members"); +}); + +test("OIDC without SCIM still hides invitations", () => { + assert.equal(resolveMembersTab("invited", false, false), "members"); }); test("member filters keep supported values", () => { diff --git a/apps/dashboard/lib/members-view.ts b/apps/dashboard/lib/members-view.ts index 36268a1..814057f 100644 --- a/apps/dashboard/lib/members-view.ts +++ b/apps/dashboard/lib/members-view.ts @@ -3,9 +3,10 @@ export type MembersTab = "members" | "invited" | "identity"; export function resolveMembersTab( requestedTab: string, managed: boolean, + invitationsEnabled: boolean, ): MembersTab { if (managed && requestedTab === "identity") return "identity"; - if (!managed && requestedTab === "invited") return "invited"; + if (invitationsEnabled && requestedTab === "invited") return "invited"; return "members"; } diff --git a/apps/docs/openapi/next.yaml b/apps/docs/openapi/next.yaml index 4b4e76a..dd6f7da 100644 --- a/apps/docs/openapi/next.yaml +++ b/apps/docs/openapi/next.yaml @@ -907,7 +907,7 @@ paths: description: Invitation created content: application/json: - schema: { $ref: "#/components/schemas/AdminInvitation" } + schema: { $ref: "#/components/schemas/AdminInvitationIssued" } "409": { description: Email is already a member or has a pending invitation } /admin/invitations/{id}: @@ -949,6 +949,23 @@ paths: schema: { $ref: "#/components/schemas/AdminInvitation" } "409": { description: Invitation has already been accepted or canceled } + /admin/invitations/{id}/regenerate: + post: + operationId: regenerateAdminInvitation + tags: [User management] + summary: Replace an outstanding invitation with a new invitation link + description: Invalidates the prior link immediately, retains its pinned email and role, and renews expiry for 24 hours. + security: [{ oauthDevice: [] }, { dashboardSession: [] }] + parameters: [{ $ref: "#/components/parameters/InvitationId" }] + responses: + "200": + description: Replacement invitation issued + content: + application/json: + schema: { $ref: "#/components/schemas/AdminInvitationIssued" } + "404": { description: Invitation does not exist in the administrator's organization } + "409": { description: Invitation is accepted or canceled, or invitations are disabled } + /admin/client-status: get: operationId: listAdminClientStatus @@ -1373,6 +1390,14 @@ components: expires_at: { type: string, format: date-time } created_at: { type: string, format: date-time } + AdminInvitationIssued: + allOf: + - { $ref: "#/components/schemas/AdminInvitation" } + - type: object + required: [invitation_url] + properties: + invitation_url: { type: string, format: uri } + AdminInvitationPage: type: object required: [items, page, per_page, total, total_pages] diff --git a/deploy/contract/governance.openapi.yaml b/deploy/contract/governance.openapi.yaml index 4b4e76a..dd6f7da 100644 --- a/deploy/contract/governance.openapi.yaml +++ b/deploy/contract/governance.openapi.yaml @@ -907,7 +907,7 @@ paths: description: Invitation created content: application/json: - schema: { $ref: "#/components/schemas/AdminInvitation" } + schema: { $ref: "#/components/schemas/AdminInvitationIssued" } "409": { description: Email is already a member or has a pending invitation } /admin/invitations/{id}: @@ -949,6 +949,23 @@ paths: schema: { $ref: "#/components/schemas/AdminInvitation" } "409": { description: Invitation has already been accepted or canceled } + /admin/invitations/{id}/regenerate: + post: + operationId: regenerateAdminInvitation + tags: [User management] + summary: Replace an outstanding invitation with a new invitation link + description: Invalidates the prior link immediately, retains its pinned email and role, and renews expiry for 24 hours. + security: [{ oauthDevice: [] }, { dashboardSession: [] }] + parameters: [{ $ref: "#/components/parameters/InvitationId" }] + responses: + "200": + description: Replacement invitation issued + content: + application/json: + schema: { $ref: "#/components/schemas/AdminInvitationIssued" } + "404": { description: Invitation does not exist in the administrator's organization } + "409": { description: Invitation is accepted or canceled, or invitations are disabled } + /admin/client-status: get: operationId: listAdminClientStatus @@ -1373,6 +1390,14 @@ components: expires_at: { type: string, format: date-time } created_at: { type: string, format: date-time } + AdminInvitationIssued: + allOf: + - { $ref: "#/components/schemas/AdminInvitation" } + - type: object + required: [invitation_url] + properties: + invitation_url: { type: string, format: uri } + AdminInvitationPage: type: object required: [items, page, per_page, total, total_pages] diff --git a/services/control-api/src/lib.rs b/services/control-api/src/lib.rs index 2ea0046..05dc05f 100644 --- a/services/control-api/src/lib.rs +++ b/services/control-api/src/lib.rs @@ -1676,6 +1676,10 @@ async fn build_app_inner( get(get_invitation).delete(cancel_invitation), ) .route("/admin/invitations/:id/resend", post(resend_invitation)) + .route( + "/admin/invitations/:id/regenerate", + post(regenerate_invitation), + ) .route("/admin/client-status", get(list_client_status)) .route("/admin/client-status/facets", get(client_status_facets)) .route( @@ -8868,11 +8872,26 @@ fn normalize_email(email: &str) -> Result { } } -fn log_invitation(state: &AppState, id: &str, email: &str) { - let url = format!( +fn invitation_url_for_base(auth_public_url: &str, id: &str) -> String { + format!( "{}/accept-invitation?id={id}", - state.config.auth_public_url.trim_end_matches('/') - ); + auth_public_url.trim_end_matches('/') + ) +} + +fn invitation_url(state: &AppState, id: &str) -> String { + invitation_url_for_base(&state.config.auth_public_url, id) +} + +fn issued_invitation_json(state: &AppState, row: InvitationRow) -> serde_json::Value { + let url = invitation_url(state, &row.id); + let mut value = invitation_json(row); + value["invitation_url"] = json!(url); + value +} + +fn log_invitation(state: &AppState, id: &str, email: &str) { + let url = invitation_url(state, id); tracing::info!(%email, %url, "Blue invitation"); } @@ -8931,12 +8950,63 @@ async fn create_invitation( log_invitation(&state, &id, &email); Ok(( StatusCode::CREATED, - Json(invitation_json( + Json(issued_invitation_json( + &state, invitation_row(&state.pool, who.organization_id, &id).await?, )), )) } +async fn regenerate_invitation( + State(state): State>, + Extension(who): Extension, + Path(id): Path, +) -> Result, ApiError> { + if state.config.auth_mode == "oidc" { + return Err(ApiError::conflict( + "invitations are disabled for identity-provider-managed workspaces", + )); + } + let auth_org = auth_org_id(&state.pool, who.organization_id).await?; + let mut transaction = state.pool.begin().await?; + // sqlx-guard: allow-raw row locking query; covered by the transaction integration flow + let (email, role, status) = sqlx::query_as::<_, (String, Option, String)>( + "select email,role,status from auth.\"invitation\" where \"organizationId\"=$1 and id=$2 for update", + ) + .bind(&auth_org) + .bind(&id) + .fetch_optional(&mut *transaction) + .await? + .ok_or_else(|| ApiError::not_found("invitation not found"))?; + if status != "pending" { + return Err(ApiError::conflict( + "only pending or expired invitations can be regenerated", + )); + } + sqlx::query!( + "update auth.\"invitation\" set status='canceled' where id=$1", + &id + ) + .execute(&mut *transaction) + .await?; + let new_id = Uuid::new_v4().to_string(); + sqlx::query!("insert into auth.\"invitation\" (id,\"organizationId\",email,role,status,\"expiresAt\",\"createdAt\",\"inviterId\") \ + values ($1,$2,$3,$4,'pending',now()+interval '24 hours',now(),$5)", + &new_id, + &auth_org, + &email, + role.as_deref(), + &who.subject) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + log_invitation(&state, &new_id, &email); + Ok(Json(issued_invitation_json( + &state, + invitation_row(&state.pool, who.organization_id, &new_id).await?, + ))) +} + async fn resend_invitation( State(state): State>, Extension(who): Extension, @@ -11701,6 +11771,19 @@ mod tests { assert!(normalize_email("developer@localhost").is_err()); } + #[test] + fn invitation_urls_ignore_a_trailing_public_url_slash() { + let expected = "https://blue.example/accept-invitation?id=invitation-id"; + assert_eq!( + invitation_url_for_base("https://blue.example", "invitation-id"), + expected + ); + assert_eq!( + invitation_url_for_base("https://blue.example/", "invitation-id"), + expected + ); + } + #[test] fn session_upload_profiles_must_belong_to_the_reported_harness() { assert!(upload_profile_is_known("claude", "claude-v1")); From e18dd6a12e1bd17aca511fa70cba278e89be127b Mon Sep 17 00:00:00 2001 From: Blocks Task Runner Date: Wed, 16 Sep 2026 16:51:15 +0000 Subject: [PATCH 2/7] fix(docs): quote invitation conflict description --- apps/docs/openapi/next.yaml | 2 +- deploy/contract/governance.openapi.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/openapi/next.yaml b/apps/docs/openapi/next.yaml index dd6f7da..f973c2b 100644 --- a/apps/docs/openapi/next.yaml +++ b/apps/docs/openapi/next.yaml @@ -964,7 +964,7 @@ paths: application/json: schema: { $ref: "#/components/schemas/AdminInvitationIssued" } "404": { description: Invitation does not exist in the administrator's organization } - "409": { description: Invitation is accepted or canceled, or invitations are disabled } + "409": { description: "Invitation is accepted or canceled, or invitations are disabled" } /admin/client-status: get: diff --git a/deploy/contract/governance.openapi.yaml b/deploy/contract/governance.openapi.yaml index dd6f7da..f973c2b 100644 --- a/deploy/contract/governance.openapi.yaml +++ b/deploy/contract/governance.openapi.yaml @@ -964,7 +964,7 @@ paths: application/json: schema: { $ref: "#/components/schemas/AdminInvitationIssued" } "404": { description: Invitation does not exist in the administrator's organization } - "409": { description: Invitation is accepted or canceled, or invitations are disabled } + "409": { description: "Invitation is accepted or canceled, or invitations are disabled" } /admin/client-status: get: From 5707ecd8606a5b53d51ce37fe9150105e871673b Mon Sep 17 00:00:00 2001 From: Blocks Task Runner Date: Wed, 16 Sep 2026 18:45:01 +0000 Subject: [PATCH 3/7] ci: retry timed-out Windows checks From 57debf15c96aea899dbb40e24e7a5cea401264b7 Mon Sep 17 00:00:00 2001 From: tomislavstipancik Date: Wed, 16 Sep 2026 13:58:26 -0700 Subject: [PATCH 4/7] fix(dashboard): surface gateway provisioning errors --- .../(dashboard)/gateway/gateway-key-form.tsx | 30 +++++++++++ .../app/(dashboard)/gateway/page.tsx | 4 +- apps/dashboard/app/actions.ts | 23 +++++++-- .../custom-provisioner/provisioner.sh | 4 ++ tests/e2e/specs/journey.spec.ts | 50 +++++++++++++++++++ 5 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 apps/dashboard/app/(dashboard)/gateway/gateway-key-form.tsx diff --git a/apps/dashboard/app/(dashboard)/gateway/gateway-key-form.tsx b/apps/dashboard/app/(dashboard)/gateway/gateway-key-form.tsx new file mode 100644 index 0000000..87ffb75 --- /dev/null +++ b/apps/dashboard/app/(dashboard)/gateway/gateway-key-form.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useActionState } from "react"; +import { AlertCircle } from "lucide-react"; +import { ensureGatewayKey, type GatewayKeyState } from "@/app/actions"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; + +const initialState: GatewayKeyState = {}; + +export function GatewayKeyForm({ label, disabled, existingError }: { label: string; disabled: boolean; existingError?: string | null }) { + const [state, action, pending] = useActionState(ensureGatewayKey, initialState); + + return ( +
+ {state.error && state.error !== existingError && ( + + + Provisioning failed + {state.error} + + )} +
+ +
+
+ ); +} diff --git a/apps/dashboard/app/(dashboard)/gateway/page.tsx b/apps/dashboard/app/(dashboard)/gateway/page.tsx index 4fd4494..53745d2 100644 --- a/apps/dashboard/app/(dashboard)/gateway/page.tsx +++ b/apps/dashboard/app/(dashboard)/gateway/page.tsx @@ -15,7 +15,7 @@ import { api, requireIdentity } from "@/lib/api"; import { RefreshButton } from "./refresh-button"; import { GatewayTabs, type GatewayTab } from "./gateway-tabs"; import { ProxyHealthRow } from "./proxy-health-row"; -import { ensureGatewayKey } from "@/app/actions"; +import { GatewayKeyForm } from "./gateway-key-form"; type GatewayStatus = { enabled: boolean; @@ -248,7 +248,7 @@ export default async function GatewayPage({ searchParams }: { searchParams: Prom {access?.error &&
Provisioning failed{access.error}
} {validationError &&
Key verification unavailable{validationError} The stored key was left unchanged.
} {access?.status === "invalid" &&
Upstream credential is invalid{access.invalidation_reason ?? "The credential was rejected or revoked by the upstream gateway."} Provision a new credential to restore gateway access.
} -
+ Date.now())} existingError={access?.error} /> )} logs={( diff --git a/apps/dashboard/app/actions.ts b/apps/dashboard/app/actions.ts index f99fe13..b72cec8 100644 --- a/apps/dashboard/app/actions.ts +++ b/apps/dashboard/app/actions.ts @@ -1,7 +1,7 @@ "use server"; import { headers } from "next/headers"; -import { redirect } from "next/navigation"; +import { redirect, unstable_rethrow } from "next/navigation"; import { revalidatePath } from "next/cache"; import { api } from "../lib/api"; import { auth, authPool } from "../lib/auth"; @@ -131,9 +131,24 @@ export async function saveHarnessManagedConfig( } } -export async function ensureGatewayKey(): Promise { - await api("/gateway/key/ensure", { method: "POST", body: '{"manual":true}' }); - revalidatePath("/gateway"); +export type GatewayKeyState = { error?: string }; + +export async function ensureGatewayKey(_: GatewayKeyState): Promise { + try { + await api("/gateway/key/ensure", { method: "POST", body: '{"manual":true}' }); + revalidatePath("/gateway"); + return {}; + } catch (error) { + unstable_rethrow(error); + if (!(error instanceof Error)) return { error: "Gateway key could not be provisioned." }; + const detail = error.message.replace(/^\d{3}:\s*/, ""); + try { + const parsed = JSON.parse(detail) as { error?: string }; + return { error: parsed.error || "Gateway key could not be provisioned." }; + } catch { + return { error: detail || "Gateway key could not be provisioned." }; + } + } } export type GatewayProxyHealthState = { diff --git a/tests/e2e/fixtures/custom-provisioner/provisioner.sh b/tests/e2e/fixtures/custom-provisioner/provisioner.sh index a85db14..1436d0d 100755 --- a/tests/e2e/fixtures/custom-provisioner/provisioner.sh +++ b/tests/e2e/fixtures/custom-provisioner/provisioner.sh @@ -22,6 +22,10 @@ case "$mode" in printf '%s\n' '{"protocol_version":1,"status":"error","error":{"code":"temporary_unavailable","message":"e2e provisioner unavailable"}}' exit 75 ;; + account-missing) + printf '%s\n' '{"protocol_version":1,"status":"error","error":{"code":"account_missing","message":"member has no upstream account"}}' + exit 4 + ;; protocol-mismatch) printf '%s\n' '{"protocol_version":2,"status":"success","result":{"credential":"must-not-persist","external_id":"e2e-mismatch","alias":"e2e","metadata":{},"expires_at":null}}' exit 0 diff --git a/tests/e2e/specs/journey.spec.ts b/tests/e2e/specs/journey.spec.ts index 79b948d..574141f 100644 --- a/tests/e2e/specs/journey.spec.ts +++ b/tests/e2e/specs/journey.spec.ts @@ -1529,6 +1529,50 @@ esac expect((await page.request.delete(`${control}/admin/invitations/${invitation.id}`)).status()).toBe(204); }); + test("member sees a provisioning error when the gateway account is missing", async ({ page, browser }) => { + await loginAsAdmin(page); + const control = process.env.E2E_CONTROL_API_URL ?? "http://127.0.0.1:8080"; + const dashboard = process.env.E2E_DASHBOARD_URL ?? "http://127.0.0.1:3000"; + const modePath = "/work/tests/e2e/artifacts/provisioner/mode"; + const email = `missing-gateway-${Date.now()}@example.com`; + const created = await page.request.post(`${control}/admin/invitations`, { data: { email, role: "member" } }); + expect(created.status(), await created.text()).toBe(201); + const invitation = await created.json(); + const memberContext = await browser.newContext(); + let memberId: string | undefined; + try { + const memberPage = await memberContext.newPage(); + await memberPage.goto(`${dashboard}/accept-invitation?id=${invitation.id}`); + await memberPage.getByLabel("Password").fill("member-password-e2e"); + await memberPage.getByRole("button", { name: "Create account" }).click(); + await expect(memberPage).toHaveURL(/\/sessions/); + const identity = await memberContext.request.get(`${control}/auth/me`); + expect(identity.status(), await identity.text()).toBe(200); + memberId = (await identity.json()).id; + + await memberPage.goto(`${dashboard}/gateway`); + await expect(memberPage.getByRole("button", { name: "Provision key" })).toBeVisible(); + await writeFile(modePath, "account-missing\n"); + const posted = memberPage.waitForResponse((response) => + response.request().method() === "POST" && new URL(response.url()).pathname === "/gateway", + ); + await memberPage.getByRole("button", { name: "Provision key" }).click(); + expect((await posted).status()).toBe(200); + await expect(memberPage.getByRole("alert")).toContainText("gateway account is not provisioned: member has no upstream account"); + await expect(memberPage.getByRole("tab", { name: "Key" })).toBeVisible(); + + await memberPage.reload(); + await expect(memberPage.getByRole("alert")).toContainText("gateway account is not provisioned: member has no upstream account"); + const access = await memberContext.request.get(`${control}/gateway/key`); + expect(access.status(), await access.text()).toBe(200); + expect(await access.json()).toMatchObject({ status: "error", error: "gateway account is not provisioned: member has no upstream account" }); + } finally { + await rm(modePath, { force: true }); + await memberContext.close(); + if (memberId) await page.request.delete(`${control}/admin/users/${memberId}`); + } + }); + test("invited member can read policy but cannot call administrator or cross-user APIs", async ({ page, browser }) => { await loginAsAdmin(page); const control = process.env.E2E_CONTROL_API_URL ?? "http://127.0.0.1:8080"; @@ -1622,6 +1666,12 @@ esac await expect(memberPage.getByRole("tab", { name: "Key" })).toBeVisible(); await expect(memberPage.getByRole("tab", { name: "Overview" })).toHaveCount(0); await expect(memberPage.getByRole("tab", { name: "Logs" })).toHaveCount(0); + const reconciled = memberPage.waitForResponse((response) => + response.request().method() === "POST" && new URL(response.url()).pathname === "/gateway", + ); + await memberPage.getByRole("button", { name: "Reconcile key" }).click(); + expect((await reconciled).status()).toBe(200); + await expect(memberPage.getByText("ready", { exact: true })).toBeVisible(); const deniedPaths = ["/harnesses", "/extensions", "/members", "/clients", "/gateway?tab=logs"]; for (const path of deniedPaths) { const response = await memberPage.goto(`${dashboard}${path}`, { waitUntil: "commit" }); From a3a4e24eb77b413a8152d7cebf2890b38e4e933d Mon Sep 17 00:00:00 2001 From: tomislavstipancik Date: Wed, 16 Sep 2026 13:58:42 -0700 Subject: [PATCH 5/7] fix(dashboard): preserve regenerated invitation link --- .../members/invitation-link-result.tsx | 19 +++++++--- .../(dashboard)/members/member-actions.tsx | 13 ++++++- apps/dashboard/app/actions.ts | 1 - .../e2e/specs/zz-dashboard-filtering.spec.ts | 37 +++++++++++++++++++ 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/apps/dashboard/app/(dashboard)/members/invitation-link-result.tsx b/apps/dashboard/app/(dashboard)/members/invitation-link-result.tsx index 9f0a23d..37123f4 100644 --- a/apps/dashboard/app/(dashboard)/members/invitation-link-result.tsx +++ b/apps/dashboard/app/(dashboard)/members/invitation-link-result.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { Check, Copy } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -31,12 +32,20 @@ export function InvitationLinkResult({ email, url }: { email: string; url: strin
event.currentTarget.select()} /> - +
-

- {copyStatus === "copied" && "Copied to clipboard."} - {copyStatus === "failed" && "Copy failed. Select and copy the link manually."} -

+ {copyStatus === "failed" && ( +

Copy failed. Select and copy the link manually.

+ )}
); diff --git a/apps/dashboard/app/(dashboard)/members/member-actions.tsx b/apps/dashboard/app/(dashboard)/members/member-actions.tsx index e25a5c1..5a2ec92 100644 --- a/apps/dashboard/app/(dashboard)/members/member-actions.tsx +++ b/apps/dashboard/app/(dashboard)/members/member-actions.tsx @@ -1,6 +1,7 @@ "use client"; import { useActionState, useState } from "react"; +import { useRouter } from "next/navigation"; import { Ellipsis } from "lucide-react"; import { cancelInvitation, @@ -122,9 +123,17 @@ function RegenerateInvitationDialog({ invitation, open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void; }) { + const router = useRouter(); const [state, action, pending] = useActionState(regenerateInvitation, {}); + + function handleOpenChange(next: boolean) { + if (pending) return; + onOpenChange(next); + if (!next && state.invitationUrl) router.refresh(); + } + return ( - { if (!pending) onOpenChange(next); }}> + {state.invitationUrl ? "Share replacement invitation link" : "Regenerate invitation link?"} @@ -134,7 +143,7 @@ function RegenerateInvitationDialog({ invitation, open, onOpenChange }: {
{state.error &&

{state.error}

} - +
)}
diff --git a/apps/dashboard/app/actions.ts b/apps/dashboard/app/actions.ts index b72cec8..eb6ee1c 100644 --- a/apps/dashboard/app/actions.ts +++ b/apps/dashboard/app/actions.ts @@ -407,7 +407,6 @@ export async function regenerateInvitation( `/admin/invitations/${form.get("invitation_id")}/regenerate`, { method: "POST" }, ); - revalidatePath("/members"); return { invitationId: invitation.id, email: invitation.email, invitationUrl: invitation.invitation_url }; } catch (error) { return { error: invitationError(error) }; diff --git a/tests/e2e/specs/zz-dashboard-filtering.spec.ts b/tests/e2e/specs/zz-dashboard-filtering.spec.ts index 8c56b27..dc1fd3f 100644 --- a/tests/e2e/specs/zz-dashboard-filtering.spec.ts +++ b/tests/e2e/specs/zz-dashboard-filtering.spec.ts @@ -824,4 +824,41 @@ test.describe.serial("dashboard table filtering", () => { } test("members and invitations apply, combine, remove, and clear filters", verifyMembersAndInvitations); + + test("regenerated invitation link remains visible until the result dialog closes", async () => { + test.skip(!invitationsEnabled, "Invitations are disabled for identity-provider-managed workspaces"); + const page = await openAdminPage(); + const email = `regenerate-${Date.now()}@example.com`; + const original = await createInvitation(page.request, email, "member"); + + await page.goto(`/members?tab=invited&q=${encodeURIComponent(email)}`, { waitUntil: "commit" }); + const row = rowWith(page, email); + await expect(row).toBeVisible(); + await row.getByRole("button", { name: `Actions for ${email}` }).click(); + await page.getByText("Regenerate invitation link", { exact: true }).click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("heading", { name: "Regenerate invitation link?" })).toBeVisible(); + await dialog.getByRole("button", { name: "Regenerate link" }).click(); + await expect(dialog.getByRole("heading", { name: "Share replacement invitation link" })).toBeVisible(); + + const replacementUrl = await dialog.getByLabel("Invitation link").inputValue(); + const replacementId = new URL(replacementUrl).searchParams.get("id"); + expect(replacementId).toBeTruthy(); + expect(replacementId).not.toBe(original.id); + + const originalResponse = await page.request.get(`${control}/admin/invitations/${original.id}`); + expect(originalResponse.status(), await originalResponse.text()).toBe(200); + expect((await originalResponse.json() as { status: string }).status).toBe("canceled"); + + await dialog.getByRole("button", { name: "Close" }).click(); + await expect(dialog).toHaveCount(0); + await expect(rowWith(page, email)).toBeVisible(); + + const replacementResponse = await page.request.get(`${control}/admin/invitations/${replacementId}`); + expect(replacementResponse.status(), await replacementResponse.text()).toBe(200); + expect((await replacementResponse.json() as { status: string }).status).toBe("pending"); + const cleanup = await page.request.delete(`${control}/admin/invitations/${replacementId}`); + expect(cleanup.status(), await cleanup.text()).toBe(204); + }); }); From 0bb49da9a68db4b013e39e86eca5bafc875a65dc Mon Sep 17 00:00:00 2001 From: tomislavstipancik Date: Wed, 16 Sep 2026 13:58:55 -0700 Subject: [PATCH 6/7] chore(deploy): update local bootstrap password --- deploy/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index a33c122..f494498 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -87,7 +87,7 @@ services: BETTER_AUTH_URL: http://127.0.0.1:3000 BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-local-development-secret-change-before-production} HARNESS_BOOTSTRAP_ADMIN_EMAIL: ${HARNESS_BOOTSTRAP_ADMIN_EMAIL:-admin@example.com} - HARNESS_BOOTSTRAP_ADMIN_PASSWORD: ${HARNESS_BOOTSTRAP_ADMIN_PASSWORD:-change-me-in-production} + HARNESS_BOOTSTRAP_ADMIN_PASSWORD: ${HARNESS_BOOTSTRAP_ADMIN_PASSWORD:-change-me-before-testing} HARNESS_OAUTH_CLIENT_ID: blue-cli # Confidential OAuth client the inference proxy uses for M2M auth. The # secret must match the proxy's HARNESS_PROXY_OAUTH_CLIENT_SECRET. From 05378bbb15424ceab49189640584e5557047fd76 Mon Sep 17 00:00:00 2001 From: "cubeorgdev[bot]" <194556374+cubeorgdev[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:19:54 -0700 Subject: [PATCH 7/7] fix(deploy): restore local bootstrap password (#161) Co-authored-by: Blocks Task Runner --- deploy/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index f494498..a33c122 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -87,7 +87,7 @@ services: BETTER_AUTH_URL: http://127.0.0.1:3000 BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-local-development-secret-change-before-production} HARNESS_BOOTSTRAP_ADMIN_EMAIL: ${HARNESS_BOOTSTRAP_ADMIN_EMAIL:-admin@example.com} - HARNESS_BOOTSTRAP_ADMIN_PASSWORD: ${HARNESS_BOOTSTRAP_ADMIN_PASSWORD:-change-me-before-testing} + HARNESS_BOOTSTRAP_ADMIN_PASSWORD: ${HARNESS_BOOTSTRAP_ADMIN_PASSWORD:-change-me-in-production} HARNESS_OAUTH_CLIENT_ID: blue-cli # Confidential OAuth client the inference proxy uses for M2M auth. The # secret must match the proxy's HARNESS_PROXY_OAUTH_CLIENT_SECRET.