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..2b8bfcc 100644
--- a/web-dashboard/src/components/shell/Breadcrumbs.tsx
+++ b/web-dashboard/src/components/shell/Breadcrumbs.tsx
@@ -1,16 +1,19 @@
-// 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)").
+// 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
+// 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,11 +73,10 @@ export function Breadcrumbs() {
);
}
-/** Static labels for team-scoped leaf segments. */
+/** Static labels for team-scoped leaf segments (multi-team trails only). */
const TEAM_LEAF_LABELS = new Map([
["apps", "Apps"],
["members", "Members"],
- ["invitations", "Invitations"],
["metrics", "Metrics"],
]);
@@ -124,6 +126,13 @@ function buildCrumbs(
return crumbs;
}
+ // 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);
@@ -136,10 +145,6 @@ function buildCrumbs(
return crumbs;
}
- if (segments[0] === "teams") {
- return [{ key: "teams", node: "Teams" }];
- }
-
if (segments[0] === "account") {
const leafLabel =
segments[1] === "profile"
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 && (
- People with access to apps and releases in this team.
+ People with access to this team, including pending and expired
+ invitations.
{canManage ? (
@@ -297,15 +310,171 @@ function MembersScreen({ teamId }: { teamId: string }) {
}
// ---------------------------------------------------------------------------
-// MemberTable (user, role badge, granted at/by, kebab Remove)
+// ManagedMembersTable (bindings + pending invitations, revoke confirm)
+// ---------------------------------------------------------------------------
+
+function ManagedMembersTable({
+ teamId,
+ bindingsQuery,
+ onRemove,
+}: {
+ teamId: string;
+ bindingsQuery: ReturnType;
+ onRemove: (binding: RoleBinding) => void;
+}) {
+ // 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();
+
+ const [pendingRevoke, setPendingRevoke] = useState(
+ null,
+ );
+ const [revokeError, setRevokeError] = useState(null);
+
+ const confirmRevoke = () => {
+ if (pendingRevoke === null || revokeInvitation.isPending) {
+ 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 ;
+ }
+
+ // 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 (
+
+ );
+}
+
/** Row kebab → Remove (AccountMenu's outside-close/Esc/first-item-focus pattern). */
function RowKebab({
binding,
@@ -1172,7 +1459,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 +1505,44 @@ 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 EXPIRED_STATUS_CHIP = {
+ className: `${CHIP} ${CHIP_TONE.neutral}`,
+ label: "Expired",
+} 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 +1611,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/pages/release/modals/PromoteModal.tsx b/web-dashboard/src/pages/release/modals/PromoteModal.tsx
index 0c9f61f..158b6b1 100644
--- a/web-dashboard/src/pages/release/modals/PromoteModal.tsx
+++ b/web-dashboard/src/pages/release/modals/PromoteModal.tsx
@@ -46,6 +46,9 @@ import {
INPUT_STATE,
SELECT_EXTRA,
TEXTAREA_EXTRA,
+ TOGGLE,
+ TOGGLE_INPUT,
+ TOGGLE_TRACK,
} from "../../../components/ui/form";
export interface PromoteModalProps {
@@ -409,44 +412,32 @@ function PromoteModalContent({
inheritedDisplay={release.isMandatory ? "Yes" : "No"}
busy={busy}
>
- {/* 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. The visual-contract
- baseline captured exactly that; reproduce it verbatim. */}
-
+
setMandatory(event.currentTarget.checked)}
/>
- Mandatory
+
+ Clients must install this update
Initial status
- {/* 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. The visual-contract
- baseline captured exactly that; reproduce it verbatim. */}
-
+
setCreateDisabled(event.currentTarget.checked)
}
/>
- Create as disabled
+
+ Create as disabled