From 3183a151a4864dc715cc3d44f57e121c4982b25c Mon Sep 17 00:00:00 2001 From: zgoldie Date: Thu, 2 Jul 2026 14:25:33 +0100 Subject: [PATCH 1/5] Merge the members and invitation page, plus some UI tweaks - Introduced SOURCE_REPO_URL in branding for easy access to the GitHub repository. - Removed InvitationsPage and its related routing, consolidating member management into MembersPage. - Updated Breadcrumbs and Sidebar components to reflect the removal of InvitationsPage. - Enhanced MembersPage to include pending invitations in the members table. - Adjusted various comments and documentation for clarity and consistency. --- .gitignore | 7 + web-dashboard/src/api/hooks/iam.ts | 2 +- web-dashboard/src/branding.ts | 2 + .../src/components/shell/Breadcrumbs.tsx | 63 +- .../src/components/shell/Sidebar.tsx | 149 +++-- web-dashboard/src/components/ui/table.ts | 2 +- web-dashboard/src/pages/InvitationsPage.tsx | 574 ------------------ web-dashboard/src/pages/MembersPage.tsx | 397 ++++++++++-- web-dashboard/src/pages/TeamOverviewPage.tsx | 8 +- .../release/modals/EditMetadataModal.tsx | 16 +- .../src/pages/release/modals/PromoteModal.tsx | 31 +- web-dashboard/src/router.tsx | 2 - 12 files changed, 478 insertions(+), 775 deletions(-) delete mode 100644 web-dashboard/src/pages/InvitationsPage.tsx diff --git a/.gitignore b/.gitignore index d3378bb..a0e0d85 100644 --- a/.gitignore +++ b/.gitignore @@ -52,5 +52,12 @@ scripts/dev/dashboard-preview.sh web-dashboard/.env.local web-dashboard/src/styles/app.local.css +# Local full-stack dev overlay (mock GitHub + dashboard on :5173) +docker-compose.dev.local.yml +deploy/dev/ +scripts/dev/up.sh +scripts/dev/mock-github.ts +infra/pulumi/package.json + # CLI global-install packing dir .tmp-pack/ diff --git a/web-dashboard/src/api/hooks/iam.ts b/web-dashboard/src/api/hooks/iam.ts index 703900d..68863de 100644 --- a/web-dashboard/src/api/hooks/iam.ts +++ b/web-dashboard/src/api/hooks/iam.ts @@ -34,7 +34,7 @@ import { type RolesListWireResponse, } from "../wire"; -/** `status` filter accepted by `GET /v1/iam/invitations` (status tabs). */ +/** `status` filter accepted by `GET /v1/iam/invitations`. */ export type InvitationStatusFilter = TeamInvitationStatus | "all"; /** Query keys for the IAM domain, lists scoped by team. */ diff --git a/web-dashboard/src/branding.ts b/web-dashboard/src/branding.ts index e0f3077..a2128af 100644 --- a/web-dashboard/src/branding.ts +++ b/web-dashboard/src/branding.ts @@ -1,3 +1,5 @@ export const PRODUCT_NAME = "Codemagic Patch"; export const PRODUCT_SHORT_NAME = "Patch"; export const SDK_DISPLAY_NAME = "Codemagic Patch SDK"; +export const SOURCE_REPO_URL = + "https://github.com/codemagic-ci-cd/codemagic-patch"; diff --git a/web-dashboard/src/components/shell/Breadcrumbs.tsx b/web-dashboard/src/components/shell/Breadcrumbs.tsx index dd06668..ab1beed 100644 --- a/web-dashboard/src/components/shell/Breadcrumbs.tsx +++ b/web-dashboard/src/components/shell/Breadcrumbs.tsx @@ -1,16 +1,16 @@ -// Route-derived breadcrumbs ("mirror Team › App › Deployment › -// Release; IDs resolve to names (skeleton text while resolving)"). Entity ids -// come from the route params (teamId/appId/depId/ -// releaseId) and resolve through the existing query hooks, cache-first — a -// navigation from a list that already populated the cache paints names -// instantly, otherwise a small inline Skeleton shows. Deployment names come -// from the app's deployment list (`useDeployments`) because no -// single-deployment GET exists (api/hooks/deployments.ts). Leaf-only routes -// (apps/members/invitations/metrics/overview, /teams, /account/*) derive a -// static label from the pathname so AppShell can render one -// above the Outlet for every screen. DOM/classes follow the -// `.crumbs` nav structure (`›` separators, `.cur` leaf); each name component falls -// back to the raw id when resolution fails so the trail never blanks. +// Route-derived breadcrumbs for app hierarchy screens ("Apps › App › +// Deployment › Release; IDs resolve to names (skeleton text while resolving)"). +// Team-level leaf routes (overview, apps list, members, metrics) +// omit breadcrumbs — the page

already carries the title. Entity ids come +// from the route params (teamId/appId/depId/releaseId) and resolve through +// the existing query hooks, cache-first — a navigation from a list that +// already populated the cache paints names instantly, otherwise a small inline +// Skeleton shows. Deployment names come from the app's deployment list +// (`useDeployments`) because no single-deployment GET exists +// (api/hooks/deployments.ts). Account routes keep a short trail +// (Account › Profile / API tokens). DOM/classes follow the `.crumbs` nav +// structure (`›` separators, `.cur` leaf); each name component falls back to +// the raw id when resolution fails so the trail never blanks. import { Fragment } from "react"; import { Link, useLocation, useParams } from "react-router"; @@ -70,14 +70,6 @@ export function Breadcrumbs() { ); } -/** Static labels for team-scoped leaf segments. */ -const TEAM_LEAF_LABELS = new Map([ - ["apps", "Apps"], - ["members", "Members"], - ["invitations", "Invitations"], - ["metrics", "Metrics"], -]); - function buildCrumbs( params: Readonly>, pathname: string, @@ -87,17 +79,7 @@ function buildCrumbs( const segments = pathname.split("/").filter((segment) => segment.length > 0); if (teamId !== undefined) { - // Single-team OSS: the team root crumb is just the fixed `default-team` - // slug, so omit it; show it only in multi-team mode where it disambiguates. - const crumbs: Crumb[] = isMultiTeam - ? [ - { - key: "team", - to: `/teams/${teamId}`, - node: , - }, - ] - : []; + const crumbs: Crumb[] = []; if (appId !== undefined) { crumbs.push( @@ -124,20 +106,9 @@ function buildCrumbs( return crumbs; } - const leaf = segments[2]; - if (leaf !== undefined) { - const leafLabel = TEAM_LEAF_LABELS.get(leaf); - if (leafLabel !== undefined) { - crumbs.push({ key: leaf, node: leafLabel }); - } - } else if (segments.length === 2) { - crumbs.push({ key: "overview", node: "Overview" }); - } - return crumbs; - } - - if (segments[0] === "teams") { - return [{ key: "teams", node: "Teams" }]; + // Team-level screens (overview, apps list, members, …) — no breadcrumb; + // the page

is the title. + return []; } if (segments[0] === "account") { diff --git a/web-dashboard/src/components/shell/Sidebar.tsx b/web-dashboard/src/components/shell/Sidebar.tsx index f310049..2477eaa 100644 --- a/web-dashboard/src/components/shell/Sidebar.tsx +++ b/web-dashboard/src/components/shell/Sidebar.tsx @@ -1,10 +1,9 @@ -// Team-scoped sidebar: brand, main nav (Apps, Metrics) and the IAM "Access" -// group (Members, Invitations), all as data-driven react-router NavLinks +// Team-scoped sidebar: brand, main nav (Apps, Metrics), and a foot section +// (Members for `iam.manage`, GitHub repo link) above Collapse. // against the route map — the DOM/class structure is ported, its hardcoded -// `.html` hrefs and global `DB` are not. Per the RBAC matrix the Access group -// is HIDDEN, not disabled, when the resolved role lacks `iam.manage` -// (useTeamRole — inferred developer and still-loading states both hide it, so -// forbidden links never flash). +// `.html` hrefs and global `DB` are not. Members is HIDDEN, not disabled, +// when the resolved role lacks `iam.manage` (useTeamRole — inferred developer +// and still-loading states both hide it, so forbidden links never flash). // The chrome renders immediately: nav needs only the route's teamId; on // team-less routes (/teams, /account/*) the shell passes the last-team // fallback, and with no team at all only brand/footer/collapse render. @@ -14,7 +13,7 @@ import { Link, NavLink } from "react-router"; import type { ReactElement, ReactNode } from "react"; -import { PRODUCT_NAME, PRODUCT_SHORT_NAME } from "../../branding"; +import { PRODUCT_NAME, PRODUCT_SHORT_NAME, SOURCE_REPO_URL } from "../../branding"; import { useTeamRole } from "../../rbac/useTeamRole"; // nav-item is split base + state under the no-merge contract: the idle/active @@ -92,6 +91,7 @@ export function SidebarBody({ {teamId !== null && }
+ {onToggleCollapsed !== undefined && ( - ))} -
-
- {panel} -
- } - title="Revoke invitation" - description="The invitee will no longer be able to use this invitation." - summary={ - pendingRevoke === null - ? [] - : [ - { - label: invitationContactLabel(pendingRevoke), - value: invitationContactValue(pendingRevoke), - }, - { label: "Role", value: pendingRevoke.role.key }, - { - label: "Expires", - value: ( - - {formatDate(pendingRevoke.expiresAt)} - - ), - }, - ] - } - confirmLabel="Revoke" - busy={revokeInvitation.isPending} - error={revokeError} - onCancel={() => setPendingRevoke(null)} - onConfirm={confirmRevoke} - /> - - ); -} - -// An invitation targets an email or a GitHub handle; display whichever is set. -function invitationContactValue(invitation: TeamInvitation): string { - return invitation.email ?? `@${invitation.githubHandle ?? ""}`; -} - -function invitationContactLabel(invitation: TeamInvitation): string { - return invitation.email !== null ? "Email" : "GitHub handle"; -} - -const STATUS_CHIP: Record< - TeamInvitationStatus, - { className: string; label: string } -> = { - pending: { className: `${CHIP} ${CHIP_TONE.blue}`, label: "Pending" }, - accepted: { className: `${CHIP} ${CHIP_TONE.green}`, label: "Accepted" }, - revoked: { className: `${CHIP} ${CHIP_TONE.red}`, label: "Revoked" }, - expired: { className: `${CHIP} ${CHIP_TONE.neutral}`, label: "Expired" }, -}; - -function InvitationTable({ - invitations, - onRevoke, -}: { - invitations: readonly TeamInvitation[]; - onRevoke: (invitation: TeamInvitation) => void; -}) { - const hasPendingRows = invitations.some( - (invitation) => invitation.status === "pending", - ); - - return ( -
-
- - - - - - - - - {hasPendingRows ? ( - - ) : null} - - - - {invitations.map((invitation) => { - const chip = STATUS_CHIP[invitation.status]; - return ( - - - - - - - {hasPendingRows ? ( - - ) : null} - - ); - })} - -
InviteeRoleInvitedExpiresStatus - Actions -
- - {invitationContactValue(invitation)} - - - - {invitation.role.key} - - - - {formatDate(invitation.createdAt)} - - - - {invitation.status === "pending" - ? relativeExpiry(invitation.expiresAt) - : formatDate(invitation.expiresAt)} - - - {chip.label} - - {invitation.status === "pending" ? ( - - ) : null} -
-
-
- ); -} - -// Table literals (legacy `.tbl` th/td/row rules). Header/cell styling lives on -// each th/td; the row carries the hover tint and drops the divider on the last -// row's cells (legacy `tbody tr:last-child td`). -const TBL_TH = - "border-b border-border bg-surface-2 px-[18px] py-[13px] text-left text-[11px] font-bold uppercase tracking-[.06em] whitespace-nowrap text-fg-3"; - -const TBL_TD = "border-b border-border px-[18px] py-[15px] align-middle"; - -const TBL_TR = - "[transition:.12s] hover:bg-surface-2 [&:last-child>td]:border-b-0"; - -// --------------------------------------------------------------------------- -// Shared screen states + helpers (file-local twins of MembersPage's) -// --------------------------------------------------------------------------- - -/** Full-page permission notice (deep link without `iam.manage`). */ -function PermissionNotice() { - return ( -
- } - title="Requires admin" - description="Members and invitations are managed by team admins — ask a team admin for access." - /> -
- ); -} - -function LoadingCard({ label }: { label: string }) { - return ( -
- -
- - - -
-
- ); -} - -function isForbiddenProblem(error: unknown): boolean { - return ( - error instanceof HttpProblemError && classifyProblem(error) === "forbidden" - ); -} - -function problemDescription(error: unknown): string { - if (error instanceof HttpProblemError) { - return error.detail ?? error.title ?? "The request couldn't be completed."; - } - return "The request couldn't be completed. Check your connection and try again."; -} - -const DAY_MS = 86_400_000; - -/** Pending rows show the relative expiry ("in 14 days"). */ -function relativeExpiry(iso: string): string { - const expiresAt = new Date(iso).getTime(); - if (Number.isNaN(expiresAt)) { - return iso; - } - const days = Math.ceil((expiresAt - Date.now()) / DAY_MS); - if (days <= 0) { - // Defensive: the server flips status on read, but clocks can skew. - return "expired"; - } - return days === 1 ? "in 1 day" : `in ${days} days`; -} - -// --- Icons (paths mirror the shared icon set) ------------------------------ - -function Glyph({ - style, - children, -}: { - style?: CSSProperties; - children: ReactNode; -}) { - return ( - - ); -} - -function MailIcon() { - return ( - - - - - ); -} - -function LockIcon() { - return ( - - - - - ); -} - -function AlertIcon() { - return ( - - - - - - ); -} diff --git a/web-dashboard/src/pages/MembersPage.tsx b/web-dashboard/src/pages/MembersPage.tsx index be9277c..2cf512c 100644 --- a/web-dashboard/src/pages/MembersPage.tsx +++ b/web-dashboard/src/pages/MembersPage.tsx @@ -2,7 +2,8 @@ // `iam.manage`-gated: while useTeamRole resolves → skeleton; non-managers // (exact viewer/developer binding, or the bindings 403 inference) get a // FULL-PAGE permission notice instead of a broken table; non-403 bindings -// failures render the problem-mapped ErrorState with retry. Removal follows +// failures render the problem-mapped ErrorState with retry. Pending +// invitations are merged into the members table (Status column). Removal follows // the optimistic pattern — the confirm closes immediately, the row is // dropped from the cached bindings list (in-flight fetches cancelled first) // and restored on error; `409 last-owner` renders the inline blocking callout @@ -11,8 +12,8 @@ // against the pre-mutation cache (the hook envelope carries no status), and // success swaps to the show-once PAT modal (`disableEscapeClose`, Copyable // full token — bindings refresh is already handled by useProvisionMember's -// invalidation). Helpers (Glyph icons, gate, dates) are file-local twins of -// InvitationsPage's — promote to components/ui if a third consumer appears. +// invalidation). Helpers (Glyph icons, gate, dates) are file-local — promote +// to components/ui if a third consumer appears. import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useParams } from "react-router"; @@ -28,8 +29,10 @@ import { iamKeys, useAddRoleBinding, useCreateInvitation, + useInvitations, useProvisionMember, useRemoveRoleBinding, + useRevokeInvitation, useRoleBindings, useRoles, } from "../api/hooks/iam"; @@ -49,7 +52,11 @@ import type { IamUserProvisionBody, IamUserProvisionResponse, } from "../api/types"; -import type { RoleBinding, RoleDefinition } from "../model/iam"; +import type { + RoleBinding, + RoleDefinition, + TeamInvitation, +} from "../model/iam"; import { formatDate } from "../model/format"; import { buttonVariants } from "../components/ui/Button"; import { CALLOUT, CALLOUT_BLOCK, CALLOUT_TONE } from "../components/ui/callout"; @@ -194,8 +201,10 @@ function MembersScreen({ teamId }: { teamId: string }) { ) : null} - { setLastOwnerBlocked(false); setPendingRemoval(binding); @@ -231,7 +240,7 @@ function MembersScreen({ teamId }: { teamId: string }) { Members

- People with access to apps and releases in this team. + People with access to this team, including pending invitations.

{canManage ? ( @@ -297,15 +306,173 @@ function MembersScreen({ teamId }: { teamId: string }) { } // --------------------------------------------------------------------------- -// MemberTable (user, role badge, granted at/by, kebab Remove) +// ManagedMembersTable (bindings + pending invitations, revoke confirm) // --------------------------------------------------------------------------- +function ManagedMembersTable({ + teamId, + bindings, + bindingsQuery, + onRemove, +}: { + teamId: string; + bindings: readonly RoleBinding[]; + bindingsQuery: ReturnType; + onRemove: (binding: RoleBinding) => void; +}) { + const invitationsQuery = useInvitations(teamId, "pending"); + const revokeInvitation = useRevokeInvitation(); + const queryClient = useQueryClient(); + const toast = useToast(); + + const [pendingRevoke, setPendingRevoke] = useState( + null, + ); + const [revokeError, setRevokeError] = useState(null); + + const confirmRevoke = () => { + if (pendingRevoke === null) { + return; + } + const invitation = pendingRevoke; + setRevokeError(null); + revokeInvitation.mutate( + { invitationId: invitation.id, teamId }, + { + onSuccess: () => { + setPendingRevoke(null); + toast.success("Invitation revoked", { + description: `${invitationContactValue(invitation)} can no longer use this invitation.`, + }); + }, + onError: (error) => { + if ( + error instanceof HttpProblemError && + error.typeSuffix === "invitation-not-pending" + ) { + setPendingRevoke(null); + toast.error("Invitation is no longer pending", { + description: + "It was accepted, revoked, or expired elsewhere — refreshing the list.", + }); + void queryClient.invalidateQueries({ + queryKey: iamKeys.invitationLists(teamId), + }); + } else { + setRevokeError(problemDescription(error)); + } + }, + }, + ); + }; + + if (bindingsQuery.isPending || invitationsQuery.isPending) { + return ; + } + + if ( + bindingsQuery.isError && + !isForbiddenProblem(bindingsQuery.error) + ) { + return ( +
+ { + void bindingsQuery.refetch(); + }} + /> +
+ ); + } + + if ( + invitationsQuery.isError && + !isForbiddenProblem(invitationsQuery.error) + ) { + return ( +
+ { + void invitationsQuery.refetch(); + }} + /> +
+ ); + } + + const pendingInvitations = invitationsQuery.data ?? []; + + return ( + <> + { + setRevokeError(null); + setPendingRevoke(invitation); + }} + /> + } + title="Revoke invitation" + description="They will no longer be able to accept this invitation." + summary={ + pendingRevoke === null + ? [] + : [ + { + label: invitationContactLabel(pendingRevoke), + value: invitationContactValue(pendingRevoke), + }, + { label: "Role", value: pendingRevoke.role.key }, + { + label: "Expires", + value: ( + + {formatDate(pendingRevoke.expiresAt)} + {" · "} + {relativeExpiry(pendingRevoke.expiresAt)} + + ), + }, + ] + } + confirmLabel="Revoke invitation" + error={revokeError} + onCancel={() => { + setPendingRevoke(null); + setRevokeError(null); + }} + onConfirm={confirmRevoke} + /> + + ); +} + +// --------------------------------------------------------------------------- +// MemberTable (members + pending invitations) +// --------------------------------------------------------------------------- + +type MemberTableRow = + | { kind: "member"; binding: RoleBinding } + | { kind: "invitation"; invitation: TeamInvitation }; + function MemberTable({ bindings, + pendingInvitations, onRemove, + onRevokeInvitation, }: { bindings: readonly RoleBinding[]; + pendingInvitations: readonly TeamInvitation[]; onRemove: (binding: RoleBinding) => void; + onRevokeInvitation: (invitation: TeamInvitation) => void; }) { const ownerCount = useMemo( () => bindings.filter((entry) => entry.role.key === "owner").length, @@ -316,20 +483,30 @@ function MemberTable({ () => new Map(bindings.map((entry) => [entry.user.id, entry.user.email])), [bindings], ); + const rows = useMemo((): MemberTableRow[] => { + const pending = [...pendingInvitations].sort( + (left, right) => + new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), + ); + return [ + ...pending.map( + (invitation): MemberTableRow => ({ + kind: "invitation", + invitation, + }), + ), + ...bindings.map( + (binding): MemberTableRow => ({ + kind: "member", + binding, + }), + ), + ]; + }, [bindings, pendingInvitations]); return (
-
- -

Role bindings

- {bindings.length} -
- - Roles grant team-wide access - -
-
- {bindings.length === 0 ? ( + {rows.length === 0 ? ( } title="No members yet" @@ -342,6 +519,7 @@ function MemberTable({ User Role + Status Granted Actions @@ -349,46 +527,113 @@ function MemberTable({ - {bindings.map((binding) => ( - - -
-
@@ -1172,7 +1417,7 @@ function ProvisionedTokenModal({ } // --------------------------------------------------------------------------- -// Shared screen states + helpers (file-local twins of InvitationsPage's) +// Shared screen states + helpers (file-local) // --------------------------------------------------------------------------- /** Full-page permission notice (deep link without `iam.manage`). */ @@ -1218,6 +1463,39 @@ function problemDescription(error: unknown): string { return "The request couldn't be completed. Check your connection and try again."; } +const ACTIVE_STATUS_CHIP = { + className: `${CHIP} ${CHIP_TONE.green}`, + label: "Active", +} as const; + +const PENDING_STATUS_CHIP = { + className: `${CHIP} ${CHIP_TONE.blue}`, + label: "Pending", +} as const; + +const DAY_MS = 86_400_000; + +function invitationContactValue(invitation: TeamInvitation): string { + return invitation.email ?? `@${invitation.githubHandle ?? ""}`; +} + +function invitationContactLabel(invitation: TeamInvitation): string { + return invitation.email !== null ? "Email" : "GitHub handle"; +} + +/** Pending rows show the relative expiry ("in 14 days"). */ +function relativeExpiry(iso: string): string { + const expiresAt = new Date(iso).getTime(); + if (Number.isNaN(expiresAt)) { + return iso; + } + const days = Math.ceil((expiresAt - Date.now()) / DAY_MS); + if (days <= 0) { + return "expired"; + } + return days === 1 ? "in 1 day" : `in ${days} days`; +} + function grantedLine( binding: RoleBinding, emailById: ReadonlyMap, @@ -1286,6 +1564,15 @@ function UsersIcon({ className }: { className?: string }) { ); } +function MailIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + function Users2Icon() { return ( diff --git a/web-dashboard/src/pages/TeamOverviewPage.tsx b/web-dashboard/src/pages/TeamOverviewPage.tsx index d0c0e1f..1fced85 100644 --- a/web-dashboard/src/pages/TeamOverviewPage.tsx +++ b/web-dashboard/src/pages/TeamOverviewPage.tsx @@ -9,8 +9,8 @@ // role badge (skeleton while resolving, inferred confidence tolerated). // Quick links → apps/members/metrics. Mandatory states: per-region skeletons, // page-level ErrorState (useTeam failure → retry; not-found/forbidden copy -// comes from the shared classifier). Breadcrumbs ("Team › Overview") come -// from the AppShell — not rendered here. TeamRoleBadge mirrors the TeamsPage +// comes from the shared classifier). Breadcrumbs are omitted on this screen +// (see Breadcrumbs.tsx). TeamRoleBadge mirrors the TeamsPage helper by design // helper by design (shared extraction would exceed this task's file set). import { Link, useParams } from "react-router"; @@ -206,8 +206,8 @@ function InvitationsTile({ teamId }: { teamId: string }) { isPending={invitationsQuery.isPending} isError={invitationsQuery.isError} onRetry={() => void invitationsQuery.refetch()} - to={`/teams/${teamId}/invitations`} - linkText="View invitations" + to={`/teams/${teamId}/members`} + linkText="View members" /> ); } diff --git a/web-dashboard/src/pages/release/modals/EditMetadataModal.tsx b/web-dashboard/src/pages/release/modals/EditMetadataModal.tsx index 6250818..06a8999 100644 --- a/web-dashboard/src/pages/release/modals/EditMetadataModal.tsx +++ b/web-dashboard/src/pages/release/modals/EditMetadataModal.tsx @@ -34,6 +34,9 @@ import { INPUT, INPUT_STATE, TEXTAREA_EXTRA, + TOGGLE, + TOGGLE_INPUT, + TOGGLE_TRACK, } from "../../../components/ui/form"; export interface EditMetadataModalProps { @@ -145,21 +148,16 @@ function EditMetadataModalContent({ >
Mandatory - {/* Legacy `.field>label` (== `.field-label`) won on specificity over - `.toggle` for this direct-child toggle label, so it carried the - field-label box wholesale: display:block (which un-flexes the row → - the `.track` collapses to the bare thumb) + the 13px/600/ - text color + a 7px bottom margin. Reproduce it verbatim. */} -
diff --git a/web-dashboard/src/router.tsx b/web-dashboard/src/router.tsx index 08361af..b476caa 100644 --- a/web-dashboard/src/router.tsx +++ b/web-dashboard/src/router.tsx @@ -27,7 +27,6 @@ import { AppDetailPage } from "./pages/AppDetailPage"; import { AppsPage } from "./pages/AppsPage"; import { CallbackPage } from "./pages/CallbackPage"; import { DeploymentDetailPage } from "./pages/DeploymentDetailPage"; -import { InvitationsPage } from "./pages/InvitationsPage"; import { LoginPage } from "./pages/LoginPage"; import { MembersPage } from "./pages/MembersPage"; import { MetricsPage } from "./pages/MetricsPage"; @@ -107,7 +106,6 @@ export const router = createBrowserRouter([ { path: "apps/:appId/deployments/:depId", element: }, { path: "apps/:appId/deployments/:depId/releases/:releaseId", element: }, { path: "members", element: }, - { path: "invitations", element: }, { path: "metrics", element: }, ], }, From 37d8371aceae02e5d3ec4d3b5082fa03ad55a512 Mon Sep 17 00:00:00 2001 From: Minsik Kim Date: Mon, 6 Jul 2026 12:09:42 +0900 Subject: [PATCH 2/5] Show expired invitations greyed-out in the members table Per review discussion: the merged members view now lists pending and expired invitations (revoked ones stay hidden, accepted ones already appear as member rows). Pending rows keep the top slot and the Revoke action; expired rows trail the members as greyed-out history with no action, since the server 409s on revoking a non-pending invitation. Also folds the duplicated bindings prop into the query prop and drops the unreachable bindings error branch (the iam.manage gate cannot open when the same bindings query errored). Co-Authored-By: Claude Fable 5 --- web-dashboard/src/pages/MembersPage.tsx | 200 +++++++++++++++--------- 1 file changed, 123 insertions(+), 77 deletions(-) diff --git a/web-dashboard/src/pages/MembersPage.tsx b/web-dashboard/src/pages/MembersPage.tsx index 2cf512c..35efb75 100644 --- a/web-dashboard/src/pages/MembersPage.tsx +++ b/web-dashboard/src/pages/MembersPage.tsx @@ -2,8 +2,12 @@ // `iam.manage`-gated: while useTeamRole resolves → skeleton; non-managers // (exact viewer/developer binding, or the bindings 403 inference) get a // FULL-PAGE permission notice instead of a broken table; non-403 bindings -// failures render the problem-mapped ErrorState with retry. Pending -// invitations are merged into the members table (Status column). Removal follows +// failures render the problem-mapped ErrorState with retry. Invitations are +// merged into the members table (Status column): pending rows sit on top, +// expired rows sit greyed-out below the members as history; revoked and +// accepted invitations are hidden (accepted ones exist as member rows). Only +// pending rows carry a Revoke action — the server 409s on any other status. +// Removal follows // the optimistic pattern — the confirm closes immediately, the row is // dropped from the cached bindings list (in-flight fetches cancelled first) // and restored on error; `409 last-owner` renders the inline blocking callout @@ -203,7 +207,6 @@ function MembersScreen({ teamId }: { teamId: string }) { ) : null} { setLastOwnerBlocked(false); @@ -240,7 +243,8 @@ function MembersScreen({ teamId }: { teamId: string }) { Members

- People with access to this team, including pending invitations. + People with access to this team, including pending and expired + invitations.

{canManage ? ( @@ -311,16 +315,16 @@ function MembersScreen({ teamId }: { teamId: string }) { function ManagedMembersTable({ teamId, - bindings, bindingsQuery, onRemove, }: { teamId: string; - bindings: readonly RoleBinding[]; bindingsQuery: ReturnType; onRemove: (binding: RoleBinding) => void; }) { - const invitationsQuery = useInvitations(teamId, "pending"); + // One query for every status: the server flips lapsed pending invitations + // to expired on read, and the pending/expired split happens client-side. + const invitationsQuery = useInvitations(teamId, "all"); const revokeInvitation = useRevokeInvitation(); const queryClient = useQueryClient(); const toast = useToast(); @@ -370,24 +374,12 @@ function ManagedMembersTable({ return ; } - if ( - bindingsQuery.isError && - !isForbiddenProblem(bindingsQuery.error) - ) { - return ( -
- { - void bindingsQuery.refetch(); - }} - /> -
- ); - } - + // No bindings error branch: this table only mounts behind `iam.manage`, + // and useTeamRole reads the same bindings query — a bindings error would + // have kept the gate closed (MembersScreen handles it). if ( invitationsQuery.isError && + invitationsQuery.data === undefined && !isForbiddenProblem(invitationsQuery.error) ) { return ( @@ -402,13 +394,20 @@ function ManagedMembersTable({ ); } - const pendingInvitations = invitationsQuery.data ?? []; + const invitations = invitationsQuery.data ?? []; + const pendingInvitations = invitations.filter( + (invitation) => invitation.status === "pending", + ); + const expiredInvitations = invitations.filter( + (invitation) => invitation.status === "expired", + ); return ( <> { setRevokeError(null); @@ -456,7 +455,7 @@ function ManagedMembersTable({ } // --------------------------------------------------------------------------- -// MemberTable (members + pending invitations) +// MemberTable (pending invitations on top, members, expired history below) // --------------------------------------------------------------------------- type MemberTableRow = @@ -466,11 +465,13 @@ type MemberTableRow = function MemberTable({ bindings, pendingInvitations, + expiredInvitations, onRemove, onRevokeInvitation, }: { bindings: readonly RoleBinding[]; pendingInvitations: readonly TeamInvitation[]; + expiredInvitations: readonly TeamInvitation[]; onRemove: (binding: RoleBinding) => void; onRevokeInvitation: (invitation: TeamInvitation) => void; }) { @@ -483,11 +484,18 @@ function MemberTable({ () => new Map(bindings.map((entry) => [entry.user.id, entry.user.email])), [bindings], ); + // The server lists invitations created_at ASC regardless of status — the + // display order (newest pending first, most recently expired first) is + // entirely this sort's responsibility. const rows = useMemo((): MemberTableRow[] => { const pending = [...pendingInvitations].sort( (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), ); + const expired = [...expiredInvitations].sort( + (left, right) => + new Date(right.expiresAt).getTime() - new Date(left.expiresAt).getTime(), + ); return [ ...pending.map( (invitation): MemberTableRow => ({ @@ -501,8 +509,15 @@ function MemberTable({ binding, }), ), + // Expired invitations trail the active members as greyed-out history. + ...expired.map( + (invitation): MemberTableRow => ({ + kind: "invitation", + invitation, + }), + ), ]; - }, [bindings, pendingInvitations]); + }, [bindings, pendingInvitations, expiredInvitations]); return (
@@ -529,56 +544,11 @@ function MemberTable({ {rows.map((row) => row.kind === "invitation" ? ( - - -
- -
-
- {invitationContactValue(row.invitation)} -
-
Pending invitation
-
-
- - - - {row.invitation.role.key} - - - -
- - {PENDING_STATUS_CHIP.label} - -
- {relativeExpiry(row.invitation.expiresAt)} -
-
- - - - Invited {formatDate(row.invitation.createdAt)} - - - - - - + ) : ( @@ -642,6 +612,77 @@ function MemberTable({ ); } +/** + * Invitation row, pending or expired. Expired rows are greyed-out history: + * neutral chip, absolute expiry date, and no Revoke — the server 409s + * (`invitation-not-pending`) on anything but a pending invitation. + */ +function InvitationRow({ + invitation, + onRevoke, +}: { + invitation: TeamInvitation; + onRevoke: (invitation: TeamInvitation) => void; +}) { + const pending = invitation.status === "pending"; + const chip = pending ? PENDING_STATUS_CHIP : EXPIRED_STATUS_CHIP; + + return ( + + +
+ +
+
{invitationContactValue(invitation)}
+
+ {pending ? "Pending invitation" : "Expired invitation"} +
+
+
+ + + + {invitation.role.key} + + + +
+ {chip.label} +
+ {pending + ? relativeExpiry(invitation.expiresAt) + : formatDate(invitation.expiresAt)} +
+
+ + + + Invited {formatDate(invitation.createdAt)} + + + + {pending ? ( + + ) : null} + + + ); +} + /** Row kebab → Remove (AccountMenu's outside-close/Esc/first-item-focus pattern). */ function RowKebab({ binding, @@ -1473,6 +1514,11 @@ const PENDING_STATUS_CHIP = { label: "Pending", } as const; +const EXPIRED_STATUS_CHIP = { + className: `${CHIP} ${CHIP_TONE.neutral}`, + label: "Expired", +} as const; + const DAY_MS = 86_400_000; function invitationContactValue(invitation: TeamInvitation): string { From cc4712b553b2565dad7bd982685f24376f7e492f Mon Sep 17 00:00:00 2001 From: Minsik Kim Date: Mon, 6 Jul 2026 12:09:54 +0900 Subject: [PATCH 3/5] Restore team breadcrumbs in multi-team mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The breadcrumb simplification dropped the team-name root crumb, but in multi-team mode it is what disambiguates which team an app hierarchy or team-level screen belongs to. Restore it (and the static leaf labels) behind isMultiTeam only; single-team OSS keeps the new minimal trails. The old dead "/teams" crumb stays removed — no such route exists. Co-Authored-By: Claude Fable 5 --- .../src/components/shell/Breadcrumbs.tsx | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/web-dashboard/src/components/shell/Breadcrumbs.tsx b/web-dashboard/src/components/shell/Breadcrumbs.tsx index ab1beed..2b8bfcc 100644 --- a/web-dashboard/src/components/shell/Breadcrumbs.tsx +++ b/web-dashboard/src/components/shell/Breadcrumbs.tsx @@ -1,7 +1,10 @@ // Route-derived breadcrumbs for app hierarchy screens ("Apps › App › // Deployment › Release; IDs resolve to names (skeleton text while resolving)"). -// Team-level leaf routes (overview, apps list, members, metrics) -// omit breadcrumbs — the page

already carries the title. Entity ids come +// In single-team OSS mode team-level leaf routes (overview, apps list, +// members, metrics) omit breadcrumbs — the page

already carries the +// title. In multi-team mode every team-scoped trail is prefixed with the +// team-name crumb (link to the team overview) since it disambiguates, and +// team-level leaves keep a static label after it. Entity ids come // from the route params (teamId/appId/depId/releaseId) and resolve through // the existing query hooks, cache-first — a navigation from a list that // already populated the cache paints names instantly, otherwise a small inline @@ -70,6 +73,13 @@ export function Breadcrumbs() { ); } +/** Static labels for team-scoped leaf segments (multi-team trails only). */ +const TEAM_LEAF_LABELS = new Map([ + ["apps", "Apps"], + ["members", "Members"], + ["metrics", "Metrics"], +]); + function buildCrumbs( params: Readonly>, pathname: string, @@ -79,7 +89,17 @@ function buildCrumbs( const segments = pathname.split("/").filter((segment) => segment.length > 0); if (teamId !== undefined) { - const crumbs: Crumb[] = []; + // Single-team OSS: the team root crumb is just the fixed `default-team` + // slug, so omit it; show it only in multi-team mode where it disambiguates. + const crumbs: Crumb[] = isMultiTeam + ? [ + { + key: "team", + to: `/teams/${teamId}`, + node: , + }, + ] + : []; if (appId !== undefined) { crumbs.push( @@ -106,9 +126,23 @@ function buildCrumbs( return crumbs; } - // Team-level screens (overview, apps list, members, …) — no breadcrumb; - // the page

is the title. - return []; + // Team-level screens (overview, apps list, members, …): in single-team + // mode no breadcrumb — the page

is the title. In multi-team mode the + // team crumb carries the disambiguating context, so keep the trail with a + // static leaf label. + if (!isMultiTeam) { + return []; + } + const leaf = segments[2]; + if (leaf !== undefined) { + const leafLabel = TEAM_LEAF_LABELS.get(leaf); + if (leafLabel !== undefined) { + crumbs.push({ key: leaf, node: leafLabel }); + } + } else if (segments.length === 2) { + crumbs.push({ key: "overview", node: "Overview" }); + } + return crumbs; } if (segments[0] === "account") { From e1270bc3554fdb4f721a76eb4a44ed5a02cebbc1 Mon Sep 17 00:00:00 2001 From: Minsik Kim Date: Mon, 6 Jul 2026 12:09:54 +0900 Subject: [PATCH 4/5] Drop local dev overlay gitignore entries and fix a stale comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev-overlay ignore block was personal-environment specific (.git/info/exclude material) — infra/pulumi/package.json in particular would silently ignore a real workspace manifest if that directory ever lands in the repo (yarn.lock already references it). Co-Authored-By: Claude Fable 5 --- .gitignore | 7 ------- web-dashboard/src/components/ui/table.ts | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index a0e0d85..d3378bb 100644 --- a/.gitignore +++ b/.gitignore @@ -52,12 +52,5 @@ scripts/dev/dashboard-preview.sh web-dashboard/.env.local web-dashboard/src/styles/app.local.css -# Local full-stack dev overlay (mock GitHub + dashboard on :5173) -docker-compose.dev.local.yml -deploy/dev/ -scripts/dev/up.sh -scripts/dev/mock-github.ts -infra/pulumi/package.json - # CLI global-install packing dir .tmp-pack/ diff --git a/web-dashboard/src/components/ui/table.ts b/web-dashboard/src/components/ui/table.ts index 6181e02..599c49f 100644 --- a/web-dashboard/src/components/ui/table.ts +++ b/web-dashboard/src/components/ui/table.ts @@ -1,8 +1,8 @@ // Table literals (legacy `.tbl` family — `.tbl-wrap`, `.tbl`, th/td/tr rules). // 1:1 port of the legacy rules; header/cell styling lives on each th/td, the // row carries the hover tint and drops the divider on the last row's cells -// (legacy `tbody tr:last-child td{border-bottom:0}`). Extracted from the -// MembersPage / Tokens / Apps). +// (legacy `tbody tr:last-child td{border-bottom:0}`). Shared by the +// MembersPage / Tokens / Apps tables. export const TBL_WRAP = "overflow-auto"; export const TBL = "w-full border-collapse text-[13.5px]"; From 569065160fd60030469548f6bd2563985d0f35af Mon Sep 17 00:00:00 2001 From: Minsik Kim Date: Mon, 6 Jul 2026 12:21:10 +0900 Subject: [PATCH 5/5] Prevent duplicate invitation revoke submits --- web-dashboard/src/pages/MembersPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web-dashboard/src/pages/MembersPage.tsx b/web-dashboard/src/pages/MembersPage.tsx index 35efb75..990659b 100644 --- a/web-dashboard/src/pages/MembersPage.tsx +++ b/web-dashboard/src/pages/MembersPage.tsx @@ -335,7 +335,7 @@ function ManagedMembersTable({ const [revokeError, setRevokeError] = useState(null); const confirmRevoke = () => { - if (pendingRevoke === null) { + if (pendingRevoke === null || revokeInvitation.isPending) { return; } const invitation = pendingRevoke; @@ -443,6 +443,7 @@ function ManagedMembersTable({ ] } confirmLabel="Revoke invitation" + busy={revokeInvitation.isPending} error={revokeError} onCancel={() => { setPendingRevoke(null);