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 && ( - ))} -
-
- {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..990659b 100644 --- a/web-dashboard/src/pages/MembersPage.tsx +++ b/web-dashboard/src/pages/MembersPage.tsx @@ -2,7 +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. 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 @@ -11,8 +16,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 +33,10 @@ import { iamKeys, useAddRoleBinding, useCreateInvitation, + useInvitations, useProvisionMember, useRemoveRoleBinding, + useRevokeInvitation, useRoleBindings, useRoles, } from "../api/hooks/iam"; @@ -49,7 +56,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 +205,9 @@ function MembersScreen({ teamId }: { teamId: string }) { ) : null} - { setLastOwnerBlocked(false); setPendingRemoval(binding); @@ -231,7 +243,8 @@ function MembersScreen({ teamId }: { teamId: string }) { Members

- 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 ( +
+ { + void invitationsQuery.refetch(); + }} + /> +
+ ); + } + + const invitations = invitationsQuery.data ?? []; + const pendingInvitations = invitations.filter( + (invitation) => invitation.status === "pending", + ); + const expiredInvitations = invitations.filter( + (invitation) => invitation.status === "expired", + ); + + 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" + busy={revokeInvitation.isPending} + error={revokeError} + onCancel={() => { + setPendingRevoke(null); + setRevokeError(null); + }} + onConfirm={confirmRevoke} + /> + + ); +} + +// --------------------------------------------------------------------------- +// MemberTable (pending invitations on top, members, expired history below) // --------------------------------------------------------------------------- +type MemberTableRow = + | { kind: "member"; binding: RoleBinding } + | { kind: "invitation"; invitation: TeamInvitation }; + function MemberTable({ bindings, + pendingInvitations, + expiredInvitations, onRemove, + onRevokeInvitation, }: { bindings: readonly RoleBinding[]; + pendingInvitations: readonly TeamInvitation[]; + expiredInvitations: readonly TeamInvitation[]; onRemove: (binding: RoleBinding) => void; + onRevokeInvitation: (invitation: TeamInvitation) => void; }) { const ownerCount = useMemo( () => bindings.filter((entry) => entry.role.key === "owner").length, @@ -316,20 +485,44 @@ 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 => ({ + kind: "invitation", + invitation, + }), + ), + ...bindings.map( + (binding): MemberTableRow => ({ + kind: "member", + binding, + }), + ), + // Expired invitations trail the active members as greyed-out history. + ...expired.map( + (invitation): MemberTableRow => ({ + kind: "invitation", + invitation, + }), + ), + ]; + }, [bindings, pendingInvitations, expiredInvitations]); return (
-
- -

Role bindings

- {bindings.length} -
- - Roles grant team-wide access - -
-
- {bindings.length === 0 ? ( + {rows.length === 0 ? ( } title="No members yet" @@ -342,6 +535,7 @@ function MemberTable({ User Role + Status Granted Actions @@ -349,46 +543,68 @@ function MemberTable({ - {bindings.map((binding) => ( - - -
- -
-
- {binding.user.displayName ?? binding.user.email} + {rows.map((row) => + row.kind === "invitation" ? ( + + ) : ( + + +
+ +
+
+ {row.binding.user.displayName ?? + row.binding.user.email} +
+ {row.binding.user.displayName !== null ? ( +
+ {row.binding.user.email} +
+ ) : null}
- {binding.user.displayName !== null ? ( -
{binding.user.email}
- ) : null}
-
- - - - {binding.role.key} - - - - - {grantedLine(binding, emailById)} - - - - - - - ))} + + + + {row.binding.role.key} + + + + + {ACTIVE_STATUS_CHIP.label} + + + + + {grantedLine(row.binding, emailById)} + + + + + + + ), + )}
@@ -397,6 +613,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, @@ -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/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: }, ], },