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/(dashboard)/members/invitation-link-result.tsx b/apps/dashboard/app/(dashboard)/members/invitation-link-result.tsx
new file mode 100644
index 0000000..37123f4
--- /dev/null
+++ b/apps/dashboard/app/(dashboard)/members/invitation-link-result.tsx
@@ -0,0 +1,52 @@
+"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";
+
+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.
+
+
Invitation link
+
+ event.currentTarget.select()} />
+
+ {copyStatus === "copied" ? : }
+
+
+ {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 ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/dashboard/app/(dashboard)/members/member-actions.tsx b/apps/dashboard/app/(dashboard)/members/member-actions.tsx
index db16bfa..5a2ec92 100644
--- a/apps/dashboard/app/(dashboard)/members/member-actions.tsx
+++ b/apps/dashboard/app/(dashboard)/members/member-actions.tsx
@@ -1,11 +1,12 @@
"use client";
-import { useState } from "react";
+import { useActionState, useState } from "react";
+import { useRouter } from "next/navigation";
import { Ellipsis } from "lucide-react";
import {
cancelInvitation,
deleteUser,
- resendInvitation,
+ regenerateInvitation,
revokeUserSessions,
updateUser,
} from "@/app/actions";
@@ -14,6 +15,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 +102,51 @@ 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 router = useRouter();
+ const [state, action, pending] = useActionState(regenerateInvitation, {});
+
+ function handleOpenChange(next: boolean) {
+ if (pending) return;
+ onOpenChange(next);
+ if (!next && state.invitationUrl) router.refresh();
+ }
+
+ return (
+
+
+
+ {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 ? : (
+
+ )}
+
+
+ );
+}
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({
)}
{
- 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 = {
@@ -312,15 +327,47 @@ export async function inspectPackageSource(
}
}
-export async function createUser(form: FormData) {
- await api("/admin/invitations", {
- method: "POST",
- body: JSON.stringify({
- email: String(form.get("email")),
- role: String(form.get("role")) as "admin" | "member",
- }),
- });
- revalidatePath("/members");
+export type InvitationLinkState = {
+ invitationId?: string;
+ email?: string;
+ invitationUrl?: string;
+ error?: string;
+};
+
+type AdminInvitationIssued = {
+ id: string;
+ email: string;
+ invitation_url: string;
+};
+
+function invitationError(error: unknown) {
+ if (!(error instanceof Error)) return "Unable to issue the invitation link.";
+ const detail = error.message.replace(/^\d{3}:\s*/, "");
+ try {
+ const parsed = JSON.parse(detail) as { error?: string; message?: string };
+ return parsed.message ?? parsed.error ?? "Unable to issue the invitation link.";
+ } catch {
+ return detail || "Unable to issue the invitation link.";
+ }
+}
+
+export async function createUser(
+ _: InvitationLinkState,
+ form: FormData,
+): Promise {
+ 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 +398,19 @@ 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" },
+ );
+ 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..f973c2b 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..f973c2b 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"));
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" });
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);
+ });
});