Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web-dashboard/src/api/hooks/iam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
2 changes: 2 additions & 0 deletions web-dashboard/src/branding.ts
Original file line number Diff line number Diff line change
@@ -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";
43 changes: 24 additions & 19 deletions web-dashboard/src/components/shell/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -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 <Breadcrumbs/>
// 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 <h1> 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";
Expand Down Expand Up @@ -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<string, string>([
["apps", "Apps"],
["members", "Members"],
["invitations", "Invitations"],
["metrics", "Metrics"],
]);

Expand Down Expand Up @@ -124,6 +126,13 @@ function buildCrumbs(
return crumbs;
}

// Team-level screens (overview, apps list, members, …): in single-team
// mode no breadcrumb — the page <h1> 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);
Expand All @@ -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"
Expand Down
149 changes: 86 additions & 63 deletions web-dashboard/src/components/shell/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -92,6 +91,7 @@ export function SidebarBody({
</Link>
{teamId !== null && <TeamNav teamId={teamId} onNavigate={onNavigate} />}
<div className="flex-1" />
<SidebarFoot teamId={teamId} onNavigate={onNavigate} />
{onToggleCollapsed !== undefined && (
<button
type="button"
Expand Down Expand Up @@ -153,73 +153,88 @@ const MAIN_NAV_ITEMS: readonly TeamNavItem[] = [
},
];

const ACCESS_NAV_ITEMS: readonly TeamNavItem[] = [
{
key: "members",
label: "Members",
segment: "members",
icon: (
<NavIcon>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</NavIcon>
),
},
{
key: "invitations",
label: "Invitations",
segment: "invitations",
icon: (
<NavIcon>
<rect x="2" y="4" width="20" height="16" rx="2.5" />
<path d="m3 7 9 6 9-6" />
</NavIcon>
),
},
];
const MEMBERS_NAV_ITEM: TeamNavItem = {
key: "members",
label: "Members",
segment: "members",
icon: (
<NavIcon>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</NavIcon>
),
};

function TeamNav({
teamId,
onNavigate,
}: {
teamId: string;
onNavigate?: () => void;
}) {
return (
<div className="px-3 pb-1 pt-3.5">
<nav className="flex flex-col gap-[2px] px-3" aria-label="Team">
{MAIN_NAV_ITEMS.map((item) => (
<SidebarNavLink
key={item.key}
teamId={teamId}
item={item}
onNavigate={onNavigate}
/>
))}
</nav>
</div>
);
}

function SidebarFoot({
teamId,
onNavigate,
}: {
teamId: string | null;
onNavigate?: () => void;
}) {
return (
<div className="border-t border-sb-border px-3 pb-1 pt-3">
<nav className="flex flex-col gap-[2px] px-3" aria-label="More">
{teamId !== null ? (
<MembersNavLink teamId={teamId} onNavigate={onNavigate} />
) : null}
<a
href={SOURCE_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className={`${NAV_ITEM} ${NAV_ITEM_IDLE}`}
onClick={onNavigate}
>
<GitHubNavIcon />
<span>Repo</span>
</a>
</nav>
</div>
);
}

function MembersNavLink({
teamId,
onNavigate,
}: {
teamId: string;
onNavigate?: () => void;
}) {
const { can } = useTeamRole(teamId);
if (!can("iam.manage")) {
return null;
}

return (
<>
<div className="px-3 pb-1 pt-3.5">
<nav className="flex flex-col gap-[2px] px-3" aria-label="Team">
{MAIN_NAV_ITEMS.map((item) => (
<SidebarNavLink
key={item.key}
teamId={teamId}
item={item}
onNavigate={onNavigate}
/>
))}
</nav>
</div>
{can("iam.manage") && (
<div className="px-3 pb-1 pt-3.5">
<div className="px-3 pb-2 text-[10px] font-bold uppercase tracking-[.13em] text-fg-3 group-data-collapsed/app:text-center group-data-collapsed/app:text-[8px]">
Access
</div>
<nav className="flex flex-col gap-[2px] px-3" aria-label="Access">
{ACCESS_NAV_ITEMS.map((item) => (
<SidebarNavLink
key={item.key}
teamId={teamId}
item={item}
onNavigate={onNavigate}
/>
))}
</nav>
</div>
)}
</>
<SidebarNavLink
teamId={teamId}
item={MEMBERS_NAV_ITEM}
onNavigate={onNavigate}
/>
);
}

Expand Down Expand Up @@ -264,6 +279,14 @@ function NavIcon({ children }: { children: ReactNode }) {
);
}

function GitHubNavIcon() {
return (
<NavIcon>
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" />
</NavIcon>
);
}

/** Brand logomark ported verbatim from shell.js (white-on-blue tile asset). */
const LOGOMARK = (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
Expand Down
4 changes: 2 additions & 2 deletions web-dashboard/src/components/ui/table.ts
Original file line number Diff line number Diff line change
@@ -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
// InvitationsPage twins now that a third consumer appears (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]";
Expand Down
Loading